关于函数参数的传递及堆栈指针的变化,一直缺乏系统的认识和了解,各种博客也只是片面的讲解某个局部知识点,并没有全局的把握和对栈的深刻理解。本文试图从汇编以及整体上,讲解函数调用时,堆栈的变化,以及到底是如何进行参数的传递。如果你细读此文,相信一定会有所收获。


堆栈指针及相关寄存器

堆栈是操作系统中,最为常见的一种数据结构。严谨的说,堆栈是包括堆和栈两种数据结构的,但是我们通常说堆栈,其实就是指栈。在栈中,最重要的两个指针是 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 - 4
  • push ebp push ebp, esp 这样的操作,你会在各个函数的开头见到,保存上一个函数栈的基址,并更新本函数的基址
  • ret,即 return,此时 sp 应该指向 call 指令刚刚压入的返回地址;执行 ret 其实就是将此时栈中的数据弹出,存至 eip 寄存器。eip 存放的是下一条即将执行的指令的地址。 同时 sp = sp + 4
  • ret 指令相当于 pop eip; esp = esp + 4
  • call 指令相当于 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 ebpret 操作,会将栈中的放回地址弹出到 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 位情况下,使用堆栈传递参数,具体过程总结如下

  1. 主程序从右向左将每个参数逐个压入栈,也就是说最后一个参数先入栈
  2. 子程序通过 ebp 寄存器访问参数,ebp + n,当 n = 8,代表第一个参数,n = 12 代表第二个参数,以此类推
  3. 子程序使用 ret 指令返回主程序
  4. 主程序指向 add esp, n 进行堆栈平衡
  5. 主程序通过 eax 取得子程序返回值

最后,放一张图来说明 x86 架构 32 位系统中,4GB 虚拟内存空间分布,希望大家对堆栈能够有一个宏观的认识

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐