从汇编角度理解 ebp&esp 寄存器、函数调用过程、函数参数传递以及堆栈平衡
本文试图从汇编以及整体上,讲解函数调用时,堆栈的变化,以及到底是如何进行参数的传递。如果你细读此文,相信一定会有所收获。
关于函数参数的传递及堆栈指针的变化,一直缺乏系统的认识和了解,各种博客也只是片面的讲解某个局部知识点,并没有全局的把握和对栈的深刻理解。本文试图从汇编以及整体上,讲解函数调用时,堆栈的变化,以及到底是如何进行参数的传递。如果你细读此文,相信一定会有所收获。
堆栈指针及相关寄存器
堆栈是操作系统中,最为常见的一种数据结构。严谨的说,堆栈是包括堆和栈两种数据结构的,但是我们通常说堆栈,其实就是指栈。在栈中,最重要的两个指针是 SP(栈指针) 和 BP(基指指针)。
- SP(Stack Pointer),栈指针,在 32 位系统中,ESP(Extended SP) 寄存器存放的就是栈指针。在 64 位系统中,表现为 RSP 寄存器。SP 永远指向系统栈最上面一个栈帧的栈顶。所以 SP 是栈顶指针
- BP(Base Pointer),基指指针,在 32 位系统中,EBP(Extended BP)寄存器存放的就是基指指针。在 64 位系统中,表现为 RBP 寄存器。BP 指向栈帧的底部,一般称之为栈底指针
上述定义相信你会在大多数博客见到,但是这些指针及寄存器的作用到底是什么呢?SP,指针即地址,存放栈顶指针,目的就是,下一次对栈操作的时候,系统可以及时找到栈的当前位置。 举个例子来说,push 压入一个操作数,会在 sp + 4 的地址的内存空间,存入一个字长的操作数。BP 的作用,会在下文讲述。
函数调用
一个函数调用另外一个函数,堆栈到底是怎么样变化的呢?SP 和 BP 是如何变更的?函数形参又是如何传递的呢?后面我们会写一个简单的 Demo 程序,加深对堆栈相关寄存器的理解。
在一个函数中,调用另外一个函数,往往有以下几个步骤
汇编指令 | 指令归属函数 | SP 变化 | 作用 |
---|---|---|---|
push arg2 | 主函数 | sp-4 | |
push arg1 | 主函数 | sp-4 | |
call function | 主函数 | sp-4 | 开始调用子程序,同时保存返回地址 |
push ebp | 子函数 | sp-4 | |
push ebp, esp | 子函数 | sp-4 | 将当前 sp 存入 bp,目的是定位函数参数 |
sub sp, #num | 子函数 | sp-num | 为子程序分配栈空间 |
… | 子函数 | … | 函数的具体实现逻辑 |
pop ebp | 子函数 | sp+4 | |
ret | 子函数 | sp+4 |
说明
push arg
在调用一个函数之前,需要把传递的参数压入栈,因此需要有。每次 push 之后,栈多了一个字长(32 位系统 --> 4 字节),因此栈顶需要往上移动 4 字节,该指令暗含sub sp, #4
call
call 指令用来调用某个函数,该指令有两个操作(1)将返回地址压入栈;(2)sp = sp - 4push ebp
push ebp, esp
这样的操作,你会在各个函数的开头见到,保存上一个函数栈的基址,并更新本函数的基址ret
,即 return,此时 sp 应该指向call
指令刚刚压入的返回地址;执行ret
其实就是将此时栈中的数据弹出,存至 eip 寄存器。eip 存放的是下一条即将执行的指令的地址。 同时 sp = sp + 4ret
指令相当于 pop eip; esp = esp + 4call
指令相当于 push eip; esp = esp - 4
看到以上代码及说明,你可能还是不能完全了解栈的变化,没关系,我们放图如下。.蓝色表示原函数的汇编指令, 绿色表示被调用函数的汇编指令和相应的栈空间。
主函数调用子函数
上图是调用函数时,栈空间的变化。从下往上看,sp 是原始函数的栈顶,这里假设原始 sp = 0xc1111 0000。在被调用函数的开头,总会有
push ebp // 将上一个函数的栈基址保存
mov ebp, esp // 本函数栈基址更新为 当前栈顶 sp
结合上图,不难看出,ebp = 0xc1111 0000 - 4 * 4。在没有调用其他函数的时候,函数内的 ebp 一般保持不变。也就说,ebp + 8 代表 arg1 的地址, ebp + 12 代表 arg2 的地址。因此 ebp 的作用之一就是找到函数的形参,当然栈中的局部变量也是通过 ebp 来定位的,子程序就是通过 ebp + 偏移量
调用主程序传递来的参数的。
子函数返回主函数
上图是函数返回上一层函数时,栈空间的变化。这次,从上往下看。因为刚开始有 push ebp
的操作,在调用函数的末尾,也需要 pop ebp
。ret
操作,会将栈中的放回地址弹出到 eip,sp 同时加 4。这样被调函数执行完毕,下一步将继续执行被调用函数之前的指令。但是此时,sp 指向原先的 arg1,并没有指向原先主函数的栈顶。如果原先栈中还有其他数据,sp 没有归位会导致主函数引用栈中数据出错。
堆栈平衡
在这种背景下,出现了堆栈平衡的概念。即,还需对 sp 进行单独操作,才能将 sp 指向原函数栈顶。以常见的 c 语言,函数有好几种调用规则。比如 cdecl 方式和 stdcall 方式。
cdecl 方式中,由主程序执行 add esp, n
指令调整 esp,达到堆栈平衡。在 stdcall 方式中,由子程序在返回时,执行 ret n
平衡堆栈。n 其实就是函数的参数所占的空间大小。
具体案例
/* stack_test.c */
#include <stdio.h>
int func(int a, int b)
{
int c = a + b;
return c;
}
int main(int argc, char const *argv[])
{
int result = func(1, 2); /* */
return 0;
}
将上面的代码编译成 32 位程序
gcc stack_test.c -m32 -o test
使用 objdump 或者 ida 查看汇编代码,可以看出,默认使用 cdecl
方式平衡堆栈。
objdump -d test -M intel
主程序
子程序
64 位系统参数传递
32 位操作系统中,正如我们在上节提到的,使用 ebp + n 的方式,即使用堆栈传递参数。而 64 位操作系统与 32 位有所不同,直接使用寄存器调用函数参数,因此也不存在堆栈平衡
将刚刚的例子编译成 64 位程序,再进行反汇编,主程序如下
子程序
64 位系统寄存器的作用,64 位程序的前六个参数通过 RDI、RSI、RDX、RCX、R8 和 R9 传递
总结
本文从汇编角度对函数的调用和传参进行了讲解,以 32 位操作系统为例,展示了堆栈在程序调用中的动态变化过程,尤其是 esp 和 ebp 寄存器。在此背景下,引入堆栈平衡的概念。最后还介绍了 64 位操作系统中,函数传参不一样的地方,即 64 位系统使用寄存器直接存取函数的参数,这样也就不存在进行平衡堆栈了。
本文给出的例子说明 C 语言默认情况下使用 Cdecl
方式进行函数的调用,在 32 位情况下,使用堆栈传递参数,具体过程总结如下
- 主程序从右向左将每个参数逐个压入栈,也就是说最后一个参数先入栈
- 子程序通过 ebp 寄存器访问参数,
ebp + n
,当 n = 8,代表第一个参数,n = 12 代表第二个参数,以此类推 - 子程序使用 ret 指令返回主程序
- 主程序指向
add esp, n
进行堆栈平衡 - 主程序通过 eax 取得子程序返回值
最后,放一张图来说明 x86 架构 32 位系统中,4GB 虚拟内存空间分布,希望大家对堆栈能够有一个宏观的认识
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)