3.7 过程
所谓的过程, 按照我的理解就是函数, 大多数机器语言的过程其实就是通过操纵程序栈来完成数据的传递, 局部变量的分配与释放, 搭配着转移控制到过程以及从过程转移出控制来实现的.
3.7.1 栈帧结构
如图是帧栈的通用结构, 最顶端的帧栈通过两个指针来界定, 帧指针(%ebp)和栈指针(%esp), 栈指针指向栈顶, 在程序执行时, 由于程序可能发生压栈和出栈操作, 栈指针可能会不断变化, 所以通常来说都是使用帧指针来进行信息的访问.
值得注意的是, 假设调用者P调用过程Q(被调用), 可以明确如下几点 :
1. Q的参数放在P的栈帧中
2. 返回地址也在P的栈帧中, 并且是末尾位置.
3. 接着是Q的栈帧, 最开始的位置是帧指针的值(%ebp), 然后是其他需要保存的寄存器的值.
3.7.2 转移控制
先说call, 如图所示, call不但支持直接调用, 还支持间接调用. call的效果是将返回地址入栈同时跳转到被调用过程的起始处, 从这句话我们可以得知如下信息 :
1. 如果需要传参, 必须在使用call之前手动将参数压入栈中...
2. 保存寄存器的过程在call之后, 也是手动完成..
然后是leave, leave的效果是将栈指针%esp指向%ebp(也就是指向该过程栈帧最开始的位置), 然后弹出%ebp(此时栈顶存放的就是%ebp), 等价代码如下:
movl %ebp, %esp
popl %ebp
接着是ret, ret的效果是将返回地址弹出栈, 然后跳转到这个位置, 我们这里也可以明确如下信息 :
1. 如果需要恢复寄存器, 必须在ret之前手动恢复.
2. 在恢复寄存器之后, 释放栈空间, 使得栈指针指向返回地址的位置才能调用考虑调用ret.
3. 你会发现其实前两步工作, 就是leave完成的工作, 所以其实leave指令可以使栈做好返回的准备.
最后, 如果函数有返回值, 我们通常使用%eax来保存返回值...
练习题3.30 可以这样分析 :
1. 首先明确call的效果, 就是先保存返回地址然后跳到被调用过程的开始位置.
2. 返回地址, 其实也就是next的第一句指令, 然后执行完这一句之后, 这一句指令本身的地址被保存在了%eax当中.
3. 间接实现了将程序计数器%eip的值存放在整数器中. (其实也是唯一的方法)
3.7.3 寄存器的使用惯例
程序寄存器组是唯一能被所有的过程共享的资源, 所以我们必须保证当一个过程(调用者)调用另外一个过程(被调用者)时, 被调用者不会覆盖调用者某个稍后会使用的寄存器, 为此IA32采用了一组统一的寄存器使用管理 :
1. %eax, %edx, %ecx被划分为调用者保存寄存器, 它们需要在发生调用之前被保存, 在调用过程中, 这些寄存器可以被随便覆盖.
2. %ebx, %esi, %edi被划分为被调用者保存寄存器, 也就是说, 如果在被调用过程中需要使用这些寄存器, 必须在一开始将这些寄存器的值保存到栈中, 并在返回之前回复它们, 因为调用者可能会在今后的计算中需要这些值.
3. 另一方面, %ebp 和 %esp 是必须保持的值...
3.7.4 过程示例
接下来是一个简单的例子, 我们来分析一个C语言的函数以及它对应的汇编代码来理解折翼过程, 如下图 :
首先是callee的汇编代码 :
很容易看出 : 首先是保存callee的调用者的帧指针, 然后为callee的帧栈设置帧指针, 接着分配24个byte的空间用来放置变量arg1和arg2, 然后取arg1和arg2的地址保存在栈上作为swap_add的参数列表, 接着把调用者保存寄存器%eax保存起来(因为它后面要用到这个寄存器, 同时这个寄存器是调用者保存寄存器, 所以在调用前就开始保存了), 最后调用call...
这里很奇怪的是栈上分配了24bytes的空间, 而实际却只用了16个bytes. 原因在于GCC坚持的x86编程的指导方针 : 一个函数所使用的所有栈空间必须是16字节的整数倍. 这里最开始的%ebp(4) + arg1(4) + arg2(4) + &arg2(4) + &arg1(4) + 返回地址(4) = 24 (在上图左边并没有表现出返回地址所占用的空间, 实际在调用call的同时返回地址已经被push到了栈上), 由于32 -24 = 8, 所以两个函数之前那一块未使用的空间就是为了用来填补这个空缺的.
然后我们接着来看swap_add的调用过程:
最开始同样是先保存帧指针然后将帧指针为当前帧栈的开始地址, 然后是被调用者保存寄存器%ebx, 依照惯例, 不同于%eax, 它是在被调用过程中保存. 然后是按照对应的函数操作... 最终将x + y 的返回值放在%eax中返回(11行). 然后恢复之前保存的%ebx以及callee(调用函数)的帧指针之后返回... 但是要注意的是此时栈指针指向&arg1...
接下来又是callee的代码, 主要看到15行的leave, 使用leave后, 栈指针首先回到帧指针位置, 然后弹出帧指针(恢复上一层的帧指针), 然后栈指针指向上一层的返回地址, 此时调用ret返回 :
这里我们可以看出, 我们既可以使用leave也可以使用popl来释放帧栈, 实际使用哪个感觉应该视情况而定...
到这里我们可以总结一下过程调用的基本思路 (按照栈从高到低的顺序):
调用前 :
1. 保存调用者保存寄存器
2. 保存需要传递的参数
3. 上两点均需手动完成
4. 使用call开始过程调用
调用时 :
返回地址被自动保存到栈上.(由call自动完成)
调用后:
1. 保存调用者的%ebp, 刷新为被吊者的%ebp(手动)
2. 保存被调用者保存寄存器(手动)
3. 完成函数过程(手动)
4. 恢复被调用者保存寄存器(手动)
4. 多次使用pushl使得]栈指针回到%ebp位置后弹出%ebp.
5. 使用leave
6. 5为手动, 6为自动, 4, 5 任选其一即可.
7. 将返回值保存在%eax当中
8. 调用ret返回
3.7.5 递归过程
没什么好说的, 结论就是其实递归调用和普通过程调用没什么区别...
练习题3.34 其实我感觉有问题, 它并没有保存寄存器%ebx的值, 同时%ebx属于被调用者寄存器, 本来应该在过程内被保存起来再使用, 只能考虑是在第一行之前保存过, 然后在忽略的.L3里面恢复了, 它计算了传入参数的值为1的位的数量.