计算机系统学习2:程序的机器级表示之函数调用

一.预备知识

1.栈


图1 栈

push A// A压入栈
pop A // A弹出栈

2.寄存器


图2 寄存器

EAX:累加器 Accumulator
EBX:地址寄存器 Base Register
ECX:计数器 Count Register
EDX:数据寄存器 Data Register
ESI:源变址寄存器 Source Index
EDI:目的变址寄存器 Destination Index
ESP:栈指针寄存器 Stack Pointer
EBP:基址指针寄存器 Base Pointer

3.汇编


图3 汇编指令

4.指针


图4 指针

二.函数调用分析

1.程序

//@C
int demo(){
    int x = 10;
    int y = 20;
    int sum = add(&x,  &y);
    printf(“the sum is %d\n”,sum);//假设这条指令的地址为100
    return sum;
}

int add(int *xp, int *yp){
    int x = *xp;
    int y = *yp;
    return x+y;
}

//@汇编
demo:
1    pushl    %ebp
2    movl     %esp     %ebp
3    subl     %24 esp
4    movl     $10     -4(%ebp)
5    movl     $20     -8(%ebp)
6    leal     -8(%ebp)   %eax
7    movl     %eax      4(%esp)
8    leal     -4(%ebp)  %eax
9    movl     %eax      esp
10   call     add
11   打印结果(略)

add:
1    pushl    %ebp
2    movl     %esp %ebp
3    pushl    %ebx
4    movl     8(%ebp)   %edx
5    movl     12(%ebp)  %ecx
6    movl     (%edx)    %ebx
7    movl     (%ecx)    %eax
8    add      %ebx      %eax
9    popl     %ebx
10   popl     %ebp
11   ret

2.程序调用分析

计算机的程序平时储存在硬盘中,当CPU要执行程序的时候,会将程序提到内存中,程序的每条指令放在操作系统在内存中分配的一块区域,之后,操作系统会告诉CPU程序的入口,也就是程序的第一条指令的存放地址,CPU可以通过这个地址在内存中访问第一条指令,开始执行程序。

x86体系的计算机,CPU执行每个函数调用的时候都会创建一个所谓的“帧”,如图5所示。

图5 函数帧

所谓的函数帧就是内存中的一段连续空间,每当调用一个程序的时候,会将程序或者数据放入这个函数帧。示例:

void 函数A(){
    ....
    函数B();
    ....
}

int 函数B(){
    ......
    函数C();

    ......
}

int 函数C(){
    ......
    函数D();  <- -  程序运行到了这一行
    ......
}

此代码的函数帧结构如图6所示:

图6 函数帧示例

需要注意的是,函数帧在内存中排列起来之后就像是一个先进后出的栈一样,其栈底在下,栈顶在上,地址从上向下,也就是说从高地址向低地址生长。


基址指针寄存器EBP会一直指向当前函数的函数帧的首地址(开始地址),而栈指针寄存器ESP则随着指令进行,直到指向函数帧的最后的地址。如图7所示:

图7 EBP,ESP功能示例

当CPU执行函数int demo()时,机器操作是:

demo:
1    pushl    %ebp
2    movl     %esp     %ebp
3    subl     %24 esp
4    movl     $10     -4(%ebp)
5    movl     $20     -8(%ebp)
6    leal     -8(%ebp)   %eax
7    movl     %eax      4(%esp)
8    leal     -4(%ebp)  %eax
9    movl     %eax      esp
10   call     add
11   打印结果(略)

现在执行:

1    pushl    %ebp//将寄存器ebp中的值压到栈中去
2    movl     %esp     %ebp //将esp的值赋给ebp

此时,新的函数栈生成,其第一行的地址为800,内容(值)为1000,这个值时上个函数帧的首地址。由于新的函数栈只有一行数据,因此esp和ebp指向同一地址800。

图8 执行

在这里约定每次操作的是4个字节,即32位。因此保存数据1000的空间长度为4,因此ebp和esp均指向了800。

接着执行:

3    subl     %24 esp// esp的值减去24

此时,函数帧继续生长(开辟空间),esp的地址变为了776,如图9所示:

图9 执行

接着执行:

4    movl     $10     -4(%ebp)//将10存入(ebp-4)的地址(796)
5    movl     $20     -8(%ebp)//将20存入(ebp-8)的地址(792)

如图10所示:


图10 执行

此时,将数据10,20存入分配的函数帧空间中,要注意,此时在机器中不再保存变量x,y,而是以值与地址的形式存在,这其实是在为变量分配地址。

接着执行:

6    leal     -8(%ebp)   %eax   //将(ebp-8)的值792(地址)存入eax中
7    movl     %eax      4(%esp) //将eax中的值792存入esp+4指向的地址(780)中
8    leal     -4(%ebp)  %eax    //将(ebp-4)的值796(地址)存入eax中
9    movl     %eax      esp     //将eax中的值796存入esp指向的地址(776)中

如图11所示

图11 执行

当CPU调用函数int add(int *xp, int *yp)时,机器操作是:

10   call add

此时我们要调用另外一个函数,但是我们还需要在执行完这个程序之后,返回到demo函数,因此我们需要记录下调用结束之后返回的地址,即调用函数的下一条命令printf(“the sum is %d\n”,sum);//假设这条指令的地址为100,如图12所示:

图12 执行

而我们的ebp寄存器只有一个,因此我们需要还需记录下ebp寄存器的值。因此把ebp的值要压入栈中,此时,esp要自动减4,同时将esp的值赋给ebp,即让ebp指向esp的地址,此时,ebp指向的地址为776-4=772.即此时,CPU执行:

1    pushl    %ebp
2    movl     %esp %ebp

为了防止使用ebx中的数据,将ebx压入栈,即:

3    pushl    %ebx

然后执行:

4    movl     8(%ebp)   %edx //将ebp+8地址中的值(776)取出放入edx中
5    movl     12(%ebp)  %ecx //将ebp+12地址中的值(780)取出放入ecx中
6    movl     (%edx)    %ebx //edx中存的地址(796)的数据取出放入ebx中 (ebx)=10
7    movl     (%ecx)    %eax //ecx中存的地址(780)的数据取出放入eax中 (eax)=20

这段代码的4,5行将x,y的地址&x,&y取出放入了寄存器中,然后又将地址中的数据取出来放入寄存器中,注意,这里做了2步操作,但是最终目标是取出地址中的数据,此时,ebx中存了数据10,eax中为20,
然后执行:

8    add      %ebx      %eax //将ebx的值与eax中的值相加,最后存入eax(累加器)中。

最后执行:

9    popl     %ebx
10   popl     %ebp

将ebx和ebp弹出栈,此时,ebp指向压入堆栈时的地址,即800.

最后执行:

11   ret

此时,函数调用结束,返回主函数的下一条命令的地址,即打印输出命令的地址100。如图所示:

图13 执行

由此可见,栈在函数调用的时候,起到了至关重要的作用,其原因在于,CPU中栈指针寄存器EBP只有一个,而其指向函数帧开始的作用无可替代,因此,将其压入栈的操作能够暂时解放EBP的生产力,将其用于开辟下一个函数帧。

参考资料

  1. 刘欣,码农翻身,CPU阿甘:函数调用的秘密,地址:http://mp.weixin.qq.com/s/EoZyMgjEml_2rWu1dA85dA

水平有限,错误和不妥之处请指出,谢谢。


全部评论

相关推荐

点赞 收藏 评论
分享
牛客网
牛客企业服务