栈是计算机中最重要、最基础的数据结构之一,它是一个先入后出的数据结构。在一个编译完成的二进制程序中,栈的空间总是有限的。简单说,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指针值入栈保存,后面恢复爷爷函数栈状态的时候会用到。所有的函数开头都要执行这两行指令,来保存调用函数的栈状态。执行后栈状态如下。

image-20220923225820423

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

image-20220923231400332

进入重点,call指令

CALL指令调用某个子函数时,下一条指令的地址作为返回地址被保存到栈中,等价于PUSH返回地址与JMP函数地址的指令序列。即:

1
2
push eip
jmp func_addr
1
80491b0:	e8 c1 ff ff ff       	call   8049176 <callee>

执行后先将下一条地址 80491b5入栈,随后跳转到子函数执行。

image-20220923232023393

1
2
3
4
08049176 <callee>:
8049176: f3 0f 1e fb endbr32
804917a: 55 push ebp
804917b: 89 e5 mov ebp,esp

保存父函数的esp指针

image-20220923232710201

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
pop eip
jmp return_addr
1
2
8049194:	5d                   	pop    ebp	//将栈顶值出栈到ebp中,esp+4
8049195: c3 ret //pop eip, esp+4

image-20220923233741531

回到main函数中继续执行

1
80491b5:	83 c4 0c             	add    esp,0xc	// esp + 0xc

image-20220923234132965

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的操作

image-20220923234721970

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

image-20220923235650993

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

结束。