本文以一个简单的程序为例,通过汇编代码查看函数调用过程,涉及如何开辟栈帧,函数如何返回等
#include <iostream> using namespace std; int sum(int a, int b) { int temp = 0; temp = a + b; return temp; } int main() { int a = 10; int b = 20; int ret = sum(a, b); cout << "ret: " << ret << endl; return 0; }
代码非常简单,调用一个sum
函数计算两数之和。
下面通过vs2017调试代码,看看代码编译成汇编指令后,是如何开辟栈帧及函数调用的。
(通过vs调试时记得把编译优化选项关了)
这是main函数部分汇编代码:
int main() { 00271630 push ebp 00271631 mov ebp,esp 00271633 sub esp,0Ch int a = 10; 00271636 mov dword ptr [ebp-8],0Ah int b = 20; 0027163D mov dword ptr [ebp-4],14h int ret = sum(a, b); 00271644 mov eax,dword ptr [ebp-4] 00271647 push eax 00271648 mov ecx,dword ptr [ebp-8] 0027164B push ecx 0027164C call 00271610 00271651 add esp,8 00271654 mov dword ptr [ebp-0Ch],eax cout << "ret: " << ret << endl; //..... }
(vs可能会用变量名代替地址,比如mov dword ptr [a],0Ah
,只需要右键取消勾选显示符号名即可看到源地址)
可以看到进入main函数做的第一件事就是开辟栈帧,push ebp
将栈底指针压栈,mov ebp esp
将·esp
复制给ebp
,即将栈底指针指向栈顶,sub esp,0Ch
栈顶指针减去0Ch
,实则就是开辟栈空间。(栈向低地址增长)
随后将0Ah
赋值给dword ptr[ebp-8]
,0Ah
是10,也就是a变量,b同理。
接着调用sum函数,在调用函数之前这里进行了参数压栈操作,先将b的值给到eax
,再进行压栈,a同理。
看到这里我们可以得出一个结论,参数是在函数调用方压栈的。
下面的call指令,调用sum函数,值得注意的是,调用call函数之前会将call后面的指令地址(00271651)压栈,为了在函数返回时继续向下执行。
进入到sum函数,这是sum函数汇编代码:
int sum(int a, int b) { 00271610 push ebp 00271611 mov ebp,esp 00271613 push ecx int temp = 0; 00271614 mov dword ptr [ebp-4],0 temp = a + b; 0027161B mov eax,dword ptr [ebp+8] 0027161E add eax,dword ptr [ebp+0Ch] 00271621 mov dword ptr [ebp-4],eax return temp; 00271624 mov eax,dword ptr [ebp-4] } 00271627 mov esp,ebp 00271629 pop ebp 0027162A ret
在sum函数中,先将ebp
压栈,再让ebp
指向esp
的位置。后将ecx
压栈:
再往下mov dword ptr [ebp-4],0
将ecx
的位置赋为0。mov eax,dword ptr [ebp+8]
,ebp + 8
刚好指向a的位置,将a的值移到eax
寄存器中。add eax,dword ptr [ebp+0Ch]
连加上b的值,存放在eax
中。mov dword ptr [ebp-4],eax
将eax
的值移到ebp - 4
的位置,最后在函数返回时,mov eax,dword ptr [ebp-4]
将结果存放在eax
寄存器中。之后进行函数返回的操作,mov esp,ebp
:
pop ebp
,弹出值给到ebp
,而现在栈顶的值刚好是之前保存的ebp
的值:
ret
指令首先弹出栈顶元素,并把弹出的内容放到PC寄存器中:
PC寄存器中存放的是下一条要执行的指令的地址,一个神奇的事情是,刚刚弹出的地址(00271651)刚好是call指令的下一条指令,也就是执行完sum函数后的下一条指令。这也就解释了函数调用完是怎么接着往后执行的。
回到main函数中,add esp,8
将栈顶指针加8,回退栈顶指针,“回收”临时的函数参数。
这时回到最初的起点,mov dword ptr [ebp-0Ch],eax
将计算所得的结果给到dword ptr [ebp-0Ch]
。至此函数执行完成。可见,其实并不复杂,当然示例比较简单,但道理都一样。清楚了整个函数调用过程,或许就能更好理解为什么不要返回局部变量的地址?
int *fun() { int temp = 5; return &temp; } int main() { int *p; p = fun();//为什么不要这样做? cout << *p << endl; return 0; }
因为在函数执行完成后,栈帧已经交还给了系统,虽然这时可以得到正确结果,但这只是因为系统没有对栈帧内容清空。
如果在打印*p
之前调用一下函数sum(1, 2)
,这时结果就是不确定的了。
int main() { int *p; p = fun(); sum(1, 2); cout << *p << endl; return 0; } //输出8322272
所以不要返回局部变量地址,即便当时程序没有报错!!!
清楚了整个函数调用过程,或许就能更好理解为什么有时未初始化的数据在调试模式下显示“烫”或者“屯”,这是因为,开辟的栈空间的每一个字节默认初始化为0xCC,而0xCCCC的汉字编码就是“烫”。有时编译器还会使用0xCDCD来初始化,这时看到的就是“屯”。
清楚了整个函数调用过程,或许就能更好理解栈非法访问以及爆栈的问题(一个进程的栈空间默认为8M左右,可以修改大小,记得之前面试被问过这个问题)。
看看ChatGPT给出的解释: