1.15 汇编与调用约定:三架构并排
一次函数调用在 x86-64、ARM64、RISC-V 64 上分别把什么放进寄存器、什么压进栈?序言和尾声为什么长这样?参数超过 6 个、结构体按值传递、可变参数、内联汇编、系统调用时,ABI 的哪条规则在起作用?
按三套 psABI 手写可汇编的函数;画出有栈帧、红区、保存寄存器的栈图;用 objdump/readelf/gdb 验证编译器是否遵守约定;写出 C 的 asm volatile 与 Rust 的 asm! 系统调用包装。
1.3 已经用 x86-64 讲过栈帧,1.14 把三套 ISA 并排放在一起。但「知道寄存器名字」不等于「会读一次真实调用」:编译器生成的序言可能只有一条 push,也可能有 endbr64、paciasp、stp x29, x30;一个 7 参数函数在第 7 个参数上突然改用栈;一个返回小结构体的函数可能完全不碰内存。这些都不是编译器的脾气,而是 ABI 的条款。
本章按「调用发生的一瞬间」组织:先看控制如何转移,再看寄存器和栈由谁保存,然后逐条对照三套指令,最后把规则放进三个完整程序。读完后,你应该能拿到任何一段陌生汇编,先找参数寄存器,再找栈调整,最后才看算术。
本章代码块带「架构 × 语言」切换器:右上角可选 x86-64 / ARM64 / RISC-V 64 与 C / Rust / 汇编。同一个主题如果只给某一套架构的实现,切到别的架构会提示,而不是显示空白。所有汇编都给出可直接交给 clang/as 的完整文件;C 代码按 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror 编写。
1.15.1 调用的契约:call、返回地址与 ret
过程调用要同时完成三件事:把控制交给被调用者;让被调用者知道返回后从哪里继续;让双方对「哪些寄存器可以随便改」有共同预期。ISA 只提供最原始的机制,ABI 把剩下的细节固定下来。
| 机制 | x86-64 | ARM64 | RISC-V 64 |
|---|---|---|---|
| 调用指令 | call foo | bl foo | jal ra, foo / call foo |
| 返回地址放在哪 | call 压入栈顶 | x30(LR) | ra(x1) |
| 返回指令 | ret(弹出栈顶到 rip) | ret(跳到 x30) | ret(jalr x0, 0(ra)) |
| 嵌套调用前 | 返回地址已在栈上,无需额外保存 | 通常 stp x29, x30, [sp, #-16]! | 通常 sd ra, ...(sp) |
| 叶子函数 | 可以完全不碰栈 | 可以完全不碰栈 | 可以完全不碰栈 |
| 返回地址可被覆盖 | 栈溢出可覆盖 | 栈上的 LR 副本可覆盖 | 栈上的 ra 副本可覆盖 |
三者的差异可以用一句话记住:x86-64 把返回地址放在内存里,ARM64 和 RISC-V 把它放在寄存器里。ARM64/RISC-V 的 bl/jal 只写链接寄存器,不写栈;所以非叶函数必须自己把 LR/ra 存到栈上,否则下一次调用会把它覆盖。x86-64 的 call 自动压栈,非叶函数反而少一条保存指令,但每次调用都多一次内存写。
# call_ret_x86.s — 只演示控制转移;可汇编:clang --target=x86_64-linux-gnu -c
.text
.globl caller
.type caller, @function
caller:
subq $8, %rsp # 对齐到 16 字节;进入函数时 rsp % 16 == 8
call callee # 返回地址压栈,rip = callee
addq $8, %rsp
ret
.globl callee
.type callee, @function
callee:
movl $7, %eax
ret # 弹出返回地址到 rip
// call_ret_arm64.s — 可汇编:clang --target=aarch64-linux-gnu -c
.text
.globl caller
.type caller, %function
caller:
stp x29, x30, [sp, #-16]! // 保存 FP/LR,并让 sp 16 字节对齐
mov x29, sp
bl callee // x30 = 返回地址,pc = callee
ldp x29, x30, [sp], #16
ret // 跳到 x30
.globl callee
.type callee, %function
callee:
mov w0, #7
ret
# call_ret_riscv64.s — 可汇编:clang --target=riscv64-linux-gnu -c
.text
.globl caller
.type caller, @function
caller:
addi sp, sp, -16 # 保持 16 字节对齐
sd ra, 8(sp) # 非叶函数必须自己保存 ra
call callee # 伪指令:auipc + jalr,ra = 返回地址
ld ra, 8(sp)
addi sp, sp, 16
ret # jalr x0, 0(ra)
.globl callee
.type callee, @function
callee:
li a0, 7
ret
ret 只是 br x30。栈上的 LR 副本是编译器为「嵌套调用」主动保存的,不是硬件约定。所以看到 stp x29, x30, [sp, #-16]! 不要以为它在「压返回地址」;它是在保存当前函数的 LR 和帧指针,供本函数返回时恢复。RISC-V 的 sd ra, 8(sp) 同理。
1.15.2 寄存器与 ABI:谁保存、谁传参
ABI 的核心是一张「寄存器责任表」。读汇编时先查这张表,比从指令猜参数快得多。
| 类别 | x86-64 SysV | ARM64 AAPCS64 | RISC-V 64 psABI |
|---|---|---|---|
| 整数参数 | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 | a0–a7 |
| 浮点参数 | xmm0–xmm7 | v0–v7 | fa0–fa7 |
| 返回值 | rax;128 位用 rdx:rax | x0;128 位用 x1:x0 | a0;128 位用 a1:a0 |
| 被调用者保存 | rbx, rbp, r12–r15 | x19–x29;v8–v15 低 64 位 | s0–s11(x8,x9,x18–x27) |
| 调用者保存 | rax, rcx, rdx, rsi, rdi, r8–r11 | x0–x18 | a0–a7, t0–t6 |
| 栈指针 | rsp | sp | sp(x2) |
| 帧指针 | rbp(可选) | x29(可选) | s0/fp(可选) |
| 链接寄存器 | 无(在栈上) | x30 | ra(x1) |
| 零寄存器 | 无 | xzr(x31 的一种视图) | x0(硬连线 0) |
| 栈对齐 | 调用前 16 字节 | 公共接口 16 字节 | 16 字节 |
| 红区 | 128 字节(叶子函数可用) | 无 | 无 |
| 变参调用标记 | %al = 使用的向量寄存器数 | 匿名参数主要走栈 | 无专用标记,va_list 保存寄存器区 |
几个容易读错的点:
x30不是被调用者保存寄存器。它是链接寄存器;一个函数如果还要调用别人,必须在调用前保存它,返回前恢复。ABI 表里通常把x30单列为「特殊」。- ARM64 的
v8–v15只保证低 64 位。传__m128/float64x2_t时,高 64 位可能被破坏;需要完整 128 位就自己保存。 - RISC-V 的
gp/tp不是普通通用寄存器。gp用于全局指针(PIC 的锚点),tp指向线程控制块;用户代码不要拿它们做临时变量。 - x86-64 的
rbp是可选的帧指针。-fomit-frame-pointer(-O2默认)会把它当普通被调用者保存寄存器用;调试时用-fno-omit-frame-pointer换可回溯性。 - 栈对齐是调用点的责任。x86-64 在
call之前rsp % 16 == 0;call压入 8 字节后,被调用者入口是rsp % 16 == 8。ARM64/RISC-V 在公共接口处要求sp % 16 == 0,函数内部只要不破坏这个条件即可。
调用者保存(caller-saved / volatile):被调用者可以随意改写,调用者若在调用后还要用,必须自己先保存。被调用者保存(callee-saved / non-volatile):被调用者若要用,必须先保存原值,返回前恢复;调用者可以假设调用前后值不变。ABI 用这条分工避免每次调用都保存全部寄存器。
红区值得单独说。x86-64 SysV 允许叶子函数使用 rsp 以下 128 字节,而不调整 rsp;信号处理时内核会跳过这块区域,不会把它踩掉。ARM64 和 RISC-V 没有红区,任何「往栈上写」都必须先减 sp。这就是为什么同一段 int f(int x){ char buf[16]; ... } 在 x86-64 上可能只有 sub $24,%rsp,而在 ARM64 上一定有 sub sp,sp,#32。
1.15.3 指令对照:同一操作的三种写法
下面这张表覆盖读编译器输出时 90% 的指令。记住「目标在左、源在右」的差异:x86-64 是 op src, dst(AT&T 语法),ARM64 是 op dst, src1, src2,RISC-V 也是 op dst, src1, src2。
| 操作 | x86-64(AT&T) | ARM64 | RISC-V 64 |
|---|---|---|---|
| 加载 8 字节 | mov 8(%rdi), %rax | ldr x0, [x1, #8] | ld a0, 8(a1) |
| 存 8 字节 | mov %rax, 8(%rdi) | str x0, [x1, #8] | sd a0, 8(a1) |
| 加法 | add %rsi, %rax | add x0, x0, x1 | add a0, a0, a1 |
| 立即数加法 | add $16, %rsp | add sp, sp, #16 | addi sp, sp, 16 |
| 比较并跳转 | cmp %rsi, %rdi; jg L | cmp x0, x1; b.gt L | bgt a0, a1, L |
| 调用 | call foo | bl foo | call foo |
| 返回 | ret | ret | ret |
| 栈分配 | sub $32, %rsp | sub sp, sp, #32 | addi sp, sp, -32 |
| 取地址 | lea 8(%rdi), %rax | add x0, x1, #8 | addi a0, a1, 8 |
| 乘加地址 | lea (%rdi,%rsi,4), %rax | add x0, x1, x2, lsl #2 | slli t0, a2, 2; add a0, a1, t0 |
| 系统调用 | syscall | svc #0 | ecall |
| 内存屏障 | mfence | dmb ish | fence rw, rw |
| 原子比较交换 | lock cmpxchg | casal(ARMv8.1 LSE) | amocas.d(Zacas)或 lr.d/sc.d |
| 空操作 | nop | nop | addi x0, x0, 0 |
RISC-V 的 sh1add/sh2add/sh3add 属于 Zba 扩展,不是基线;写可移植代码时用 slli + add。ARM64 的 casal 需要 ARMv8.1-LSE,老核只有 ldaxr/stlxr 循环。x86-64 的 cmpxchg 不加 lock 只是单核原子,别把两者混为一谈。
现在把表变成代码。max3 返回三个 long 的最大值,三套实现都能单独汇编成目标文件:
/* max3.c — 对照用;cc -O2 -S max3.c 可看编译器生成的版本 */
long max3(long a, long b, long c)
{
long m = a > b ? a : b;
return m > c ? m : c;
}
#![allow(unused)]
fn main() {
// max3.rs — rustc --edition 2021 -O --emit asm max3.rs
#[no_mangle]
pub extern "C" fn max3(a: i64, b: i64, c: i64) -> i64 {
let m = if a > b { a } else { b };
if m > c { m } else { c }
}
}
# max3_x86_64.s — clang --target=x86_64-linux-gnu -c max3_x86_64.s
.text
.globl max3
.type max3, @function
max3:
movq %rdi, %rax # rax = a
cmpq %rsi, %rax # a ? b
cmovl %rsi, %rax # 若 a < b,rax = b(有符号)
cmpq %rdx, %rax
cmovl %rdx, %rax # 若 m < c,rax = c
ret
.size max3, . - max3
// max3_arm64.s — clang --target=aarch64-linux-gnu -c max3_arm64.s
.text
.globl max3
.type max3, %function
max3:
cmp x0, x1 // 比较 a, b
csel x0, x0, x1, gt // x0 = a > b ? a : b
cmp x0, x2
csel x0, x0, x2, gt // x0 = m > c ? m : c
ret
.size max3, . - max3
# max3_riscv64.s — clang --target=riscv64-linux-gnu -c max3_riscv64.s
.text
.globl max3
.type max3, @function
max3:
blt a0, a1, 1f # 若 a < b,跳到 1f
mv a0, a1 # 否则 a0 = b
1: blt a0, a2, 2f
mv a0, a2
2: ret
.size max3, . - max3
注意 RISC-V 版本用了两个标签 1:/2: 和 blt 的「小于则跳」形式:a0 先保存较大者,再与 a2 比较。这不是最优指令序列(有 max 扩展会更好),但它是基线指令集,任何 RV64GC 都能跑。
1.15.4 序言、尾声与栈帧
函数序言(prologue)做三件事:给局部变量腾空间;保存本函数要用的被调用者保存寄存器;在需要时建立帧指针。尾声(epilogue)按相反顺序恢复。编译器只在必要时做这些,所以「序言长短」本身就是信息:看到 push %rbp 通常说明有可变长栈或需要回溯;看到 sub $8,%rsp 往往只是对齐。
先看一个必须分配栈帧的函数:它取局部变量地址、调用另一个函数、并在调用后继续使用参数。
/* frame.c — 取地址 + 调用 + 调用后复用参数,迫使编译器保存状态 */
#include <stdio.h>
static long add_one(long x) { return x + 1; }
long framed(long x, long y)
{
long local = x * 2; /* 需要地址,必须落在栈上 */
long *p = &local;
long r = add_one(y); /* 非叶调用 */
return *p + r + x; /* 调用后还要用 x,必须跨调用保存 */
}
int main(void)
{
printf("%ld\n", framed(10, 20));
return 0;
}
// frame.rs — 与 C 版本等价;Rust 不保证局部变量一定在栈上,但语义相同
fn add_one(x: i64) -> i64 { x + 1 }
#[inline(never)]
fn framed(x: i64, y: i64) -> i64 {
let local = x * 2;
let p = &local; // 取地址,强制 local 有内存位置
let r = add_one(y);
*p + r + x
}
fn main() {
println!("{}", framed(10, 20));
}
x86-64 的典型序言/尾声:
# framed_x86_64.s — 与 C 版本对应的手写实现;可链接进 C 程序
.text
.globl framed
.type framed, @function
framed:
pushq %rbp # 保存调用者的帧指针
movq %rsp, %rbp # 建立本帧
pushq %rbx # 保存被调用者保存寄存器
subq $24, %rsp # 局部变量 + 对齐:rbp 后已 16 对齐,push 8 后需补 8
movq %rdi, %rbx # rbx = x,跨调用保存
leaq (%rdi,%rdi), %rax # local = x * 2
movq %rax, -32(%rbp) # local 在栈上
movq %rsi, %rdi # 第一个参数 = y
call add_one
addq -32(%rbp), %rax # rax = add_one(y) + local
addq %rbx, %rax # + x
addq $24, %rsp
popq %rbx
popq %rbp
ret
.size framed, . - framed
// framed_arm64.s — 与 C 版本对应的手写实现
.text
.globl framed
.type framed, %function
framed:
stp x29, x30, [sp, #-32]! // 保存 FP/LR,分配 32 字节
mov x29, sp
str x19, [sp, #16] // 保存 x19
mov x19, x0 // x19 = x,跨调用保存
lsl x9, x0, #1 // local = x * 2
str x9, [sp, #24] // local 在栈上
mov x0, x1 // 参数 = y
bl add_one
ldr x9, [sp, #24]
add x0, x0, x9 // r + local
add x0, x0, x19 // + x
ldr x19, [sp, #16]
ldp x29, x30, [sp], #32
ret
.size framed, . - framed
# framed_riscv64.s — 与 C 版本对应的手写实现
.text
.globl framed
.type framed, @function
framed:
addi sp, sp, -32 # 分配栈帧
sd ra, 24(sp) # 保存返回地址
sd s0, 16(sp) # 保存帧指针
sd s1, 8(sp) # 保存 x
addi s0, sp, 32 # s0 = 调用者的 sp(帧指针)
mv s1, a0 # s1 = x
slli t0, a0, 1 # local = x * 2
sd t0, -32(s0) # local 放在本帧的最低槽(s0 = 旧 sp)
mv a0, a1 # 参数 = y
call add_one
ld t0, -32(s0)
add a0, a0, t0
add a0, a0, s1
ld ra, 24(sp)
ld s0, 16(sp)
ld s1, 8(sp)
addi sp, sp, 32
ret
.size framed, . - framed
三张栈图放在一起看,差异一目了然:
x86-64(向下增长,rsp 指向栈顶) 高地址 ┌──────────────────────────┐ │ 调用者的帧 │ ├──────────────────────────┤ │ 第 7、8… 个参数(如有) │ ← 调用前由调用者放入 ├──────────────────────────┤ │ 返回地址(call 压入) │ ← 进入被调用者时 rsp 指向这里 ├──────────────────────────┤ │ 保存的 rbp │ ← push %rbp ├──────────────────────────┤ │ 保存的 rbx / r12–r15 │ ├──────────────────────────┤ │ 局部变量 / 对齐填充 │ ├──────────────────────────┤ │ 红区:叶子函数可用的 128B │ ← 不调整 rsp 也能写 └──────────────────────────┘ 低地址 ARM64(sp 16 字节对齐;LR 在 x30) 高地址 ┌──────────────────────────┐ │ 调用者的帧 │ ├──────────────────────────┤ │ 第 9 个及以后的参数 │ ├──────────────────────────┤ │ 保存的 x29、x30(stp) │ ← 非叶函数通常第一件事 ├──────────────────────────┤ │ 保存的 x19–x28(按需) │ ├──────────────────────────┤ │ 局部变量 / 对齐填充 │ └──────────────────────────┘ 低地址 RISC-V 64(sp 16 字节对齐;ra 在 x1) 高地址 ┌──────────────────────────┐ │ 调用者的帧 │ ├──────────────────────────┤ │ 第 9 个及以后的参数 │ ├──────────────────────────┤ │ 保存的 ra │ ← 非叶函数必须自己保存 ├──────────────────────────┤ │ 保存的 s0/s1…(按需) │ ├──────────────────────────┤ │ 局部变量 / 对齐填充 │ └──────────────────────────┘ 低地址
读图时抓住三个偏移:返回地址在 x86-64 是硬件压的,在 ARM64/RISC-V 是软件存的;被调用者保存寄存器一定在调用者的栈帧之上(更高地址);局部变量在 rsp/sp 与保存区之间。-O2 下很多函数没有帧指针,rbp/x29 被当成普通寄存器,栈图会少一层,但责任划分不变。
- 「每个函数都有序言」:叶子函数可以完全没有;
max3连栈都不碰。 - 「
push %rbp是为了保存返回地址」:返回地址已由call压栈;push %rbp保存的是调用者的帧指针。 - 「ARM64 的
stp x29, x30是压返回地址」:它保存的是当前函数的 FP 和 LR;真正的返回地址在x30,不是从栈上弹出来的。
1.15.5 参数传递:寄存器用完以后
整数/指针参数先用寄存器,这是三套 ABI 的共同点;超过寄存器数量的部分走栈,但「第几个开始走栈」和「栈上如何对齐」各不相同。
| 场景 | x86-64 SysV | ARM64 AAPCS64 | RISC-V 64 psABI |
|---|---|---|---|
| 第 1–6/8 个整数 | 6 个 GPR | 8 个 GPR | 8 个 GPR |
| 第 7 个起(x86) | 调用者栈帧,[rsp+8] 起 | — | — |
| 第 9 个起 | — | 调用者栈帧 | 调用者栈帧 |
| 栈参数顺序 | 从右到左压,最低地址是第 7 个 | 从左到右放在连续槽位 | 从左到右放在连续槽位 |
| 每个栈槽 | 8 字节 | 8 字节(按类型对齐) | 8 字节(按类型对齐) |
| 小于 8 字节的整数 | 占用完整 8 字节槽 | 占用完整 8 字节槽 | 占用完整 8 字节槽 |
| 结构体 ≤16 字节 | 拆成 1–2 个 INTEGER/SSE 八字节 | 拆成 1–2 个寄存器 | 拆成 1–2 个寄存器(≤XLEN×2) |
| 大结构体 | 调用者在栈上复制,传指针 | 调用者复制,传指针(通常) | 调用者复制,传指针 |
| 浮点参数 | 独立用 xmm0–xmm7 计数 | 独立用 v0–v7 计数 | 独立用 fa0–fa7 计数 |
| 可变参数 | %al 记录向量寄存器个数 | 匿名参数主要走栈 | 寄存器 + va_list 保存区 |
「结构体拆成寄存器」有分类规则,不是看字段名。SysV 把每个八字节 classify 成 INTEGER、SSE、MEMORY 等;两个八字节都合法才走寄存器,否则整块进 MEMORY。AAPCS64 的规则类似但阈值和 HFA(homogeneous floating-point aggregate)规则不同:四个 float 的结构体可以作为 HFA 走 s0–s3。RISC-V 的规则更直白:≤2×XLEN 且字段对齐合适就走 a0/a1,否则内存。
下面的 argmix.c 是本章的完整程序一:它把 8 个参数、结构体、浮点和可变参数放进一个可运行程序。
这些规则直接影响「同一个结构体在三套架构上是否走寄存器」。写跨平台 FFI 时,最稳妥的做法是不要按值传大结构体,而是显式传指针;这样三套 ABI 的行为一致,也避免 Rust/Go 等语言的结构体布局差异。
/* argmix.c — 8 个整数、结构体、浮点、可变参数的完整程序
* cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 argmix.c -o argmix
*/
#include <stdarg.h>
#include <stdio.h>
struct pair { long a; long b; };
static long sum8(long a, long b, long c, long d,
long e, long f, long g, long h)
{
return a + b + c + d + e + f + g + h;
}
static struct pair make_pair(long a, long b)
{
struct pair p = { .a = a, .b = b };
return p; /* 16 字节,通常用两个寄存器返回 */
}
static double mix(long a, double x, long b, double y)
{
return (double)a * x + (double)b * y;
}
static long sum_v(int n, ...)
{
va_list ap;
long total = 0;
va_start(ap, n);
for (int i = 0; i < n; i++)
total += va_arg(ap, long);
va_end(ap);
return total;
}
int main(void)
{
struct pair p = make_pair(30, 12);
printf("sum8 = %ld\n", sum8(1, 2, 3, 4, 5, 6, 7, 8));
printf("pair = %ld + %ld\n", p.a, p.b);
printf("mix = %.1f\n", mix(2, 1.5, 3, 2.0));
printf("sum_v = %ld\n", sum_v(4, 10L, 20L, 30L, 40L));
return 0;
}
// argmix.rs — 与 C 版本对应;Rust 稳定版不能定义 C 可变参数函数,
// 变参部分用固定切片代替,语义用 sum_slice 表达。
// rustc --edition 2021 -O argmix.rs
#[repr(C)]
pub struct Pair {
a: i64,
b: i64,
}
#[no_mangle]
pub extern "C" fn sum8(a: i64, b: i64, c: i64, d: i64,
e: i64, f: i64, g: i64, h: i64) -> i64 {
a + b + c + d + e + f + g + h
}
#[no_mangle]
pub extern "C" fn make_pair(a: i64, b: i64) -> Pair {
Pair { a, b }
}
fn mix(a: i64, x: f64, b: i64, y: f64) -> f64 {
a as f64 * x + b as f64 * y
}
fn sum_slice(xs: &[i64]) -> i64 {
xs.iter().copied().sum()
}
fn main() {
let p = make_pair(30, 12);
println!("sum8 = {}", sum8(1, 2, 3, 4, 5, 6, 7, 8));
println!("pair = {} + {}", p.a, p.b);
println!("mix = {:.1}", mix(2, 1.5, 3, 2.0));
println!("sum_v = {}", sum_slice(&[10, 20, 30, 40]));
}
用 -O2 -S 看 sum8,x86-64 会从 8(%rsp)/16(%rsp) 取第 7/8 个参数;ARM64 和 RISC-V 则全部从 x0–x7/a0–a7 取。注意 x86-64 的偏移是相对于进入函数时的 rsp,不是相对于 rbp;一旦编译器插入 push %rbp,偏移会变成 16(%rbp)/24(%rbp)。
可变参数还有两个跨架构差异:
- x86-64 必须在
call前设置%al,告诉被调用者用了多少个向量寄存器。printf("%f", 1.0)的调用点会先movl $1, %eax;不设置的话,va_arg(ap, double)可能从错误的保存区取值。 - ARM64 的匿名参数通常直接放在栈上,
va_start不需要保存x0–x7的寄存器区;RISC-V 则把寄存器参数先存进va_list的保存区,再按偏移取。写自己的可变参数函数时,不要假设三套架构的va_list布局相同。
C 的 struct pair 在 C 侧按值传,Rust 侧必须标 #[repr(C)];否则 Rust 默认布局可能重排字段。Rust 的 extern "C" 函数只能使用 FFI-safe 类型:i64、*const T、#[repr(C)] 结构体可以;String、Vec<T>、&str 不行。把 String 传给 C 要用 as_ptr() 并保证 NUL 结尾,或者用 CString。
1.15.6 完整程序二:不依赖 libc 的 _start
这个完整程序只做两件事:用 write 输出一行,用 exit 返回状态 42。它不链接 libc,入口是 _start,因此能直接看到系统调用 ABI。三套实现都给出完整文件,并说明如何汇编、链接、检查退出码。
三套系统调用号
| 调用 | x86-64 | ARM64 | RISC-V 64 |
|---|---|---|---|
read | 0 | 63 | 63 |
write | 1 | 64 | 64 |
openat | 257 | 56 | 56 |
close | 3 | 57 | 57 |
exit | 60 | 93 | 93 |
exit_group | 231 | 94 | 94 |
系统调用号是内核 ABI,不是 ISA 的一部分;ARM64 和 RISC-V 采用 asm-generic 编号,所以大多数编号相同,但 x86-64 有自己的历史编号。写跨架构代码时不要硬编码,用 SYS_write/libc;只有在写 _start 或调试内核入口时才直接写数字。
# start_x86_64.s — 静态链接、无 libc
# clang --target=x86_64-linux-gnu -nostdlib -static -Wl,-e,_start start_x86_64.s -o start_x86_64
.section .rodata
msg:
.ascii "ch15 x86-64: write/exit via syscall\n"
.set len, . - msg
.text
.globl _start
.type _start, @function
_start:
movq $1, %rax # SYS_write
movq $1, %rdi # fd = stdout
leaq msg(%rip), %rsi # buf(RIP 相对,PIE 安全)
movq $len, %rdx # count
syscall # 返回 rax;失败为 -errno
movq $60, %rax # SYS_exit
movq $42, %rdi # 退出码
syscall # 不返回
.size _start, . - _start
// start_arm64.s — 静态链接、无 libc
// clang --target=aarch64-linux-gnu -nostdlib -static -Wl,-e,_start start_arm64.s -o start_arm64
.section .rodata
msg:
.ascii "ch15 arm64: write/exit via syscall\n"
.set len, . - msg
.text
.globl _start
.type _start, %function
_start:
mov x8, #64 // SYS_write
mov x0, #1 // fd
adr x1, msg // buf;ADR 范围 ±1 MiB
mov x2, #len // count
svc #0 // 返回 x0;失败为 -errno
mov x8, #93 // SYS_exit
mov x0, #42
svc #0
.size _start, . - _start
# start_riscv64.s — 静态链接、无 libc
# clang --target=riscv64-linux-gnu -nostdlib -static -Wl,-e,_start start_riscv64.s -o start_riscv64
.section .rodata
msg:
.ascii "ch15 riscv64: write/exit via syscall\n"
.set len, . - msg
.text
.globl _start
.type _start, @function
_start:
li a7, 64 # SYS_write(asm-generic)
li a0, 1 # fd
la a1, msg # buf;auipc + addi
li a2, 37 # count;li 对符号差需要重定位,这里写常量
ecall # 返回 a0;失败为 -errno
li a7, 93 # SYS_exit
li a0, 42
ecall
.size _start, . - _start
编译与验证:
# 1) 交叉汇编 + 链接(clang 需要对应 target 的集成汇编器;链接用 lld)
clang --target=x86_64-linux-gnu -nostdlib -static -Wl,-e,_start start_x86_64.s -o start_x86_64
clang --target=aarch64-linux-gnu -nostdlib -static -Wl,-e,_start start_arm64.s -o start_arm64
clang --target=riscv64-linux-gnu -nostdlib -static -Wl,-e,_start start_riscv64.s -o start_riscv64
# 2) 看 ELF 类型、入口、段
readelf -h start_x86_64 | grep -E 'Type|Machine|Entry'
readelf -l start_x86_64 | sed -n '/LOAD/,+1p'
# 3) 在能执行对应架构的机器/QEMU 上验证
./start_x86_64; echo "exit=$?" # 输出一行,exit=42
qemu-aarch64 ./start_arm64; echo "exit=$?"
qemu-riscv64 ./start_riscv64; echo "exit=$?"
三个细节值得停下来看:
_start没有调用者,所以不需要保存ra/LR,也不能ret;结束必须走exit/exit_group。如果误用ret,x86-64 会从栈顶弹一个垃圾地址,ARM64 会跳回未初始化的x30。write的返回值必须检查。返回-EBADF、-EFAULT时不会设置errno;裸系统调用返回负值,libc 包装才把它转成-1并设置errno。- RISC-V 的
li a2, 37是手算长度。GNU as 对li reg, symbol_difference可能报fixup value out of range;稳妥写法是把长度定义成汇编期常量(本文件用显式数字),或用la t0, msg; la t1, msg_end; sub a2, t1, t0在运行时算。
内核在进入 _start 时,栈顶不是返回地址,而是 argc、argv[]、envp[]、auxv[]。glibc 的 _start 会把这些取出来交给 __libc_start_main,后者再调用 main(argc, argv, envp)。手写 _start 如果要用参数,必须自己按这个布局取;这也是「不要随便用 _start 替代 main」的原因。
1.15.7 完整程序三:asm volatile 与 Rust asm!
内联汇编的难点不是指令,而是告诉编译器你改了什么。约束(constraint)写错,编译器会把寄存器分配给你没声明的值,产生只在 -O2 下出现的 bug。下面这个程序用内联汇编直接发 write 系统调用,并用各架构的周期计数器测一次调用的开销。
C 版本用一个文件覆盖三套架构,编译时由 #if 选择:
/* inline_all.c — 三架构内联汇编;用对应 target 的 cc 编译
* cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 inline_all.c -o inline_all
*/
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
static const char msg[] = "inline asm: syscall ok\n";
#if defined(__x86_64__)
static long sys_write(int fd, const void *buf, size_t n)
{
long ret;
__asm__ volatile("syscall"
: "=a"(ret)
: "a"(1L), "D"((long)fd), "S"(buf), "d"(n)
: "rcx", "r11", "memory"); /* syscall 破坏 rcx/r11 */
return ret;
}
static uint64_t ticks(void)
{
uint32_t lo, hi;
__asm__ volatile("lfence\n\trdtsc" : "=a"(lo), "=d"(hi) :: "memory");
return ((uint64_t)hi << 32) | lo;
}
#elif defined(__aarch64__)
static long sys_write(int fd, const void *buf, size_t n)
{
register long x8 __asm__("x8") = 64; /* SYS_write */
register long x0 __asm__("x0") = fd;
register const void *x1 __asm__("x1") = buf;
register long x2 __asm__("x2") = (long)n;
__asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2) : "memory");
return x0;
}
static uint64_t ticks(void)
{
uint64_t v;
__asm__ volatile("isb\n\tmrs %0, cntvct_el0" : "=r"(v) :: "memory");
return v;
}
#elif defined(__riscv)
static long sys_write(int fd, const void *buf, size_t n)
{
register long a7 __asm__("a7") = 64; /* SYS_write */
register long a0 __asm__("a0") = fd;
register const void *a1 __asm__("a1") = buf;
register long a2 __asm__("a2") = (long)n;
__asm__ volatile("ecall" : "+r"(a0) : "r"(a7), "r"(a1), "r"(a2) : "memory");
return a0;
}
static uint64_t ticks(void)
{
uint64_t v;
__asm__ volatile("fence\n\trdcycle %0" : "=r"(v) :: "memory");
return v;
}
#else
#error "unsupported architecture"
#endif
int main(void)
{
uint64_t t0 = ticks();
long n = sys_write(1, msg, sizeof msg - 1);
uint64_t t1 = ticks();
printf("wrote %ld bytes, %" PRIu64 " ticks\n", n, t1 - t0);
return n == (long)(sizeof msg - 1) ? 0 : 1;
}
Rust 版本用 std::arch::asm!,约束写法和 C 不同:寄存器用 in("rax")/lateout("rax") 指定,options(nostack) 表示不碰栈。下面的文件也覆盖三套架构,cfg 只编译当前目标的那一份:
// inline_all.rs — rustc --edition 2021 -O inline_all.rs
use std::arch::asm;
#[allow(dead_code)]
static MSG: &[u8] = b"rust asm!: syscall ok\n";
#[allow(dead_code)]
#[cfg(target_arch = "x86_64")]
unsafe fn sys_write(fd: i32, buf: &[u8]) -> isize {
let ret: isize;
unsafe {
asm!(
"syscall",
in("rax") 1isize, // __NR_write
in("rdi") fd as isize,
in("rsi") buf.as_ptr(),
in("rdx") buf.len(),
lateout("rax") ret,
lateout("rcx") _, // syscall 破坏 rcx
lateout("r11") _, // 和 r11
options(nostack),
);
}
ret
}
#[allow(dead_code)]
#[cfg(target_arch = "aarch64")]
unsafe fn sys_write(fd: i32, buf: &[u8]) -> isize {
let ret: isize;
unsafe {
asm!(
"svc #0",
in("x8") 64isize, // __NR_write
inout("x0") fd as isize => ret,
in("x1") buf.as_ptr(),
in("x2") buf.len(),
options(nostack),
);
}
ret
}
#[allow(dead_code)]
#[cfg(target_arch = "riscv64")]
unsafe fn sys_write(fd: i32, buf: &[u8]) -> isize {
let ret: isize;
unsafe {
asm!(
"ecall",
in("a7") 64isize, // __NR_write(asm-generic)
inout("a0") fd as isize => ret,
in("a1") buf.as_ptr(),
in("a2") buf.len(),
options(nostack),
);
}
ret
}
#[cfg(target_os = "linux")]
fn main() {
let n = unsafe { sys_write(1, MSG) };
std::process::exit(if n == MSG.len() as isize { 0 } else { 1 });
}
#[cfg(not(target_os = "linux"))]
fn main() {
println!("build for *-linux-gnu to run the Linux syscall path");
}
两段代码的关键差别:
| 关注点 | C asm volatile | Rust asm! |
|---|---|---|
| 指令文本 | 字符串,\n\t 分隔 | 字符串,可多条 |
| 输出 | "=a"(ret) | lateout("rax") ret |
| 输入 | "D"(fd) | in("rdi") fd |
| 读写同一寄存器 | "+r"(x0) | inout("x0") x => ret |
| 内存破坏 | "memory" clobber | 默认可能读写内存;options(nomem) 才是承诺不碰 |
| 栈 | 编译器不知道,需自行保证 | options(nostack) 声明不碰栈 |
| 标志寄存器 | 默认认为被改 | options(preserves_flags) 才承诺不变 |
| 模板修饰 | %0、%1 | {}、{0}、inout 绑定 |
- 忘记
volatile:没有输出的asm可能被优化掉;系统调用、rdtsc必须volatile(Rust 的asm!默认有副作用,不需要volatile)。 - 忘记
"memory":内核可能读写你传入的缓冲区;不声明内存破坏,编译器会把写操作重排到syscall之后。 - x86-64 忘记
rcx/r11:syscall用它们保存rip/rflags;不声明会被编译器当成活值。 - ARM64 用
x8当普通输入:x8是系统调用号寄存器,必须在svc前设置;用in("x8"),不要让编译器随便分配。 - RISC-V 的
rdcycle可能被内核禁用:scounteren未开放时执行会触发非法指令;生产代码用clock_gettime(CLOCK_MONOTONIC)。
1.15.8 实验一:用 objdump/readelf 读 ABI
目标:不运行程序,只从目标文件和汇编输出确认「参数放在哪、栈调了多少、调用了谁」。
# 1) 生成汇编与目标文件;保留帧指针便于对照
cc -std=c17 -D_GNU_SOURCE -O2 -fno-omit-frame-pointer -S frame.c -o frame.s
cc -std=c17 -D_GNU_SOURCE -O2 -c frame.c -o frame.o
# 2) 看符号:framed 是 GLOBAL FUNC,add_one 是 LOCAL FUNC
readelf -sW frame.o | grep -E 'framed|add_one|UND'
# 3) 看重定位:调用 add_one 会留下 R_X86_64_PLT32
readelf -rW frame.o
# 4) 反汇编,读序言/尾声与参数寄存器
objdump -d --no-show-raw-insn frame.o | sed -n '/<framed>:/,/^$/p'
# 5) 三套架构的汇编对照(不需要 sysroot)
clang --target=aarch64-linux-gnu -O2 -S frame.c -o frame-arm64.s
clang --target=riscv64-linux-gnu -O2 -S frame.c -o frame-riscv64.s
x86-64 上 framed 的关键行通常长这样(-O2 也可能把 framed 内联掉,用 __attribute__((noinline)) 阻止):
<framed>:
push %rbp
mov %rsp,%rbp
push %rbx
sub $0x18,%rsp
mov %rdi,%rbx # x 跨调用保存在 rbx
...
call <add_one>
...
add $0x18,%rsp
pop %rbx
pop %rbp
ret
readelf -sW 里 framed 的 Ndx 是 .text,add_one 可能是 LOCAL;如果 add_one 在另一个文件,frame.o 里会出现 UND add_one,重定位表里出现 R_X86_64_PLT32。ARM64 的对应重定位是 R_AARCH64_CALL26,RISC-V 是 R_RISCV_CALL_PLT/R_RISCV_JAL。看到这些名字,就知道「这里有一个跨文件的调用,链接器要填地址」。
进一步验证结构体规则:把 struct pair 改成三个 long,重新编译,观察 x86-64 是否从 rdi/rsi/rdx 取;改成 24 字节,观察是否变成 MEMORY 类、调用者是否在栈上复制。ARM64 上把 struct { float a,b,c,d; } 传给函数,观察是否用 s0–s3(HFA)。这些差异不需要背,编译一次就看见。
1.15.9 实验二:用 gdb 与 perf 验证栈和开销
目标:在真实进程里看栈帧、返回地址和调用开销。
# 编译带调试信息、禁用内联,便于设断点
cc -std=c17 -D_GNU_SOURCE -g -O0 -fno-omit-frame-pointer frame.c -o frame_dbg
gdb -q ./frame_dbg
(gdb) break framed
(gdb) run
(gdb) info registers rdi rsi rsp rbp rbx
(gdb) x/6gx $rsp # 返回地址、保存的 rbp、局部变量
(gdb) bt # 依赖帧指针;-O2 省略帧指针时可能断链
(gdb) disassemble /r framed # 指令 + 机器码
(gdb) finish
(gdb) info registers rax # 返回值
ARM64 上用 gdb-multiarch 或 qemu-aarch64 -g 1234 ./frame_arm64,把寄存器换成 x0/x1/sp/x29/x30,用 x/6gx $sp 看保存的 x29/x30。RISC-V 换成 a0/a1/sp/s0/ra。重点不是命令,而是确认三件事:入口 sp 是否 16 字节对齐;返回地址在栈的哪个偏移;被调用者保存寄存器是否真的被保存。
# 调用开销:同一程序跑 100 万次,比较 frame.c 与 argmix.c
perf stat -e cycles,instructions,branches,branch-misses ./argmix
perf stat -e cycles,instructions ./frame_dbg
perf record -g ./argmix && perf report --stdio | head -40
# Rust 侧:看生成代码与单态化体积
cargo asm --release --bin argmix 2>/dev/null | sed -n '1,60p'
cargo llvm-lines --release 2>/dev/null | head -20
perf stat 的 instructions / cycles 是 IPC;分支预测失败率高时,检查 argmix 里的条件分支或换数据。注意容器/虚拟机里 perf_event_paranoid 可能禁止计数;先 cat /proc/sys/kernel/perf_event_paranoid,必要时用 sudo sysctl kernel.perf_event_paranoid=1(只在自己的测试机改)。cargo asm/cargo llvm-lines 需要额外安装,没装就跳过,不影响本章代码。
1.15.10 十二条汇编与 ABI 陷阱
ret跳到随机地址:函数用jmp而不是call进入,或栈上的返回地址被缓冲区覆盖。修复:确认调用点用call/bl/jal;开启栈保护-fstack-protector-strong。- x86-64 的
movaps段错误:调用前rsp不是 16 字节对齐,SSE 指令要求对齐。修复:在call前让rsp % 16 == 0;序言里sub的总量保持 16 的倍数。 - 叶子函数用红区,后来加了调用就崩:红区只在叶子函数有效,调用者的栈帧会覆盖
rsp以下。修复:一旦调用其他函数,先sub再写;内核/信号代码用-mno-red-zone。 - 调用者保存寄存器在调用后值变了:把
rax/rcx/rdx/rsi/rdi/r8-r11当跨调用变量。修复:跨调用值放rbx/r12-r15(并保存),或调用前压栈。 - 被调用者保存寄存器没恢复:手写汇编用了
rbx却直接ret。修复:成对push/pop;ARM64 用stp/ldp保存x19–x29;RISC-V 用sd/ld保存s0–s11。 printf("%f", x)输出垃圾:x86-64 可变参数调用前没设%al。修复:让编译器生成调用,或在手写调用点movl $向量寄存器个数, %eax。- ARM64 嵌套调用后返回错地址:忘记保存
x30,内层bl覆盖了外层 LR。修复:非叶函数开头stp x29, x30, [sp, #-16]!,返回前ldp。 - RISC-V 递归只返回一层:没有保存
ra。修复:非叶函数在栈上保存ra;递归函数每层都要独立保存。 - 结构体按值传参在 C/Rust 间不一致:Rust 默认布局可能重排,C 侧按 SysV 分类。修复:
#[repr(C)];大结构体改成指针;用bindgen/cbindgen生成声明。 - 内联汇编在
-O0正常、-O2错:缺少"memory"或 clobber,编译器重排/复用寄存器。修复:按实际读写声明输入输出和 clobber;系统调用加"memory"。 - 裸
syscall返回负数被当成字节数:裸系统调用失败返回-errno,不是-1。修复:检查ret < 0并取-ret;或者用 libc 包装并读errno。 li reg, symbol_difference汇编报fixup value out of range:RISC-V 的li对需要重定位的符号差不能直接展开。修复:用常量、.equ的绝对值,或la两个符号后在运行时相减。
还有三条不属于「错误」但经常浪费时间的差异:x86-64 的 long 是 64 位,Windows LLP64 是 32 位;ARM64 的 char 默认无符号(取决于工具链/ABI,不要依赖);RISC-V 的压缩指令(C 扩展)会让 objdump 的地址步长变成 2 字节,反汇编时不要假设每条指令 4 字节。
1.15.11 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 调用机制 | 能说出三套架构的返回地址在哪、非叶函数保存什么 | 1.15.1 |
| 寄存器责任 | 能默写参数、返回值、被调用者保存寄存器 | 1.15.2 |
| 指令对照 | 给一条 C 操作,能写三套汇编 | 1.15.3 |
| 栈帧 | 能画 framed 的三架构栈图并标出对齐 | 1.15.4 |
| 参数传递 | 能解释第 7/9 个参数、结构体、%al | 1.15.5 |
| 完整程序 | 能独立汇编/链接 _start 与内联汇编程序 | 1.15.6–1.15.7 |
| 实验 | 能用 objdump/readelf/gdb 找到证据 | 1.15.8–1.15.9 |
Q1 x86-64 进入函数时 rsp % 16 是多少?要在 call 前对齐,序言应该 sub 多少?
Q2 ARM64 的 stp x29, x30, [sp, #-16]! 中,x30 保存的是什么?为什么 RISC-V 没有对应的「链接寄存器自动压栈」?
Q3 一个函数有 9 个 long 参数,分别在 x86-64/ARM64/RISC-V 上从哪里取第 9 个?
Q4 为什么 syscall 的内联汇编要写 "rcx","r11",而 ARM64 的 svc 不用?
答案
A1 入口是 8(call 压了 8 字节);call 前要 0。若序言 push %rbp(8),再 sub 8 的奇数倍即可;常见 push %rbp; mov %rsp,%rbp; sub $16,%rsp 仍保持 0。
A2 保存当前函数的 LR(返回地址);bl 会覆盖 x30,所以非叶函数必须先存。RISC-V 的 jal 把返回地址写进 ra 寄存器而不是栈,硬件不自动压栈,软件必须自己 sd ra,...(sp)。
A3 x86-64 从 8(%rsp)(进入函数时)取第 7 个,第 9 个在 24(%rsp);ARM64 第 9 个在调用者栈上 [sp];RISC-V 第 9 个也在调用者栈上 0(sp)。
A4 x86-64 的 syscall 用 rcx 保存返回 rip、r11 保存 rflags,硬件会写它们;ARM64 的 svc 不通过这些寄存器返回,返回值只在 x0,没有等价的隐式 clobber。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C CSAPP | 第 3 章 3.7 过程、3.8 数组、3.11 浮点 | 1.15.1–1.15.5 | 完整:栈帧、参数、被调用者保存、递归;扩展到 ARM64/RISC-V |
| C CSAPP | 第 7 章 链接(ELF/重定位) | 1.15.8 | 背景:只取符号与重定位的读法,细节见 1.7 |
| RISC-V 规范 | 调用约定、整数/浮点寄存器、ecall | 1.15.1–1.15.7 | 参考:按 psABI 独立改写,不复制正文 |
| rCore 附录 D | RISC-V 汇编、cargo asm | 1.15.7–1.15.9 | 参考:只借「生成汇编/看调用」的组织方式 |
| Rust Book | 第 4、9、20 章(所有权、错误、FFI) | 1.15.7 | 背景:asm! 与 unsafe 的边界见 1.17 |
下一站:1.16 编译、链接与优化 把这些汇编放回工具链:C 的
-O2与 Rust 的-C opt-level=3各做了什么,ELF 的段/符号/重定位如何被链接器改写,LTO、no_std与 FFI 又怎样改变产物。