C语言函数调用栈
span
注:接下来如无特殊说明,汇编语法都是intel语法,同时讨论的是 32 位的调用流程。64 位由于使用寄存器传参比较简单类比即可,这里不再讨论
前置:寄存器
与8086相比,x86与x86-64在寄存器使用上变得自由了许多,不再会有各种奇怪的限制,八个通用寄存器真正意义上的变成了通用的寄存器。
但是编译器在生成汇编代码(机器代码)和汇编开发者编写汇编程序的时候,仍会遵守一定的规则。
eax,edx,ecx为主调函数保存寄存器(caller-saved registers),这个这个名称看中文不太好理解,看英文会好理解一点,所谓caller-saved,就是说是由主调函数保存的,换句话说,被调函数可以随便修改这几个寄存器,不需要考虑是否会影响主调函数。而主调函数
ebp,esp,esi为被调函数保存寄存器(callee-saved registers),也就是说被调函数需要考虑改变这几个寄存器会不会对主调函数产生影响,如果要改变这几个寄存器就必须先压入栈中保存下来,并在函数返回时出栈。而ebp,esp则必须被保存,否则无法恢复栈帧。
栈帧
先来解释一下每个函数栈帧的构造,栈帧的底部,也就是ebp所指向的地址,栈帧的顶部,则是esp指向的地址,所以ebp被称为帧基指针,esp则被称为栈顶指针,两者一起指定了函数栈帧的范围。
简单的说了一下构造,我们应该想想栈帧存在的意义是什么,个人的理解,有以下几点:
1.最重要的,保存函数的返回地址。即函数执行完后,应该返回到主调函数的什么地方,但是要注意的是,返回地址的栈位置,是属于主调函数的栈帧的,这是因为,返回地址是在主调函数调用被调函数之前被压入栈中的,传递的参数也是同理,事实上传参是在函数调用(call)之前进行的。以及给主调函数的寄存器备份。(ebp,esp,esi)
注1:返回地址被叫做主调函数返回地址,其实有点容易混淆,要记住主调函数返回地址代表的时返回主调函数时的地址
注2:于是我们可以发现,虽然函数可能在代码段中不连续,但是函数栈帧是以顺序、连续的方式在栈中存储的,即每个函数栈帧下面都会接着其调用函数的栈帧。
2.保存局部变量。往往被调函数需要定义局部变量,为了访问方便,局部变量总是存储在该函数的栈帧中。
于是就可以很好的理解下面这张图了。

接下来再解释一下
push ebp
mov ebp,esp
push ebp
的目的是保存主调函数的ebp,那么为什么要有ebp的存在呢,这是因为在函数执行的过程中,esp的值是不固定的,因此我们无法通过esp定位局部变量,所以就需要ebp来指定局部变量的基地址
再来看一段汇编代码


可以看到,由于我们定义了四个int类型变量,esp就与ebp有了16的差值。然后后面四个mov指令是为了赋值,可见他们都利用ebp来寻址,而ebp在这个过程中也一直没有发生改变。
mov ebp,esp
当前的esp,指向栈帧底,即存储主调函数ebp的位置,把esp赋值给ebp,就保存了栈基地址。
在有些编译器生成的汇编代码中,也会有enter
和leave
两条语句我们可以认为enter
与push ebp mov ebp,esp
等价,leave
与mov esp,ebp pop ebp
,两者分别可以做到进入函数时和退出函数时对esp和ebp的维护。
现在我们就可以理解函数调用时发生的栈操作了:
函数参数(N~1)入栈(call之前在代码段中显式写出)→主调函数返回地址入栈(在call指令执行)→ebp入栈→局部变量(1~N)入栈N
注:以上为使用C/C++的默认调用约定时的情况
术语:
函数序:
1.若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。
2.被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。
函数跋
1.一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是上面的第二步的逆向执行。
2.恢复上面的步骤一中保存的寄存器值,包含主调函数的帧基指针寄存器。
3.被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。
调用约定的问题,我准备在碰到实际题目的时候再逐个学习