主要内容:
为了简化,之前例子中所有的函数都没有参数。我们当时只考虑了局部变量,而函数的参数也是在函数的活动记录中存储。
void foo(int bar, int* baz) // 这些变量在哪里?
{
char snink[4]; // 这四个字符打包为一个静态的数组
short* why; // 紧挨着放置
; // 不关心其他的代码
}
// 函数局部变量,函数参数都存储在相邻的内存中
foo函数的活动记录如下:
为何函数参数按照从右向左的顺序而从高地址向低地址存放。
中间空白的空间存储着函数调用的某些信息。显然,foo函数会被main函数或者其他函数调用。甚至是foo函数自身(递归的情况)。因此我们要在这里记录下一些信息。以便告诉我们到底是哪块代码调用了foo,并且foo执行结束之后,该从哪里继续执行代码。
如果这个调用函数存在的话,那么这里依赖于saved PC值。这个值好像函数调用没有被指令流中断那样,在调用之后,要继续执行的指令地址.
那么,当函数foo被调用的时候,活动记录是如何被创建的?
int main(int argc, char** argv)
{
int i = 4; // 为局部变量申请空间
foo(i, &i);
return 0;
}
开始时,main函数的活动记录如下:
首先,编译器要生成为局部变量申请空间的代码,只有从main函数实现中,才知道需要有多少局部变量来实现函数试图完成的功能。
所以根据函数调用规范:一个c函数最初要做的就是为局部变量申请空间。main函数在调用foo时,已经有了活动记录的一部分。
为局部变量申请空间:
在这里,我们使用一个特殊的寄存器来维护活动记录的基地址,即SP(stack pointer),这个寄存器中总是指向执行中栈的最低地址。
SP = SP -4; // 向下偏移4 字节,为i申请空间
M[SP] = 4; // 初始化 i
为什么是-4呢?因为这里的局部变量i的size = 4;
这里的边界,即SP指向的位置,就是栈中已使用内存和未使用内存的边界。
在函数调用的时候,main函数需要为调用参数留出空间,因此它需要为这些参数创建一个部分的活动记录。并且按照函数的参数进行初始化,然后要做的就是将控制权转移给foo函数了!【跳转到foo函数的汇编代码】
SP = SP - 8; # 为参数申请空间
R1 = M[SP + 8]; # i;
R2 = SP + 8; # &i;
M[SP] = R1; # 初始化参数 bar
M[SP + 4] = R2; # 初始化参数 baz
这里,我们已经为foo的活动记录分配好了一部分栈帧了
存储着函数返回后应该执行的第一条汇编语句
然后我们通过call 跳转指令将控制权交给foo函数
SP = SP - 8; # 为参数申请空间
R1 = M[SP + 8]; # i;
R2 = SP + 8; # &i;
M[SP] = R1; # 初始化参数 bar
M[SP + 4] = R2; # 初始化参数 baz
CALL <foo>; # 控制权转移
SP = SP + 8; # 如果没有执行foo函数,那么这条指令就是接下来要执行的语句,这个地址就是要存储到被称为saved PC 中的地址。
CALL是一个简单的跳转指令,它将跳转到foo函数的第一条汇编语句,并执行它。然后通过某种方式保证【saved PC】foo函数在执行完成后能够正确地返回到调用位置,并继续执行.
执行CALL语句时自动完成的事情:将函数返回后要执行的第一条语句地址放在saved PC中。
因此在foo函数执行完后,它能根据自己活动记录中的信息,返回到调用位置,并执行下一条语句。
void foo(int bar, int* baz)
{
char snink[4];
short* why;
why = (short*)(snink + 2);
*why = 50;
}
foo:
SP = SP - 8; # 首先为局部变量申请空间,并且将这里保持未初始化的状态。因为C代码中没有对应的初始化语句
R1 = SP + 6; # snink + 2;
M[SP] = R1; # 将why中的值更新
R1 = M[SP]; # reload R1, R1中存储的地址就是50要写入的地址
M[R1] = .2 50; # 只写入两个字节
# 准备函数返回
SP = SP + 8; # 将局部变量的空间释放。此时SP指向的是saved PC
RET; # 将saved PC 中的值取出,放到PC寄存器中,并且让SP + 4,回收saved PC 然后继续执行剩下的代码【PC + 4】,看起来和没有执行过foo函数一样。
首先要为局部变量申请空间,从而完成整个活动记录
然后通过load - alu - store 操作内存
在函数返回之前,回收为foo局部变量申请的空间。
函数返回,RET:将saved PC取出,放在PC中,然后执行跳转。并且将SP + 4,回收为saved PC 申请的空间。
这也是一个4字节的特殊寄存器,用于在调用者和被调用函数之间传递返回值。
RV可以看作是一个专门用来放置返回信息的地方。
因此一旦返回到调用main函数的函数中时,这个函数会立即查看RV,将里面的值作为返回值取出。
通过这个例子可以看出在函数调用过程中栈的增长和消退
为什么将函数的活动记录分成两个部分,由调用者和被调用函数分别完成,而不能让调用函数来完成整个事情呢?
因为调用者必须要负责将调用函数的参数进行赋值-> 完成参数传递:只有调用者知道怎样将有意义的参数值写入内存。
只有被调用的函数本身知道自身的实现中有多少个局部变量,要为这些变量申请多大的空间。
int fractorial(int n)
{
if(n == 0)
return 1;
return n*factorial(n - 1);
}
<factorial>:
R1 = M[SP + 4];
BNE R1, 0, PC + 12; # 假设在我们的系统中,PC的值不会自增4;
RV = 1;
RET;
R1 = M[SP + 4]; # BNE 跳转到的位置
R1 = R1 - 1;
SP = SP - 4; # 为n-1申请空间
CALL <factorial>;
SP = SP + 4; # 回收空间
R1 = M[SP + 4]; # get n
RV = R1*RV; # n*factorial(n - 1);
RET;
如果返回值的类型大小超过了寄存器的容量,那么它会将一个内存中临时的结构体的地址放到返回值寄存器中。并且假设调用者知道这个结构体被返回。将RV中的地址进行解引用即可拿到实际的结构体变量。
Q&A:
R1 存储着当前n的值,RV寄存器中存储递归调用的值。
即使之前存储了n的值,在这里也要重新从内存中读取一次n的值,因为我们不知道factorial的复杂程度,会不会也使用了R1 寄存器,这样R1寄存器在调用结束之后的值是未知的。
所有的函数调用都使用同样的寄存器堆。
所以要养成从一条c语句过度到另一条c语句时重新读取变量的习惯。
上下文无关的代码:更简洁,而且当c语言代码更改时,上下文无关的汇编代码无需更改还能正常工作。
CALL 语句
当程序载入的时候,这里的call语句会被替换成PC-28或者其他的值,因为这个值正好是factorial 的第一条汇编语句所在的位置。这里的call语句只是一个占位符。这样方便编译器进行处理。
可以把汇编中的CALL类比为c语言中的goto标签,实际上它所实现的功能就是这样。
真正的汇编器和链接器会遍历所有的.o文件,并且将这些符号转换成PC相关的地址。这种转换被推迟到链接时刻进行,因为编译器希望能够让不同.o文件中的函数之间的跳转全部翻译成这种PC相关的地址的形式。