现在,通过上一篇我们已经了解如何在栈上传参数,那么我们本篇来进一步了解
- 寄存器在在各个函数栈中如何保存各个栈桢的数据状态。
- 函数递归的机制。
- 参数的传递具体约定。
IA32平台的寄存器的使用约定
C/C++编译器遵守如下约定的规则。
- 当该函数是处于调用者角色时,如果该函数执行过程中产生的临时数据会已存储在%eax,%edx,%ecx这些寄存器中,那么在其执行call指令之前会将这些寄存器的数据写入其栈帧内指定的内存区域,这个过程叫做调用者保存约定(英文原名称:Caller Save)。
- 当该函数是处于被调用者角色时,那么在其使用这些寄存器%ebx,%esp,%edi之前,那么该函数会保存这些寄存器中的信息到其栈帧指定的内存区域,这个过程叫被调用者保存约定。
- %eax总会被用作返回整数值。
- %esp,%ebp总被分别用着指向当前栈帧的顶部和底部,主要用于在当前函数推出时,将他们还原为原始值
- 对于全栈角度来说,%esp也叫做整个栈的栈指针(Stack Pointer)
- 对于某个函数的栈帧来说,%ebp也叫做该函数的帧指针(Frame Pointer)
示例导入
下面我们用一个计算整数的阶乘的示例来展开我们本篇的主题。为了这个示例引用自University of Washinton的计算机科学公开课section 5的示例。源公开课提供的汇编代码和我反编译的有些出入,所以这里的汇编示例以我本文的为准。
- C源代码
void fact_helper(int x,int *res){
if(x<=1){
return;
}else{
int z=*res*x;
*res=z;
fact_helper(x-1,res);
}
}
int sfact(int x){
int val=1;
fact_helper(x,&val);
return val;
}
int main(void){
sfact(4);
return 0;
}
- sfact函数的汇编版本
sfact:
pushl %ebp
movl %esp, %ebp
subl $40, %esp
movl $1, -12(%ebp)
leal -12(%ebp), %eax
movl %eax, 4(%esp)
movl 8(%ebp), %eax
movl %eax, (%esp)
call fact_helper
movl -12(%ebp), %eax
leave
ret
.size sfact, .-sfact
.globl main
.type main, @function
- fact_helper函数的汇编版本
fact_helper:
pushl %ebp
movl %esp, %ebp
subl $40, %esp
cmpl $1, 8(%ebp)
jg .L2
jmp .L1
.L2:
movl 12(%ebp), %eax
movl (%eax), %eax
imull 8(%ebp), %eax
movl %eax, -12(%ebp)
movl 12(%ebp), %eax
movl -12(%ebp), %edx
movl %edx, (%eax)
movl 8(%ebp), %eax
leal -1(%eax), %edx
movl 12(%ebp), %eax
movl %eax, 4(%esp)
movl %edx, (%esp)
call fact_helper
.L1:
leave
ret
.size fact_helper, .-fact_helper
.globl sfact
.type sfact, @function
Ok,代码的细节没什么好说的,我们进入正题,当x=4的时,sfact函数的内部此时往被调用函数传入的参数如下注意的是第二个参数传入的是sfact局部变量val的int指针,也就是说facter_helper内部对参数val指针所指向的内存位置中的数值的修改,都能直接反映到sfact函数当中。
当x=4时,从sfact函数调用fact_helper(4,&val)之后,我们的进入第一个fact_helper的栈帧,当执行到int z=*res*x这条语句的栈帧状态如下图所示,
- movl 12(%ebp),%eax :fact_helper通过12(%ebp)移位寻址res参数传递的指针变量即0xf2e0
- movl (%eax),%eax:并且解引res的指针获取变量值1,缓存到eax寄存器中
- imull 8(%ebp),%eax:通过移位寻址获取参数x=3,并且跟当前eax寄存器中的变量值作乘法运算。运算结果覆盖eax寄存器原有的内容。
- movl %eax,-12(%ebp):执行到这条指令的时候,其实等价于C语句将计算的结果保存到临时变量z,而这些运算操作都是在调用被调用函数之前,当前函数者需要保存刚才的数据状态,从程序栈的角度来看,计算机结果本地变量区域,这就是所谓的“调用者保存约定”做样做的目的有二
(一):以便后续调用链中的被调用函数使用,当然需要以参数形式依次传递该临时变量。
(二):以便被调用函数返回后,调用者函数继续使用原有的临时变量执行其他计算。
当执行C代码执行到*res=z;语句,等价的语句见图中的红色的汇编指令,我这里补充一点,在程序栈中传递中只要传递对应指针类型的变量,调用链中末端的被调用函数凭着该指针变量指向的内存位置,可以修改调用链前端任何函数栈桢的数据状态,这不是函数栈的原生特性,但却是指针的强大之处。
在下图中,表明fact_helper的栈帧已经通过mov %edx,%(eax)指令修改了sfact栈中位于0xf2e0内置的数据(由1改成3)。
下图中表明fact_helper在递归调用它自身的副本之前,准备参数的一些操作,从目前函数内部修改持有的参数x传递给他的被调用fact_helper副本,从应函数栈的角度来看就是将位于8(%ebp)位置的参数加载到eax寄存器,然后这里的重点就是leal -(%eax),%edx。
lea不是用于计算源操作数的寻址表达式,然后再赋值给目标操作数(通常是一个寄存器)的吗?谁说lea指令的源操作数不能是常量的!哈哈~这里其实就是将eax寄存器中的数值3减1,然后赋值给寄存器%edx,此时%edx寄存器的值是2,稍后%edx寄存器的数值也会在fact_helper递归调用之前被写入目前帧中的参数区域,记住这是“调用者保存约定”。
修改后的参数分别被写入到当前帧中参数区域,分别位于栈顶位置附近的%esp和4(%esp)的地方,跟着就会递归调用fact_helper副本,如下图所示。这里穿插个话题,前面几遍文章,我好像没明确说过为什么栈帧中的参数排列顺序和C/C++代码函数定义的参数表的顺序是相反的?
首先,理解这个问题其实很简单?我们知道栈的成长方向是朝着低地址方向增长的。后面首先写入栈的参数区域的参数所在的内存地址比后面跟着写入栈的其他参数的内存地址都要高,并且我们当前活动的栈帧向它的前一栈获取参数是依次通过“N(%ebp)”即%ebp+N,N是参数类型内存对齐后的大小依次递增N的2倍去向前一桢的高地址方向查找参数,比如我们本例子的参数都是int类型,刚好符合x86下内存32位对齐的规则,恰好N就是4字节;又例如参数的类型是一个char类型,N也会是4字节,因为4字节中有效的字节是1个字节,其余三个字节只是填充位通常以0填充。
下图是我们fact_helper第一次递归调用之后,这是整个程序栈中的第二个fect_helper副本,此时该函数的状态“轮回”和我们上文中图1的栈枕结构,但是这次数据状态和图1时的状态是不一样的,他获得的第一个参数x=2,这是递归调用能按照我们的想法运行的重要条件。
每次递归调用在执行主体代码之前,需要通过一个传入参数来和阀值进行逻辑判断确定是否继续递归,如果不满足条件就执行return返回。否则,会无限递归然后直到栈溢出报错。通常不用等到操作系统去强制停止,较新版的C/C++编译器已经强制停止了。另外每个被递归调用函数的副本能正确返回之前的上一个调用函数的执行位置,另外一个关键条件就是返回地址。前文已经重点说过两次了,这里不废话了。
接下来的栈帧跟 图2时的fact_helper栈帧的结构是一样的。但此时的它数据状态->局部变量z是6,这个局部变量会被再次写入到指针变量所指向的内存位置0xf2e0的变量值为6,也就是每执行一次递归fact_helper的副本,都会及时修改sfact栈帧中的局部变量val。
接下来图7跟图3时的fact_helper栈帧的结构是一样的,同理图8的图4时的fact_helper栈帧的结构是一样的。
但此时的,fact_helper的栈帧副本持有的递归条件变量已经递减为1,这就决定它在第二次递归时,必定之执行return语句,
此时图9是fact_helper第一个递归执行了它内部上下文中的call 80483dd指令,并准备开辟第二个fact_helper时(尚未进入)的状态,这里已经完整地展示之前整个调用链中每个函数栈帧的状态。
此时的递归条件变量,决定了整个递归结束,并依次返回每个递归的副本的前一个调用它的副本的下一条指令执行剩余的指令。正如下图一样栈所示的一样,第二个递归的副本是最后一个结束整个递归调用的关键,在汇编版本中,它会执行L1的分支代码即leave和ret释放它的栈内存,然后调用链之前,其他函数根据其返回地址依次返回其前一个调用函数执行剩余的代码,最后各自会依次执行其上下文的leave和ret指令出栈,最终释放栈内存。
后记
我们从本篇已经讨论了,至起码在里发文讨论栈的话题,我目前已经讲的是很透彻的了。
你可能会发现,我们栈中有很多未使用的内存空间,因此函数栈并不是其他书本中描述的那样栈看起来是“连续的”的错觉,造成栈中的空间使用的不连续性,有很多方面原因,
其中一个是编译器在编译时会执行很多内存对齐的操作,在x86平台中,我们知道寄存器一次性能够吞吐4个字节的数据,典型的用户自定义的数据类型,其相邻的数据成员有的类型尺寸不能到4的倍数的地址边界,那么编译器就会向其中塞入没用的字节块,这些字节块通常是以0填充。有关内存对齐请查看我之前的另外一文《C/C++ 结构体及其数组的内存对齐》
另一方面跟操作系统的内存管理实现有关,因为不同的操作系统的内存管理机制存在差异的,这方面原因就比较复杂得多了。
最后,栈话题几乎接近尾声,我们前几篇都是以x86环境下讨论栈的实现细节,我最后一篇是以x86_64环境和x86环境下栈的差异结束栈的话题,敬请期待。