计算机体系结构之三--函数的调用和返回【译】
说明
本文翻译自斯坦福开放课程【编程范式】的系列课件,有删改。
本系列会持续更新,预计一周发布1到2篇。
如有意见/建议或是存在版权问题,欢迎园友指正。
转载无需通知本人,但请注明出处,谢谢!
函数调用分析
对有递归特性的编程语言来说,区分函数定义和函数调用是十分有必要的。函数定义规定了函数的行为,函数每次调用都创建一个函数实例。虽然一个函数只有一个定义,随着时间的流逝,它可能产生很多不同的实例。对于一个递归函数来说,若干个实例可能会同时存在。
每个函数实例都需要分配内存空间,一个函数从调用到返回,它的活动记录包括以下内容:
- 本地存储 包括参数、局部变量、以及编译器在实现函数时使用的辅助空间。
- 返回信息 当这次调用结束时,执行流程应该在哪里继续?怎样继续?
活动记录指的是一个函数实例被分配的内存块。活动记录只存在于函数执行时,一旦函数返回,这块内存就会被释放。
活动记录
一个活动记录保存和一个实例(一次调用、一个函数)相关的状态。活动记录的主要任务是保存参数和局部变量。实际上,活动记录也可能会保存辅助信息。
编译器用函数声明生成活动记录。正如C语言中,一个活动记录就是一个集合,集合的每一项包括名字和类型(比如int a就是一项)。这些项存储在同一块内存中。下面的表达式把两个整数视为参数:
void Simple(int a, int b) // 两个参数 { int temp1, temp2; // 局部变量 .... }
从上面的声明可以看出,编译器生成活动记录时需要四个整数:a, b ,temp1和temp2。假设一个整数占据4个字节,那么这个活动记录至少需要16个字节。编译器不会在函数声明阶段产生一个活动记录,函数调用时,编译器会给这个函数实例分配内存空间。
栈
栈是一种分配、回收函数实例的完美结构。函数调用时,它的活动记录会入栈并且初始化。活动记录必须在函数执行之前保持不变。函数退出时,该活动记录占据的内存空间会被释放。当前活动记录出栈,上一个函数的实例成为新的栈顶。因为函数调用随处可见,栈分配、回收的效率就十分重要了。
void A(void){ B(); } void B(void){ C(); } void C(void){ printf("Hi!"); }
函数调用的时候有一个限制:函数必须以调用顺序的逆序返回。如果A先调用B,然后B调用C,最终函数必须以C、B、A的顺序返回。对于函数B的实例来说,在A实例之后和C实例之前返回是不可能的。只有正在执行的函数实例有权调用变量,其他所有实例都被暂时挂起。在上面的例子中,当C实例打印出“Hi!”的时候,A和B就被挂起。B在等待C退出,同样A也在等待B。
栈这种结构非常适合存储活动记录--它准备好当前实例,同时保持其他的实例挂起。当维护一个集合时,栈是一种相当常用的抽象数据结构,就像堆成一列的盘子。如果从顶部观察的话,只能看见最上面的盘子。栈定义了两种操作--入栈和出栈。入栈就是拿一个新盘子放在栈的顶部。新盘子阻挡你看到下面的盘子。出栈就是拿掉最顶部的盘子,下面那个盘子成为这个栈的新“栈顶”。
在这个函数调用的例子中,这堆盘子实际上就是活动记录。最上面的盘子就是当前正在执行函数的活动记录。当函数被调用之后,它的活动记录会出栈,下面的实例成为当前执行的函数。当该函数也退出后,它下面的实例继续执行。
活动协议
编译器生成代码时必须遵循一连串协议,为的是把控制流和信息从当前执行环境传递到被调用函数。不同的语言,甚至相同语言的不同编译器可能使用着略微不同的协议。
下面是一个用来传递参数、控制函数执行的简单协议:
- 当前执行环境分配一块内存,入栈同时生成了一个活动记录。
- 初始化活动记录中的参数部分(这里是把当前执行环境的值复制到被调函数活动记录中)。
- 当前执行环境保存状态和返回信息。在参数之上入栈额外的4个字节。
- 被调函数成为新的当前执行环境,被调函数把它的局部变量入栈,执行函数。
- 被调函数正常执行,期间从它的活动记录中调用变量和参数。
- 当被调函数退出后,活动记录的部分内容出栈。
- 初始函数再度成为当前执行环境,依据返回信息继续执行,同时移除栈中被调函数的参数,被调函数的活动记录随之完全出栈。
注意到在这个协议中,局部变量并没有被初始化--栈指针仅仅给它们分配了空间。参数看起来像是初始化了的局部变量。
由于栈是很多计算机活动的核心,大部分计算机操纵栈的指令都很特殊,为的是使上述协议高效实现。有些编译器使用寄存器代替栈来传递参数,现在我们忽略这种情况。为了让操作更快,有几个寄存器专门用于内务管理。SP寄存器用于保存栈顶地址。PC寄存器专用于保存当前执行指令的地址,RV寄存器专用于传递函数的返回值。其他的寄存器(R1,R2,等)是多功能的,用途广泛。
协议也规定出活动记录的空间是如何分配的。我们选一种简单的规则来进行说明。我们约定,把参数按从右到左的顺序入栈,(从右向左入栈和C编译器有关,这点会在后面详述),紧随其后的是返回地址,然后把局部变量按从上向下的顺序入栈。我们假设函数的返回值不会放在栈里,而是放在那个特殊的寄存器RV中。注意,这点决定了返回值不能超过一个机器字长,通常,这种情况会使用一种复杂的(常常也是颇费开销的)手段来处理,这里不提。就像大部分现代体系结构一样,实例栈也是从上往下增长,从高地址到低地址。下面以本文前段声明的Simple函数为例,给出一种活动记录布局:
协议中划定了参数和局部变量的分布,我们可以把活动记录视做一对相似且相邻的结构,一块放变量、另一块放参数,把返回地址夹在中间。SP刚好指向局部变量块的基地址,上面紧跟着返回地址,再上面是参数块。
struct SimpleActivationLocal{ int temp2; // 偏移量是 0(从栈顶开始计算) int temp1; // 偏移量是 4 }; struct SimpleActivationParameters{ int a; // 偏移量是12(从栈顶开始计算) int b; // 偏移量是16 }
在编译后的代码中,函数访问变量和参数时,靠的是它们各自距离栈顶的偏移量。在Simple函数里,参数b距离栈顶的偏移量是16字节。以下几点应当注意:
- 协议允许编译器在不知道函数确切结束位置的情况下生成代码。
- 每个函数实例运行同一份代码。
- 当前使用的标识符不会在代码中留下任何痕迹,特殊变量的类型信息不会放在代码中,但是编译器能使用那些数据。
调用和返回
为了支持函数调用操作,我们现在往指令集里添加两条指令。这两条特殊的指令直接控制函数之间的执行流、保存和恢复执行状态。
Call指令比Jmp指令小一些,它能立刻把控制流重定向到另一个地址,它也能在栈上开辟空间、保存返回地址--当控制流返回到调用函数时,要执行的下一条指令的地址。我们列出函数名,以此控制它们的跳转。这实际上就是编译器的工作方法。链接器负责把函数名转换成地址。地址是运行时需要的东西,因为在那时我们不知道怎样在内存中通过函数名来找到一个函数。
# 跳转到strlen & 在栈中保存下一条指令的地址 Call <strlen>
Ret指令通常是函数体生成代码中的最后一句。当函数执行完毕时,释放占用的栈空间,把返回值保存在RV寄存器,接着函数使用Ret指令恢复到调用函数的上下文。当Ret指令被调用时,我们设想栈指针已经指向了返回地址,所以它能很容易的把这个返回地址放入PC寄存器。当返回地址出栈(此时栈指针指向最右边的参数),此时PC寄存器正好指向被调函数之前的位置,就好像这个函数从来没有被调用过一样。
# 把控制权还给保存在栈上的指令地址 Ret
形式参数和实际参数
结构化语言中的函数和过程经常会接收“参数”--由调用者提供的、传递给被调函数的值。在函数体内存在的参数和在运行时传入的参数是不同的。在函数里声明的、在函数体里用到的是“形式参数”。当函数被调用时传入的参数叫“实际参数”。比如:
void Jane(void){ int arf, meow; John(arf, meow); } int John(int left, int right){ return (left * right); }
在上面的例子中,left和right是John函数的形式参数。当John函数被Jane函数调用的时候,arf和meow是John函数的实际参数。
值参数
在C语言里,所有的参数都是通过值来传递的。为了值参数,活动记录有一个槽位专门放置形式参数的副本。函数被调用时,调用者仅仅把实际参数的值拷贝到槽位里面。通过这种方式,后面形式参数发生的变化不会反映到实际参数上。
也许,当被调函数改变它的形式参数时,我们想让它的实际参数也发生变化。比如:
int main(void){ int i, j; i = 5; j = 76; Betty(i, j); printf("%d %d\n", i ,j); } void Betty(int m, int n){ m = 20; n = 30; }
这个过程的输出是:5 76
也许,我们想在执行Betty函数时改变i和j。C语言中有一个众所周知的方法:传递指针然后修改它们指向空间所存放的值。比如,如果我们想允许Betty函数(间接地)修改它的形式参数,我们可以这样写:
int main(void){ int i, j; i = 5; j = 76; Betty(&i, &j); printf("%d %d\n", i, j); } void Betty(int *m, int *n){ *m = 20; *n = 30; }
会输出:20 30
(全文终)