计算机体系结构 04:C 函数怎样变成机器程序
C 函数怎样变成机器程序
本篇的核心问题是:C 里的函数、局部变量、参数和返回值,怎样落到寄存器、栈和内存地址上?第 03 篇已经把一条指令解释为 PC、寄存器和内存的状态转移;这一篇在 ISA 之上加一层 ABI(Application Binary Interface,应用二进制接口)。ISA 规定一条机器指令怎样改变架构状态,ABI 规定多个编译单元、函数和库怎样约定寄存器用途、参数位置、返回值位置、栈对齐和可调用边界。
目标 ABI 选为 RISC-V psABI 的 ILP32 方向:整数寄存器 XLEN 为 32,int、long、指针宽度都是 32 位。RISC-V psABI 的整数调用约定把 x10 到 x17 命名为 a0 到 a7,用于参数和返回值;x1 是返回地址 ra;x2 是栈指针 sp;s0 到 s11 是被调用者保存寄存器。当前本机 Apple clang 21 不含 RISC-V 后端,不能生成真实 RV32I 目标文件,因此 RV32I ABI 只做规则和手算;同时用本机 AArch64 真实编译与反汇编展示“参数寄存器、返回值寄存器、返回地址和栈帧”这些 ABI 机制在另一套真实目标上怎样出现。
证据等级:手算、功能执行。手算部分追踪一个叶子函数和一个显式循环;功能执行部分复用 examples/computer-architecture/base/ 的 RV32I 子集执行器,证明同一套寄存器/内存约定可以执行求和与条件循环。工具缺口在文末单列。
C 语义先约束“允许什么”,ABI 再约束“放在哪里”
考虑一个短函数:
1 | |
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 / x10 或 t1 |
返回前放入 a0 |
| 临时元素 | t2 / x7 |
每次 lw 后累加 |
这张表不是唯一答案。编译器可以换寄存器,可以展开循环,可以把 s 直接放在 a0,也可以在优化级别不同的时候生成更多访存。ABI 只规定函数边界可见的事实:参数怎样进来,返回值怎样出去,哪些寄存器由谁负责恢复。函数内部的寄存器选择属于编译器优化空间。
为了和第 03、07 篇共用执行器,基础实验没有直接使用 a0/t0 名称,而使用更短的教学程序:x1 保存数组指针,x2 保存计数,x3 保存累加器,x4 保存当前元素。它不是 psABI 函数,只是同一计算的裸机指令流。
1 | |
裸机程序的好处是边界清楚:没有 C 运行时、没有系统调用、没有链接器脚本、没有启动代码。代价也清楚:它不能证明真实编译器会生成同样的寄存器分配。
栈帧只在边界需要时出现
很多初学者会把“函数调用”直接等同于“必有栈帧”。这不准确。栈是 ABI 提供的一块内存约定,用来保存寄存器、放溢出参数、容纳局部对象和维持调用链。一个不调用其他函数、局部变量都能放进寄存器的叶子函数,可以没有栈帧。一个需要调用别的函数的函数,至少要处理返回地址和调用后仍需保留的值。
假设有一个函数:
1 | |
helper 调用会覆盖 ra,也可能覆盖调用者保存寄存器。若 z 在调用后还要使用,要么放到 callee-saved 寄存器并保存恢复,要么放到栈槽里。这里的“保存”不是 C 语法要求,而是 ABI 对跨函数边界的二进制协作要求。
伪指令和真实编码要分开
汇编里常见的 li、mv、nop 往往是伪指令。它们便于人写,但不是机器必须拥有的真实 opcode。比如 mv x5,x6 通常可展开成 addi x5,x6,0,nop 可展开成 addi x0,x0,0。第 03 篇中的机器码只出现真实编码,不依赖伪指令。
这个区分会影响调试。阅读反汇编时,工具可能把真实机器码显示成伪指令形式,也可能按基础指令显示。文章里讨论 ISA 语义时,应回到真实编码和字段;讨论程序员意图时,伪指令可以作为读写便利。
本机 AArch64 编译证据:同一个问题在真实目标上长什么样
为了不把工具缺口变成整篇文章的空洞,基础实验增加了一个本机可编译样例 examples/computer-architecture/base/src/abi_sample.c:
1 | |
运行脚本会用 Apple clang 21 在本机 arm64 上生成汇编和反汇编:
1 | |
add_then_call 的关键反汇编如下:
1 | |
可下载附件:abi_sample.c、abi_sample_aarch64.s、abi_sample_aarch64_objdump.txt、environment.txt。
这段证据来自真实工具链,但它支撑的是 AArch64 ABI,不是 RISC-V ABI。w0/w1 是 AArch64 的参数/返回寄存器,x30 是 link register,x29 常用作 frame pointer;RISC-V 对应的是 a0/a1、ra 和 sp/s0 这套命名。两者的共性是 ABI 都要解决同一类问题:参数从哪里进、结果从哪里出、调用后如何回到调用者、跨调用仍要用的值由谁保存。
RV32I 工具链验证的实际边界
写作时检查了本机工具:
1 | |
尝试 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 | |
对应附件:sum.jsonl。这份轨迹只用于 RV32I 裸机手算对照,不替代 RISC-V 目标文件反汇编。
它说明手写裸机循环在教学执行器里得到了正确结果。它不说明编译器会选择同样的寄存器,也不说明 ABI 栈帧已经被真实目标文件验证。
验收
本篇验收标准是能区分三层事实。C 语言层负责表达“这个函数允许产生什么结果”;ABI 负责表达“函数边界上参数、返回值、返回地址和保存责任怎样约定”;机器码负责表达“每一条指令怎样改变寄存器、内存和 PC”。读者还应能指出 AArch64 真机反汇编与 RV32I 手算各自能证明什么,不能把一套 ABI 的寄存器名直接套到另一套 ISA 上。
练习一:参数和返回值在哪里
题目:在 RISC-V ILP32 调用约定下,int f(int x, int y) 的两个参数和一个返回值通常放在哪些寄存器?
解答:前两个整数参数放在 a0、a1,也就是 x10、x11。一个 int 返回值放在 a0。如果函数内部要调用别的函数,调用前后仍要使用的临时值不能随意依赖 a0-a7 或 t 寄存器保持不变。
练习二:什么时候可以没有栈帧
题目:一个只计算 return x + y; 的叶子函数是否一定需要调整 sp?
解答:不一定。参数已在 a0/a1,结果可以直接写回 a0,不调用其他函数、不保存返回地址、不需要栈上局部对象时,可以没有栈帧。是否省略栈帧是实现选择,但不能破坏 ABI 在函数边界上的保存规则。


