栈是计算机中最重要、最基础的数据结构之一,它是一个先入后出的数据结构。在一个编译完成的二进制程序中,栈的空间总是有限的。简单说,Stack 是由于函数运行而临时占用的内存区域。在Linux上,可以使用命令“ulimit -a”查看或更改当前系统默认的栈大小。
操作栈的常用指令是PUSH和POP,即入栈和出栈。
有两个特殊的寄存器用于栈操作
- ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
- EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
1 2
| push xxx #使esp值减去4位或8位,将操作数写入esp指向的内存中。 pop xxx #从esp指向的内存中读取数据写入其它地址或寄存器,再将esp值加4或8位。
|
用一段程序展示栈在函数调用中操作过程
一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include<stdio.h> int callee(int a,int b ,int c) { return a+b+c; }
int main() { int ret; ret = callee(1,2,3); ret+= 4; return ret; }
|
1 2
| # gcc -m32 -fno-stack-protector -no-pie ./hello.c -o test # objdump -d -M intel test
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| 08049196 <main>: 8049196: f3 0f 1e fb endbr32 804919a: 55 push ebp 804919b: 89 e5 mov ebp,esp 804919d: 83 ec 10 sub esp,0x10 80491aa: 6a 03 push 0x3 80491ac: 6a 02 push 0x2 80491ae: 6a 01 push 0x1 80491b0: e8 c1 ff ff ff call 8049176 <callee> 80491b5: 83 c4 0c add esp,0xc 80491b8: 89 45 fc mov DWORD PTR [ebp-0x4],eax 80491bb: 83 45 fc 04 add DWORD PTR [ebp-0x4],0x4 80491bf: 8b 45 fc mov eax,DWORD PTR [ebp-0x4] 80491c2: c9 leave 80491c3: c3 ret 08049176 <callee>: 8049176: f3 0f 1e fb endbr32 804917a: 55 push ebp 804917b: 89 e5 mov ebp,esp 8049187: 8b 55 08 mov edx,DWORD PTR [ebp+0x8] 804918a: 8b 45 0c mov eax,DWORD PTR [ebp+0xc] 804918d: 01 c2 add edx,eax 804918f: 8b 45 10 mov eax,DWORD PTR [ebp+0x10] 8049192: 01 d0 add eax,edx 8049194: 5d pop ebp 8049195: c3 ret
|
为了方便叙述,将main函数称为父函数,callee为子函数,main的上一层函数为爷爷函数
1 2 3 4
| 08049196 <main>: 8049196: f3 0f 1e fb endbr32 804919a: 55 push ebp 804919b: 89 e5 mov ebp,esp
|
这段汇编代码将爷爷函数的ebp指针值入栈保存,后面恢复爷爷函数栈状态的时候会用到。所有的函数开头都要执行这两行指令,来保存调用函数的栈状态。执行后栈状态如下。

1 2 3 4
| 804919d: 83 ec 10 sub esp,0x10 //esp指针值减0x10 80491aa: 6a 03 push 0x3 // 参数0x3入栈,esp指针值减0x4 80491ac: 6a 02 push 0x2 //参数0x2入栈 esp指针值减0x4 80491ae: 6a 01 push 0x1 //参数0x1入栈 esp指针值减0x4
|

进入重点,call指令
CALL指令调用某个子函数时,下一条指令的地址作为返回地址被保存到栈中,等价于PUSH返回地址与JMP函数地址的指令序列。即:
1
| 80491b0: e8 c1 ff ff ff call 8049176 <callee>
|
执行后先将下一条地址 80491b5入栈,随后跳转到子函数执行。

1 2 3 4
| 08049176 <callee>: 8049176: f3 0f 1e fb endbr32 804917a: 55 push ebp 804917b: 89 e5 mov ebp,esp
|
保存父函数的esp指针

1 2 3 4 5
| 8049187: 8b 55 08 mov edx,DWORD PTR [ebp+0x8] 804918a: 8b 45 0c mov eax,DWORD PTR [ebp+0xc] 804918d: 01 c2 add edx,eax 804918f: 8b 45 10 mov eax,DWORD PTR [ebp+0x10] 8049192: 01 d0 add eax,edx
|
这段指令完成a+b+c的操作,通过ebp偏移从栈中取操作数,将运算结果放到eax寄存器中。栈无变化。
被调用函数结束时,程序将执行RET指令跳转到这个返回地址,将控制权交还给调用函数,等价于POP返回地址与JMP返回地址的指令序列。因此无论调用了多少层子函数,由于栈后入先出的特性,程序控制权最终会回到main函数。即:
1 2
| 8049194: 5d pop ebp //将栈顶值出栈到ebp中,esp+4 8049195: c3 ret //pop eip, esp+4
|

回到main函数中继续执行
1
| 80491b5: 83 c4 0c add esp,0xc // esp + 0xc
|

1 2 3
| 80491b8: 89 45 fc mov DWORD PTR [ebp-0x4],eax //[ebp-0x4] = eax 80491bb: 83 45 fc 04 add DWORD PTR [ebp-0x4],0x4 //[ebp-0x4] = [ebp-0x4] + 0x4 80491bf: 8b 45 fc mov eax,DWORD PTR [ebp-0x4] //eax = [ebp-0x4]
|
完成ret += 4的操作

leave指令将EBP寄存器的内容复制到ESP寄存器中,以释放分配给该过程的所有堆栈空间。然后,它从堆栈恢复EBP寄存器的旧值。即:
1 2
| mov esp,ebp //将ebp指向(ebp内部应当保存一个地址,所谓指向即这个地址对应的空间)的值赋给esp pop ebp
|
1 2
| 80491c2: c9 leave 80491c3: c3 ret //pop eip, esp + 4
|

至此分配给程序的栈空间全部收回,控制权回到爷爷函数的控制下。继续执行。
结束。