目录
一、什么是函数栈帧?
1.1 - 栈
1.2 - 栈内存
1.3 - 栈帧
1.4 - 相关寄存器
1.5 - 相关汇编指令
二、函数栈帧的创建和销毁
2.1 - main 函数栈帧的创建
2.2 - main 函数局部变量的创建
2.3 - 函数传参和调用
2.3.1 - 进入 add 函数
2.3.2 - add 函数栈帧的销毁
2.4 - 接受函数的返回值
编辑
三、总结
栈(stack)是只允许在栈顶进行插入(入栈,push)和删除(出栈,pop)的操作受限制的线性表。栈的特点是后进先出(Last In First Out,简称 LIFO)。
栈内存(stack memory)是一个由系统自动分配和回收的内存空间。关于栈内存的增长方向有两种:一种是向上增长,即低地址向高地址增长;另一种是向下增长,即高地址向低地址增长。在目前常见的体系结构和编译系统中,栈内存大多是向下增长的。
在 C 语言中,每个函数的每次调用,都有它自己独立的一个栈帧(stack frame),栈帧中保存了该函数的返回地址和局部变量。寄存器 ebp 指向当前的栈帧的底部,寄存器 esp 指向当前的栈帧的顶部。
寄存器 | 作用 |
---|---|
ebp | extended base pointer,用于存放栈帧底部的指针 |
esp | extended stack ponter,用于存放栈帧顶部的指针 |
eax | 累加器(acculator),它是很多加法乘法指令的缺省寄存器 |
ebx | 基地址(base)寄存器,在内存寻址时存放基地址 |
ecx | 计数器(counter),是重复(REP)前缀指令和 LOOP 指令的内定计数器 |
edx | 总是被用来放整数除法产生的余数 |
esi | 源索引(source index)寄存器 |
edi | 目标索引(destination index)寄存器 |
汇编指令 | 作用 |
---|---|
push | 数据入栈,同时 esp 栈顶寄存器也要发生改变 |
pop | 数据出栈,同时 esp 栈帧寄存器也要发生改变 |
move | move A, B,即将数据 B 移到 A 中 |
call | 函数调用:1. 压入返回地址 2. 转入目标函数 |
add | 加法 |
sub | 减法 |
rep | 重复 |
lea | 加载有效地址(load effective address) |
演示代码:
#include
int add(int a, int b)
{
int c = 0;
c = a + b;
return c;
}
int main()
{
int a = 10;
int b = 20;
int ret = 0;
ret = add(a, b);
printf("ret = %d\n", ret);
return 0;
}
在 VS2019 x86 平台上进行调试,首先点击"调试" --> "窗口" --> "调用堆栈" ,然后右击勾选"显示外部代码",我们可以观察到:
这说明在 main 函数调用之前,是由 invoke_main 函数来调用 main 函数的。当然在 invoke_main 函数之前还有函数调用。
调试到 main 函数开始执行的第一行,右击鼠标转到反汇编。
int main()
{
// main 函数栈帧的创建
00691820 push ebp
00691821 mov ebp,esp
00691823 sub esp,0E4h
00691829 push ebx
0069182A push esi
0069182B push edi
0069182C lea edi,[ebp-24h]
0069182F mov ecx,9
00691834 mov eax,0CCCCCCCCh
00691839 rep stos dword ptr es:[edi]
// main 函数局部变量的创建
int a = 10;
0069183B mov dword ptr [ebp-8],0Ah
int b = 20;
00691842 mov dword ptr [ebp-14h],14h
int ret = 0;
00691849 mov dword ptr [ebp-20h],0
// 函数传参和调用
ret = add(a, b);
00691850 mov eax,dword ptr [ebp-14h]
00691853 push eax
00691854 mov ecx,dword ptr [ebp-8]
00691857 push ecx
00691858 call 00691023
// 接受函数的返回值
0069185D add esp,8
00691860 mov dword ptr [ebp-20h],eax
// 函数传参和调用
printf("ret = %d\n", ret);
00691863 mov eax,dword ptr [ebp-20h]
00691866 push eax
00691867 push 697B30h
0069186C call 006910D2
00691871 add esp,8
return 0;
00691874 xor eax,eax
}
00431820 push ebp
00431821 mov ebp,esp
00431823 sub esp,0E4h
00431829 push ebx
0043182A push esi
0043182B push edi
0043182C lea edi,[ebp-24h]
0043182F mov ecx,9
00431834 mov eax,0CCCCCCCCh
00431839 rep stos dword ptr es:[edi]
1. ebp 和 esp 的初始值:
2.push ebp,即把 ebp 的值压入栈中,同时让 esp - 4:
3. mov ebp, esp,即把 esp 赋值给 ebp,这相当于产生了 main 函数栈帧的 ebp:
4.sub esp, 0E4h,即让 esp - 0xe4,此时的 esp 就是 main 函数栈帧的 esp:
5.push ebx,即把 ebx 的值压入栈中,同时让 esp - 4。
6.push esi,即把 esi 的值压入栈中,同时让 esp - 4。
7.push edi,即把 edi 的值压入栈中,同时让 esp - 4。
上面 3 条指令把 3 个寄存器的值都保存在栈区,是因为这 3 个寄存器的值在函数执行过程中可能会被修改,因此先保存寄存器中原来的值,以便在退出函数时恢复。
8.lea edi, [ebp-24h],即将 ebp - 0x24 赋值给 edi:
9.mov ecx, 9,即将 9 赋值给 ecx:
10.mov eax, 0CCCCCCCCh,即将 0xCCCCCCCC 赋值给 eax:
11. rep stos dword ptr es:[edi],这句汇编代码再加上上面 3 句汇编代码等价于下面的伪代码:
edi = ebp - 0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx == 0; --ecx, edi += 4)
{
*(int*)edi = eax;
}
int a = 10;
0043183B mov dword ptr [ebp-8],0Ah
int b = 20;
00431842 mov dword ptr [ebp-14h],14h
int ret = 0;
00431849 mov dword ptr [ebp-20h],0
1.mov dword ptr [ebp-8],0Ah,即将 0xa 存储到 ebp - 8 的地址处。
2.mov dword ptr [ebp-14h],14h,即将 0x14 存储到 ebp - 0x14 的地址处。
3.mov dword ptr [ebp-20h],0,即将 0 存储到 exp - 0x20 的地址处。
这表明局部变量是在其所在的函数的栈帧空间中创建的。
ret = add(a, b);
00691850 mov eax,dword ptr [ebp-14h]
00691853 push eax
00691854 mov ecx,dword ptr [ebp-8]
00691857 push ecx
00691858 call 00691023
1.mov eax,dword ptr [ebp-14h],即传递 b,将 ebp - 0x14 地址处存放的 20 放到 eax 寄存器中。
2.push eax,即将 eax 的值压入栈中,同时让 esp - 4。
3.mov ecx,dword ptr [ebp-8],即传递 a,将 ebp - 8 地址处存放的 10 放到 ecx 寄存器中。
4.push ecx,即将 ecx 的值压入栈中,同时让 esp - 4。
5.call 00691023,即执行调用 add 函数的操作,但是在调用 add 函数之前,会把 call 指令的下一条指令的地址,即 0069185D,压入栈中,同时让 esp - 4:
按 f11,再按一次 f11 就跳转到 add 函数。
int add(int a, int b)
{
// add 函数栈帧的创建
006917F0 push ebp
006917F1 mov ebp,esp
006917F3 sub esp,0CCh
006917F9 push ebx
006917FA push esi
006917FB push edi
// 局部变量的创建
int c = 0;
006917FC mov dword ptr [ebp-8],0
// 计算 a + b,并把结果保存到 c 中
c = a + b;
00691803 mov eax,dword ptr [ebp+8]
00691806 add eax,dword ptr [ebp+0Ch]
00691809 mov dword ptr [ebp-8],eax
return c;
0069180C mov eax,dword ptr [ebp-8]
}
1.add 函数栈帧的创建和局部变量的创建与 main 函数相似,只是在栈帧空间的大小上有些差异。
2.mov eax,dword ptr [ebp+8],即把 ebp + 8 地址处的值存储到 eax 寄存器中。
3.add eax,dword ptr [ebp+0Ch],即把 ebp + 0xc 地址处的值加到 eax 寄存器中。
4.mov dword ptr [ebp-8],eax,即把 eax 中的结果保存到 ebp - 8 的地址处,即放到变量 c 中。
5.mov eax,dword ptr [ebp-8],即把 ebp - 8 地址处的值,即变量 c 中的值,放到 eax 中,通过 eax 带回计算的结果,做函数的返回值。
0069180F pop edi
00691810 pop esi
00691811 pop ebx
00691812 mov esp,ebp
00691814 pop ebp
00691815 ret
1.pop edi,即在栈顶弹出一个值,存放到 edi 中,同时让 esp + 4。
2.pop esi,即在栈顶弹出一个值,存放到 esi 中,同时让 esp + 4。
3.pop ebx,即在栈顶弹出一个值,存放到 ebx 中,同时让 esp + 4。
4.mov esp,ebp,即将 ebp 赋值给 esp,相当于回收了 add 函数的栈帧空间。
5.pop ebp,即将弹出栈顶的值存放到 ebp 中,这个值恰好就是main函数的ebp,同时让 eps + 4,此时就恢复了 main 函数的栈帧维护。
6.ret,首先让 esp + 4,然后跳转到 call 指令下一条指令的地址处。
0069185D add esp,8 // 让 esp + 8
00691860 mov dword ptr [ebp-20h],eax // 将 eax 中的值存储到 ebp - 0x20 的地址处,即存储到 main 函数中的 ret 变量中
当我们理解了函数栈帧的创建和销毁,以下的问题就能够很好地理解了:
局部变量是如何创建的?
局部变量不初始化时,内容为什么是随机的?
函数调用时参数是如何传递的?传参的顺序又是怎样的?
函数的形参和实参分别是怎样实例化的?
函数的返回值是如何带回的?