从汇编角度来理解递归工作栈的原理
这里提及寄存器与栈的概念,这里以c和汇编的程序为例。图片来源于博主Casualet。
注意,阅读本文之前,请先复习一下汇编语言的常见指令。可以通过文章《几种基本汇编指令详解》进行简略复习。
为什么用汇编去分析呢?因为汇编更为底层,能够深入操作系统的内容。这儿给出了 C 与汇编的对比,很明显有两个调用函数和一个 main() 函数,首先对 main() 函数的汇编进行分析。
先是头三行。
pushl %ebp // 压栈操作,即 esp 先下移 4 个字节,然后读取 ebp 的值,并存入 esp 的内存单元中 movl %esp, %ebp // ebp = esp,栈顶指针等于栈底指针 subl $4, %esp // esp = esp - 4,即 esp 向下移动 4 个字节,相当于开辟了 1 个局部 int,同时默认了 ebp 为栈底
这三条用于保存栈的信息,ebp 寄存器指向栈底,esp 寄存器指向栈顶,栈底是高地址而栈底是低地址。执行完这三行后,栈就为 main 开辟了一个新空间,新空间从 ebp 开始到 esp 结束。开辟前与开辟后寄存器的位置关系如下图:
开辟前
开辟后
这样做的原因是,当 main 的全部指令执行完后,我们需要消除它的栈空间,并返回原来的状态,如何返回呢?通过保存 ebp = 100 这个信息。这也就是为什么要做上面这三个步骤。
然后继续看下一条指令,“movl $8,(%esp)” ,将数值 8 放在 esp 的内存单元中。
效果图
接下来,程序利用指令 CALL,进入了被调用函数 f()。这里讲一下 call 指令的作用。常见的 CPU 的 CALL 指令(“调用”指令)的功能,就是以下两点:
(1) 将下一条指令的所在地址(即当时程序计数器 PC 的内容)入栈
(2) 将子程序的地址送入 PC(即开始执行子程序)
这时候会将 EIP 寄存器压入栈( EIP 用来存储 CPU 要读取指令的地址), eip 此时指向的是 call 的下一条指令,即将“addl $1, %eax”这一条指令的地址压入栈中( eax 是 X86 汇编语言上 cpu 通用的寄存器名称,是 32 位寄存器,用来暂时存储数值或地址),随后进入函数 f() 并执行头三行。
pushl %ebp movl %esp, %ebp subl $4, %esp
效果图
接下来再来看看汇编版本 f() 函数的 12 到 14 行。
movl 8(%ebp), %eax movl %eax, (%esp) call g
第一行表示将 ebp + 8 地址单元中的值放入 eax 中,同时由上图可知 ebp+8 单元里的值实际上是 8。这儿的 8 又正好是C语言里 f(int x)的 传参。所以我们可以发现,在32位 X86 的情况下函数的参数传递是通过栈来实现的,更具体地说,参数传递是通过 bsp 向上位移 8 位找到第一个传入的参数(当然,也可能没参数,而且这里的次序“第一”不一定是从左往右,还可能是从右往左)。
因此,我们在用 call 命令调用函数之前,会先把需要的参数存入栈中,然后再使用 call 命令将 eip 压栈。在进入新的函数后,把旧的 ebp 压栈,然后在新 ebp 的内存单元里存储了旧 ebp 的地址,所以我们可以通过新 ebp 的内存单元得到函数需要的参数值。
接下来看第二行的指令。“movl %eax, (%esp)” ,会把 eax 的值放入 esp 所指向的内存单元。
在第三行中,调用 g() 函数,,又可以压入 call 指令的下一条指令的地址,并将子程序的地址送入 PC,开始执行 g() 的片段。
效果图
进行g() 函数,执行前两条指令,得到的结果如下:
然后看第三条指令。“movl 8(%ebp), %eax” ,将 ebp + 8 内存单元里的值存储在 eax 中,相当于是将传参 8 存在了 eax 里。
第四条指令是立即数寻址,相当于 eax = 3 + eax,此时 eax = 11。
第五条指令,“popl %ebp”,将栈顶的元素取出并赋值给寄存器 ebp,此时 ebp 变成了 72,这个值也是上一个函数 f() 中 ebp 的值。
得到下图:
然后 ret 执行。ret 执行时会把栈顶元素弹到 eip 中,即把在这里 leave 的地址弹到 eip 中,这样就可以执行 leave 指令了。
执行leave 前的结果图是如下。
leave 指令类似“movl %ebp, %esp”同时加上“popl %ebp”,起到撤销当前这一层堆栈的作用。
由已知,ebp = 72 中存取的值是 84,这又是上一个的旧 ebp 的值。弹出 ebp 后,得到下图。
此时遇到了f() 的 ret 指令,所以弹出 addl 到 eip,开始执行“addl $1, %eax”,由于之前 eax = 11,所以现在变成了 12。
然后碰到了f() 的 leave 指令,弹出,达到清栈的目的。效果图如下:
这时,栈恢复了最初始的模样。此时 main() 中还剩下一条 ret 指令,由于之前一开始我们没考虑过 main 的地址压栈,所以这部分问题留给操作系统了。
总结
在每一个函数的执行过程中,都会有一段从 ebp 到 esp 的独立栈空间。对于一个函数,ebp 内存单元里的值是调用这个函数的上一个函数的栈空间的 ebp 的值。这种机制使得 leave 指令可以清空一个函数的栈、达到调用之前的状态。
由于在进入一个新的栈之前,有一个 eip 压栈的过程,所以 leave 指令之后的 ret 指令正好对应了上一个函数的返回地址,也就是返回上一个函数时要执行的下一条指令的地址。
另外,由于对于一个函数的栈空间来说,在三十二位的环境下,当前 ebp 上移一个内存单元(四字节)是上一个函数的返回地址,上移两个单元(八字节)是当前函数的传参的地址。所以我们知道了当前 ebp 位置的话,就可以通过栈的机制来获得参数。