程序的表示、转换与链接 - week7

本文是 程序的表示、转换与链接 中第 7 周的内容,主要介绍了 C 语言程序中过程调用、也就是函数调用对应的机器级表示, 包括如何传递参数,如何将控制转移到被调用过程, 寄存器使用约定,递归函数的实现等等。 通过了解这些内容,能够更清楚机器执行的详细过程,同时也能更清楚函数调用过程中栈空间是如何变化的;课程选用的指令系统是前面介绍过的 IA-32 指令系统。

过程调用概述

如下是一个简单的例子,通过这个例子主要阐述 3 个问题

  • 过程调用的机器级代码是什么
  • 参数怎么传递到 add 函数中
  • add 的结果是如何返回给调用过程的
1
2
3
4
5
6
7
8
9
10
int add(int x, int y) {  
return x+y;
}

int main() {
int t1=125;
int t2=80;
int sum=add(t1, t2);
return sum;
}

关于第一个问题和第三个问题,当 main 中调用 add 函数时,实际上是通过 call 指令实现的,call 指令会把返回地址,也就是 call 指令下一条指令的地址放到栈里面,这样的话在 add 函数最后执行 return 指令的时候,可以从栈里面取出这个返回地址使得它能够正确的返回到 main 执行。

关于第二个问题,参数是通过栈传递的,即在调用的时候会把参数压栈,如下所示是进程的虚拟地址 (也叫) 空间分布的情况,其中栈是从高地址往低地址增长的,指向栈顶的指针也叫 ESP 指针

exec2virtualaddress

如下图所示是过程调用的机器级表示,调用的函数执行的是 P 过程,被调用的函数执行的是 Q 过程;P 过程做的事情主要是把参数和返回地址压栈,Q 过程做的则是保存现场、执行被调用函数,恢复现场并返回调用函数;其中现场指的是共享的通用寄存器,因为是共享的,因此被 Q 使用时需要先保存 P 之前在上面的值,后面再恢复

过程调用机器执行过程

前面提到因为过程 P 和 Q 共享通用寄存器,因此需要保存和恢复现场,实际上这只是针对部分的寄存器,在 IA-32 中的具体约定如下:

  • EAX、EDX 和 ECX 是调用者保存的寄存器 (必要时),被调用者不需要先保存再使用
  • EBX、ESI 和 EDI 是被调用者保存的寄存器 (必要时),被调用者需要先保存在使用

因此,为了减少准备和结束阶段的开销,每个过程应该优先使用 EAX、EDX 和 ECX 这几个寄存器

由于 IA-32 共有 8 个寄存器,剩下的两个 EBP 和 ESP 是帧指寄存器和栈指寄存器,分别用来指向当前栈帧的底部和顶部

因此,过程 P 调用过程 Q 时栈和栈帧的变化如下图所示

stackAndStackFrame

上面简单介绍了过程调用的一些基本操作,下图则是基于上面的例子更详细地描述其对应的汇编指令和栈的变化

procedure example

caller 这个函数对应的指令序列如上图所示,指令对应的基本操作在图中已经描述的比较清楚了,在这里着重讲指令序列中前三条指令,前三条指令是准备阶段, 做的事情是保存 P 的现场,并且形成新的栈帧,这三条指令的执行过程如下所示

第一条 pushl 指令:把老的 EBP 的值压栈,在这条指令执行完后,栈顶指针 ESP 也会自动指向这个地方
第二条 movl 指令:把当前的 ESP 赋给了个 EBP, 相当于是栈底的形成
第三条 subl 指令:会把 ESP 减去 24 (相当于用了 24 个字节来存储在 caller 中的变量、参数等)

而在被调用的 add 函数中最开始的两条指令其实也是上面的 pushl 和 movl,起作用便是形成栈底

最后的 leave 指令实际上是退栈,其操作是把 EBP 的内容送到 ESP,相当于是把栈里面所有的空间释放掉了

此外,汇编指令中一些符号含义如下

  • 前加 $ 符号的表示这是一个立即数,如第四条指令的 $125
  • -8(%ebp) 表示 EBP 的地址减去 8 个字节后所在地址里存的值
  • (%ecx) 表示 ECX 寄存器存的地址对应的值

因此,一个 C 过程的大致结构如下

summary

过程调用的参数传递

这里讲了一个比较经典的例子,就是按地址传参和按值传参带来的会带来不同结果,其中的原因就是这两种方式最终对应的机器指令是不一样的

swap compare

下面是上面分别按地址传递和按照参数传递对应的汇编指令,从中可知,两者的一些关键区别有

  • 按地址传递时被压栈的是参数的地址而不是参数本身 (使用 leal 指令),按参数传递时被压栈的是参数本身(使用 movl 指令)
  • 按地址传递时会通过寄存器 ebx 和 ecx 暂时存储两个参数的值,然后把参数赋值给存储着这两个参数地址的寄存器 eax 和 ebx 中,一共用了 4 个寄存器;而按值传递参数时,则只用了 2 个寄存器,只是对入口的参数进行交换
swap address
swap val

递归过程调用

下图是一个简单的递归过程对应的机器指令,假定这个过程是通过 P 调用的

recursive compile code

从上图中可知

  • 在递归过程中,栈帧是一个个累积叠加生成的
  • 在递归过程当中用到了被调用者保存的寄存器 ebx,起作用是保护调用过程的现场
  • ebp 加 8 即 8(%ebp) 是第一个参数的地址
  • eax 中存储着返回值
  • cmpl 和 jle 指令是用来表示上面的条件比较部分的,如果满足条件就会跳到 L2 执行退栈等清理工作;如果不满足,则会递归地执行入栈、生成新的栈帧的操作等
  • addl 指令会在递归返回后一次执行累加操作得到最终结果

因此,递归过程的总体执行流程如下

recursive

递归调用中有很多额外的开销,这种额外的开销体现在

  • 空间:每递归调用一次都会形成一个新的栈帧,如果递归深度很大,栈帧的个数也变大,导致占用的栈的空间越来越多,最终引起栈溢出
  • 时间:每递归调用一次,都需要额外执行一些指令用于生成新的栈帧、进行参数压栈等等 而这些准备阶段以及恢复阶段的额外的指令的执行都需要花时间

因此,正常情况下如果能够不用递归,尽量不要用递归,因为它在空间上面和时间上面都会增加很多额外的开销

选择结构的机器级指令

if-else, 用了条件转移指令 jbe 和无条件转移指令 jmp

if-else

switch-case 语句有一张跳转表,即下图中的 L8, 其跳转到的目标实际上是通过基址加比例变址加位移量的方式来找到的。这个跳转表实际上是在在后面讲到的目标文件中 (可重定位目标文件或者是可执行目标文件)。 在这些文件当中都有相应的一个段,叫只读数据节,rodata。

swithc compilation

循环结构的机器级指令

循环结构的逻辑及其机器级表示如下图所示

cycle structure

下面是 for 循环语句的机器代码及其分析,其他循环结构的机器指令结构类似,从中可知,实现同样功能,循环比起递归要节省时间和空间,因此优先使用循环方式去实现

for compilation

小结

这一章主要介绍了 C 语言中常见的过程调用、递归调用、选择语句、循环语句等对应的机器级指令和具体的执行过程,通过了解这些语句的具体执行过程,能够更清楚计算机在执行时栈内存是如何分配的,以及为什么通过循环来实现相同功能时比递归更节省资源