Bootstrap

王道C语言笔记(高级阶段:组层原理数据表示与汇编实战&操作系统文件部分的算法)

第一章:C语言语法进阶

一、条件运算符与逗号运算符

1、条件运算符:

        条件运算符是C语言中唯一的一种三目运算符。三目运算符代表有三个操作数;双目运算符代表有两个操作数,如逻辑与运算符就是双目运算符;单目运算符代表有一个操作数,如逻辑非运算符就是单目运算符。运算符也称操作符,三目运算符通过判断问号之前的表达式的真假,来确定整体表达式的值,如下例所示:如果a>b为真,那么三目表达式整体的值为 a,所以max 的值等于a,如果 a>b为假、那么三目表达式整体的值为b,所以 max的值等于 b。

基础语法

条件表达式 ? 表达式1 : 表达式2

执行逻辑:

完整代码:

#include <stdio.h>

int main() {
    int a,b,max;
    while(scanf("%d%d",&a,&b))
    {
        max=a>b?a:b;
        printf("max=%d\n",max);
    }
    //想让上述代码结束可以输入单个字符a
    return 0;
}

2、逗号运算符:

逗号运算符的优先级最低,我们需要掌握的是,逗号表达式的整体值是最后一个表达式的值。

基础语法:

表达式1, 表达式2, ..., 表达式N

执行逻辑:

完整代码:

#include <stdio.h>

int main() {
    int i,j;
    i=10;
    j=1;
    if(i,j-1)
    {
        printf("if excute\n");
    }
    for(i=0,j=1;i<10;i++)
    {

    }
    return 0;
}

二、自增自减运算符

1、自增、自减运算符和其他运算符有很大的区别:

        因为其他运算符除赋值运算符可以改变量本身的值外,不会有这种效果。自增、自减就是对变量自身进行加1、减1操作,那么有了加法和减法运算符为什么还要发明这种运算符呢?原因是自增和自减来源于B语言,当时KenThompson 和 Dennis M.Ritchie(C语言的发明者)为了不改变程序员的编写习惯,在C语言中保留了 B语言中的自增和自减。因为自增、自减会改变变量的值,所以自增和自减不能用于常量!

2、例题:

        下例中的j=i++>-1,对于后++或者后--,首先我们需要去掉++或--运算符,也就是首先计算j=i>-1,因为i本身等于-1,所以得到j的值为0, 接着单独计算i++,也就是对i加 1,所以i从-1加1得到 0,因此 printf("i=%d,j=%d\n",i,j);语句的执行结果是0和0。

完整代码:

#include <stdio.h>

//考研初试如果使用++,或者--,最好单独使用,不要和其他运算符混到一起
int main() {
    int i=-1,j;
    //5++;//如果打开该句,会造成编译不通
    j=i++>-1;//后++等价于j=i>-1;i=i+1;
    printf("i=%d,j=%d\n",i,j);

    return 0;
}

3、自增自减运算符与取值运算符(408 初试不需要掌握,初试考C的学校需要,部分机试答案含有)

#include <stdio.h>
#include <stdlib.h>

//只有比后增优先级高的操作符,才会作为一个整体,如:[]、()
int main() {
    int a[3]={3,7,8};
    int *p;
    int j;
    p=a;//p指向数组起始元素
    j=*p++;//先把*p的值赋给j,然后对p加1。j=*p;p++;
    printf("a[0]=%d,j=%d,*p=%d\n",a[0],j,*p);//输出3 3 7
    j=p[0]++; //先把 p[0]赋给j,然后对 p[0]加1
    printf("a[0]=%d,j=%d,*p=%d\n",a[0],j,*p);//输出3 7 8

    return 0;
}

三、位运算符

1、位运算符<<、>>、~、|、^、&依次是左移、右移、按位取反、按位或、按位异或、按位与.

  • 位运算符只能用于对整型数据进行操作。
  • 左移:高位丢弃,低位补0,相当于乘以2.工作中很多时候申请内存时会用左移,

        例如:要申请1GB大小的空间,可以使用malloc(1<<30).

  • 右移:低位丢弃,正数的高位补0(无符号数我们认为是正数),负数的高位补1,相当于除以 2.移位比乘法和除法的效率要高,负数右移,对偶数来说是除以2,但对奇数来说是先减1后除以2.

        例如,-8>>1,得到的是-4,但-7>>1得到的并不是-3而是-4.另外,对于-1来说,无论右移多少位,值永远为-1.

  • C语言的左移和右移相当于是算术左移与算术右移,考研中的逻辑左移与右移,左移和右移空位都补 0.
  • 异或:相同的数进行异或时,结果为0,任何数和0异或的结果是其本身。
  • 按位取反:数位上的数是1变为 0,0变为1.
  • 按位与和按位或:用两个数的每一位进行与和或
#include <stdio.h>

int main() {
    short i=5;//short是short int的缩写,short是整型,是2个字节的整型,int是4个字节
    short j;

    //左移 右移
    j=i<<1;//一个变量移动以后自身不会变化
    printf("i=%d\n",j);//左移是乘以2,结果为10
    j=i>>1;
    printf("j=%d\n",j);//右移是除2,结果是2
    printf("------------------\n");

    //无符号数的左移与右移
    i=0x8011;
    unsigned short s=0x8011;//在short前加unsigned就是无符号数
    unsigned short r=0;
    j=i>>1;//对i右移,对有符号数进行右移
    r=s>>1;//对s右移,对无符号数进行右移
    printf("j=%d,r=%u\n",j,r);//有符号数和无符号数右移之后的结果是不一样的
    printf("------------------\n");

    //接下来看 按位与,按位或,按位异或,按位取反
    i=5,j=7;
    printf("i & j=%d\n",i&j);
    printf("i | j=%d\n",i|j);
    printf("i ^ j=%d\n",i^j);
    printf("~i=%d\n",~i);
    return 0;
}

2、异或运算符特性:

        异或运算符有以下特性:

  • 归零律a ^ a = 0(相同数异或结果为0)
  • 恒等律a ^ 0 = a(任何数与0异或结果不变)
  • 交换律a ^ b = b ^ a
  • 结合律(a ^ b) ^ c = a ^ (b ^ c)

我们可以完成下面的题目,在一堆数中找出出现1次的那个数,其他数是出现2次.        

    #include <stdio.h>
    
    int main() {
        int i;
        int arr[5]={8,5,3,5,8};
        int result = 0;
        for(i=0;i<5;i++)
        {
            result ^= arr[i];
        }
        printf("%d\n",result);//输出为3
    
        return 0;
    }

    四、switch & do-while

    1、switch

    判断的一个变量可以等于几个值或几十个值时,使用if和elseif语句会导致 elseif分支非常多,这时可以考虑使用switch语句,switch语句的语法格式如下:

     switch(表达式)
            {
                case 常量表达式 1:语句1
                case 常量表达式 2:语句2
                ...
                case 常量表达式 n:语句n
                default:语句 n+1
            }

    下面来看一个使用switch语句的例子。
            如例1所示,输入一个年份和月份,然后打印对应月份的天数,如输入一个闰年和2月,则输出为29天.具体代码如下所示,switch语句中 case后面的常量表达式的值不是按照1到12的顺序排列的,这里要说明的是,switch语句匹配并不需要常量表达式的值有序排列,输人值等于哪个常量表达式的值,就执行其后的语句,每条语句后需要加上 break语句,代表匹配成功一个常量表达式时就不再匹配并跳出switch 语句.
        如果一个 case语句后面没有break语句,那么程序会继续匹配下面的case 常量表达式例2中的代码是对例1中代码的优化。例2的代码执行效果和上面的代码执行效果一致,原理是只要匹配到1、3、5、7、8、10、12 中的任何一个,就不再拿 mon 与case 后的常量表达式的值进行比较, 而执行语句 printf("mon=%dis 31days\n",mon),完毕后执行 break 语句跳出switch语句.switch语句最后加入default 的目的是,在所有case后的常量表达式的值都未匹配时,打印输出错误标志或者一些提醒,以便让程序员快速掌握代码的执行情况。

    #include <stdio.h>
    
    int main() {
        int mon,year;
        while(scanf("%d%d",&year,&mon))
        {
            switch (mon)
            {
                case 2:printf("mon=%d is %d days\n",mon,28+(year%4==0&&year%100!=0||year%400==0));break;
                case 1:printf("mon=%d is 31days\n",mon);break;
                case 3:printf("mon=%d is 31days\n",mon);break;
                case 5:printf("mon=%d is 31days\n",mon);break;
                case 7:printf("mon=%d is 31days\n",mon);break;
                case 8:printf("mon=%d is 31days\n",mon);break;
                case 10:printf("mon=%d is 31days\n",mon);break;
                case 12:printf("mon=%d is 31days\n",mon);break;
                case 4:printf("mon=%d is 30days\n",mon);break;
                case 6:printf("mon=%d is 30days\n",mon);break;
                case 9:printf("mon=%d is 30days\n",mon);break;
                case 11:printf("mon=%d is 30days\n",mon);break;
                default:
                    printf("error mon\n");
            }
        }
        return 0;
    }

    升级版:

    #include <stdio.h>
    
    int main() {
        int mon,year;
        while(scanf("%d%d",&year,&mon))
        {
            switch (mon)
            {
                case 2:printf("mon=%d is %d days\n",mon,28+(year%4==0&&year%100!=0||year%400==0));break;
                case 1:
                case 3:
                case 5:
                case 7:
                case 8:
                case 10:
                case 12:printf("mon=%d is 31days\n",mon);break;
                case 4:
                case 6:
                case 9:
                case 11:printf("mon=%d is 30days\n",mon);break;
                default:
                    printf("error mon\n");
            }
        }
        return 0;
    }

    2、do while语句的特点是:

    先执行循环体,后判断循环条件是否成立,其一般形式为

    do
    {
        循环体语句;
    }while(表达式);

            执行过程如下:首先执行一次指定的循环体语句,然后判断表达式,当表达式的值为非零(真)时,返回重新执行循环体语句,如此反复,直到表达式的值等于0为止,例3是使用dowhile语句计算 1到100之间所有整数之和的例子,do while语句与while 语句的差别是,do while语句第一次执行循环体语句之前不会判断表达式的值, 也就是如果i的初值为101,那么依然会进入循环体,实际工作中 do while 语句应用较少.

     do while 语句计算1到100 之间的所有整数之和:

    #include <stdio.h>
    
    int main() {
        int i=1,total=0;
        do{
            total=total+i;
            i++;
        }while(i<=100);//无论这里判断条件是什么循环都会先执行一次
        printf("total=%d\n",total);
        return 0;
    }

    五、二维数组&二位指针

    1、二维数组

    二维数组定义的一般形式如下:

    类型说明符 数组名[常量表达式][常量表达式];

     例如,定义a为3x4(3行4列)的数组,b为5x10(5行10列)的数组:

    float a[3][4].b[5][10];

    可以将二维数组视为一种特殊的一维数组:一个数组中的元素类型是一维数组的一维数组.

    例如,可以把二维数组 a[3][4]视为一个一维数组,它有3个元素 a[0]、a[1]和 a[2],每个元素又是一个包含 4个元素的一维数组,如图1所示。

    二维数组中的元素在内存中的存储规则是按行存储,即先顺序存储第一行的元素,后顺序存储第二行的元素,数组元素的获取依次是从 a[0][0]到 a[0][1],直到最后一个元素 a[2][3].

    图2中显示了存储二维数组 a[3][4]中每个元素时的顺序

    #include <stdio.h>
    
    int main()
    {
        //通过调试查看元素存放顺序
        int a[3][4]={1,2,3,4,5,6,7,8,9,10,11,12 };
        printf("sizeof(a)=%d\n",sizeof(a));//掌握二维数组大小
        printf("a[2][3]=%d\n", a[2][3]);//最后一个元素是 a[2][3]
        return 0;
    }
    

     2、二级指针(不在 408 大纲范围,408 初试不考,初试考C 的学校可能需要,机试用到概率极低))

            如果掌握了 C++的引用,其实不需要去学习二级指针,对于考研必要性很低,二级指针是指针的指针,二级指针的作用是服务于一级指针变量,对一级指针变量实现间接访问。下面我们通过一个实例来让大家理解一下二级指针。

    #include <stdio.h>
    
    int main()
    {
        int i=10;
        int *p=&i;
        int **p2=&p;//如果我们需要把一个一级指针变量的地址存起来,那么就需要二级指针类型
        printf("sizeof(p2)=%d\n",sizeof(p2));//p2和p同样大,都是8个字节
        printf("**p2=%d\n",**p2);//通过两次取值可以拿到ic
        return 0;
    }

    第二章:数据的的机器级表示

    一、补码与内存

    1、补码讲解及内存实战演示(组成常考选择题)

            计算机的CPU无法做减法操作(硬件上没有减法器),只能做加法操作。CPU中有一个逻辑单元叫加法器。计算机所做的减法,都是通过加法器将其变化为加法实现的.那么减法具体是如何通过加法实现的呢?实现 2-5的方法是2+(-5),由于计算机只能存储0和1.因此我们编写程序来查看计算机是如何存储-5的,5的二进制数为101.称为原码。计算机用补码表示-5,补码是对原码取反后加1的结果,即计算机表示-5时会对5的二进制数(101)取反后加1,如图1所示.-5在内存中存储为0xffffb,因为对5取反后得0xffffffa,加1后得 OxffffHb(由于是X86架构是小端存储,小端存储是低字节在前,即低字节在低地址,高字节在后,即高字节在高地址,fb对于0xfffffffb是最低的字节,因此fb在最前面,大端和小端相反),对其加2后得0xfffffd,见图2,它就是k的值。当最高位为1(代表负数)时,要得到原码才能知道 0xfffffffd 的值,即对其取反后加1(当然,也可以减1后取反,结果是一样的)得到3,所以其值为-3.

            考研注意,假设变量A的值为-5,通过8位表示,那么A 值为11111011,A厕的值为 10000101,符号位是不动的,只有值的部分是5,通过符号位不动,其他值取反加1,这个是考研考的一个原理,和不少C编程书籍上讲的存在差异,正数的补码和原码一致. 

     2、反码(代码不好演示,考试考的概率低)

            反码是一种在计算机中数的机器码表示。对于单个数值(二进制的0和1)而言,对其进行取反操作就是将0为1,1为0.正数的反码和原码一样,负数的反码就是在原码的基础上符号位保持不变,其他位取反.

    6是正数,补码与原码一致,-3的补码是11111101 

    二、整型的不同类型&&溢出

    1、整型不同类型解析(组成要考,选择题)

            如图1所示,整型变量包括6种类型,如图2所示,其中有符号短整型与无符号短整型的最高位所代表的意义不同。不同整型变量表示的整型数的范围如表1所示,超出范围会发生溢出现象,导致计算出错。

    (有符号位第一位0是正数,1是负数,无符号位第一位不代表正负) 

     

    考研补充说明:

            考研会考8位的,也就是1个字节的整型数的大小,对于1个字节的有符号类型的数值范围是-2的7次幂~(2的7次幂-1),也就是-128到127,对于8位的无符号(unsigned)类型的数值范围是0~(2的8次幂--1),也就是 0-255.

            最小的负数是-32768,最大的负数是-1,有符号整数个数为32768,无符号整数个数为65535。

    2、溢出解析

            如下例所示,有符号短整型数可以表示的最大值为32767,当我们对其加1时,b的值会变为多少呢?实际运行打印得到的是-32768。为什么会这样?因为32767对应的十六进制数为0x7ff,加1后变为0x8000,其首位为1,因此变成了一个负数。取这个负数的原码后,就是其本身,值为 32768,所以 0x8000是最小的负数,即-32768.这时就发生了溢出,我们对32767加1,希望得到的值是 32768,但结果却是-32768,因此导致计算结果错误。在使用整型变量时,一定要注意数值的大小,数值不能超过对应整型数的表示范围.有的读者可能会问在编写的程序中数值大于 2的64次幂-1 时怎么办?答案是可以自行实现大整数加法。

    #include <stdio.h>
    
    //整型不同类型的演示,以及溢出演示
    int main() {
        int i=10;
        short a=32767;
        short b=0;
        long c;//32位的程序是4个字节,64位的是8个字节
        i=a+1;//发生了溢出,解决溢出的办法是用更大的空间来存储,比如:将long换成int
        printf("%d\n",i);//b并不是32767
        printf("----------------------\n");
    
        unsigned int m=3;
        unsigned short n=0x8056;//无符号类型,最高位不认为是符号位
        unsigned long k=5;
        b=0x8056;
        printf("b=%d\n",b);//b是有符号类型,所以输出是负值
        printf("n=%u\n",n);//无符号类型要用%u,用%d是不规范的
    
        return 0;
    }

    三、浮点数IEEE754标准

    1、浮点数IEEE754 标准解析及实战计算演示(组成要考选择题)

            在C语言中,要使用 foat 关键字或 double 关键字定义浮点型变量.float型变量占用的内存空间为4字节,double型变量占用的内存空间为8字节。与整型数据的存储方式不同,浮点型数据是按照指数形式存储的,系统把一个浮点型数据分成小数部分(用M表示)和指数部分(用E表示)并分别存放。指数部分采用规范化的指数形式,指数也分正、负(符号位,用S表示),如图1所示
            数符(即符号位)占1位,是0时代表正数,是1时代表负数,表1是IEEE-754浮点型变量存储标准。

    S:S是符号位,用来表示正、负,是1时代表负数,是0时代表正数.

    E:E代表指数部分(指数部分的值规定只能是1到254,不能是全0,全1),指数部分运算前都要减去127(这是IEEE-754的规定),因为还要表示负指数,这里的10000001转换为十进制数为129,129-127=2,即实际指数部分为2.

    M:M 代表小数部分,这里为00100000000000000000000.底数左边省略存储了一个1(这是IEEE-754 的规定),使用的实际底数表示为1.00100000000000000000000

    (1.001 小数部分 1.125(1+0+0+0.125) 小数部分乘以指数部分就是4.5)

    (移位算法:1001.1 4+0.5=4.5)

    上面表1可以变为如下表格:

    2、执行方法如下:

    #include <stdio.h>
    
    int main() {
        float f = 4.5;
        float f1 = 1.456;
        return 0;
    }

             在上图中我们可以看到 4.5,也就是变量f的内存是0000 9040,因为是小端存储,所以实际值是 0x40900000.

    首先先看f的小数部分,如下表2所示,M(灰色)代表小数部分,这里为00100000000000000000000,总计23位,底数左边省略存储了一个1(这是IEEE-754的规定),使用的实际底数表示为1.00100000000000000000000

             接着看指数部分,计算机并不能直接计算 10的幂次,f的指数部分是表2中的EEEEEEEE所对应的部分,也就是10000001,其十进制值为129,129-127=2,即实际指数部分为2指数值为2,代表2的2次幂因此将1.001向左移动2位即可,也就是100.1;然后转换为十进制数,整数部分是4,小数部分是2-1,刚好等于0.5,因此十进制数为 4.5.浮点数的小数部分是通过2-1+2-2+2-+…来近似一个浮点数的.

            可能你会疑惑,不应该是小数部分乘以指数部分么,怎么变成左移 2位,其实是等价的,对于小数部分 1.001,其十进制值为2°+2-3=1.125.那么 1.125*指数部分,也就会4,就是 1.125*4=4.5,也就是等于 4.5.

            在上图中我们可以看到1.456,也就是变量f1的内存是35 5e ba 3f,因为是小端存储,所以实际值是 0x 3f ba 5e 35.

            首先先看f1的小数部分,如下表3所示,M(灰色)代表小数部分,这里为0111010010111100011 0101,总计23位。底数左边省略存储了一个1(这是IEEE-754的规定),使用的实际底数表示为1.011 1010 0101 1110 0011 0101.

            接着看指数部分,计算机并不能直接计算10的幂次,f1的指数部分是表3中的 EEEEEEEE所对应的部分,也就是01111 111,其十进制值为127,127-127=0,即实际指数部分为0指数值为0,代表2的0次幂。因此1.01110100101111000110101无需做移动.

            浮点数的小数部分是通过 2°+22+23+2-+2-…来近似一个浮点数的,1+0.25+0.125+0.0625+0.015625=1.453125

    四、浮点数精度丢失

            浮点型变量分为单精度(foat)型、双精度(double)型,如表1所示,因为浮点数使用的是指数表示法,需要记忆数值的范围(考研会考),同时我们需要注意浮点数的精度问题。

            上表中 double 类型是-1022到 1023,是通过 1-2046(因为不能是全0,全1,全1是2047)减去 1023,得到-1022到 1023。

            如下例所示,我们赋给 a的值为1.23456789e10,加20 后,应该得到的值为 1.234567892e10但b输出结果却是b=12345678848.000000,变得更小了。我们将这种现象称为精度丢失,因为 float 型数据能够表示的有效数字为 7位,最多只保证 1.234567e10 的正确性,要使结果正确就需要把 a和b均改为 double 型,因为 double 可以表示的精度为 15~16 位。

    #include <stdio.h>
    
    //提醒 scanf 读取 double类型时,要用 if,如 double d;scanf("%f",&d);初试用不到,机试可能考
    int main() {
        //赋值的一瞬间发生 精度丢失,因为浮点常量默认是8 个字节存储,double 型
        double a= 1.23456789e10;
        double b;
        b=a+ 20;//计算时,精度丢失 12345678920
        printf("b=%f\n",b);
        //%f 即可以输出 float,也可以输出 double类型
    
        return 0;
    }

            另外针对强制类型转换,int 转foat 可能造成精度丢失,因为int是有10 位有效数字的,但是int强制转为 double 不会,foat转为 double也不会丢失精度。

    第三章:汇编语言零基础入门

    一、汇编指令格式讲解-C语言转汇编方法讲解

    1、汇编指令格式       

             在去看汇编指令前,我们来看下CPU是如何执行我们的程序的,如下图所示,我们编译后的可执行程序,也就是 main.exe,是放在代码段的,PC指针寄存器存储了一个指针,始终指向要执行的指令,读取了代码段的某一条指令后,会交给译码器来解析,这时候译码器就知道要做什么事情了,CPU 中的计算单元加法器不能直接对栈上的某个变量a,直接做加1操作的,需要首先将栈,也就是内存上的数据,加载到寄存器中,然后再用加法器做加1操作,再从寄存器搬到内存上去。

    (CPU 读写寄存器的速度比读写内存的速度要快很多)

    操作码字段(你要实施什么操作):表征指令的操作特性与功能(指令的唯一标识)不同的

    指令操作码(要操作哪里的内存):不能相同地址码字段:指定参与操作的操作数的地址码

    指令中指定操作数存储位置的字段称为地址码,地址码中可以包含存储器地址,也可包含寄存器编号.

    指令中可以有一个、两个或者三个操作数,也可没有操作数,根据一条指令有几个操作数地址可将指令分为零地址指令。一地址指令、二地址指令、三地址指令。4个地址码的指令很少被使用(考研考不到,这里不列了)

            零地址指令:只有操作码,没有地址码(空操作止等)。

            一地址指令:指令编码中只有一个地址码,指出了参加操作的一个操作数的存储位置,如果还有另一个操作数则隐含在累加器中。
            eg:INC   AL   自加指令

            二地址指令:指令编码中有两个地址,分别指出了参加操作的两个操作数的存储位置,结果存储在其中一个地址中。
            (op a1,a2) a1和 a2 的操作结果放人a1
            eg:   MOV   AL,BL
                    ADD    AL,30

            三地址指令:指令编码中有3个地址码,指出了参加操作的两个操作数的存储位置和一个结果的地址(考研很少考)。

            (op al,a2,a3:al和a2的结果放人 a3)

            地址指令格式中,从操作数的物理位置来说有可归为三种类型

            寄存器-寄存器(RR)型指令:需要多个通用寄存器或个别专用寄存器,从寄存器中取操作数把操作结果放入另一个寄存器,机器执行寄存器-寄存器型的指令非常快,不需要访存。

            寄存器-存储器(RS)型指令:执行此类指令时,既要访问内存单元,又要访问寄存器。

            存储器-存储器(SS)型指令:操作时都是涉及内存单元,参与操作的数都是放在内存里从内存某单元中取操作数,操作结果存放至内存另一单元中,因此机器执行指令需要多次访问内。

            寄存器英文:register

            存储器英文:storage

            (*)复杂指令集:变长           x86   CISC   Complex Instruction Set Computer

            精简指令集:等长/定长   arm   RISC   Reduced Instruction Set Computinar

    2、生成汇编方法

             掌握把 C 语言转换汇编的手法,那么就不会再害怕汇编了!

    编译过程(了解即可,是大纲范围,考的概率不高)

    第一步:main.c-->编译器--》main.ss文件(.s文件就是汇编文件,文件内是汇编代码);

    第二步:我们的 main.s汇编文件--》汇编器--》main.obj(main.obj 里面是机器码);

    第三步:main.obj 文件-》链接器-》可执行文件exe。

    CLion 生成汇编的步骤(很多书籍不讲这个,直接就给你看汇编指令)

    1、首先我们需要设置环境变量,对 此电脑右键点击,选择属性

    环境变量设置好后,永久生效的,后面生成汇编不需要再次设置!
    Mac 电脑无需设置环境变量,直接进行下面操作即可。

    首先完成C代码的编写,如下图所示

    然后再终端窗口中输人 gcc -S- fverbose-asm main.c就可以生成汇编文件 main.s,如下图所示

    因为 CLion 看汇编代码没有颜色,因此大家阅读汇编代码,通过《VScode 安装流程》安装VScode 及 asm 插件后,来看代码高亮后效果.

    VS 生成汇编的步骤(针对部分同学使用的 VS,没有安装 CLion 的如何操作)1、如图1所示,右键项目,点击属性。

    如果2所示,选择输出文件,汇编程序输出选择程序集、机器码和源代码

    VS 的汇编后缀是 xx.cod

    二、汇编常用指令

    1、相关寄存器

    EBP和 ESP外,其他几个寄存器的用途是比较任意的,也就是什么都可以存。

    2、常用指令

            汇编指令通常可以分为数据传送指令、逻辑计算指令和控制流指令,下面以Imtel格式为例(考研考的就是 Intel 的汇编),介绍一些重要的指令。以下用于操作数的标记分别表示寄存器、内存和常数。

    • <reg>:表示任意寄存器,若其后带有数字,则指定其位数,如<reg32>表示32位寄存器(eax、ebx、ecx、edx、esi、edi、esp或ebp);<reg16>表示16 位寄存器(ax、bx、cx或dx);<reg8>表示8位寄存器(ah、al、bh、bl、ch、cl、dh、dl)。
    • <mem>:表示内存地址(如[eax]、[var+4]或 dword ptr [eax+ebx])。
    • <con>:表示8位、16位或32位常数。<con8>表示8位常数:<con16>表示16位常数;<con32>表示32 位常数。(也称为立即数)
    (1)数据传送指令
    1)mov 指令

    将第二个操作数(寄存器的内容、内存中的内容或常数值)复制到第个操作数(寄存器或内存),但不能用于直接从内存复制到内存.其语法如下:

    • mov <reg>,<reg>
    • mov <reg>,<mem>
    • mov <mem>,<reg>
    • mov <reg>,<con>
    • mov <mem>,<con>

    举例:

    • mov eax,ebx             #将 ebx值复制到 eax
    • mov byte ptr [var] 5   #将5保存到var值指示的内存地址的一字节中
    2)push 指令

            将操作数压人内存的栈,常用于函数调用。ESP是栈顶,压栈前先将ESP值减4(栈增长方向与内存地址增长方向相反),然后将操作数压人ESP指示的地址,其语法如下:

    push <reg32>
    push <mem>
    push <con32>

    举例(注意,栈中元素固定为32位):

    push eax:将eax的值压栈。

    push [var]:将var所指示的内存地址的4字节值压栈。

    3)pop指令

            与push指令相反,pop指令执行的是出栈工作,出栈前先将ESP指示的地址中的内容出栈,然后将ESP值加4。其语法如下:

    pop <reg>

    pop <mem>

    举例:

    pop edi:弹出栈顶元素送到edi。

    pop [ebx]:弹出栈顶元素送到ebx所指示的内存地址的4字节中。

    (2)算术和逻辑运算指令
    1)add/sub指令

    add指令将两个操作数相加,相加的结果保存到第一个操作数中;sub指令用于两个操作数相减,相减的结果保存到第一个操作数中。它们的语法如下:

    add <reg>,<reg>

    add <reg>,<mem>

    add <mem>,<reg>

    add <reg>,<con>

    add <mem>,<con>

    【或】

    sub <reg>,<reg>

    sub <reg>,<mem>

    sub <mem>,<reg>

    sub <reg>,<con>

    sub <mem>,<con>

    举例:

    sub eax,10          #eax <-eax - 10

    add byte ptr [var],10   #10与var所指示的内存地址的一字节值相加,并将结果保存在var所指示的内存地址的字节中。

     2)inc/dec指令

    incdec指令分别表示将操作数自加1、自减1。它们的语法如下:

    inc <reg>

    inc <mem>

    【或】

    dec <reg>

    dec <mem>

    举例:

    dec eax:eax的值自减1。

    inc dword ptr [var]:var所指示的内存地址的4字节值自加1。

    3)imul指令

            带符号整数乘法指令,有两种格式:①两个操作数,将两个操作数相乘,将结果保存在第一个操作数中,第一个操作数必须为寄存器;②三个操作数,将第二个和第三个操作数相乘,将结果保存在第一个操作数中,第一个操作数必须为寄存器。其语法如下:

    imul <reg32>,<reg32>

    imul <reg32>,<mem>

    imul <reg32>,<reg32>,<con>

    imul <reg32>,<mem>,<con>

    举例:

    imul eax,[var]:eax = eax × [var]。

    imul esi,edi,25:esi = edi × 25。

    操作结果可能溢出,则编译器置溢出标志OF=1,以使CPU调出溢出异常处理程序。

    4)idiv指令

            带符号整数除法指令,它只有一个操作数,即除数,而被除数则为edx:eax中的内容(64位整数),操作结果有两部分:商和余数,商送到eax,余数则送到edx。其语法如下:

    idiv <reg32>

    idiv <mem>

    举例:

    idiv ebx

    idiv dword ptr [var]

    5)and/or/xor指令

      andorxor指令分别是按位与、按位或、按位异或操作指令,用于操作数的位操作(按位与,按位或,异或),操作结果放在第一个操作数中。它们的语法如下:

    and <reg>,<reg>

    and <reg>,<mem>

    and <mem>,<reg>

    and <reg>,<con>

    and <mem>,<con>

    【或】

    or <reg>,<reg>

    or <reg>,<mem>

    or <mem>,<reg>

    or <reg>,<con>

    or <mem>,<con>

    【或】

    xor <reg>,<reg>

    xor <reg>,<mem>

    xor <mem>,<reg>

    xor <reg>,<con>

    xor <mem>,<con>

    举例:

    and eax,0xF   #将eax中的前28位全部置为0,最后4位保持不变。

    xor edx,edx   #将edx中的内容置为0。

    6)not指令

    位翻转指令,将操作数中的每一位翻转,即0→1、1→0。其语法如下:

    not <reg>

    not <mem>

    举例:

    not byte ptr [var]:将var所指示的内存地址的一字节的所有位翻转。

    7)neg指令

    取负指令。其语法如下:

    neg <reg>

    neg <mem>

    举例:

    neg eax:eax = -eax。

    8)shl/shr指令

    逻辑移位指令,shl为逻辑左移、shr为逻辑右移,第一个操作数表示被操作数,第二个操作数指示移位的位数。它们的语法如下:

    shl <reg>,<con8>

    shr <reg>,<con8>

    shl <mem>,<con8>

    shr <mem>,<con8>

    【或】

    shl <reg>,cl

    shr <reg>,cl

    shl <mem>,cl

    shr <mem>,cl

    举例:

    shl eax,1:将eax值左移1位,相当于乘以2。

    shr ebx,cl:将ebx值右移n位(n为cl中的值),相当于除以2。

    9)lea指令。

    地址传送指令,将有效地址传送到指定的寄存器。

    举例:

    lea eax, DWORD PTR _arrs[ebp]:将DWORD PTR _arrs[ebp]对应空间的内存地址值放到eax中。

    (3)控制流指令

    x86处理器维持着一个指示当前执行指令的指令指针(IP),当一条指令执行后,此指针自动指向下一条指令。IP寄存器不能直接操作,但可以用控制流指令更新。通常用标签(label)指示程序中的指令地址,在x86汇编代码中,可在任何指令前加入标签。例如:

    mov esi, [ebp+8]  
    begin: xor ecx,ecx  
    mov eax, [esi]

            这样就用beginbegin代表标签名,可以为别的名字)指示了第二条指令,控制流指令通过标签就可以实现程序指令的跳转。

    1)jmp指令

    jmp指令控制IP转移到label所指示的地址(从label中取出指令执行)。其语法如下:

    jmp <label>

    举例:

    jmp begin:跳转到begin标记的指令执行。

    2)jcondition指令

    条件转移指令,依据CPU状态字中的一系列条件状态转移。CPU状态字中包括指示最后一个算术运算结果是否为0,运算结果是否为负数等。其语法如下:

    je <label>(jump when equal)

    jne <label>(jump when not equal)

    jz <label>(jump when last result was zero)

    jg <label>(jump when greater than)

    jge <label>(jump when greater than or equal to)

    jl <label>(jump when less than)

    jle <label>(jump when less than or equal to)

    举例:

    cmp eax, ebx

    jle done:如果eax的值小于等于ebx的值,跳转到done指示的指令执行,否则执行下一条指令。

    3)cmp/test指令

    cmp指令用于比较两个操作数的值,test指令对两个操作数进行逐位与运算,这两类指令都不保存操作结果,仅根据运算结果设置CPU状态字中的条件码。其语法如下:

    cmp <reg>,<reg>

    cmp <reg>,<mem>

    cmp <mem>,<reg>

    cmp <reg>,<con>

    test <reg>,<reg>

    test <reg>,<mem>

    test <mem>,<reg>

    test <reg>,<con>

    举例:

    cmp dword ptr [var], 10:将var所指示的内存地址的4字节内容,与10比较。

    jne loop:如果不相等则跳转到loop处执行。

    test eax,eax:测试eax是否为零。

    jz xxxx:如果为零则跳转到xxxx处执行。

    4)call/ret指令

    分别用于实现子程序(过程、函数等)的调用及返回。其语法如下:

    call <label>

    ret

      call指令首先将当前执行指令地址入栈,然后无条件转移到由标签指示的指令。与其他简单的跳转指令不同,call指令保存调用之前的地址信息(当call指令结束后,返回调用之前的地址)。ret指令实现子程序的返回机制,ret指令弹出栈中保存的指令地址,然后无条件跳转到保存的指令地址执行。callret是程序(函数)调用中最关键的两条指令。

    3、条件码

            编译器通过条件码(标志位)设置指令和各类转移指令来实现程序中的选择结构语句。3.2中的控制流指令中的Jcondition条件转移指令,就是根据条件码来实现跳转。

    (1)条件码(标志位)

            除了整数寄存器,CPU还维护着一组条件码(标志位)寄存器,它们描述了最近的算术或逻辑运算操作的属性。可以检测这些寄存器来执行条件分支指令,最常用的条件码有:

    • CF:进(借)位标志,最近无符号整数加运算后的进位,或者无符号正数减法运算时有借位情况。有进(借)位CF=1;否则CF=0。例如:(unsigned)t < (unsigned)a,因为判断大小是相减,如果t是3,a是4,则CF=1。

    • ZF:零标志,最近的操作的运算结果是否为0。若结果为0,ZF=1;否则ZF=0。例如:(t=0)。

    • SF:符号标志,最近的带符号数运算结果的符号,负数时,SF=1;否则SF=0。

    • OF:溢出标志,最近带符号数运算的结果是否溢出,若溢出,OF=1;否则OF=0。

    可见,OF和SF对无符号数运算来说没有意义,而CF对带符号数运算来说没有意义。ZF标志位既可以用于有符号数,也可以用于无符号数。

    如何判断溢出:

    简单来说,正数相加变负数为溢出,负数相加变正数为溢出。但考研通常会给出十六进制的两个数来考溢出,判断方法如下(针对有符号数):

    • 数据位高位进位,符号位高位未进位,溢出(两个大正数相加,例如120和25)。

    • 数据位高位未进位,符号位高位进位,溢出(两个小负数相加,例如-128和-113)。

    • 数据位高位进位,符号位高位进位,不溢出(两个大负数相加,例如-1和-1)。

    • 数据位高位未进位,符号位未进位,不溢出(两个小正数相加,例如2和3)。

    简单一句话就是数据位高位和符号位高位进位不一样的时候会溢出。

    如果是针对无符号数,判断方法如下:

    • 数据位高位进位,进位发生,溢出(两个大正数相加,例如120和140)。

    • 数据位高位未进位,进位未发生,无溢出(两个小正数相加,例如2和3)。

            常见的算术逻辑运算指令(如addsubimulorandshlincdecnotsal等)会设置条件码。但有两类指令只设置条件码而不改变任何其他寄存器,即cmptest指令。cmp指令和sub指令的行为一样,test指令与and指令的行为类似,但它们只设置条件码,而不更新目的寄存器。(在后面 408的组成原理课程中,将进行详细的讲解,这里先记住哪些指令会设置条件码即可)

    【注意】乘法溢出后,可以跳转到“溢出自陷指令",例如imnt 0x2e 就是一条自陷指令,但是考研只需要掌握溢出,可以跳转到“溢出自陷指令”即可,不需要记自陷指令有哪些.

    三、各种变量赋值汇编

    我们针对整型、整型数组、整型指针变量的赋值(浮点与字符等价的),对应的汇编进行解析,首先我们编写下面的C代码:

    #include<stdio.h>
    int main() {
        int arr[3] = {1, 2, 3};
        int *p;
        int i = 5;
        int j = 10;
        i = arr[2];
        p = arr;
        printf("=%d\n", i);
        return 0;
    }

    然后根据上一小节生成汇编的方法,在终端窗口输入

    gcc -m32 -masm=intel -S -fverbose-asm main.c

    (苹果的M1电脑如果不可用,可以直接看课件我的汇编即可,也可以去掉-masm=intel,这样汇编和考研大题格式有差异);

    如下图所示,得到汇编文件main.s

            

    接下来我们来分析转换后的汇编代码,首先#号代表注释,我们从main标签位置开始看。我们的C代码在让CPU去运行时,其实所有的变量名都已经消失了,实际是数据从一个空间,拿到另一个空间的过程。

            栈内变量先定义在低地址还是高地址,取决于操作系统与CPU的组合,你的可能和我的不一样,因此不用去研究,没有意义(也不属于考研大纲范围)。

            我们访问所有变量的空间都是通过栈指针(esp时刻都存着栈指针,也可以称为栈顶指针)的偏移,来获取对应变量内存空间的数据的。

    .text
    .def _main; scl 2; type 32; .endef
    .section .rdata,"dr"
    LC0:
        .ascii "=%d\n\0"
    .text
    .globl _main
    _main:
        push ebp
        mov ebp, esp
        and esp, -16
        sub esp, 48
    # main.c:4: int arr[3] = {1, 2, 3};
        mov DWORD PTR [esp+24], 1
        mov DWORD PTR [esp+28], 2
        mov DWORD PTR [esp+32], 3
    # main.c:6: int i = 5;
        mov DWORD PTR [esp+44], 5
    # main.c:7: int j = 10;
        mov DWORD PTR [esp+40], 10
    # main.c:8: i = arr[2];
        mov eax, DWORD PTR [esp+32]
        mov DWORD PTR [esp+44], eax
    # main.c:9: p = arr;
        lea eax, [esp+24]
        mov DWORD PTR [esp+36], eax
    # main.c:10: printf("=%d\n", i);
        mov eax, DWORD PTR [esp+44]
        mov DWORD PTR [esp+4], eax
        mov DWORD PTR [esp], OFFSET FLAT:LC0
        call _printf
    # main.c:11: return 0;
        mov eax, 0
    # main.c:12:
        leave
        ret
    .ident "GCC: (x86_64-posix-sjlj-rev0, Built by MinGW-W64 project) 8.1.0"
    .def printf; scl 2; type 32; .endef

    大家转的汇编的偏移值可能和我这里有一些差异,这个没关系,这一节大家理解变量赋值的汇编指令及原理即可,主要掌握的指令是mov,还有lea,还有PTR,下面是PTR介绍。

    PTR(Pointer,即指针)的缩写。汇编里面PTR是规定的字(既保留字,是用来临时指定类型的)。
    (可以理解为,PTR是临时的类型转换,相当于C语言中的强制类型转换)
    如:

    • mov ax, bx:是把BX寄存器“里”的值赋予AX,由于二者都是寄存器,长度已定(word型),所以没有必要加WORD PTR

    • mov ax, word ptr [bx]:是把内存地址等于“BX寄存器的值”的地方所存放的数据,赋予AX,由于只是给出一个内存地址,不知道希望赋予AX的是byte还是word,所以可以用word ptr明确指出。如果不用(即mov ax, [bx]),则在8086中默认传递一个字(两个字节)给AX。

    Intel中的:

    • dword ptr:长字(4字节)

    • word ptr:双字(2字节)

    • byte ptr:一字节

    四、选择与循环汇编

    #include <stdio.h>
    
    int main() {
        int i = 5;
        int j = 10;
        if (i < j)
            printf("i is small\n");
        for (i = 0; i < 5; i++)
            printf("this is loop\n");
        return 0;
    }

    然后根据之前小节生成汇编的方法,在终端窗口输入gcc -m32 -masm=intel -S -fverbose-asm main.c,如下图所示,得到汇编文件main.s

    接下来我们来分析转换后的汇编代码,首先#号代表注释,我们从main标签位置开始看即可。我们的C代码在让CPU去运行时,其实所有的变量名都已经消失了,实际是数据从一个空间,拿到另一个空间的过程。

    栈内变量先定义在低地址还是高地址,取决于操作系统与CPU的组合,你的可能和我的不一样,因此不用去研究,没有意义(也不属于考研大纲范围)。我们访问所有变量的空间都是通过栈指针(esp时刻都存着栈指针,也可以称为栈顶指针)的偏移,来获取对应变量内存空间的数据的。

    .text  # 这里是文字常量区,放了我们的字符串常量,LC0和LC1分别是我们要用到的两个字符串常量的起始地址
    .def _main; scl 2; type 32; .endef
    .section .rdata, "dr"
    LC0:
        .ascii "i is small\n\0"
    LC1:
        .ascii "this is loop\n\0"
    
    .text
    .globl _main
    _main:
        push ebp
        mov ebp, esp
        and esp, -16
        sub esp, 32
    
    # main.c:5: int i = 5;
        mov DWORD PTR [esp+28], 5  # 把常量5放入栈指针(esp寄存器存的栈指针)偏移28个字节的位置,这个位置是变量i的空间
    
    # main.c:6: int j = 10;
        mov DWORD PTR [esp+24], 10  # 把常量10放入栈指针(esp寄存器存的栈指针)偏移24个字节的位置,这个位置是变量j的空间
    
    # main.c:7: if (i < j)
        mov eax, DWORD PTR [esp+28]  # 把栈指针(esp寄存器存的栈指针)偏移28个字节的位置内的值,放入eax寄存器
        cmp eax, DWORD PTR [esp+24]  # 比较eax寄存器内的值和栈指针偏移24个字节位置的值的大小,拿eax寄存器值减去DWORD PTR [esp+24],然后设置条件码
        jge L2  # 如果eax寄存器大于等于DWORD PTR [esp+24],那么跳转到L2,否则直接往下执行,jge是根据条件码ZF和SF来判断的
    
    # main.c:9: printf("i is small\n");
        mov DWORD PTR [esp], OFFSET FLAT:LC0  # 把LC0(也就是上面那个字符串)的地址,放到寄存器栈指针指向的内存位置
        call _puts
    
    L2:
    # main.c:11: for (i = 0; i < 5; i++)
        mov DWORD PTR [esp+28], 0  # 把常量0放入栈指针(esp寄存器存的栈指针)偏移28个字节的位置,这个位置是变量i的空间
        jmp L3  # 无条件跳转到L3
    
    L4:
    # main.c:13: printf("this is loop\n");
        mov DWORD PTR [esp], OFFSET FLAT:LC1  # 把LC1(也就是上面那个字符串)的地址,放到寄存器栈指针指向的内存位置
        call _puts
    
    # main.c:11: for (i = 0; i < 5; i++)
        add DWORD PTR [esp+28], 1
    
    L3:
    # main.c:11: for (i = 0; i < 5; i++)
        cmp DWORD PTR [esp+28], 4  # 比较栈指针偏移28个字节位置的值与4的大小,拿DWORD PTR [esp+28]减去4,然后设置条件码
        jle L4  # 如果小于等于4,跳转到L4
    
    # main.c:15: return 0;
        mov eax, 0
    
    # main.c:16:
        leave
        ret

    这一节大家理解选择、循环的汇编指令及原理即可,主要掌握的指令是cmpjgejmpjle等,以及了解一下字符串常量是存在文字常量区的。

    五、函数调用汇编

    这一小节我们针对C语言的指针的间接访问,函数调用,返回值,对应的汇编进行解析,首先我们编写下面C代码:

    #include <stdio.h>
    
    int add(int a, int b) {
        int ret;
        ret = a + b;
        return ret;
    }
    
    int main() {
        int a, b, ret;
        int *p;
        p = &a;
        b = *p + 2;
        ret = add(a, b);
        printf("add result=%d\n", ret);
        return 0;
    }

    然后根据之前小节生成汇编的方法,在终端窗口输入gcc -m32 -masm=intel -S -fverbose-asm main.c,如下图所示,得到汇编文件main.s

    接下来我们来分析转换后的汇编代码,首先#号代表注释,我们从main标签位置开始看即可。我们的C代码在让CPU去运行时,其实所有的变量名都已经消失了,实际是数据从一个空间,拿到另一个空间的过程。

    栈内变量先定义在低地址还是高地址,取决于操作系统与CPU的组合,你的可能和我的不一样,因此不用去研究,没有意义(也不属于考研大纲范围)。我们访问所有变量的空间都是通过栈指针(esp时刻都存着栈指针,也可以称为栈顶指针)的偏移,来获取对应变量内存空间的数据的。

    函数调用的汇编原理解析

    先必须明确的一点是,函数栈是向下生长的。所谓向下生长,是指从内存高地址向低地址的路径延伸。于是,栈就有栈底和栈顶,栈顶的地址要比栈底的低。

    对于x86体系的CPU而言,寄存器ebp可称为基指针或基址指针(base pointer),寄存器esp可称为栈指针(stack pointer)。这里需要说明的几点如下:

    1. ebp在未改变之前始终指向栈底的开始,所以ebp的用途是在堆栈中寻址(寻址的作用会在下面详细介绍)。

    2. esp会随着数据的入栈和出栈而移动,即esp始终指向栈顶。

    如下图2所示,假设函数A调用函数B,称函数A为调用者,称函数B为被调用者,则函数调用过程可以描述如下:

    1. 首先将调用者(A)的堆栈的基址(ebp)入栈,以保存之前任务的信息。

    2. 然后将调用者(A)的栈顶指针(esp)的值赋给ebp,作为新的基址(即被调用者B的栈底)。原有函数的栈顶,是新函数的栈底。

    3. 再后在这个基址(被调用者B的栈底)上开辟(一般用sub指令)相应的空间用作被调用者B的栈空间。

    4. 函数B返回后,当前栈顶的ebp恢复为调用者A的栈顶(esp),使栈顶恢复函数B被调用前的位置;然后调用者A从恢复后的栈顶弹出之前的ebp值(因为这个值在函数调用前一步被压入堆栈)。

    图2 函数调用过程

    高地址
    A函数的ebp
    A函数的栈空间
    A函数的esp
    返回地址
    push ebp后,A的ebp保存在这里,esp指向这里
    B函数的ebp
    B函数的栈空间
    B函数的esp
    低地址

    图3 main调用add的汇编原理图

    高地址
    main函数的ebp
    main函数的栈空间
    main函数的esp
    返回地址
    push ebp后,main的ebp保存在这里,esp指向这里
    add函数的ebp
    add函数的栈空间
    add函数的esp
    低地址

    .text
    .globl _add
    .def _add; scl 2; type 32; .endef
    _add:  # add函数的入口,这里阅读需要结合咱们的函数调用图(图3)
        push ebp  # 把原有函数,也就是main函数的栈基指针压栈,压栈是把ebp的值保存到内存上,位置就是esp指向的位置
        mov ebp, esp  # 把main的栈顶指针esp,作为add函数的栈基指针ebp
        sub esp, 16  # 由于add函数自身要使用栈空间,把esp减去16,表示add函数的栈空间大小是16个字节
    # main.c:5: ret = a + b;
        mov edx, DWORD PTR [ebp+8]  # 拿到实参,也就是a的值,放入edx
        mov eax, DWORD PTR [ebp+12]  # 拿到实参,也就是b的值,放入eax
        add eax, edx  # 将eax和edx相加
        mov DWORD PTR [ebp-4], eax  # 把eax,也就是ret的值,放入ebp减4个字节位置
    # main.c:6: return ret;
        mov eax, DWORD PTR [ebp-4]  # 把ebp-4位置的值(ret)放入eax
    # main.c:7:
        leave
        ret  # 函数返回,弹出压栈的指令返回地址,回到main函数执行
    .text
    .globl _add
    .def _add; scl 2; type 32; .endef
    _add:  # add函数的入口,这里阅读需要结合咱们的函数调用图(图3)
        push ebp  # 把原有函数,也就是main函数的栈基指针压栈,压栈是把ebp的值保存到内存上,位置就是esp指向的位置
        mov ebp, esp  # 把main的栈顶指针esp,作为add函数的栈基指针ebp
        sub esp, 16  # 由于add函数自身要使用栈空间,把esp减去16,表示add函数的栈空间大小是16个字节
    # main.c:5: ret = a + b;
        mov edx, DWORD PTR [ebp+8]  # 拿到实参,也就是a的值,放入edx
        mov eax, DWORD PTR [ebp+12]  # 拿到实参,也就是b的值,放入eax
        add eax, edx  # 将eax和edx相加
        mov DWORD PTR [ebp-4], eax  # 把eax,也就是ret的值,放入ebp减4个字节位置
    # main.c:6: return ret;
        mov eax, DWORD PTR [ebp-4]  # 把ebp-4位置的值(ret)放入eax
    # main.c:7:
        leave
        ret  # 函数返回,弹出压栈的指令返回地址,回到main函数执行
    .def main; scl 2; type 32; .endef
    .section .rdata, "dr"
    LC0:
        .ascii "add result=%d\12\0"
    .text
    .globl _main
    _main:
        push ebp
        mov ebp, esp
        and esp, -16
        sub esp, 32
    # main.c:12: a = 5;
        mov DWORD PTR [esp+16], 5  # 把常量5放入栈指针(esp寄存器存的栈指针)偏移16个字节的位置,这个位置是变量a的空间
    # main.c:13: p = &a;
        lea eax, [esp+16]  # 这里用的lea,和mov不一样,是将esp+16位置的地址,放到eax寄存器中
        mov DWORD PTR [esp+28], eax  # 把eax中的值放到栈指针偏移28字节位置,也就是指针变量p中
    # main.c:14: b = *p + 2;
        mov eax, DWORD PTR [esp+28]  # 把指针偏移28字节位置,也就是指针变量p的值,放到eax寄存器
        mov eax, DWORD PTR [eax]  # 把eax寄存器中的值作为地址,去内存访问到对应的数据,放入eax中
        add eax, 2  # 对eax中的值加2,结果还是在eax中
        mov DWORD PTR [esp+24], eax  # 把eax中的值放到栈指针偏移24字节位置,也就是变量b中
    # main.c:15: ret = add(a, b);
        mov eax, DWORD PTR [esp+16]  # 把栈指针偏移16字节位置,也就是变量a的值,放到eax寄存器
        mov edx, DWORD PTR [esp+24]  # 把栈指针偏移24字节位置,也就是变量b的值,放到edx寄存器
        mov DWORD PTR [esp+4], edx  # 把edx中的值(变量b),放到栈指针偏移4字节的内存位置
        mov DWORD PTR [esp], eax  # 把eax中的值(变量a),放到栈指针指向的内存位置
        call _add  # 调用add函数
        mov DWORD PTR [esp+20], eax  # 把add函数返回的值放入栈指针偏移20字节位置,也就是变量ret中
    # main.c:16: printf("add result=%d\n", ret);
        mov eax, DWORD PTR [esp+20]  # 把栈指针偏移20字节位置的值(ret)放入eax
        mov DWORD PTR [esp+4], eax  # 把eax中的值(ret)放到栈指针偏移4字节位置
        mov DWORD PTR [esp], OFFSET FLAT:LC0  # 把LC0(格式化字符串)的地址放入栈指针指向的内存位置
        call _printf
    # main.c:17: return 0;
        mov eax, 0
    # main.c:18:
        leave
        ret

    这一节大家理解指针变量的间接访问原理

    第四章:文件的操作(C语言)

    一、文件操作原理

    二、文件的打开及关闭

    三、文件读写

    四、文件位置指针偏移

    第五章:socket实现网络编程实战

    一、Socket实现网络编程原理

    二、Socket实现网络编程Windows系统

    三、Socket实现网络编程Mac系统

    四、 网络通信两台机器

    ;