在x86的计算机系统中,内存空间中的栈主要用于保存函数的参数返回值返回地址本地变量等。一切的函数调用都要将不同的数据、地址压入或者弹出栈。因此,为了更好地理解函数的调用,我们需要先来看看栈是怎么工作的。

栈是一种计算机系统中的数据结构,它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。
栈的操作常用操作:

  • 进栈(PUSH):将一个数据放入栈里叫进栈(PUSH)
  • 出栈(POP):将一个数据从栈里取出叫出栈(POP)
  • 栈顶:常用寄存器ESP,ESP是栈指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
  • 栈底:常用寄存器EBP,EBP是基址指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

Pasted image 20250922230806

上面例子中栈的生长方向是从高地址到低地址的,这是因为在下文讲的栈帧中,栈就是向下生长的,因此这里也用这种形式的栈。pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问。

有了这些栈的基本知识,我们现在可以来看看在x86-32bit系统下,C语言函数是如何调用的了。

栈帧

栈帧,也就是stack frame,其本质就是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)。栈帧有栈顶和栈底之分,其中栈顶的地址最低,栈底的地址最高,SP(栈指针)就是一直指向栈顶的
在x86-32bit中,我们用 ebp 指向栈底,也就是基址指针;用 esp 指向栈顶,也就是栈指针。下面是一个栈帧的示意图:
Pasted image 20250923002401
一般来说,我们将 ebpesp 之间区域当做栈帧。并不是整个栈空间只有一个栈帧,每调用一个函数,就会生成一个新的栈。在函数调用过程中,我们将调用函数的函数称为“调用者(caller)”,将被调用的函数称为“被调用者(callee)”。在这个过程中,

  1. “调用者”需要知道在哪里获取“被调用者”返回的值;
  2. “被调用者”需要知道传入的参数在哪里,
  3. 返回的地址在哪里。同时,我们需要保证在“被调用者”返回后,ebp, esp 等寄存器的值应该和调用前一致。因此,我们需要使用栈来保存这些数据。
    https://gcc.godbolt.org

    函数调用

    我们直接通过实例来看函数是如何调用的。
    这是一个有参数但没有调用任何函数的简单函数,它被其他函数调用。
int MyFunction(int x, int y, int z)
{
int a, b, c;
a = 114;
b = 514;
c = 19;
return;
}

int main()
{
int x = 1, y = 2, z = 3;
MyFunction(1, 2, 3);
return 0;
}

对于这段代码,汇编代码大致如下:

MyFunction:
push ebp
mov ebp, esp
sub esp, 0xc
mov DWORD PTR [ebp-0x4], 0x72
mov DWORD PTR [ebp-0x8], 0x202
mov DWORD PTR [ebp-0xc], 0x13
leave
ret

main:
push ebp
mov ebp, esp
sub esp, 0xc
mov DWORD PTR [ebp-0x4], 0x1
mov DWORD PTR [ebp-0x8], 0x2
mov DWORD PTR [ebp-0xc], 0x3
push 0x3
push 0x2
push 0x1
call MyFunction
xor eax, eax
leave
ret


调用函数时,用 call 指令:
call MyFunction
等价于:

  1. push eip_next (把“call 下一条指令的地址”压栈,就是下图中的返回地址,还记得吗,eip/rip保存下一条执行的指令的地址)
  2. jmp MyFunction (跳转到函数入口)
    所以栈顶保存了“函数执行完后该回到哪里”的地址。
    接着是MyFunction开头的 push ebp ; mov ebp, esp
    光看代码可能还是不太明白,我们先来看看此时的栈是什么样的:
    Pasted image 20250923002450此时调用者做了两件事情:
  3. 将被调用函数的参数按照从右到左的顺序压入栈中。
  4. 将返回地址压入栈中。这两件事都是调用者负责的,因此压入的栈应该属于调用者的栈帧。
    我们再来看看被调用者,它也做了两件事情:
  5. 将老的(调用者的) ebp 压入栈,此时 esp 指向它。
  6. esp 的值赋给 ebp, ebp 就有了新的值,它也指向存放老 ebp 的栈空间。这时,它成了是函数 MyFunction() 栈帧的栈底。这样,我们就保存了“调用者”函数的 ebp,并且建立了一个新的栈帧。
    只要这步弄明白了,下面的操作就好理解了。在 ebp 更新后,我们先分配一块0xc字节的空间用于存放本地变量,这步用 sub 指令实现。通过使用 mov转移指令 配合 字节数 ptr [offset] 我们便可以给 a, bc 赋值了。
    Pasted image 20250923002920

    函数返回

    上面讲的都是函数的调用过程,我们现在来看看函数是如何返回的。从下面这个例子我们可以看出,和调用函数时正好相反。当函数完成自己的任务后,它会将 esp 移到 ebp 处,然后再弹出旧的 ebp 的值到 ebp。这样,ebp 就恢复到了函数调用前的状态了。
    leave ;//等同于mov ebp, esp ; pop ebp
    ret ;//等同于pop eip;
    Pasted image 20250923003918
    最后一步ret ;//等同于pop eip; 使eip的值为返回地址

call call back:
eip/rip保存下一条执行的指令的地址
返回地址存的是是调用者call被调用者的指令的下一条

于是程序回到上一个函数接着运行下去,直至整个程序运行完。

宏观一例

Pasted image 20250207201642
Pasted image 20250207201650
Pasted image 20250207185644