每一次函数调用都是一个过程,为函数开辟栈空间,用于本次函数调用中临时变量的保存、现场保护。这块栈空间我们称为函数栈帧。
我们从汇编角度来分析函数调用过程中底层寄存器的使用,通过ebp(栈底指针)和esp(栈顶指针)控制的空间来体现栈帧的大小,主要问题有三个:现场保护信息、形参实参传递、函数指针回落。
文章比较长,如果想直接了解过程,可以只查看函数栈帧图。
准备工作
编译器:Visual Studio 2017
分析的例子如下:
计算两个数的加和,定义了一个子函数ADD。
#include <stdio.h>
#include <stdlib.h>
int ADD(int, int);
int ADD(int x, int y) {
int c = 0;
c = x + y;
return c;
}
int main(void) {
int x = 10;
int y = 20;
int z = 0;
z = ADD(x, y);
printf("%d", z);
system("pause");
return 0;
}
在逐语句调试状态下,查看其反汇编代码,以及内存和局部变量,具体过程如下:
① 按F11,逐语句调试程序。
② 转到反汇编:
③
主函数的反汇编代码如下:
int main(void) {
00F73B80 push ebp
00F73B81 mov ebp,esp
00F73B83 sub esp,0E4h
00F73B89 push ebx
00F73B8A push esi
00F73B8B push edi
00F73B8C lea edi,[ebp+FFFFFF1Ch]
00F73B92 mov ecx,39h
00F73B97 mov eax,0CCCCCCCCh
00F73B9C rep stos dword ptr es:[edi]
int x = 10;
00F73B9E mov dword ptr [ebp-8],0Ah
int y = 20;
00F73BA5 mov dword ptr [ebp-14h],14h
int z = 0;
00F73BAC mov dword ptr [ebp-20h],0
z = ADD(x, y);
00F73BB3 mov eax,dword ptr [ebp-14h]
00F73BB6 push eax
00F73BB7 mov ecx,dword ptr [ebp-8]
00F73BBA push ecx
00F73BBB call 00F71078
00F73BC0 add esp,8
00F73BC3 mov dword ptr [ebp-20h],eax
printf("%d", z);
00F73BC6 mov eax,dword ptr [ebp-20h]
00F73BC9 push eax
00F73BCA push 0F76BCCh
00F73BCF call 00F7137A
00F73BD4 add esp,8
system("pause");
00F73BD7 mov esi,esp
00F73BD9 push 0F76BD0h
00F73BDE call dword ptr ds:[00F7A16Ch]
00F73BE4 add esp,4
00F73BE7 cmp esi,esp
system("pause");
00F73BE9 call 00F71122
return 0;
00F73BEE xor eax,eax
}
00F73BF0 pop edi
00F73BF1 pop esi
00F73BF2 pop ebx
00F73BF3 add esp,0E4h
00F73BF9 cmp ebp,esp
00F73BFB call 00F71122
00F73C00 mov esp,ebp
00F73C02 pop ebp
00F73C03 ret
主函数的栈帧
首先分析主函数的栈帧:
① mainCRTstartup调用主函数,为main函数建立栈帧:
先查看 ebp 和 esp 地址:
所以在程序未开始之前,函数栈帧空间如下:
② 开始 按 F11 单步执行主程序:
push ebp ; ebp压栈,其作用最后为保护现场信息,esp 的值 会 减4
mov ebp,esp ; esp 赋值给 ebp 即栈顶栈底指向同一内存空间,形成空栈。
因为 为 esp 和 ebp ,所以每次操作,字节变化为双字,即4个字节。
执行完第一条语句,我们发现 esp 上升了 四个字节的单位,eip 的值 也发生了变化,但在这里并不是重点,暂不讨论。
继续执行 mov ebp, esp; esp中的值为esp指向空间的地址,赋值给ebp后,esp和ebp指向同一空间。如下图:
此时的函数栈帧如图:
③ 执行第三条往下的语句:
sub esp,0E4h ;sub 为 减操作
push ebx
push esi
push edi
执行 sub esp, 0E4h ; esp 指针上移,实际上为main函数开辟了 一段空间。
再将 ebx, esi, edi 压入栈内,esp 的指针再 上升 12 个字节。
此时函数栈帧如下:
④ 执行接下来的语句:
lea edi,[ebp-0E4h] ;将[ebp-0E4h]的有效地址赋值给edi,即未push edi 之前的
;esp 值 赋值给 edi
mov ecx,39h ;设置循环次数为 39H
mov eax,0CCCCCCCCh ;将eax的值设为 0CCCCCCCCH
rep stos dword ptr es:[edi]
;串存储指令,英文缩写 store string , 将 eax 中的 数据传送到目的地址.
;rep 为重复操作,重复次数是 ecx 的值。
;目的地址 设置了段超越 即存储到附加段 ES 中,
;dword ptr 将每次操作的对象 设置为双字。
这段代码 将为main函数开辟的空间,赋值为CC。
我们查看执行后内存的内容:
红色部分的值也是CC CC CC CC,截这张图的时候,已经执行了mov dword ptr [x],0Ah 这条代码,因为Visual Studio 2017 每次调试,内存的地址都不相同,所以就这样贴上了。
此时的函数栈帧为:
⑤ 开始为临时变量 x, y, z 初始化空间:
mov dword ptr [x],0Ah
mov dword ptr [y],14h
mov dword ptr [z],0
此时的函数栈帧如下:
上图这里没有体现出实际空间大小比例,以下是真正的内存空间:
子函数ADD的调用及其栈帧
① 子函数形参的传递:
mov eax,dword ptr [y] ;将 y 的值 赋给 eax
push eax ;将 eax 压栈
mov ecx,dword ptr [x] ;将 x 的值 赋给 ecx
push ecx ;将 ecx 压栈
执行结束后,寄存器的值如下:
函数栈帧如下:
② 子函数ADD 的调用:
call ADD (013E1078h) ;调用子函数,将 eip 指向 ADD 函数的 第一条代码
add esp,8 ;esp 上升 八个字节,用来存放call指令的下一条指令eip
单步执行后,eip 指向ADD子函数的内部:
应当注意的是, 这里保存eip的值,是为了执行完子函数后机器能正确的执行接下来的指令。
③ 子函数内部汇编代码:
013E16D0 push ebp ;013E16D0 为 eip 的值
013E16D1 mov ebp,esp
013E16D3 sub esp,0CCh
013E16D9 push ebx
013E16DA push esi
013E16DB push edi
013E16DC lea edi,[ebp-0CCh]
013E16E2 mov ecx,33h
013E16E7 mov eax,0CCCCCCCCh
013E16EC rep stos dword ptr es:[edi]
上述过程,和主函数栈帧的建立类似,不再赘述,不过此时push ebp,保护的是main函数 栈帧的 ebp。
④ 继续执行:
mov dword ptr [ebp-8],0
此时的函数栈帧如下:
接下来的代码:
mov eax,dword ptr [ebp+8] ;[ebp+8]的值为 y = 20, 赋值给 eax
add eax,dword ptr [ebp+0Ch];[ebp+0Ch]的值为 x = 10, 相加之后的结果在eax中
mov dword ptr [ebp-8],eax ;[ebp-8]为c, 将结果赋值给 c
mov eax,dword ptr [ebp-8] ;将 c 的值 重新赋值给 eax
;这句话是return c;的反汇编,通过eax将结果传递给主函数
结果如下:
⑤ 指针回落
pop edi
pop esi
pop ebx ;依次弹出值给edi, esi, ebx
mov esp,ebp ;esp 下落,即回收了ADD的空间
pop ebp ;ebp 指向main函数的栈底指针
ret ;ret 会将eip 的值修改为 主函数中 call 指令的 下一条指令的地址
此时的函数栈帧图如下:
这里的空间回收:表现为 esp 和 ebp 的指向变化,其空间里的值还在,只不过被认为垃圾值,其使用权归还给系统。
回到主函数
mov dword ptr [ebp-20h],eax ;将子函数中计算得到的结果赋给 z
接下来的代码是调用printf 函数,和system(“pause”)的汇编代码,其创建堆栈的过程一样。
最后是 return 0;从主函数返回到系统。