C 函数怎样变成机器程序

本篇的核心问题是:C 里的函数、局部变量、参数和返回值,怎样落到寄存器、栈和内存地址上?第 03 篇已经把一条指令解释为 PC、寄存器和内存的状态转移;这一篇在 ISA 之上加一层 ABI(Application Binary Interface,应用二进制接口)。ISA 规定一条机器指令怎样改变架构状态,ABI 规定多个编译单元、函数和库怎样约定寄存器用途、参数位置、返回值位置、栈对齐和可调用边界。

目标 ABI 选为 RISC-V psABI 的 ILP32 方向:整数寄存器 XLEN 为 32,intlong、指针宽度都是 32 位。RISC-V psABI 的整数调用约定把 x10x17 命名为 a0a7,用于参数和返回值;x1 是返回地址 rax2 是栈指针 sps0s11 是被调用者保存寄存器。当前本机 Apple clang 21 不含 RISC-V 后端,不能生成真实 RV32I 目标文件,因此 RV32I ABI 只做规则和手算;同时用本机 AArch64 真实编译与反汇编展示“参数寄存器、返回值寄存器、返回地址和栈帧”这些 ABI 机制在另一套真实目标上怎样出现。

证据等级:手算、功能执行。手算部分追踪一个叶子函数和一个显式循环;功能执行部分复用 examples/computer-architecture/base/ 的 RV32I 子集执行器,证明同一套寄存器/内存约定可以执行求和与条件循环。工具缺口在文末单列。

C 语义先约束“允许什么”,ABI 再约束“放在哪里”

考虑一个短函数:

1
2
3
4
5
6
7
int sum5(const int *a) {
int s = 0;
for (int i = 0; i < 5; i++) {
s += a[i];
}
return s;
}

C 语言层面关心的是 a 指向至少 5 个 int,每次读取一个元素,累加不发生有符号溢出,最后返回 int。WG14 对 C11 的说明指出,公开可得的 N1570 是接近 C11 正式标准的工作草案;这里用它限定语言层事实。C 中有符号整数溢出属于未定义行为,不能拿“某台机器上补码回绕”的结果反推 C 语言保证。本文避开溢出,输入选择 3,4,5,6,7

ABI 层面关心另一组问题:指针参数放在哪个寄存器,返回值放在哪个寄存器,函数调用前后哪些寄存器必须保持,栈指针怎样调整。RISC-V psABI 规定前八个整数参数走 a0-a7,前两个返回值也走 a0-a1。如果 sum5 是叶子函数并且只用临时寄存器,它可以不建立栈帧;一旦需要调用其他函数、保存返回地址或保留 callee-saved 寄存器,就必须按约定维护栈。

一个叶子函数的寄存器分配

手写一个符合直觉的 RV32I 版本,可以这样分配:

C 对象 可能的寄存器 说明
参数 a a0 / x10 调用者传入的指针
循环计数 i t0 / x5 调用者不要求保留
累加器 s a0 / x10t1 返回前放入 a0
临时元素 t2 / x7 每次 lw 后累加

这张表不是唯一答案。编译器可以换寄存器,可以展开循环,可以把 s 直接放在 a0,也可以在优化级别不同的时候生成更多访存。ABI 只规定函数边界可见的事实:参数怎样进来,返回值怎样出去,哪些寄存器由谁负责恢复。函数内部的寄存器选择属于编译器优化空间。

为了和第 03、07 篇共用执行器,基础实验没有直接使用 a0/t0 名称,而使用更短的教学程序:x1 保存数组指针,x2 保存计数,x3 保存累加器,x4 保存当前元素。它不是 psABI 函数,只是同一计算的裸机指令流。

1
2
3
4
5
6
7
8
9
0x08000093  # addi x1,x0,128
0x00500113 # addi x2,x0,5
0x00000193 # addi x3,x0,0
0x0000a203 # lw x4,0(x1)
0x004181b3 # add x3,x3,x4
0x00408093 # addi x1,x1,4
0xfff10113 # addi x2,x2,-1
0xfe0118e3 # bne x2,x0,-16
0x08302a23 # sw x3,148(x0)

裸机程序的好处是边界清楚:没有 C 运行时、没有系统调用、没有链接器脚本、没有启动代码。代价也清楚:它不能证明真实编译器会生成同样的寄存器分配。

栈帧只在边界需要时出现

很多初学者会把“函数调用”直接等同于“必有栈帧”。这不准确。栈是 ABI 提供的一块内存约定,用来保存寄存器、放溢出参数、容纳局部对象和维持调用链。一个不调用其他函数、局部变量都能放进寄存器的叶子函数,可以没有栈帧。一个需要调用别的函数的函数,至少要处理返回地址和调用后仍需保留的值。

假设有一个函数:

1
2
3
4
int add_then_call(int x, int y) {
int z = x + y;
return helper(z) + z;
}

helper 调用会覆盖 ra,也可能覆盖调用者保存寄存器。若 z 在调用后还要使用,要么放到 callee-saved 寄存器并保存恢复,要么放到栈槽里。这里的“保存”不是 C 语法要求,而是 ABI 对跨函数边界的二进制协作要求。

伪指令和真实编码要分开

汇编里常见的 limvnop 往往是伪指令。它们便于人写,但不是机器必须拥有的真实 opcode。比如 mv x5,x6 通常可展开成 addi x5,x6,0nop 可展开成 addi x0,x0,0。第 03 篇中的机器码只出现真实编码,不依赖伪指令。

这个区分会影响调试。阅读反汇编时,工具可能把真实机器码显示成伪指令形式,也可能按基础指令显示。文章里讨论 ISA 语义时,应回到真实编码和字段;讨论程序员意图时,伪指令可以作为读写便利。

本机 AArch64 编译证据:同一个问题在真实目标上长什么样

为了不把工具缺口变成整篇文章的空洞,基础实验增加了一个本机可编译样例 examples/computer-architecture/base/src/abi_sample.c

1
2
3
4
5
6
7
8
NOINLINE int helper(int z) {
return z * 2 + 1;
}

NOINLINE int add_then_call(int x, int y) {
int z = x + y;
return helper(z) + z;
}

运行脚本会用 Apple clang 21 在本机 arm64 上生成汇编和反汇编:

1
2
3
cc -std=c11 -Wall -Wextra -pedantic -O0 -fno-omit-frame-pointer -S src/abi_sample.c -o outputs/abi_sample_aarch64.s
cc -std=c11 -Wall -Wextra -pedantic -O0 -fno-omit-frame-pointer -c src/abi_sample.c -o build/abi_sample.o
/Library/Developer/CommandLineTools/usr/bin/llvm-objdump -d build/abi_sample.o > outputs/abi_sample_aarch64_objdump.txt

add_then_call 的关键反汇编如下:

1
2
3
4
5
6
7
8
9
10
1c: d10083ff      sub sp, sp, #0x20
20: a9017bfd stp x29, x30, [sp, #0x10]
24: 910043fd add x29, sp, #0x10
...
40: b94007e0 ldr w0, [sp, #0x4]
44: 94000000 bl 0x44 <_add_then_call+0x28>
...
50: a9417bfd ldp x29, x30, [sp, #0x10]
54: 910083ff add sp, sp, #0x20
58: d65f03c0 ret

可下载附件:abi_sample.cabi_sample_aarch64.sabi_sample_aarch64_objdump.txtenvironment.txt

这段证据来自真实工具链,但它支撑的是 AArch64 ABI,不是 RISC-V ABI。w0/w1 是 AArch64 的参数/返回寄存器,x30 是 link register,x29 常用作 frame pointer;RISC-V 对应的是 a0/a1rasp/s0 这套命名。两者的共性是 ABI 都要解决同一类问题:参数从哪里进、结果从哪里出、调用后如何回到调用者、跨调用仍要用的值由谁保存。

RV32I 工具链验证的实际边界

写作时检查了本机工具:

1
2
Apple clang version 21.0.0 (clang-2100.3.34.2)
Target: arm64-apple-darwin27.0.0

尝试 clang --target=riscv32-unknown-elf -march=rv32i -mabi=ilp32 失败,错误为没有兼容的 RISC-V target。Apple llvm-objdump --version 只列出 AArch64、ARM、WebAssembly、x86 等 target。第 04 篇因此不填写“真实 RISC-V 编译并反汇编首个循环已完成”。这个缺口不阻止 ABI 概念和裸机轨迹教学,但它限制了本文能支持的结论:RV32I 部分是 psABI 规则、手算和教学功能执行,AArch64 部分才是本机真实编译/反汇编。

已完成的功能执行证据是:

1
{"halt":"pc_at_end","steps":29,"x0":0,"x1":148,"x3":25,"mem148":25,"mem160":0}

对应附件:sum.jsonl。这份轨迹只用于 RV32I 裸机手算对照,不替代 RISC-V 目标文件反汇编。

它说明手写裸机循环在教学执行器里得到了正确结果。它不说明编译器会选择同样的寄存器,也不说明 ABI 栈帧已经被真实目标文件验证。

验收

本篇验收标准是能区分三层事实。C 语言层负责表达“这个函数允许产生什么结果”;ABI 负责表达“函数边界上参数、返回值、返回地址和保存责任怎样约定”;机器码负责表达“每一条指令怎样改变寄存器、内存和 PC”。读者还应能指出 AArch64 真机反汇编与 RV32I 手算各自能证明什么,不能把一套 ABI 的寄存器名直接套到另一套 ISA 上。

练习一:参数和返回值在哪里

题目:在 RISC-V ILP32 调用约定下,int f(int x, int y) 的两个参数和一个返回值通常放在哪些寄存器?

解答:前两个整数参数放在 a0a1,也就是 x10x11。一个 int 返回值放在 a0。如果函数内部要调用别的函数,调用前后仍要使用的临时值不能随意依赖 a0-a7t 寄存器保持不变。

练习二:什么时候可以没有栈帧

题目:一个只计算 return x + y; 的叶子函数是否一定需要调整 sp

解答:不一定。参数已在 a0/a1,结果可以直接写回 a0,不调用其他函数、不保存返回地址、不需要栈上局部对象时,可以没有栈帧。是否省略栈帧是实现选择,但不能破坏 ABI 在函数边界上的保存规则。

参考资料