栈和函数调用(详细版)
栈是计算机中最重要、最基础的数据结构之一,它是一个先入后出的数据结构。在一个编译完成的二进制程序中,栈的空间总是有限的。简单说,Stack 是由于函数运行而临时占用的内存区域。在Linux上,可以使用命令“ulimit -a”查看或更改当前系统默认的栈大小。
操作栈的常用指令是PUSH和POP,即入栈和出栈。
有两个特殊的寄存器用于栈操作
- ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
- EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
1 | push xxx #使esp值减去4位或8位,将操作数写入esp指向的内存中。 |
用一段程序展示栈在函数调用中操作过程
一个例子
1 |
|
1 | # gcc -m32 -fno-stack-protector -no-pie ./hello.c -o test |
1 | 08049196 <main>: |
为了方便叙述,将main函数称为父函数,callee为子函数,main的上一层函数为爷爷函数
1 | 08049196 <main>: |
这段汇编代码将爷爷函数的ebp指针值入栈保存,后面恢复爷爷函数栈状态的时候会用到。所有的函数开头都要执行这两行指令,来保存调用函数的栈状态。执行后栈状态如下。
1 | 804919d: 83 ec 10 sub esp,0x10 //esp指针值减0x10 |
进入重点,call指令
CALL指令调用某个子函数时,下一条指令的地址作为返回地址被保存到栈中,等价于PUSH返回地址与JMP函数地址的指令序列。即:
1 | push eip |
1 | 80491b0: e8 c1 ff ff ff call 8049176 <callee> |
执行后先将下一条地址 80491b5
入栈,随后跳转到子函数执行。
1 | 08049176 <callee>: |
保存父函数的esp指针
1 | 8049187: 8b 55 08 mov edx,DWORD PTR [ebp+0x8] |
这段指令完成a+b+c
的操作,通过ebp偏移从栈中取操作数,将运算结果放到eax寄存器中。栈无变化。
被调用函数结束时,程序将执行RET指令跳转到这个返回地址,将控制权交还给调用函数,等价于POP返回地址与JMP返回地址的指令序列。因此无论调用了多少层子函数,由于栈后入先出的特性,程序控制权最终会回到main函数。即:
1 | pop eip |
1 | 8049194: 5d pop ebp //将栈顶值出栈到ebp中,esp+4 |
回到main函数中继续执行
1 | 80491b5: 83 c4 0c add esp,0xc // esp + 0xc |
1 | 80491b8: 89 45 fc mov DWORD PTR [ebp-0x4],eax //[ebp-0x4] = eax |
完成ret += 4
的操作
leave指令将EBP寄存器的内容复制到ESP寄存器中,以释放分配给该过程的所有堆栈空间。然后,它从堆栈恢复EBP寄存器的旧值。即:
1 | mov esp,ebp //将ebp指向(ebp内部应当保存一个地址,所谓指向即这个地址对应的空间)的值赋给esp |
1 | 80491c2: c9 leave |
至此分配给程序的栈空间全部收回,控制权回到爷爷函数的控制下。继续执行。
结束。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Lucky Future的技术栈!
评论
WalineDisqusjs