计算机系统学习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的生产力,将其用于开辟下一个函数帧。
参考资料
- 刘欣,码农翻身,CPU阿甘:函数调用的秘密,地址:http://mp.weixin.qq.com/s/EoZyMgjEml_2rWu1dA85dA
水平有限,错误和不妥之处请指出,谢谢。