函数调用与栈
函数调用与栈
内存栈
在C语言中函数的调用必须借助于栈。
关于栈是什么就不再做笔记了,但需要注意的是,这里的栈与数据结构中的栈虽然原理相同但并不是一个东西。在这里,栈就是一段计算机内存,只不过这段内存满足数据后进先出的规则。
另外还需要注意的是,内存栈是向下增长的,即栈顶在下、栈底在上。之所以这样子,是因为我们将内存的高地址视为在上面、低地址在下面,而栈是由高地址向低地址推进的。如下图所示:
调用函数前
在C程序调用函数前,它会现将函数所要用到的参数值以 逆序 的方式压入栈中。当调用函数时,函数所使用的参数值就是这些被压入栈中的值。此时栈中的情况如下图所示:
这里要注意的是逆序压入,这是C语言调用的约定。比如调用函数fun(a, b, c, d),则压栈的顺序为d、c、b、a 。
这里也解释了为什么C语言调用函数后,作为参数的变量,它们的值并没有发生改变。其原因就在于它们本身并没有参与被调用函数中的运算,真正参与运算的是它们在栈中的拷贝。
在压入参数值后,将会一条call跳转指令。call指令会将调用完函数后将要返回的地址压入栈中,然后跳转到函数起始处(即将%eip寄存器的值修改为函数的起始地址)。此时栈中情况如图所示:
调用函数时
现在eip指针(即%eip寄存器)已经指向了函数的起始地址,但是函数的起始部分并不是立马开始执行那些C语言写的代码,它还有一些准备工作。
调用函数时,函数会先将%ebp寄存器的值压入栈中保存起来,然后用movl指令将栈指针寄存器%esp的值复制给%ebp。此时栈中情况如图所示:
接下来准备工作完成,开始执行真正的函数代码了。在函数中免不了会定义局部变量,这些局部变量同样会依次压入栈中。假设函数中已经定义了n个局部变量,定义的顺序是局部变量1、局部变量2、…、局部变量n,此时栈中的情况如下:
关于寄存器%ebp
%ebp寄存器被称为基址指针寄存器,在C语言的约定中它被用于访问函数的参数和局部变量。如何访问?当然是将%ebp作为基地址,然后通过偏移地址来访问栈中的各个值,所以ebp才叫作 基址 指针寄存器。
栈帧
栈帧就是一个函数所对应的栈中所有的栈变量。就以上图为例,上图中整个栈就是一个栈帧,里面的栈变量包括了参数、局部变量和要返回的地址。
函数调用将要结束时
函数调用将要结束时,函数会做以下几件事:
- 将返回值存储到%eax寄存器(这个同样是C语言的约定)
- 通过栈保存的内容来恢复到调用前的状态
- 跳转回调用该函数的程序,即跳转到之前保存在栈中的地址。
下面是返回主程序的汇编指令:
mov %ebp, %esp
popl %ebp
ret
- 先将%esp寄存器指向%ebp所指的地方,注意%ebp所指的内容一直保存着 旧的%ebp的值 。
- 再用一条popl %ebp指令即可将%ebp恢复到调用函数前的状态。这里要注意的是,当执行完栈弹出指令后,栈指针%esp就会指向栈中的下一条内容,即要返回的地址
- 最后用一条ret指令便跳回到主程序。ret指令会弹出栈顶的值,然后将该值复制到%eip寄存器。注意上一条说的,会发现现在复制到%eip的值正是要返回的地址。
函数调用结束后
调用结束后主程序还有做一些事,就是将栈中剩余的参数部分弹出,当所有参数被弹出后,%esp的值也就恢复到调用函数之前的状态了。
注
- 从前面的分析便不难理解C语言为何无法改变传入函数的参数变量了;另外C语言中也不将函数中局部变量的地址作为返回值也可以轻易理解(函数返回时,其栈帧中的局部变量已经全部被舍弃了,该地址的内容已经无法预知)。
- 注意C语言中,%ebp和%esp寄存器都会被恢复,但其它寄存器情况就不一定了,而%eax寄存器的内容一定会被刷新(因为它保存了返回值)。
- 如果完全自己写汇编的话,完全可以不用%ebp保存栈帧的基地址,但是这是C语言的约定,并且由于硬件的结构该寄存器就是专门派这个用处的,效率是更高的。
- 这里只是讨论了C语言调用函数的一般情况,并没有讨论全局变量、返回值为一个结构(如果结构较大时,很明显之前说的%eax就会出现保存不下返回值的情况)等情况。
- 在提醒一下内存中栈是向下推进的,不是向上!!