Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-64ARM64RISC-V 64
调用指令call foobl foojal 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
为什么 ARM64 的 `ret` 不弹栈

ret 只是 br x30。栈上的 LR 副本是编译器为「嵌套调用」主动保存的,不是硬件约定。所以看到 stp x29, x30, [sp, #-16]! 不要以为它在「压返回地址」;它是在保存当前函数的 LR 和帧指针,供本函数返回时恢复。RISC-V 的 sd ra, 8(sp) 同理。

1.15.2 寄存器与 ABI:谁保存、谁传参

ABI 的核心是一张「寄存器责任表」。读汇编时先查这张表,比从指令猜参数快得多。

类别x86-64 SysVARM64 AAPCS64RISC-V 64 psABI
整数参数rdi, rsi, rdx, rcx, r8, r9x0–x7a0–a7
浮点参数xmm0–xmm7v0–v7fa0–fa7
返回值rax;128 位用 rdx:raxx0;128 位用 x1:x0a0;128 位用 a1:a0
被调用者保存rbx, rbp, r12–r15x19–x29;v8–v15 低 64 位s0–s11(x8,x9,x18–x27)
调用者保存rax, rcx, rdx, rsi, rdi, r8–r11x0–x18a0–a7, t0–t6
栈指针rspspsp(x2)
帧指针rbp(可选)x29(可选)s0/fp(可选)
链接寄存器无(在栈上)x30ra(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,函数内部只要不破坏这个条件即可。
定义 1.15.1 · 调用者保存与被调用者保存

调用者保存(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)ARM64RISC-V 64
加载 8 字节mov 8(%rdi), %raxldr x0, [x1, #8]ld a0, 8(a1)
存 8 字节mov %rax, 8(%rdi)str x0, [x1, #8]sd a0, 8(a1)
加法add %rsi, %raxadd x0, x0, x1add a0, a0, a1
立即数加法add $16, %rspadd sp, sp, #16addi sp, sp, 16
比较并跳转cmp %rsi, %rdi; jg Lcmp x0, x1; b.gt Lbgt a0, a1, L
调用call foobl foocall foo
返回retretret
栈分配sub $32, %rspsub sp, sp, #32addi sp, sp, -32
取地址lea 8(%rdi), %raxadd x0, x1, #8addi a0, a1, 8
乘加地址lea (%rdi,%rsi,4), %raxadd x0, x1, x2, lsl #2slli t0, a2, 2; add a0, a1, t0
系统调用syscallsvc #0ecall
内存屏障mfencedmb ishfence rw, rw
原子比较交换lock cmpxchgcasal(ARMv8.1 LSE)amocas.d(Zacas)或 lr.d/sc.d
空操作nopnopaddi 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 被当成普通寄存器,栈图会少一层,但责任划分不变。

序言/尾声的三个常见误解
  1. 「每个函数都有序言」:叶子函数可以完全没有;max3 连栈都不碰。
  2. 「push %rbp 是为了保存返回地址」:返回地址已由 call 压栈;push %rbp 保存的是调用者的帧指针。
  3. 「ARM64 的 stp x29, x30 是压返回地址」:它保存的是当前函数的 FP 和 LR;真正的返回地址在 x30,不是从栈上弹出来的。

1.15.5 参数传递:寄存器用完以后

整数/指针参数先用寄存器,这是三套 ABI 的共同点;超过寄存器数量的部分走栈,但「第几个开始走栈」和「栈上如何对齐」各不相同。

场景x86-64 SysVARM64 AAPCS64RISC-V 64 psABI
第 1–6/8 个整数6 个 GPR8 个 GPR8 个 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 布局相同。
跨语言 FFI 的结构体规则

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-64ARM64RISC-V 64
read06363
write16464
openat2575656
close35757
exit609393
exit_group2319494

系统调用号是内核 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=$?"

三个细节值得停下来看:

  1. _start 没有调用者,所以不需要保存 ra/LR,也不能 ret;结束必须走 exit/exit_group。如果误用 ret,x86-64 会从栈顶弹一个垃圾地址,ARM64 会跳回未初始化的 x30。
  2. write 的返回值必须检查。返回 -EBADF、-EFAULT 时不会设置 errno;裸系统调用返回负值,libc 包装才把它转成 -1 并设置 errno。
  3. 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`

内核在进入 _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 volatileRust 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 绑定
内联汇编的五个坑
  1. 忘记 volatile:没有输出的 asm 可能被优化掉;系统调用、rdtsc 必须 volatile(Rust 的 asm! 默认有副作用,不需要 volatile)。
  2. 忘记 "memory":内核可能读写你传入的缓冲区;不声明内存破坏,编译器会把写操作重排到 syscall 之后。
  3. x86-64 忘记 rcx/r11:syscall 用它们保存 rip/rflags;不声明会被编译器当成活值。
  4. ARM64 用 x8 当普通输入:x8 是系统调用号寄存器,必须在 svc 前设置;用 in("x8"),不要让编译器随便分配。
  5. 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 陷阱

现象 → 原因 → 修复
  1. ret 跳到随机地址:函数用 jmp 而不是 call 进入,或栈上的返回地址被缓冲区覆盖。修复:确认调用点用 call/bl/jal;开启栈保护 -fstack-protector-strong。
  2. x86-64 的 movaps 段错误:调用前 rsp 不是 16 字节对齐,SSE 指令要求对齐。修复:在 call 前让 rsp % 16 == 0;序言里 sub 的总量保持 16 的倍数。
  3. 叶子函数用红区,后来加了调用就崩:红区只在叶子函数有效,调用者的栈帧会覆盖 rsp 以下。修复:一旦调用其他函数,先 sub 再写;内核/信号代码用 -mno-red-zone。
  4. 调用者保存寄存器在调用后值变了:把 rax/rcx/rdx/rsi/rdi/r8-r11 当跨调用变量。修复:跨调用值放 rbx/r12-r15(并保存),或调用前压栈。
  5. 被调用者保存寄存器没恢复:手写汇编用了 rbx 却直接 ret。修复:成对 push/pop;ARM64 用 stp/ldp 保存 x19–x29;RISC-V 用 sd/ld 保存 s0–s11。
  6. printf("%f", x) 输出垃圾:x86-64 可变参数调用前没设 %al。修复:让编译器生成调用,或在手写调用点 movl $向量寄存器个数, %eax。
  7. ARM64 嵌套调用后返回错地址:忘记保存 x30,内层 bl 覆盖了外层 LR。修复:非叶函数开头 stp x29, x30, [sp, #-16]!,返回前 ldp。
  8. RISC-V 递归只返回一层:没有保存 ra。修复:非叶函数在栈上保存 ra;递归函数每层都要独立保存。
  9. 结构体按值传参在 C/Rust 间不一致:Rust 默认布局可能重排,C 侧按 SysV 分类。修复:#[repr(C)];大结构体改成指针;用 bindgen/cbindgen 生成声明。
  10. 内联汇编在 -O0 正常、-O2 错:缺少 "memory" 或 clobber,编译器重排/复用寄存器。修复:按实际读写声明输入输出和 clobber;系统调用加 "memory"。
  11. 裸 syscall 返回负数被当成字节数:裸系统调用失败返回 -errno,不是 -1。修复:检查 ret < 0 并取 -ret;或者用 libc 包装并读 errno。
  12. 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 个参数、结构体、%al1.15.5
完整程序能独立汇编/链接 _start 与内联汇编程序1.15.6–1.15.7
实验能用 objdump/readelf/gdb 找到证据1.15.8–1.15.9
即时自测 · 1.15

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 规范调用约定、整数/浮点寄存器、ecall1.15.1–1.15.7参考:按 psABI 独立改写,不复制正文
rCore 附录 DRISC-V 汇编、cargo asm1.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 又怎样改变产物。