Archipelago

Lec 5调用约定、栈与内存布局

讲义完成更新于 2026-09-29

Lec 5 调用约定、栈与内存布局

TL;DR

  • 调用约定(Calling Convention)是软件与编译器间的 ABI 契约,规定 a0–a7 传参、a0–a1 返回,解决寄存器使用冲突。
  • 寄存器划分为 Caller-saved(t0–t6/a0–a7/ra,调用者自保)与 Callee-saved(s0–s11/sp,被调用者需压栈保护并复原)。
  • 栈从高地址向低地址增长,栈帧(Stack Frame)保存跨调用寄存器、超额参数(>8 个)及生命周期跨调用的局部变量。
  • 叶子过程不触发子调用可零栈开销运行;非叶子过程必须压栈保存返回地址 ra;尾递归可被编译器直接优化为寄存器循环。
  • 进程虚拟内存布局由低到高划分为保留区、代码段(Text)、静态数据段(Static)、堆(向上增长)与栈(向下增长)。

本节内容

本节按"调用过程 → 活动记录 → 栈 → 内存布局"的顺序展开。我们先讲调用约定:函数之间如何传参、返回、共享寄存器(caller-saved vs callee-saved);然后讲当寄存器不够用时,数据如何溢出到栈上,引出活动记录(栈帧)和帧指针;接着用 leaf_example、swap、fact、sort 等例题把翻译流程走通,并讲清嵌套过程与递归;最后给出完整的内存布局(text/static/heap/stack)以及堆的动态分配。

寄存器的命名与角色、jal/jalr 的指令格式已在 Lec 4 介绍,本节聚焦它们在过程调用中的用法与约定。


一、调用约定与调用过程

1.1 什么是调用约定

函数调用中,如何传递参数和返回值?谁负责保存哪些寄存器?这需要一套调用约定(calling convention)。

调用约定

在不同过程(procedure,即函数/子程序)之间,关于如何使用寄存器、如何传参与返回的一套规则。它不是硬件强制的,而是软件(编译器、汇编程序员)之间的约定——大家都遵守,独立编译的代码才能正确互相调用。

过程(procedure)是根据参数执行特定任务的存储子程序。执行一个过程要走六个步骤:

  1. 将参数放到过程能访问到的地方(寄存器 a0–a7,或栈)。
  2. 将控制权转移到过程(jal)。
  3. 过程获取所需的存储资源(必要时在栈上开栈帧)。
  4. 执行任务。
  5. 将结果放到调用者能访问到的地方(a0–a1)。
  6. 将控制权返回调用点(jalr,因为同一过程可能从多处被调用)。

1.2 控制转移指令

控制权的转移依赖两条跳转指令:

  • jal x1, ProcedureAddress(jump-and-link):跳到目标过程,同时把下一条指令地址(PC+4)保存到 x1(ra)作为返回地址。"link"就是这个被保存的、指向调用点的链接,让过程执行完能跳回来。
  • jalr x0, 0(x1)(jump-and-link register):跳到 x1 保存的地址,即回到调用者。目标寄存器写 x0 表示不保存新的返回地址(只是"回去",不需要再记从哪回去)。

所以:调用者用 jal x1, X 跳到过程 X,被调用者执行完后用 jalr x0, 0(x1) 跳回。

jal 也可用作纯无条件跳转(不保存返回地址):jal x0, Label,写入 x0 等于丢弃返回地址。

1.3 参数与返回值寄存器

符号名寄存器描述
a0 到 a7x10 到 x17函数参数
a0 到 a1x10 到 x11函数返回值

第一个参数在 a0,第二个在 a1……以此类推。调用者有责任在调用前把参数放进 a0–a7。

RISC-V 过程调用参数传递与 a0-a7 寄存器映射示意图

第一个返回值在 a0,第二个在 a1。被调用者有责任在返回前把返回值放进 a0–a1。

RISC-V 过程调用返回值通过 a0 与 a1 传递机制示意图

1.4 caller-saved vs callee-saved

如果 caller 和 callee 都要用同一个寄存器怎么办?这正是调用约定要解决的核心冲突。RISC-V 把寄存器分成两类共享策略:

  • 被调用者保存(callee-saved):如 s0–s11、sp。约定它们跨调用保持不变。callee 若要使用,必须先存到栈、返回前恢复,使 caller 看到的值不变。
  • 调用者保存(caller-saved):如 t0–t6、a0–a7、ra。callee 可以随意覆盖。caller 若在调用后还需要原值,得自己在调用前先保存。

这套划分的意义在于减少不必要的保存:临时用一下的值放 t/a 寄存器(callee 随便用,caller 用完即弃);需要跨越函数调用存活的值放 s 寄存器(callee 负责守护)。两边各自只保存"自己真正在乎"的寄存器。

跨调用保存规则总结

需要保存(跨调用不变)不需要保存(可能被覆盖)
saved 寄存器:s0–s11临时寄存器:t0–t6
栈指针:sp参数/返回值寄存器:a0–a7
帧指针:fp(如果使用)返回地址:ra(caller-saved)
栈中 sp 以上的内容

保护栈的方式有三条:callee 不写 sp 以上的区域(保护栈内容);callee 退出时把 sp 加回减去的等量值(保护 sp 本身);callee 把要用的 saved 寄存器先存栈再恢复(保护寄存器值)。

1.5 完整寄存器约定表

寄存器符号名英文用途跨调用保存者
x0zeroHardwired zero硬件固定为 0—
x1raReturn address返回地址调用者
x2spStack pointer栈指针被调用者
x3gpGlobal pointer全局指针—
x4tpThread pointer线程指针—
x5–x7t0–t2Temporaries临时寄存器调用者
x8s0/fpSaved register / Frame pointer保存寄存器/帧指针被调用者
x9s1Saved register保存寄存器被调用者
x10–x11a0–a1Function arguments / return values参数和返回值调用者
x12–x17a2–a7Function arguments函数参数调用者
x18–x27s2–s11Saved registers保存寄存器被调用者
x28–x31t3–t6Temporaries临时寄存器调用者

大多数程序可以使用多达八个参数寄存器、十二个保存寄存器和七个临时寄存器而无需访问内存。


二、栈与活动记录

2.1 寄存器不够用 → 溢出到栈

如果一个过程需要的存储超过了寄存器容量怎么办?

Sol:分配一块专门的内存区域来存放,过程完成后必要时再恢复——这叫把寄存器溢出到内存(spill registers to memory)。实现它的理想结构就是栈(stack):一个后进先出(LIFO)的队列,配一个指针指示最近分配的地址,也就是下一个溢出值该放哪。

过程调用中寄存器溢出至栈内存与栈指针向下增长示意图

RISC-V 栈的几个基本性质:

  • 栈从高地址向低地址增长,后进先出(LIFO)。
  • SP(栈指针,sp/x2) 指向栈顶。
  • push(压栈):先腾空间,再存数据。
    addi sp, sp, -8   # 分配空间(doubleword 占 8 字节)
    sd   a1, 0(sp)    # 把元素放进去
  • pop(弹栈):先取数据,再移指针。
    ld   a1, 0(sp)    # 把元素拿走
    addi sp, sp, 8    # 释放空间

任何过程都可以使用栈,但返回前必须把栈恢复成进入时的样子,即 sp 必须重置为过程开始时的值。

2.2 活动记录(栈帧)

活动记录(activation record,也叫栈帧 stack frame)保存一个过程中所有放不进寄存器的存储需求:需要保存的寄存器、返回地址、较大的局部变量等。它分配在栈上,遵循 LIFO。当前正在执行的过程的活动记录永远位于栈顶。

2.3 叶子过程与非叶子过程

  • 叶子过程(leaf procedure)不调用其他过程。它内部不会发生 jal,ra 不会被覆盖,所以不需要保存 ra;如果只用临时寄存器(t)和参数寄存器(a),甚至连栈都不用碰——最简单的情况。
  • 非叶子过程(non-leaf procedure)会调用其他过程。一旦执行 jal x1, ...,ra 就被新返回地址覆盖,所以必须在调用前把 ra 压栈,返回前再恢复。同样,若用了 saved 寄存器(s),也必须先存后恢复。

2.4 帧指针(frame pointer)

有些 RISC-V 编译器用帧指针 fp(即 x8/s0)指向当前栈帧的第一个 doubleword。好处是:fp 在过程执行期间保持不变,而 sp 可能在过程体中被进一步调整(如为局部数组分配空间)。如果 sp 中途变化,同一个局部变量在不同位置就需要不同偏移来访问,代码更难读。帧指针提供一个稳定的基地址,所有栈上变量都用相对 fp 的固定偏移访问。

帧指针在调用时用 sp 的值初始化,返回时用 fp 恢复 sp。如果过程内部不改变 sp(只在入口和出口各调整一次),编译器会省略帧指针以节省一条指令——RISC-V 的 C 编译器只在过程体中会改变 sp 时才使用帧指针。

2.5 C 的两种存储类别

C 变量除了类型(int、char…),还有存储类别(storage class):

  • 自动变量(automatic):过程内部的局部变量,过程退出就消失,对应栈上的空间。
  • 静态变量(static):整个程序生命周期内存在,不随过程退出消失。所有过程外部声明的变量默认是 static,过程内用 static 关键字声明的也是。

RISC-V 编译器保留 x3(gp,全局指针)指向静态数据区(static data segment),方便访问全局变量和常量。


三、过程翻译:三步法与例题

教材给出一个通用的 C → 汇编翻译方法:

  1. 寄存器分配(register allocation)——参数用 a0–a7,局部变量用 saved 或 temporary 寄存器。
  2. 生成过程体代码(produce code for the body)——逐行翻译运算、控制流。
  3. 保存和恢复寄存器(preserve registers)——按调用约定,在过程开头把需要的寄存器存栈,结尾恢复。

下面的例题都按这三步展开。

例题 1

leaf_example——翻译并理解栈操作

long long int leaf_example (long long int g, long long int h,
                            long long int i, long long int j)
{
  long long int f;
  f = (g + h) - (i + j);
  return f;
}

Solution:

  • 参数 g、h、i、j 对应 x10、x11、x12、x13。
  • 局部变量 f(既非参数也非返回值)对应 x20。它不能用参数寄存器(x10–x17),也不能用随时可能被覆盖的临时寄存器。
  • 编译后从标签 leaf_example: 开始。
  • 下一步保存寄存器。由于赋值语句需要两个临时寄存器(x5、x6),加上 x20,先把它们压栈。压栈分两步:在栈上分配空间、把旧值存进去。这里 sp 减 24 字节(每个寄存器 8 字节 × 3)。
addi sp, sp, -24 ; 腾出 3 个 doubleword 的空间
sd   x5, 16(sp)  ; 保存 x5
sd   x6, 8(sp)   ; 保存 x6
sd   x20, 0(sp)  ; 保存 x20

leaf_example 栈帧

接着是过程主体与收尾:

add  x5, x10, x11 ; x5 = g + h
add  x6, x12, x13 ; x6 = i + j
sub  x20, x5, x6  ; f = (g + h) - (i + j)
addi x10, x20, 0  ; 返回值 f 拷贝到 a0
; 恢复三个寄存器
ld   x20, 0(sp)
ld   x6, 8(sp)
ld   x5, 16(sp)
addi sp, sp, 24   ; 弹出 3 个 doubleword
jalr x0, 0(x1)    ; 跳回调用者

优化:上面保存了 x5、x6、x20 三个寄存器,但 x5、x6 是临时寄存器(caller-saved),callee 不必保存。实际上只有 x20(saved 寄存器)才必须由 callee 保存。优化后只需保存/恢复 x20,省掉 x5、x6 的两次 sd 和两次 ld,共减少四条指令。

例题 2

swap——叶子过程的最简形式

void swap(long long int v[], long long int k)
{
    long long int temp;
    temp = v[k];
    v[k] = v[k+1];
    v[k+1] = temp;
}

第一步,寄存器分配:v 在 x10,k 在 x11,局部变量 temp 给 x5(叶子过程可用临时寄存器)。

第二步,过程体。常见易错点:内存按字节寻址,doubleword 相隔 8 字节,所以索引 k 要先乘以 8(左移 3 位)再加基地址:

swap:
  slli x6, x11, 3  ; x6 = k * 8
  add  x6, x10, x6 ; x6 = v + k*8,即 &v[k]
  ld   x5, 0(x6)   ; temp = v[k]
  ld   x7, 8(x6)   ; x7 = v[k+1](相邻元素 +8)
  sd   x7, 0(x6)   ; v[k] = v[k+1]
  sd   x5, 8(x6)   ; v[k+1] = temp
  jalr x0, 0(x1)   ; 返回调用者

第三步,保存寄存器:swap 是叶子过程,只用了临时寄存器(x5、x6、x7),没用 saved 寄存器,所以不需要任何保存/恢复。开头就是过程体,结尾就是 jalr 返回——这是叶子过程的最简形式。


四、嵌套过程与递归

嵌套过程(nested procedures)是指一个被调用过程在执行期间,自身又作为调用者发起了对其他子过程的调用。此时会出现一个致命的冲突:新的 jal 指令会无情地将新的返回地址覆写到 ra(x1)寄存器中。如果不预先保护,当子过程返回后,父过程将彻底丢失自己当初由祖父过程传入的原始返回地址,系统陷入无法返回的死循环。

递归(recursion)则是嵌套过程的极端特例:过程自身调用自身。由于每一层递归都具备独立的局部参数与执行上下文,必须由调用约定和栈结构配合,为每一层递归调用开辟独立的活动记录(栈帧),以维护完整的动态调用链。

例题

阶乘 fact——经典递归翻译与栈帧展开

考虑最基础的阶乘递归算法:

long long int fact (long long int n)
{
    if (n < 1) return 1;
    else return (n * fact(n - 1));
}

在汇编翻译时,我们需要明确两点核心约束:

  1. fact 内部会执行 jal x1, fact,因此返回地址寄存器 ra(x1)必须在调用前压栈保存。
  2. 实参 n 存放在 x10(Caller-saved 寄存器)。当准备递归调用 fact(n-1) 时,必须把新的实参 n-1 放入 x10,这意味着原本的 n 会被子调用的实参和最终返回值覆写。但后续执行乘法 n * fact(n-1) 时仍然需要原先的 n,因此当前的 n 也必须在调用前压栈保存。

每个栈帧需要容纳两个 64 位(8 字节)数据,因此每次进入过程需分配 16 字节空间(sp = sp - 16):

fact:
  addi sp, sp, -16  ; 在栈顶开辟 2 个 doubleword(16字节)的活动记录
  sd   x1, 8(sp)    ; 保存当前的返回地址 ra
  sd   x10, 0(sp)   ; 保存当前的参数 n
  addi x5, x10, -1  ; 计算 x5 = n - 1
  bge  x5, x0, L1   ; if (n - 1) >= 0 则跳转到递归分支 L1

  ; --- 基准情形(Base Case):n < 1 ---
  addi x10, x0, 1   ; 返回值 a0 = 1
  addi sp, sp, 16   ; 回收当前栈帧(此时 x1 与 x10 未被破坏,无需从栈恢复)
  jalr x0, 0(x1)    ; 跳回上一层调用者

L1:
  ; --- 递归调用分支 ---
  addi x10, x10, -1 ; 构造子调用的实参:a0 = n - 1
  jal  x1, fact     ; 发起递归调用,跳转到 fact 并将返回地址写入 x1
  addi x6, x10, 0   ; 子调用返回,x6 暂存 fact(n-1) 的返回值

  ; --- 栈帧现场恢复与展开 ---
  ld   x10, 0(sp)   ; 从栈中恢复属于本层调用的原始参数 n
  ld   x1, 8(sp)    ; 从栈中恢复属于本层调用的返回地址 ra
  addi sp, sp, 16   ; 释放本层栈帧空间
  mul  x10, x10, x6 ; 计算最终返回值:a0 = n * fact(n-1)
  jalr x0, 0(x1)    ; 携带返回值跳回上层调用点

栈帧执行细节与对齐规约

  1. RISC-V 16 字节栈对齐硬约束:RISC-V 64 位 ABI 规范严格要求栈指针 sp 在任何函数调用边界处都必须保持 16 字节边界对齐。在上述 fact 例子中,保存 ra(8 字节)和 n(8 字节)恰好等于 16 字节;如果一个函数仅需保存 1 个 8 字节寄存器,编译器依然必须扣减 16 字节(addi sp, sp, -16),留下 8 字节的未用填充(padding)。这一对齐约定保证了后续加载 128 位宽总线数据或 SIMD/向量寄存器时,不会引发由于跨缓存行造成的性能降级或非对齐硬件陷阱。
  2. 基准情形的分支直达优化:注意观察基准情形(n < 1)的代码路径:程序直接将 1 赋给 x10,回收栈帧后立即执行 jalr x0, 0(x1) 返回。在此路径上,由于进入函数后并未发生二次子调用,寄存器 x1(ra)和 x10(n)的值从未被破坏。编译器因此能够直接省去从内存执行 ld 的反向读取指令,实现了零内存读开销的短路退出。
  3. 递归回溯的动态求值链:在执行 fact(3) 时,物理栈上会线性叠放三个栈帧。只有当叶子节点的 fact(0) 返回 1 时,回溯计算才被激活。每一层在弹栈时还原对应的 n,完成一次乘法并继续向上反弹,直至最终将控制权与最终积移交回初始调用者。

尾递归优化(tail recursion)

传统递归如上述 fact 之所以必须保留全部栈帧,是因为子调用返回后,父过程仍然有尚未完成的计算(即乘以 n 的乘法操作)。这意味着父过程的局部状态(n 和 ra)必须常驻栈中等待子过程结算。当递归深度达到上万层时,不断堆叠的栈帧会迅速消耗数百 KB 甚至数 MB 内存,最终引发栈溢出(Stack Overflow)段错误。

而尾调用(tail call)指的是某个调用的返回值直接作为当前过程的返回值,返回后当前过程不需要做任何额外计算。当尾调用指向自身时,即称为尾递归。

考虑利用累加器(Accumulator)改写后的求和函数:

long long int sum(long long int n, long long int acc) {
    if (n > 0) return sum(n - 1, acc + n);
    else       return acc;
}

在该版本中,状态累加在传参前就已经在寄存器中计算完成(acc + n),递归调用 sum 是函数的绝对最后一个动作。由于返回后无需任何运算,调用者根本不需要在子调用返回时回到这里,而是可以直接让子调用把结果直接返回给祖父调用者。

更进一步,现代编译器识别出尾递归后,甚至不需要重新分配栈帧或使用 jal,而是直接原地更新实参寄存器并无条件跳回函数入口,将递归直接降级为一个高效的原地循环:

sum:
  ble  x10, x0, sum_exit  ; 若 n <= 0 则达到终止条件,直接退出
  add  x11, x11, x10      ; 累加:acc = acc + n(利用寄存器就地更新)
  addi x10, x10, -1       ; 步进:n = n - 1
  jal  x0, sum            ; 原地跳回 sum 开头(无条件跳转,x0丢弃返回地址,循环复用当前状态)
sum_exit:
  addi x10, x11, 0        ; 返回值 a0 = acc
  jalr x0, 0(x1)          ; 一次性直接返回到最初的外部调用者

编译器降级机制与体系结构收益

  1. Jump 替代 Call 的硬件秘密:常规过程调用使用 jal x1, label,它会强行覆盖 x1(ra);而编译器对尾调用优化后生成的指令是 jal x0, sum。因为写入的目标寄存器是硬连线恒为零的 x0,硬件会直接丢弃生成的返回地址。这意味着最初由调用者传入的原始返回地址始终安全无虞地保留在 x1 中。当循环终止时,单条 jalr x0, 0(x1) 即可瞬间跨越万次迭代,直接返回到最顶层的外部调用点。
  2. 空间复杂度从 O(n)O(n) 到 O(1)O(1) 的蜕变:优化后的汇编完全消除了 addi sp、sd 和 ld 等全部访存操作。调用栈的物理开销被彻底清零,将原本可能导致系统崩溃的深层递归,转换为了在 CPU 核心内部寄存器间极速流转的紧凑流水线。对于现代超标量处理器而言,消除了内存总线事务意味着 100% 的 L1 Cache 命中与更高的指令吞吐量。

4.3 综合实战:sort 排序的完整过程栈帧翻译

例题 3

sort——非叶子过程 + 嵌套循环 + 过程调用的完整翻译

排序算法是检验调用约定理解的试金石。考虑以下典型的 C 语言冒泡排序实现,其内部嵌套调用了之前实现的 swap 过程:

void sort(long long int v[], long long int n)
{
    long long int i, j;
    for (i = 0; i < n; i += 1) {
        for (j = i - 1; j >= 0 && v[j] > v[j+1]; j -= 1) {
            swap(v, j);
        }
    }
}

按照标准的三步法对该非叶子过程进行汇编拆解:

第一步:寄存器分配与生存期分析

  • 入参分配:数组基址 v 传入 x10,长度 n 传入 x11。
  • 寄存器冲突:sort 内部需要频繁调用 swap(v, j)。而 swap 同样遵循调用约定,要求把自己的参数放在 x10 和 x11 中。如果 sort 直接使用 x10 和 x11 维护 v 和 n,第一次执行 swap 后它们就会被立即摧毁。因此,sort 必须把自己的实参搬迁到跨调用稳定的 Callee-saved 寄存器中:x21 保存 v,x22 保存 n。
  • 循环变量:外层索引 i 分配给 x19,内层索引 j 分配给 x20(两者同属 Callee-saved 寄存器)。

第二步:栈帧规划与现场保护(Prologue)

  • sort 总共使用了 5 个跨调用必须保护的寄存器:返回地址 ra(x1,非叶子过程必存)以及 x19、x20、x21、x22。
  • 寄存器选择的代价考量:为什么不选用易失的 Caller-saved 寄存器(如 t0-t3)?若使用 t 系列寄存器保存 v, n, i, j,由于内层循环频繁调用 swap,每次子调用前后都必须强制执行对应的 sd 与 ld 保存恢复操作,将直接导致栈访存开销退化为极其沉重的 O(n2)O(n^2)。相反,在函数入口(Prologue)一次性将 4 个 Callee-saved 寄存器和 ra 压栈,在退出(Epilogue)统一恢复,仅产生 O(1)O(1) 的常数级内存交互,极大地保护了循环核心路径的执行效率。
  • 栈空间计算:5 个 64 位双字共需 40 字节。按 16 字节对齐规则,40 向上取整到 48 字节?但在简单模型中扣减 40 字节(addi sp, sp, -40),将 5 个寄存器线性排布在 0(sp) 到 32(sp)。

第三步:循环逻辑与精简寻址展开

  • RISC 架构的显式寻址:CISC 架构(如 x86)原生支持类似 mov rax, [rdi + rsi*8] 的复合比例变址寻址,而 RISC-V 坚持精简流水线原则,将内存寻址彻底限制在 base + immediate。因此访问数组元素 v[j] 时,必须显式拆分为两步:首先执行算术左移 slli x5, x20, 3 计算物理字节偏移(j×8j \times 8),再执行 add x5, x21, x5 计算有效物理地址 &v[j]\&v[j]。
  • 相邻元素的偏置复用:获取 v[j+1]v[j+1] 时无需重新计算基址,直接利用 ld x7, 8(x5) 的立即数偏置寻址即可无缝命中相邻双字。
  • 分支短路:利用 ble x6, x7, exit2 实现条件判断,一旦逆序违背立即提前跳出内层循环,精准还原 C 语言的短路求值逻辑。
; ---- 保存寄存器(prologue) ----
  addi sp, sp, -40   ; 为 5 个双字寄存器腾出栈空间
  sd   x1, 32(sp)    ; 保存 ra(sort 是非叶子过程,调用 swap 会覆盖 ra)
  sd   x22, 24(sp)   ; 保存原调用者的 s2
  sd   x21, 16(sp)   ; 保存原调用者的 s1
  sd   x20, 8(sp)    ; 保存原调用者的 s0
  sd   x19, 0(sp)    ; 保存原调用者的 s3

; ---- 拷贝参数到安全寄存器 ----
  mv   x21, x10      ; x21 = v(避免被后续 swap 调用参数覆写)
  mv   x22, x11      ; x22 = n

; ---- 外层循环 for (i = 0; i < n; i++) ----
  li   x19, 0        ; i = 0
for1tst:
  bge  x19, x22, exit1   ; 若 i >= n,外层循环结束,跳到 exit1
  addi x20, x19, -1      ; j = i - 1

; ---- 内层循环 for (j = i - 1; j >= 0 && v[j] > v[j+1]; j--) ----
for2tst:
  blt  x20, x0, exit2    ; 若 j < 0,内层循环退出
  slli x5, x20, 3        ; 元素偏移:x5 = j * 8 字节
  add  x5, x21, x5       ; 计算物理地址:x5 = v + j*8 = &v[j]
  ld   x6, 0(x5)         ; 加载 x6 = v[j]
  ld   x7, 8(x5)         ; 加载 x7 = v[j+1]
  ble  x6, x7, exit2     ; 若 v[j] <= v[j+1],满足排序顺序,退出内层循环

; ---- 发起子调用 swap(v, j) ----
  mv   x10, x21          ; 子过程参数 1:a0 = v
  mv   x11, x20          ; 子过程参数 2:a1 = j
  jal  x1, swap          ; 调用 swap 过程(覆写 ra)
  addi x20, x20, -1      ; j -= 1
  j    for2tst           ; 继续内层循环测试

exit2:
  addi x19, x19, 1       ; i += 1
  j    for1tst           ; 继续外层循环测试

exit1:
; ---- 现场还原与退出(epilogue) ----
  ld   x19, 0(sp)        ; 逆序恢复寄存器原始值
  ld   x20, 8(sp)
  ld   x21, 16(sp)
  ld   x22, 24(sp)
  ld   x1, 32(sp)        ; 恢复最初传入的返回地址 ra
  addi sp, sp, 40        ; 回收栈帧
  jalr x0, 0(x1)         ; 安全返回最顶层调用者

工程反思与过程内联优化 在上述完整翻译中,C 语言短短 9 行代码扩展成了 34 条汇编指令。其中有相当一部分开销集中在 swap 的传参和寄存器保护上。

  • 过程内联(Procedure Inlining):如果编译器在优化级别启用内联,将 swap 的四条指令直接展开到内层循环体内,不仅能省去调用前的参数传递(mv)和跳转指令,而且 sort 自身瞬间退化为一个叶子过程,从而连 ra 都不需要压栈保护。
  • 内联的代价:内联虽快,但会复制指令。如果被调过程体积大或被引用次数多,会导致可执行文件膨胀,进而可能抬高指令 Cache(I-Cache)缺失率,反而降低整体吞吐。

4.4 经典真题剖析:递归调用的栈状态追踪

栈追踪(2023 春季 Q4 第 5 题)

下面这段代码计算以 a1 为底 a0 的对数,涉及 ilog2(求以 2 为底的整数对数)和 idiv(整数除法)两个递归过程。题目在若干时间点记录内存和寄存器的值,要求据此填表推导。

jal ra, log_a_x        # TIME POINT 0(本行执行后)

ilog2:                 # 求 a0 的 ilog2
  addi sp, sp, -4
  sw   ra, 0(sp)
  addi t1, zero, 1
  blt  t1, a0, ilog_else
  addi a0, zero, 0
  beq  zero, zero, ilog_ret
ilog_else:
  srli a0, a0, 1
  jal  ra, ilog2
  addi a0, a0, 1
ilog_ret:
  lw   ra, 0(sp)
  addi sp, sp, 4
  jalr zero, 0(ra)

idiv:                  # 求 a0 / a1
  addi sp, sp, -4
  sw   ra, 0(sp)
  addi t1, zero, 0
  bge  a0, a1, idiv_else
  addi a0, zero, 0
  beq  zero, zero, idiv_ret
idiv_else:
  sub  a0, a0, a1
  jal  ra, idiv
  addi a0, a0, 1
idiv_ret:
  lw   ra, 0(sp)
  addi sp, sp, 4
  jalr zero, 0(ra)

log_a_x:               # 计算以 a1 为底 a0 的对数
  addi sp, sp, -12
  sw   ra, 0(sp)
  sw   s0, 4(sp)
  sw   s1, 8(sp)
  add  s0, a0, zero    # TIME POINT 1(第 38 行执行后)
  addi a0, a1, 0
  jal  ra, ilog2
  addi s1, a0, 0
  addi a0, s0, 0       # TIME POINT 2(第 42 行执行后)
  jal  ra, ilog2
  addi a1, s1, 0
  jal  ra, idiv
  lw   s1, 8(sp)       # TIME POINT 3(第 46 行执行后)
  lw   s0, 4(sp)
  lw   ra, 0(sp)
  addi sp, sp, 12
  jalr zero, 0(ra)     # TIME POINT 4(第 50 行执行后)

时间切片推导与栈布局机制解析

求解此类栈追踪题的核心心智模型,在于精确还原 sp 随着调用深入与返回的回缩过程,以及 Callee-saved 寄存器在栈帧槽位中的镜像备份:

  1. TIME POINT 0(入口起点):在外部发起 jal ra, log_a_x 刚刚执行完毕时,ra 记录了外部返回地址,此时 sp 处于初始高地址,尚未为 log_a_x 分配栈帧。
  2. TIME POINT 1(主函数环境建立):log_a_x 入口执行 addi sp, sp, -12,在栈上分配 3 个字(32位系统每个字4字节)的空间,分别将 ra、s0、s1 压入 0(sp)、4(sp)、8(sp)。此时形参 a0 被安全暂存到被调用者保护寄存器 s0 中,栈顶指针下移 12 字节。
  3. TIME POINT 2(第一次子递归归并):已经完成了第一次 ilog2(a1) 计算,计算结果被安全暂存至 s1。此时恢复了 a0 = s0(即原始的参数 x),正准备发起第二次递归调用 ilog2(x)。在此期间,ilog2 内部可能经历了多次嵌套压栈(每次 sp 下移 4 字节存 ra),但由于其内部严格执行了 lw ra 和 addi sp, sp, 4,此时 sp 已经完全复原到 TIME POINT 1 的基准位置。
  4. TIME POINT 3(核心运算结束):此时已完成 idiv 整数除法,最终的商已经存入 a0。代码刚刚执行完对 s0、s1 和 ra 的出栈还原,即将回收栈帧。
  5. TIME POINT 4(全程弹栈返回):执行 addi sp, sp, 12 彻底销毁了 log_a_x 的活动记录,sp 恢复为 TIME POINT 0 的初始高度,单条 jalr zero, 0(ra) 顺利跳回外部调用者。

五、内存布局

低地址
│
├── Reserved 区域(操作系统保留)
├── Text segment(代码段:程序的机器码)
├── Static data segment(全局变量、常量等)
├── Heap(堆:动态分配内存,如 malloc)
│          ↓(向高地址增长)
│
│          ↑(向低地址增长)
├── Stack(栈:局部变量、函数调用)
高地址

内存布局

这些地址只是软件约定,并不是 RISC-V 架构的一部分。用户地址空间设为 2382^{38},占总的 2642^{64} 地址空间。栈指针初始化为 0x0000 003f ffff fff0,并向下增长到数据段;程序代码("text")从 0x0000 0000 0040 0000 开始。

注意栈和堆朝彼此生长(栈从高地址向低地址、堆从低地址向高地址),这样可以灵活共享中间的空闲内存。

超过八个参数怎么办?

RISC-V 的约定是:前八个参数放寄存器 x10–x17,其余参数放到栈上,紧挨着帧指针,被调用者通过帧指针寻址。

堆的动态内存分配

静态数据区适合大小固定的数据(全局变量、常量、定长数组),但链表、树这类结构在运行时不断增长缩小,不适合放静态区。堆(heap)就是为动态数据准备的区段,紧挨静态数据区上方,向高地址增长。

C 用两个显式函数管理堆:malloc() 在堆上分配指定大小、返回指针;free() 释放之前分配的空间。完全手动控制内存的分配释放,这正是许多难调 bug 的来源:

  • 内存泄漏(memory leak):分配了却忘记释放。程序跑得越久,被"遗忘"的内存越多,最终可能耗尽可用内存导致崩溃。
  • 悬空指针(dangling pointer):过早释放了内存,却仍有指针指向那块已释放区域。之后通过它读写,会访问到意义不明的数据,或已被重新分配给别处的内存,导致不可预测的行为。

Java 为避免这两类 bug,采用自动内存管理和垃圾回收(garbage collection),程序员不需要手动 free。

核心机制思考与底层洞察

::: insight 栈帧契约与内存生命周期的软硬件闭环

  1. 栈不是硬件实体,而是软件用通用寄存器维持的抽象幻象:硬件 CPU 内部根本没有内置的“栈容器”,它只有一个普通的通用寄存器 x2(sp)。所谓的“压栈”和“弹栈”,在底层不过是 addi sp, sp, -16(移动地址指针)加上 sd/ld(内存总线事务)。理解这一点,就能彻底看穿缓冲区溢出(Stack Overflow)与栈破坏(Stack Smashing)攻击的本质——攻击者并非颠覆了某种黑盒硬件机制,而仅仅是通过越界写入,覆写了保存在栈内存上的 ra 目标地址,从而在被调函数执行 jalr zero, 0(ra) 时劫持 PC 指向恶意 shellcode。
  2. Caller 与 Callee 保存划分的经济学精算:ABI 之所以将 32 个寄存器严格二分为 Caller-saved 和 Callee-saved,完全是一场关于全局访存开销(Memory Traffic)的数学权衡。如果全部规定由 Caller 保存,那么无论子函数是否需要,调用者都不得不将所有活跃寄存器强行刷入慢速 DRAM;如果全部规定由 Callee 保存,深层嵌套中的每一个简单函数都会背负巨大的压栈还原开销。二分天下使得简单叶子函数享受“纯寄存器零访存”极速执行,而需要复杂跨调用生命周期的变量则托付给 s0–s11 稳定护航。
  3. 相向而生:堆与栈的几何对称性:Text 段只读、Static 段定长,唯有堆(低向高)与栈(高向低)在虚拟地址空间中相向生长。这种设计在有限虚拟内存时代最大化了两者共享自由内存空间的弹性。而在现代操作系统中,内核在栈底设置了带页保护的 Guard Page,并在堆栈之间留出巨大的虚拟地址留白。理解汇编层面的内存布局,是后续无缝理解虚拟内存(Paging)、缺页中断(Page Fault)与写时复制(COW)的唯一基石。 :::