CS107

Lec 10 汇编 活动记录以及函数返回

主要内容:

1. 真实的函数活动记录

​为了简化,之前例子中所有的函数都没有参数。我们当时只考虑了局部变量,而函数的参数也是在函数的活动记录中存储。

foo函数

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的活动记录分配好了一部分栈帧了

saved PC

存储着函数返回后应该执行的第一条汇编语句

然后我们通过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中。

  1. 此时知道PC的值,那么可以计算出要执行的下一条语句PC + 4
  2. 实际上会将SP减去4个字节,为saved PC 申请空间
  3. M[SP] = PC + 4;

因此在foo函数执行完后,它能根据自己活动记录中的信息,返回到调用位置,并执行下一条语句。

foo 的完整实现

void foo(int bar, int* baz)
{
    char snink[4];
    short* why; 
   
    why = (short*)(snink + 2); 
    *why = 50;
    
}

foo 函数的汇编

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 申请的空间。

RV寄存器

​这也是一个4字节的特殊寄存器,用于在调用者和被调用函数之间传递返回值。

RV可以看作是一个专门用来放置返回信息的地方。

​因此一旦返回到调用main函数的函数中时,这个函数会立即查看RV,将里面的值作为返回值取出。

​通过这个例子可以看出在函数调用过程中栈的增长和消退

函数活动记录-两个部分,分开负责

​为什么将函数的活动记录分成两个部分,由调用者和被调用函数分别完成,而不能让调用函数来完成整个事情呢?

​因为调用者必须要负责将调用函数的参数进行赋值-> 完成参数传递:只有调用者知道怎样将有意义的参数值写入内存。

​只有被调用的函数本身知道自身的实现中有多少个局部变量,要为这些变量申请多大的空间。

2.递归函数中的CALL,RET如何工作

factorial 函数

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相关的地址的形式。