Skip to content

LA32R汇编编程简介

栈帧布局

通过前一节的介绍,我们了解到进程执行过程中会在内存中为其分配栈空间。栈是我们最常用到的存放可读写临时数据的区域。通常我们会使用栈来管理函数运行过程中的返回地址、参数和局部变量等信息。栈的大小在程序运行过程中是不断动态调整的。

LA32R架构下函数栈实现为满递减栈(Full Descending Stack)类型。这里“递减”指的是栈向着地址减小的方向增长。“满”是指栈顶指针($sp)总是指向最后一个压入栈的元素。

栈被组织成一个个栈帧(Stack Frame)。每个栈帧是栈上一段地址连续的空间,对应着一个处在执行过程中的函数。

LA32R栈帧布局

上图中我们给出了一个栈帧布局的示意。其中从高地址向下,依次是调用当前函数的调用者栈帧、当前函数的栈帧,如果此时当前函数再调用其它函数,则被调用函数的栈帧紧靠在当前栈帧向下布局。每个函数的栈帧中从高地址向下通常大致划分为三个区域,首先是存放当前函数的局部变量以及Caller Save寄存器的值,接下来是动态栈空间(如果当前函数使用并分配了动态栈空间的话),再往下靠近被调用函数栈帧的区域是用来存放那些无法通过寄存器传递的子函数参数值的。结合着上述栈帧布局的示意,读者应该更直观的体会:前面讲寄存器使用约定时的Caller Save和Callee Save寄存器分别存在哪个函数对应的栈帧,以及前面讲到函数调用约定时用栈传参时参数是存放在哪个函数的栈帧上。

大多数情况下,函数只需要用$sp来管理栈帧。如果在编译时能够确定栈帧的大小,那么在进入一个函数时,可以一次性分配所需的栈空间(一次性意味着后面操作当前栈帧过程中$sp的值不再变化),同时栈帧中的内容相对于$sp的偏移也是容易计算的。不过,如果一个函数的栈帧在编译期无法确定大小,例如C语言中使用alloca()调用分配动态栈空间,会使得在操作当前栈帧的过程中$sp的值发生变化,这就会导致栈帧中的内容相对于$sp的偏移值计算起来变得麻烦。于是引入了栈帧指针(Frame Pointer)。它总是指向当前活跃栈帧的起始位置,而在操作当前栈帧过程中这个位置总是固定不变,因此在栈帧指针的协助下,能够方便维护栈帧中内容位置的计算。最常见地,在释放当前栈帧时,$sp不用再重新计算它应该向上移动多少位置,可以直接利用$fp中的值来快速恢复。

下面来看几个例子,来帮助读者建立起直观的认识。

extern int foo(int a, int b, int c, int d, int e, 
               int f, int g, int h, int i, int j); 

int normal(void) {
    return foo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
}

上述C程序如果用gcc -O2 -S来编译,则生成如下的汇编代码(为方便阅读,已将形如$r##的寄存器名替换为寄存器使用约定中的ABI别名)。可以看到这里只通过sp来确定各内容在栈中的位置。

normal:
    addi.w      $sp, $sp, -32       #分配栈帧
    addi.w      $t0, $zero, 10      #第10个参数的值
    st.w        $t0, $sp, 4         #通过栈向foo传第10个参数
    addi.w      $t0, $zero, 9       #第9个参数的值
    st.w        $t0, $sp, 0         #通过栈向foo传第9个参数
    addi.w      $a7, $zero, 8       #通过寄存器向foo传第8个参数
    addi.w      $a6, $zero, 7       #通过寄存器向foo传第7个参数
    addi.w      $a5, $zero, 6       #通过寄存器向foo传第6个参数
    addi.w      $a4, $zero, 5       #通过寄存器向foo传第5个参数
    addi.w      $a3, $zero, 4       #通过寄存器向foo传第4个参数
    addi.w      $a2, $zero, 3       #通过寄存器向foo传第3个参数
    addi.w      $a1, $zero, 2       #通过寄存器向foo传第2个参数
    addi.w      $a0, $zero, 1       #通过寄存器向foo传第1个参数
    st.w        $ra, $sp, 28        #在栈中保存ra
    bl          %plt(foo)           #调用foo函数
    ld.w        $ra, $sp, 28        #从栈中恢复ra
    addi.w      $sp, $sp, 32        #释放栈帧
    jr          $ra                 #函数返回

如果前述C程序用gcc -O2 -fno-omit-frame-pointer来编译,使其强行使用栈帧指针,则生成如下的代码。可以看到函数一开始会将$fp的旧值压入栈中,然后将其指向当前栈帧的起始位置处。

normal:
    addi.w      $sp, $sp, -32       #分配栈帧
    st.w        $fp, $sp, 24        #在栈中保存fp
    st.w        $ra, $sp, 28        #在栈中保存ra
    addi.w      $fp, $sp, 32        #fp指向栈帧起始位置
    addi.w      $t0, $zero, 10      #第10个参数的值
    st.w        $t0, $sp, 4         #通过栈向foo传第10个参数
    addi.w      $t0, $zero, 9       #第9个参数的值
    st.w        $t0, $sp, 0         #通过栈向foo传第9个参数
    addi.w      $a7, $zero, 8       #通过寄存器向foo传第8个参数
    addi.w      $a6, $zero, 7       #通过寄存器向foo传第7个参数
    addi.w      $a5, $zero, 6       #通过寄存器向foo传第6个参数
    addi.w      $a4, $zero, 5       #通过寄存器向foo传第5个参数
    addi.w      $a3, $zero, 4       #通过寄存器向foo传第4个参数
    addi.w      $a2, $zero, 3       #通过寄存器向foo传第3个参数
    addi.w      $a1, $zero, 2       #通过寄存器向foo传第2个参数
    addi.w      $a0, $zero, 1       #通过寄存器向foo传第1个参数
    bl          %plt(foo)           #调用foo函数
    ld.w        $ra, $sp, 28        #从栈中恢复ra
    ld.w        $fp, $sp, 24        #从栈中恢复fp
    addi.w      $sp, $sp, 32        #释放栈帧
    jr          $ra                 #函数返回

我们看到上面的汇编中$fp并没有用来做什么事情,维护它的代码是冗余的,下面我们所要看看例子中将实际用到$fp

#include <alloca.h>

extern int foo(int a, int b, int c, int d, int e,
               int f, int g, int h, int i, int j);

int dynamic(unsigned int size) {
    int *p = alloca(size);
    p[0] = 0x123;
    return foo((int)p, p[0], 3, 4, 5, 6, 7, 8, 9, 10);
}
dynamic:
    addi.w      $a0, $a0, 15        #size向上对齐到16整倍数,步骤1/3
    addi.w      $sp, $sp, -32       #分配栈帧中固定大小的部分
    srli.w      $a0, $a0, 4         #size向上对齐到16整倍数,步骤2/3
    st.w        $fp, $sp, 24        #在栈中保存fp
    st.w        $ra, $sp, 28        #在栈中保存ra
    addi.w      $fp, $sp, 32        #fp指向栈帧起始位置
    slli.w      $a0, $a0, 4         #size向上对齐到16整倍数,步骤3/3
    sub.w       $sp, $sp, $a0       #在栈上分配alloca申请的空间
    addi.w      $a0, $sp, 16        #p数组的起始地址,通过寄存器向foo传第1个参数
    addi.w      $t0, $a0, 291       #0x123
    st.w        $t0, $a0, 0         #向p[0]存入0x123
    addi.w      $t0, $zero, 10      #第10个参数的值
    st.w        $t0, $sp, 4         #通过栈向foo传第10个参数
    addi.w      $t0, $zero, 9       #第9个参数的值
    st.w        $t0, $sp, 0         #通过栈向foo传第9个参数
    addi.w      $a7, $zero, 8       #通过寄存器向foo传第8个参数
    addi.w      $a6, $zero, 7       #通过寄存器向foo传第7个参数
    addi.w      $a5, $zero, 6       #通过寄存器向foo传第6个参数
    addi.w      $a4, $zero, 5       #通过寄存器向foo传第5个参数
    addi.w      $a3, $zero, 4       #通过寄存器向foo传第4个参数
    addi.w      $a2, $zero, 3       #通过寄存器向foo传第3个参数
    addi.w      $a1, $zero, 291     #通过寄存器向foo传第2个参数
    bl          %plt(foo)           #调用foo函数
    addi.w      $sp, $fp, -32       #释放掉当前栈帧通过alloca动态分配的部分
    ld.w        $ra, $sp, 28        #从栈中恢复ra
    ld.w        $fp, $sp, 24        #从栈中恢复fp
    addi.w      $sp, $sp, 32        #释放掉当前栈帧余下的固定大小的部分
    jr          $ra                 #函数返回

上面的代码中size要向上对齐到16整倍数的原因是LA32R的ABI要求栈帧16字节对齐。