程序的机器级表示
用任何汇编语言或高级语言写的源程序最终都必须翻译成以指令形式表示的机器语言,才能在计算机上运行。
过程调用的机器级表示
将整个程序分成若干个模块后,编译器对每个模块可以分别编译。为了彼此统一,编译的模块代码之间必须遵循一些调用接口约定,这些约定成为调用约定,具体有由 ABI 规范定义,有编译器强制执行。
1. IA-32 中用于过程调用的指令
CALL 和 RET 指令都是用于过程调用的主要指令,他们都属于一种无条件转移指令,都会改变程序的执行顺序。为了支持嵌套和递归调用,通常利用栈来保存返回地址,入口参数和过程内部定义的非静态局部变量,因此,CALL 指令在跳转到被调用过程执行之前先要把返回地址压栈,RET 指令在返回调用过程之前要从栈中取出返回地址。
2. 过程调用的执行步骤
假定过程 P 调用过程 Q,则 P 称为调用者,Q 称为被调用者。步骤如下:
- P 将入口参数放到 Q 能访问到的地方。
- P 将返回地址存到特定的地方,然后将控制转移到 Q。
- Q 保存 P 的现场,并为自己的非静态局部变量分配空间。
- 执行 Q 的过程体(函数体)。
- Q 恢复 P 的现场,并释放局部变量所占的空间。
- Q 取出返回地址,将控制转移到 P。
- 上述步骤中,1~2 步是在过程 P 中完成的,第 2 步由 CALL 指令实现。
- 3~6 步都在 Q 中完成,在执行 Q 之前的第 3 步通常称为准备阶段,用于保存 P 的现场并为 Q 的非静态局部变量分配空间,第 5 步通常称为结束阶段,用于恢复 P 的现场并释放 Q 的局部变量所占空间,最后在第 6 步执行 RET 指令返回过程 P。
3. 过程调用所使用的栈
从上述执行步骤来看,在过程调用中,需要为入口参数,返回地址,调用过程执行时用到的寄存器,被调用过程中的非静态局部变量,过程返回时的结果等数据找到存放空间。
如果有足够的寄存器,最好把这些数据都保存到寄存器中,这样,CPU 执行指令时,可以快速地从寄存器中取到数据。但寄存器数量有限,且他们是所有过程都共享的,某时刻只能被一个过程使用,对于一些复杂类型的非静态局部变量(数组,结构等)也不可能保存在寄存器中。
因此,除寄存器外,还需要有一个专门的存储区域来保存这些数据,这个存储区域就是栈。
4. IA-32 的寄存器使用约定
寄存器 EAX,ECX 和 EDX 是调用者保存寄存器(即如果调用者有使用到这三个寄存器时,必须先保存到栈中,再进行过程调用,这样被调用者才能直接去使用这三个寄存器)
寄存器 EBX,ESI,EDI 是被调用者保存寄存器(被调用者必须先将他们的值保存到栈中再使用他们,并在返回之前先恢复他们的值。)
5. IA-32 的栈,栈帧及其结构
IA-32 使用栈来支持过程的嵌套调用,过程的入口参数,返回地址,被保存寄存器的值,被调用过程中的非静态局部变量等都会被压入栈中。IA-32 可以通过执行 MOV,PUSH 和 POP 指令存取栈中元素。
每个过程都有自己的栈区,称为栈帧,因此,一个栈由若干栈帧组成,每个栈帧用专门的帧指针寄存器 EBP 指定起始位置,因此,当前栈帧的范围在帧指针 EBP 和 ESP 指向区域之间。
6. 变量的作用域和生存期
被调用者内部的非静态局部变量只在执行过程中有效,当被调用者返回后,这些变量所占的空间全部被释放。C 语言中的 auto 型变量就是过程内的非静态局部变量,因为他是通过执行指令而动态,自动地在栈中非陪并在过程结束时释放的,因而其作用域仅局限于过程内部。
7. 按值和按址传递参数
下面有两个程序,程序 1 按值进行参数传递,程序 2 按址进行传递。
两个程序的 main 函数的汇编代码如下:
区别:程序一用 leal 指令,程序二用 movl 指令。
两个程序的 main 函数的存储空间如下:
两个程序的 swap 函数的汇编代码如下:
程序一的 swap 过程体比程序二的 swap 过程体多了三条指令,而且使用了较多的寄存器。(由于使用了被调用者保存寄存器 EBX,他的值必须在准备阶段被保存到栈中,而在结束阶段从栈中恢复,因而他比程序二又多了一条 push 指令和一条 pop 指令。
因为程序一的 swap 函数的形式参数 x 和 y 用的是指针型变量名,相当于间接寻址,需要先取出地址,然后根据地址再存取 x 和 y 的值,因而改变了调用过程 main 的栈帧中局部变量 a 和 b 所在位置的内容;而程序二中的 swap 函数的形参 x 和 y 用的是基本数据类型变量名,直接存取 x 和 y 的内容,因而改变的是 swap 函数的入口参数 x 和 y 所在位置的值。
至此,我们分析了程序一和程序二之间明显的差别。程序已中调用 swap 后回到 main 执行时,a 和 b 的值已经交换过了,而在程序二的执行过程中,swap 过程实际上交换的是其两个入口参数所在位置上的内容,并没有真正交换 a 和 b 的值。
编译器并不为形式参数分配存储空间,而是给形式参数对应的实参分配空间,形式参数实际上只是被调用函数使用时的一个名称而已。不管是按值传递还是按址传递参数,在调用过程用 CALL 指令调用被调用过程时,对应的实参应该都已有具体的值,并已将实例的值存放到调用过程的栈帧中作为入口参数,以等待被调用过程中的指令所用。
8. 递归过程调用
过程调用中使用的栈机制和寄存器使用约定,使得可以进行过程的嵌套调用和递归调用。
递归调用过程的执行一直要等到满足跳出条件时才结束。若栈大小为 2MB,则不考虑其他调用过程所用栈帧的情况下,当递归深度 n 达到大约 2MB/16B = 2^12 = 131072 时,发生栈溢出。
此外,为了支持过程调用,每个过程中还包括了准备阶段和结束阶段。没增加一此过程调用,就要增加许多条包含在准备阶段和结束阶段的额外指令,这些额外指令的执行时间开销对程序的性能影响很大,因而,应该尽量避免不必要的过程调用,特别是递归调用。
9. 非静态局部变量的存储分配
编译器在给非静态局部变量分配空间时,通常将其占用的空间分配在本过程的栈帧中,有些编译器在编译优化的情况下,也可能会把属于基本的简单数据类型的非静态局部变量分配在通用寄存器中,但是,对于复杂的数据类型变量,如数组,结构和联合等数据类型变量,则一定会分配在栈帧中。