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.16 编译、链接与优化:C 与 Rust 的工具链

本章回答

.c/.rs 到可执行文件之间经过哪几步?ELF 的段、节、符号表、重定位表各存什么?静态库、动态库、GOT/PLT 怎样把 printf 接到 libc?-O2 与 -C opt-level=3 各自做了什么?LTO、no_std、panic=abort、C/Rust FFI 与 bindgen/cbindgen 又如何改变产物?

读完你会

用 gcc -E/-S/-c 与 rustc --emit 拆开流水线;用 readelf/objdump/nm 读节、符号、重定位;构建静态库与带 SONAME 的动态库;用 LTO/opt-level 对比产物;写一个 no_std 静态库并被 C 调用;用 bindgen/cbindgen 维持 C/Rust 边界。

1.7 已经讲过链接器的符号解析、重定位和 GOT/PLT;1.15 把汇编与 ABI 摊开。这一章换一个视角:把工具链当成一条可观测的流水线。同一份源码,在 -O0 和 -O2 下、在 -flto 开与关时、在 C 与 Rust 两套驱动下,产物到底差在哪几个字节、哪几个节、哪几条重定位。

本章的代码都按可直接构建来写:C 用 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror,Rust 用 --edition 2021/Cargo。示例以 x86-64 Linux 为主,涉及 ABI 的地方并列 ARM64/RISC-V;交叉工具链的差异单独标注。

阅读方式

本章代码块带「架构 × 语言」切换器。工具链命令本身与架构无关,统一放在 bash 块里;只有涉及寄存器、重定位类型、内联汇编或 FFI 的代码才分架构。切换器选择会保存在浏览器里,翻到下一节仍生效。

1.16.1 四阶段流水线:C 与 Rust 的同一张图

编译不是「一个程序」,而是一串程序。C 的驱动 cc 依次调用预处理器、编译器、汇编器、链接器;Rust 的 rustc 自己完成词法/语法/类型检查/MIR/单态化,再把 LLVM IR 交给 LLVM 生成目标文件,最后调用链接器。两者的产物在 ELF 层汇合。

C:
  hello.c
    │  cpp(预处理:宏、#include、条件编译)
    ▼
  hello.i
    │  cc1(编译:GIMPLE/RTL,优化)
    ▼
  hello.s
    │  as(汇编:指令编码、重定位项)
    ▼
  hello.o(ET_REL)
    │  collect2/ld(符号解析、重定位、布局)
    ▼
  a.out(ET_EXEC/ET_DYN)

Rust:
  hello.rs
    │  rustc:词法/语法/类型检查 → HIR → MIR(借用检查)→ 单态化
    ▼
  LLVM IR(.ll)
    │  LLVM:优化 → 目标代码
    ▼
  hello.o(ET_REL)
    │  cc/ld(与 C 相同的链接器)
    ▼
  a.out
阶段C 命令Rust 命令主要输入/输出能在这里做的检查
预处理/展开cc -Erustc -Zunpretty=expanded(nightly).c → .i;.rs → 展开源码宏、#include、条件编译
编译cc -Srustc --emit=asm源码 → 汇编优化、内联、向量化
汇编cc -c / asrustc --emit=obj汇编 → .o指令编码、重定位
链接cc / ldrustc / cc.o + 库 → 可执行/共享库符号、GOT/PLT、段布局
LLVM IRclang -S -emit-llvmrustc --emit=llvm-ir源码 → .ll跨语言优化、LTO 输入
MIR—rustc -Zunpretty=mir(nightly)Rust 源码 → MIR借用检查、优化前语义

最小验证:

# C:四步分开,注意每步的输入输出
cc -E hello.c -o hello.i
cc -S hello.i -o hello.s
cc -c hello.s -o hello.o
cc hello.o -o hello

# 看驱动实际调用了什么(最后一段是链接器命令行)
cc -v hello.c -o hello 2>&1 | sed -n '/collect2/,/^$/p'

# Rust:同一份源码的不同产物
rustc --edition 2021 --emit=llvm-ir hello.rs -o hello.ll
rustc --edition 2021 --emit=asm hello.rs -o hello.s
rustc --edition 2021 --emit=obj hello.rs -o hello.o
rustc --edition 2021 hello.rs -o hello

# Cargo:看 rustc 的完整参数
cargo build --release -vv 2>&1 | grep 'rustc --crate-name'

cc 不是编译器,是驱动程序;rustc 也不是「只编译 Rust」,它在最后一步同样调用系统链接器(Linux 上通常是 cc/ld,macOS 上是 cc/ld64)。因此 C 与 Rust 的链接错误长得很像:undefined reference、cannot find -lxxx、relocation truncated to fit。

为什么先看 `.o` 而不是可执行文件

.o 保留了完整的节、符号表、重定位表,且没有经过链接器的地址分配。想理解「编译器生成了什么」,看 .o;想理解「链接器改了什么」,对比 .o 和最终可执行文件。直接看 a.out 的 objdump -d,地址已经是链接后的,重定位也消失了,很多线索就断了。

1.16.2 ELF:节、符号与重定位

ELF 用同一套容器装三种文件:可重定位目标文件(ET_REL)、可执行文件(ET_EXEC/ET_DYN)、共享库(ET_DYN)。readelf -h 先看类型、机器和入口:

readelf -h hello.o | grep -E 'Type|Machine|Start of section'
readelf -h hello   | grep -E 'Type|Machine|Entry'

节与段:链接器视图 vs 加载器视图

结构谁用内容关键属性
.text链接器/加载器指令SHF_ALLOC + SHF_EXECINSTR
.rodata链接器/加载器字符串、常量表、跳转表只读、可合并
.data链接器/加载器已初始化全局/静态变量可写、占文件
.bss链接器/加载器未初始化/零初始化全局SHT_NOBITS,不占文件
.symtab/.strtab链接器静态符号表与字符串可被 strip 删除
.dynsym/.dynstr动态链接器动态符号表运行必需
.rela.text链接器代码段重定位链接后消失
.rela.dyn/.rela.plt动态链接器数据/PLT 重定位运行或加载时处理
.got/.got.plt动态链接器全局偏移表可写,RELRO 可只读
.plt动态链接器过程链接桩可执行
.init_array/.fini_array运行时构造/析构函数指针由 crtbegin/crtend 生成
.eh_frame异常/回溯调用帧信息可用 -fno-asynchronous-unwind-tables 去掉

.bss 的 NOBITS 值得单独记:文件里只占一个节头,记录大小和地址;加载器把它映射到零页,第一次写触发缺页才分配物理页。static char pool[64 << 20]; 不会让二进制膨胀 64 MiB,但会占虚拟地址空间。

接下来用一个完整程序把符号和重定位都跑出来:

/* elf_demo.c — 观察 .text/.data/.bss/.rodata、强弱符号和重定位
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O0 -fno-pie -c elf_demo.c
 */
#include <stdio.h>

int g_init = 42;              /* .data:已初始化全局 */
int g_zero;                   /* .bss:未初始化全局 */
static int s_init = 7;        /* .data:局部静态 */
static int s_zero;            /* .bss:局部静态 */
const char banner[] = "elf-demo";  /* .rodata */

__attribute__((weak)) int weak_fn(void) { return 1; }

int add(int a, int b) { return a + b; }

int main(void)
{
    printf("%s: %d\n", banner, add(g_init, g_zero) + s_init + s_zero + weak_fn());
    return 0;
}
# 生成目标文件与可执行文件
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O0 -fno-pie -c elf_demo.c -o elf_demo.o
cc -no-pie elf_demo.o -o elf_demo

# 1) 节表:看 .text/.data/.bss/.rodata 的偏移、大小、标志
readelf -SW elf_demo.o | grep -E '\.(text|data|bss|rodata|symtab|rela)'

# 2) 符号表:Ndx 是节索引;UND 表示未定义;WEAK 是弱符号
readelf -sW elf_demo.o | grep -E 'g_init|g_zero|s_init|s_zero|banner|weak_fn|add|main|printf'

# 3) 重定位:printf 是外部调用,add 是同文件调用
readelf -rW elf_demo.o

# 4) 反汇编带重定位注释
objdump -dr --no-show-raw-insn elf_demo.o | sed -n '/<main>:/,/^$/p'

在 x86-64 的 readelf -rW 输出里,printf 会留下 R_X86_64_PLT32(调用 PLT),banner 会留下 R_X86_64_PC32(RIP 相对取地址)。ARM64 对应 R_AARCH64_CALL26 和 R_AARCH64_ADR_PREL_PG_HI21/R_AARCH64_ADD_ABS_LO12_NC;RISC-V 对应 R_RISCV_CALL_PLT 和 R_RISCV_PCREL_HI20/R_RISCV_PCREL_LO12_I。名字不同,含义相同:链接器要在这里填一个地址或偏移。

重定位架构出现场景链接器填什么
R_X86_64_PC32x86-64同文件数据/函数引用目标地址 − 下一条指令地址
R_X86_64_PLT32x86-64外部函数调用PLT 桩地址 − 下一条指令
R_X86_64_64x86-64绝对 64 位地址(.data 指针)目标绝对地址
R_AARCH64_CALL26ARM64bl 调用26 位有符号偏移
R_AARCH64_ADR_PREL_PG_HI21ARM64adrp 取页基址页相对高 21 位
R_RISCV_CALL_PLTRISC-Vcall 外部函数auipc+jalr 对
R_RISCV_PCREL_HI20RISC-Vauipc 取符号高位PC 相对高 20 位
R_RISCV_32/R_RISCV_64RISC-V绝对地址32/64 位绝对地址

符号表里的 Ndx 也值得读:Ndx=1 是 .text,Ndx=3 是 .data,Ndx=4 是 .bss,UND 是未定义,ABS 是绝对值。readelf -sW 的 Bind 列有 LOCAL、GLOBAL、WEAK;Type 列有 FUNC、OBJECT、NOTYPE、TLS。强符号重复定义会报 multiple definition;弱符号可以被强符号覆盖,这正是 __attribute__((weak)) 和 C++ 内联函数的链接基础。

三个 ELF 常见误解
  1. 「-g 才有符号表」:.symtab 默认就有;-g 增加的是 .debug_*。strip 删的是 .symtab/.debug_*,不会删 .dynsym(否则动态链接失效)。
  2. 「.bss 里的变量在文件里有 64 MiB」:.bss 是 NOBITS,文件里只有节头;ls -l 看到的体积不包含它。
  3. 「readelf -r 看到的重定位运行时要处理」:.rela.text 在链接后就被消费掉了;运行时还在的是 .rela.dyn/.rela.plt,由 ld.so 处理。

1.16.3 静态库、动态库与 GOT/PLT

库不是「一种文件」,而是两种交付方式:静态库(.a,一组 .o 的归档,链接时按需抽取)和共享库(.so,运行时由 ld.so 映射)。同一个 mathlib.c 可以同时产出两者。

下面的 mathlib 是本章的完整程序一:头文件、实现、主程序、版本脚本一应俱全。

/* mathlib.h — 公开接口;C 与 Rust FFI 都用它 */
#ifndef MATHLIB_H
#define MATHLIB_H

#include <stddef.h>

struct pair { long a; long b; };

long math_sum(const long *xs, size_t n);
long pair_sum(struct pair p);
long math_dot(const long *a, const long *b, size_t n);
const char *math_version(void);

#endif
/* mathlib.c — 编译成 libmath.a 和 libmath.so */
#include "mathlib.h"

long math_sum(const long *xs, size_t n)
{
    long s = 0;
    for (size_t i = 0; i < n; i++)
        s += xs[i];
    return s;
}

long math_dot(const long *a, const long *b, size_t n)
{
    long s = 0;
    for (size_t i = 0; i < n; i++)
        s += a[i] * b[i];
    return s;
}

long pair_sum(struct pair p)
{
    return p.a + p.b;
}

const char *math_version(void)
{
    return "mathlib 1.0";
}
/* main.c — 动态/静态链接都用它 */
#include <stdio.h>
#include "mathlib.h"

int main(void)
{
    long a[] = {1, 2, 3, 4};
    long b[] = {10, 20, 30, 40};
    printf("%s: sum=%ld dot=%ld\n", math_version(),
           math_sum(a, 4), math_dot(a, b, 4));
    return 0;
}

版本脚本只导出三个公开符号,其余符号变成本地,减少符号冲突和攻击面:

/* mathlib.map — GNU ld / lld 的版本脚本 */
MATHLIB_1.0 {
    global:
        math_sum;
        math_dot;
        math_version;
    local:
        *;
};

构建静态库与共享库:

# 1) 统一用 -fPIC 编译,静态库也能受益于 PIC(略有开销)
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror -fPIC -c mathlib.c -o mathlib.o

# 2) 静态库:ar 归档;链接时只抽取用到的成员
ar rcs libmath.a mathlib.o

# 3) 共享库:SONAME 是运行时依赖的名字;版本脚本限制导出
cc -shared -Wl,-soname,libmath.so.1 \
   -Wl,--version-script=mathlib.map mathlib.o -o libmath.so.1.0
ln -sf libmath.so.1.0 libmath.so.1
ln -sf libmath.so.1 libmath.so

# 4) 链接:动态版用 -lmath,静态版直接给 .a
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror main.c \
   -L. -lmath -Wl,-rpath,'$ORIGIN' -o app-dynamic
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror main.c libmath.a -o app-archive

# 5) 验证
./app-dynamic && ./app-archive
readelf -d app-dynamic | grep -E 'NEEDED|RPATH|RUNPATH|SONAME'
readelf -sW libmath.so.1.0 | grep -E 'math_(sum|dot|version)'
nm -D --defined-only libmath.so.1.0
ldd app-dynamic

-Wl,-rpath,'$ORIGIN' 把「到可执行文件所在目录找库」写进 DT_RUNPATH(新链接器)或 DT_RPATH(老链接器)。注意 shell 里必须用单引号包住 $ORIGIN,否则会被 shell 展开成空串;这是构建脚本最常见的坑之一。

GOT/PLT:一次外部调用发生了什么

动态库的代码在链接时不知道自己在进程里的加载地址,因此不能把 printf 的绝对地址写死。ELF 用位置无关代码(PIC) + GOT/PLT 解决:

调用点(.text)                   .plt                         .got.plt
call printf@plt  ───────────────▶ printf@plt:
                                    jmp *printf@GOT(%rip) ───▶ [printf 的真实地址]
                                    push $reloc_index                 ▲
                                    jmp plt0                          │
                                                                      │
第一次调用:GOT 里是 plt0 的 resolver 地址 ──▶ ld.so 解析 ──▶ 写回 GOT
之后调用:jmp *GOT 直接到 libc,不再进 resolver
  • GOT(Global Offset Table) 是数据段里的一张指针表,保存外部符号的运行时地址。
  • PLT(Procedure Linkage Table) 是一小段代码桩,第一次调用负责触发解析,之后只是间接跳转。
  • 延迟绑定(lazy binding) 是默认策略:只有真正调用到的函数才解析。LD_BIND_NOW=1 或链接 -Wl,-z,now 改成加载时全部解析(更安全,启动稍慢)。
  • -fno-plt 让调用点直接 call *printf@GOT(%rip),省掉 PLT 一跳,但需要 GOT 可读;现代发行版常用。
  • RELRO 把 GOT 在重定位后设为只读,防止 GOT 劫持;-Wl,-z,relro,-z,now 是完整 RELRO。

观察命令:

# PLT 桩长什么样
objdump -d -j .plt app-dynamic
objdump -d -j .plt.sec app-dynamic 2>/dev/null   # -fcf-protection 下可能有 .plt.sec

# 动态重定位:JUMP_SLOT 对应 PLT,GLOB_DAT 对应数据符号
readelf -rW app-dynamic | grep -E 'JUMP_SLOT|GLOB_DAT|RELATIVE'

# 动态段:NEEDED、SONAME、RPATH/RUNPATH、BIND_NOW、FLAGS
readelf -dW app-dynamic

# 运行时绑定过程(只看 mathlib 相关)
LD_DEBUG=bindings ./app-dynamic 2>&1 | grep -E 'math_(sum|dot|version)|printf' | head

# 强制加载时绑定
LD_BIND_NOW=1 ./app-dynamic

LD_DEBUG=bindings 的输出会显示每个符号从哪个对象解析到哪个地址;如果看到 binding file ./app-dynamic to ./libmath.so.1.0: normal symbol 'math_sum',说明动态链接器按 NEEDED 找到了库。找不到时先看 ldd,再看 DT_RUNPATH,最后看 LD_LIBRARY_PATH 和 /etc/ld.so.cache(ldconfig -p)。

Rust 侧:cdylib 与 staticlib

Rust 生成同样两种库,只需要在 Cargo.toml 里声明 crate type:

[package]
name = "mathlib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "staticlib"]

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
#![allow(unused)]
fn main() {
// src/lib.rs — 导出 C ABI;#[no_mangle] 保证符号名不被改名
#[no_mangle]
pub extern "C" fn math_sum(xs: *const i64, n: usize) -> i64 {
    if xs.is_null() { return 0; }
    let mut s = 0i64;
    for i in 0..n {
        s = s.wrapping_add(unsafe { *xs.add(i) });
    }
    s
}
}
cargo build --release
# Linux 产物:target/release/libmathlib.so、libmathlib.a
nm -D --defined-only target/release/libmathlib.so | grep math_sum
readelf -d target/release/libmathlib.so | grep -E 'SONAME|NEEDED'

Rust 的 cdylib 默认导出所有 #[no_mangle] pub extern "C" 符号;staticlib 会把 Rust 标准库、compiler_builtins 一起打进去,体积较大但链接简单。若要把 panic 跨 FFI 传播,必须用 extern "C-unwind" 并确保两边都支持 unwind;默认 extern "C" 下 panic 跨边界是未定义行为,生产代码用 panic = "abort" 或 catch_unwind 在边界内处理。

同一个外部调用的三种汇编形态

把 math_sum 的调用写成汇编,能直接看到 PLT/重定位的落点。三段都能单独汇编成 .o:

# call_math_x86_64.s — clang --target=x86_64-linux-gnu -c
    .text
    .globl call_math
    .type call_math, @function
call_math:
    subq    $8, %rsp          # 调用前 16 字节对齐
    call    math_sum@PLT      # 重定位:R_X86_64_PLT32
    addq    $8, %rsp
    ret
    .size call_math, . - call_math
// call_math_arm64.s — clang --target=aarch64-linux-gnu -c
    .text
    .globl call_math
    .type call_math, %function
call_math:
    stp     x29, x30, [sp, #-16]!
    mov     x29, sp
    bl      math_sum          // 重定位:R_AARCH64_CALL26
    ldp     x29, x30, [sp], #16
    ret
    .size call_math, . - call_math
# call_math_riscv64.s — clang --target=riscv64-linux-gnu -c
    .text
    .globl call_math
    .type call_math, @function
call_math:
    addi    sp, sp, -16
    sd      ra, 8(sp)
    call    math_sum          # 重定位:R_RISCV_CALL_PLT
    ld      ra, 8(sp)
    addi    sp, sp, 16
    ret
    .size call_math, . - call_math

用 readelf -rW call_math.o 看,x86-64 留下 R_X86_64_PLT32,ARM64 留下 R_AARCH64_CALL26,RISC-V 留下 R_RISCV_CALL_PLT。链接进共享库后,x86-64 的 call 目标会变成 .plt 桩;ARM64/RISC-V 的 bl/call 也通过 PLT(或 -fno-plt 的 GOT 间接跳转)到达 libc。三者的共同点是:目标文件里不写死绝对地址,把绑定留到链接或加载阶段。

1.16.4 优化:-O2 与 -C opt-level=3 各做什么

优化发生在 IR 上,而不是在源码或最终汇编上。GCC 的 -O2 打开一套成熟的标量优化(内联、常量传播、死代码删除、循环优化、指令调度),-O3 再加上更激进的循环变换和向量化;Rust 的 -C opt-level 映射到 LLVM 的优化管道,3 与 C 的 -O3 接近但不完全等价,因为 Rust 还会插入边界检查、别名元数据和 panic 分支。

/* opt.c — 对比 -O0/-O2/-O3 的循环优化
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 opt.c -o opt
 */
#include <stddef.h>
#include <stdio.h>

__attribute__((noinline))
long sum(const long *a, size_t n)
{
    long s = 0;
    for (size_t i = 0; i < n; i++)
        s += a[i];
    return s;
}

int main(void)
{
    long a[64];
    for (size_t i = 0; i < 64; i++)
        a[i] = (long)i;
    printf("%ld\n", sum(a, 64));
    return 0;
}
// opt.rs — 与 C 版本对应;rustc --edition 2021 -C opt-level=3 -O opt.rs
use std::hint::black_box;

#[inline(never)]
fn sum(a: &[i64]) -> i64 {
    a.iter().copied().sum()
}

fn main() {
    let a: Vec<i64> = (0..64).collect();
    println!("{}", sum(black_box(&a)));
}
# C:四级优化,生成汇编与可执行文件
for o in 0 1 2 3; do
  cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O$o -S opt.c -o opt-O$o.s
  cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O$o opt.c -o opt-O$o
done

# Rust:opt-level 与 target-cpu
rustc --edition 2021 -C opt-level=0 --emit=asm opt.rs -o opt-rust-O0.s
rustc --edition 2021 -C opt-level=3 --emit=asm opt.rs -o opt-rust-O3.s
rustc --edition 2021 -C opt-level=3 -C target-cpu=native --emit=asm opt.rs -o opt-rust-native.s

# 看代码体积(text 段)
size opt-O0 opt-O2 opt-O3 opt-rust-O3

-O0 的 sum 会逐条从内存加载 a[i]、做加法、写回栈;-O2 会把 s 放在寄存器、把循环缩减成「指针递增 + 比较 + 加法」;-O3 在 -march 允许时可能展开/向量化。Rust 的 opt-level=3 通常也会把 iter().sum() 变成同样的标量循环,但安全切片版本在 -O0 下会保留边界检查;要观察差异,用 --emit=asm 对比,而不是猜。

开关CRust影响
基础优化-O2-C opt-level=2(cargo --release 默认 3)内联、DCE、循环简化
激进优化-O3-C opt-level=3向量化、循环展开、更多内联
体积优先-Os-C opt-level=s减小代码,可能牺牲速度
目标 CPU-march=native/-mtune=-C target-cpu=native使用 AVX/NEON 等扩展
溢出检查有符号溢出是 UB,默认不检查debug 默认检查;release 默认关闭Rust 可用 -C overflow-checks=on 强制
调试断言-DNDEBUG 控制 assert-C debug-assertions=on/off影响 debug_assert!
单态化/代码单元无-C codegen-units=1更多跨单元内联,编译更慢
LTO-flto-C lto=fat/thin跨 crate/文件优化
优化等级不是「越高越快」
  1. -O3 可能让代码膨胀、I-cache 命中下降;服务端常用 -O2 + PGO/LTO。
  2. -march=native 生成的二进制在不支持该指令集的机器上会 SIGILL;发布包不要用。
  3. Rust 的 opt-level=3 不会自动打开 LTO;codegen-units 默认 16,跨单元内联受限。
  4. unsafe 不会自动让代码变快;优化器仍受别名、边界和 panic 路径约束。
  5. -fno-strict-aliasing 能让某些 C 代码「恢复正常」,但它关掉的是优化假设,不是 bug 的修复。

1.16.5 LTO、节回收、可见性与 strip

传统编译是「每个 .c 独立生成 .o,链接器只看符号和重定位」。跨文件内联、跨文件常量传播、跨文件死代码删除都做不了。LTO(Link Time Optimization)把 IR 保留到链接阶段,让链接器(更准确地说,链接器调用的 LTO 插件)看到整个程序的 IR。

/* lto_a.c — LTO 可以把 helper 内联进 compute,并删除未使用的分支 */
#include <stdio.h>

static inline long helper(long x) { return x * 2 + 1; }

long compute(long x) { return helper(x) + helper(x + 1); }

int main(void)
{
    printf("%ld\n", compute(20));
    return 0;
}
# 普通编译:helper 在 lto_a.o 内部,跨文件不可见(本文件内可内联)
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror lto_a.c -o no-lto
nm no-lto | grep -E 'helper|compute' || true

# LTO:保留 IR,链接时优化;-flto=auto 使用并行 LTO
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror -flto=auto lto_a.c -o with-lto
size no-lto with-lto
nm with-lto | grep -E 'helper|compute' || true

# 节回收:每个函数/数据独立成节,链接器删掉未引用的节
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror \
   -ffunction-sections -fdata-sections \
   -Wl,--gc-sections lto_a.c -o gc-sections
size gc-sections

# 隐藏符号:共享库只导出白名单
cc -shared -fPIC -fvisibility=hidden \
   -Wl,--version-script=mathlib.map mathlib.c -o libmath-hidden.so
nm -D --defined-only libmath-hidden.so

LTO 的代价是链接变慢、内存占用上升,且工具链必须匹配:GCC 的 LTO 插件与 ar 必须配套,静态库要用 gcc-ar/gcc-ranlib 创建,否则归档里的 IR 不会被识别。常见报错是 plugin needed to handle lto object,不是代码错。

Rust 的 profile 写法:

[profile.release]
opt-level = 3
lto = "fat"            # 或 "thin";false 关闭
codegen-units = 1      # 最大化跨单元内联
panic = "abort"        # 去掉 unwind 表,体积更小
strip = "symbols"      # 发布时删 .symtab/.debug_*
cargo build --release
size target/release/myapp
nm -C target/release/myapp | head
cargo bloat --release --crates    # 需要 cargo-bloat
cargo llvm-lines --release        # 需要 cargo-llvm-lines
手段主要收益代价/风险
-flto/lto="fat"跨文件内联、常量传播、DCE链接慢、内存高、工具链需匹配
-ffunction-sections -Wl,--gc-sections删除未引用函数/数据节变多,链接器工作量增加
-fvisibility=hidden减少动态符号、避免冲突忘加导出属性会导致符号找不到
版本脚本精确控制 ABI过严会隐藏内部测试符号
strip减小体积删掉调试/静态符号,崩溃栈变差
panic="abort"去掉 unwind 表、减小体积不能 catch_unwind,panic 直接终止
为什么 `strip` 之后 `backtrace` 还能工作

strip 默认删 .symtab 和 .debug_*,但保留 .dynsym(动态链接需要)。有调试信息时,backtrace 靠 .eh_frame 和 .debug_line 还原函数名/行号;strip 后只剩地址,需要单独的 debug 文件(objcopy --only-keep-debug)或构建时保留符号服务器。发布二进制可以 strip,但要保留一份未 strip 的产物用于定位崩溃。

1.16.6 no_std、panic 与 abort:把运行时拆掉

Rust 标准库 std 依赖操作系统:文件、线程、动态内存、panic 展开、backtrace。写内核模块、引导程序、嵌入式固件时,这些都不能用;#![no_std] 只链接 core(以及可选的 alloc),由你提供 panic 处理和入口。

一个 no_std 静态库最小形态,也是本章的完整程序二:

#![allow(unused)]
fn main() {
// nostd.rs — 无标准库静态库;rustc --edition 2021 --crate-type staticlib \
//   -C panic=abort -C opt-level=2 nostd.rs -o libnostd.a
#![no_std]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}                  // 裸机没有 stderr;生产环境应写串口/停机
}

#[no_mangle]
pub extern "C" fn no_std_add(a: i32, b: i32) -> i32 {
    a.wrapping_add(b)
}
}
/* nostd_c.c — 链接 no_std 静态库的 C 调用者
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror nostd_c.c libnostd.a -o nostd_c
 */
#include <stdint.h>
#include <stdio.h>

extern int32_t no_std_add(int32_t a, int32_t b);

int main(void)
{
    printf("no_std_add=%d\n", no_std_add(20, 22));
    return 0;
}
rustc --edition 2021 --crate-type staticlib \
      -C panic=abort -C opt-level=2 nostd.rs -o libnostd.a
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror nostd_c.c libnostd.a -o nostd_c
./nostd_c                         # no_std_add=42

nm libnostd.a | grep no_std_add   # 确认符号是 T(text,已定义)
readelf -h nostd_c | grep Type    # 仍是普通 ELF 可执行文件

no_std 不是「不链接任何东西」:它仍然链接 core、compiler_builtins,以及 C 运行时(crt0、libc)如果最终产物是普通可执行文件。真正裸机要:

  • #![no_main] + 自定义 _start,或用链接脚本指定入口;
  • 提供 #[panic_handler];
  • 如果 panic 策略是 unwind,还要 eh_personality(通常直接 panic=abort 避免);
  • 若用 alloc,提供 #[global_allocator];
  • 若目标没有硬件浮点/原子,提供 compiler_builtins 的对应实现或 -C target-feature 调整;
  • 链接脚本、启动代码、中断向量表。

C 的对应物是 freestanding 环境:

/* freestanding.c — 不依赖 libc 的最小 C 程序;x86-64 Linux
 * cc -std=c17 -ffreestanding -nostdlib -static -Wl,-e,_start freestanding.c -o freestanding
 */
typedef unsigned long usize;

static long sys_write(int fd, const char *buf, usize n)
{
    long ret;
    __asm__ volatile("syscall"
                     : "=a"(ret)
                     : "a"(1L), "D"((long)fd), "S"(buf), "d"(n)
                     : "rcx", "r11", "memory");
    return ret;
}

void _start(void)
{
    static const char msg[] = "freestanding ok\n";
    sys_write(1, msg, sizeof msg - 1);
    __asm__ volatile("syscall" :: "a"(60L), "D"(0L) : "rcx", "r11", "memory");
    for (;;) { }              /* exit 不返回;给编译器一个终止路径 */
}

C 版本没有 main、没有 printf、没有 exit;-nostdlib 去掉启动文件和 libc,入口由 -Wl,-e,_start 指定。它和 Rust no_std 的差别只在语言层:C 本来就「默认接近裸机」,Rust 需要显式声明 no_std 并补上 panic 语义。

`panic=abort` 的四个连带影响
  1. catch_unwind 失效:abort 策略下 panic 直接 abort(),不会展开栈,catch_unwind 捕获不到。
  2. 析构函数不保证执行:panic 跨 FFI 边界是 UB;Drop 在 abort 时不会运行。
  3. 体积变小但不等于零成本:panic 格式化代码仍在,除非 panic_immediate_abort(nightly)或自定义 panic handler。
  4. 测试行为变化:#[should_panic] 测试在 abort 下无法工作;库作者不要假设调用方用 unwind。

1.16.7 C/Rust FFI、bindgen 与 cbindgen

FFI 的难点不是「怎么声明」,而是谁拥有内存、谁负责释放、错误怎么表示、panic 能否跨边界。下面从 Rust 导出给 C、C 导出给 Rust 两个方向各给一个完整程序三。先把 ABI 边界钉死:

项x86-64 SysVARM64 AAPCS64RISC-V 64 psABI
整数/指针参数rdi, rsi, rdx, rcx, r8, r9x0–x7a0–a7
浮点参数xmm0–xmm7v0–v7fa0–fa7
返回值raxx0a0
调用/返回call/retbl/retjal/ret
栈对齐16 字节16 字节16 字节
结构体按值按八字节分类按 HFA/整数分类≤2×XLEN 走寄存器

C 侧头文件是契约,Rust 侧必须逐字匹配:long 在 Linux x86-64/ARM64/RISC-V 都是 64 位,但 Windows LLP64 是 32 位;size_t 是无符号指针宽度;char 的符号性由目标决定。跨平台代码用 <code>&lt;stdint.h&gt;</code> 的 int32_t/int64_t/size_t,不要用裸 long。

Rust 导出给 C

#![allow(unused)]
fn main() {
// ffi_rs.rs — 导出 C ABI;rustc --edition 2021 --crate-type staticlib \
//   -C panic=abort ffi_rs.rs -o librustffi.a
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a.wrapping_add(b)
}

/// # Safety
/// `xs` 必须指向至少 `n` 个 `i32`,且在整个调用期间有效。
#[no_mangle]
pub unsafe extern "C" fn rust_sum(xs: *const i32, n: usize) -> i32 {
    if xs.is_null() {
        return 0;
    }
    let mut s: i32 = 0;
    for i in 0..n {
        s = s.wrapping_add(unsafe { *xs.add(i) });
    }
    s
}
}
/* ffi_c.c — 调用 Rust 静态库
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror ffi_c.c librustffi.a -o ffi_c
 */
#include <stdint.h>
#include <stdio.h>

extern int32_t rust_add(int32_t a, int32_t b);
extern int32_t rust_sum(const int32_t *xs, size_t n);

int main(void)
{
    int32_t xs[] = {1, 2, 3, 4};
    printf("rust_add=%d rust_sum=%d\n", rust_add(2, 3), rust_sum(xs, 4));
    return 0;
}
rustc --edition 2021 --crate-type staticlib -C panic=abort -O \
      ffi_rs.rs -o librustffi.a
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror ffi_c.c librustffi.a -o ffi_c
./ffi_c                          # rust_add=5 rust_sum=10
nm librustffi.a | grep -E 'rust_add|rust_sum'

C 导出给 Rust

// rust_ffi.rs — 调用 C 静态库 libmath.a
// rustc --edition 2021 -O rust_ffi.rs -L . -l static=math -o rust_ffi
use std::os::raw::{c_char, c_long};

#[link(name = "math", kind = "static")]
extern "C" {
    fn math_sum(xs: *const c_long, n: usize) -> c_long;
    fn math_dot(a: *const c_long, b: *const c_long, n: usize) -> c_long;
    fn math_version() -> *const c_char;
}

fn main() {
    let a = [1 as c_long, 2, 3, 4];
    let b = [10 as c_long, 20, 30, 40];
    let (s, d) = unsafe { (math_sum(a.as_ptr(), 4), math_dot(a.as_ptr(), b.as_ptr(), 4)) };
    let v = unsafe { std::ffi::CStr::from_ptr(math_version()) };
    println!("{}: sum={} dot={}", v.to_string_lossy(), s, d);
}
# 先按 1.16.3 生成 libmath.a(C 静态库)
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror -c mathlib.c -o mathlib.o
ar rcs libmath.a mathlib.o
rustc --edition 2021 -O rust_ffi.rs -L . -l static=math -o rust_ffi
./rust_ffi                       # mathlib 1.0: sum=10 dot=300

#[link(name = "math", kind = "static")] 让 rustc 在链接时加 -lmath;-L . 告诉它到当前目录找。若 C 库是动态库,把 kind 改成 "dylib",并确保运行时能找到 .so(rpath 或 LD_LIBRARY_PATH)。

bindgen 与 cbindgen:让声明自动同步

手写 extern "C" 容易漏字段、错类型。两个工具分别解决两个方向:

工具方向输入输出安装
bindgenC → RustC 头文件bindings.rscargo install bindgen-cli(需要 libclang)
cbindgenRust → CRust crate.hcargo install cbindgen

bindgen 的典型用法:

# wrapper.h 只放 #include,避免把系统头全部展开
cat > wrapper.h <<'H'
#include "mathlib.h"
H

bindgen wrapper.h \
  --allowlist-function 'math_.*' \
  --allowlist-type 'pair' \
  --no-layout-tests \
  -o bindings.rs

生成的 bindings.rs 大致是:

#![allow(unused)]
fn main() {
/* 由 bindgen 生成(示例,实际输出随版本变化) */
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct pair {
    pub a: ::std::os::raw::c_long,
    pub b: ::std::os::raw::c_long,
}

extern "C" {
    pub fn math_sum(xs: *const ::std::os::raw::c_long, n: usize) -> ::std::os::raw::c_long;
    pub fn math_dot(a: *const ::std::os::raw::c_long,
                    b: *const ::std::os::raw::c_long,
                    n: usize) -> ::std::os::raw::c_long;
    pub fn math_version() -> *const ::std::os::raw::c_char;
}
}

cbindgen 从 Rust 生成 C 头:

# cbindgen.toml
language = "C"
include_guard = "MATHLIB_H"
cpp_compat = false
[export]
prefix = ""
include = ["stdint.h", "stddef.h"]
cbindgen --config cbindgen.toml --crate mathlib --output mathlib.h

生成的头会包含 int64_t/size_t 和 #ifdef __cplusplus extern "C" 保护。把生成物提交到仓库并在 CI 里 git diff --exit-code,可以防止 Rust 结构体改了而 C 头没更新。

FFI 的八个边界
  1. String/Vec 直接跨边界:布局不是 C ABI,C 侧无法释放。用 CString/裸指针 + 显式长度,并约定谁释放。
  2. #[repr(C)] 缺失:Rust 默认布局可重排字段,C 侧读错偏移。
  3. panic 跨 extern "C":UB。边界内 catch_unwind,或 panic=abort;需要传播用 extern "C-unwind"。
  4. 空指针 + 长度 0:C 允许 free(NULL),Rust 的 slice::from_raw_parts 要求非空且对齐;先用 is_null() 分支。
  5. bool/枚举布局:C 的 _Bool 与 Rust bool 都是 1 字节,但 C 枚举的底层类型由实现决定;FFI 用 #[repr(C)] 或固定 int32_t。
  6. errno 被覆盖:Rust 的 last_os_error() 必须在失败后立刻读;中间调用 println! 可能覆盖 errno。
  7. 所有权重复释放:Rust 侧 Box::from_raw 接管后,C 侧不能再 free;反之亦然。用「谁分配谁释放」或提供成对的 foo_new/foo_free。
  8. size_t/long 宽度:Linux 64 位 long 是 64 位,Windows 是 32 位;用 <code>&lt;stdint.h&gt;</code> 固定宽度,别让绑定生成器猜。

1.16.8 实验一:从 ELF 到 GOT/PLT 的证据链

目标:用四个工具串起「源码 → 目标文件 → 动态库 → 运行时绑定」的证据链,而不是只看 ldd。

# 0) 准备 1.16.3 的 libmath.a/libmath.so 与 app-dynamic
# 1) ELF 头与节:确认 ET_DYN(PIE/共享库)和 .plt/.got.plt 的存在
readelf -hW app-dynamic | grep -E 'Type|Machine|Entry'
readelf -SW app-dynamic | grep -E '\.(plt|got|text|data|bss|dynamic|dynsym)'

# 2) 动态段:NEEDED、SONAME、RUNPATH、BIND_NOW
readelf -dW app-dynamic

# 3) 重定位:哪些是加载时填的(RELATIVE/GLOB_DAT),哪些是 PLT(JUMP_SLOT)
readelf -rW app-dynamic | sed -n '1,40p'

# 4) PLT 桩与调用点
objdump -d -j .plt app-dynamic | sed -n '1,40p'
objdump -d app-dynamic | sed -n '/<main>:/,/^$/p' | grep -E 'call|math_'

# 5) 符号:静态符号 vs 动态符号
nm -C app-dynamic | grep -E 'math_(sum|dot|version)| main$'
nm -D --defined-only libmath.so.1.0

# 6) 运行时绑定
LD_DEBUG=bindings ./app-dynamic 2>&1 | grep -E 'math_(sum|dot|version)' | head
LD_DEBUG=libs ./app-dynamic 2>&1 | grep -E 'find library|libmath' | head

预期:readelf -d 里看到 NEEDED libmath.so.1、RUNPATH $ORIGIN;readelf -r 里 math_sum 是 R_X86_64_JUMP_SLOT(ARM64 为 R_AARCH64_JUMP_SLOT,RISC-V 为 R_RISCV_JUMP_SLOT);objdump -d -j .plt 看到 jmp *...@GOTPCREL(%rip);LD_DEBUG=bindings 看到 binding file ... to ...: normal symbol 'math_sum'。

打桩验证:运行时替换 math_sum

/* interpose.c — LD_PRELOAD 用的替身;返回固定值,证明符号可被劫持
 * cc -shared -fPIC -O2 -Wall -Wextra -Werror interpose.c -o libinterpose.so
 */
#include <stddef.h>

long math_sum(const long *xs, size_t n)
{
    (void)xs;
    (void)n;
    return 999;                  /* 真实实现应调用 dlsym(RTLD_NEXT, ...) */
}
cc -shared -fPIC -O2 -Wall -Wextra -Werror interpose.c -o libinterpose.so
LD_PRELOAD=./libinterpose.so ./app-dynamic
# 预期输出 mathlib 1.0: sum=999 dot=300

如果 math_sum 被版本脚本绑定到 MATHLIB_1.0,某些加载器要求替身也带版本;这时用 .symver 或链接版本脚本。生产环境不要用 LD_PRELOAD 做「热修复」,它依赖加载顺序、权限和安全策略(setuid 程序会忽略 LD_PRELOAD)。

1.16.9 实验二:用 perf、size、cargo 看优化与 LTO

目标:把「优化等级」从信仰变成数字:指令数、IPC、代码体积、符号数量。

# 1) 同一输入的指令数/周期数;先确认 perf 权限
cat /proc/sys/kernel/perf_event_paranoid
perf stat -e cycles,instructions,branches,branch-misses ./opt-O2
perf stat -e cycles,instructions,branches,branch-misses ./opt-O3
perf stat -e cycles,instructions ./opt-rust-O3

# 2) 代码体积与节布局
size opt-O0 opt-O2 opt-O3 opt-rust-O3
readelf -SW with-lto | grep -E '\.(text|rodata|data|bss)'
nm with-lto | wc -l

# 3) Rust:LTO/codegen-units 的产物差异
cargo build --release
size target/release/myapp
cargo bloat --release --crates 2>/dev/null | head -30
cargo llvm-lines --release 2>/dev/null | head -20

# 4) 保留调试信息,再分离/剥离
objcopy --only-keep-debug myapp myapp.debug
strip --strip-all myapp -o myapp.stripped
objcopy --add-gnu-debuglink=myapp.debug myapp.stripped
readelf -SW myapp.stripped | grep -E 'symtab|debug'

读数时注意:

  • 指令数少不等于快:向量化可能减少指令但增加频率下降/功耗;看 cycles 与 IPC 一起。
  • size 的 text 段不等于文件大小:还有 .rodata、重定位、符号表;ls -l 与 size 可能差很多。
  • LTO 后符号变少:内联/死代码删除会删除本地符号;nm 数量下降不一定是 bug。
  • cargo bloat 需要未 strip 的二进制;发布时 strip="symbols" 会让它看不到符号。
读优化报告的顺序
  1. 先看 perf stat 的 IPC 和分支 miss,判断是前端、后端还是分支瓶颈;
  2. 再看 perf record -g 的热点函数,确认是不是你以为的函数;
  3. 对比 -O2/-O3 的汇编,找向量化/展开是否真的发生;
  4. 最后才调 target-cpu/LTO/PGO;不要一开始就全开。

1.16.10 十二条工具链陷阱

现象 → 原因 → 修复
  1. undefined reference,但库明明存在:命令行顺序错,库在引用它的目标文件之前。修复:把 -lfoo 放到 .o 之后;互相依赖的静态库重复写或用 --start-group。
  2. 共享库运行时 cannot open shared object file:没有 rpath,LD_LIBRARY_PATH 也没设,ldconfig 缓存里没有。修复:-Wl,-rpath,'$ORIGIN'(注意单引号)或安装到系统库目录并 ldconfig。
  3. relocation R_X86_64_32 against ... can not be used when making a shared object:编译共享库时没加 -fPIC。修复:所有进入 .so 的目标文件都用 -fPIC 重编。
  4. libfoo.so.1 找不到,只有 libfoo.so:SONAME 与文件名/符号链接不一致。修复:链接时 -Wl,-soname,libfoo.so.1,并创建 libfoo.so.1 -> libfoo.so.1.0 和 libfoo.so -> libfoo.so.1。
  5. rpath 的 ORIGIN 变成空串:shell 展开了变量,或链接器不识别。修复:用单引号包住 rpath 中的 ORIGIN 字面量;用 readelf -d 确认 RUNPATH。
  6. LTO 静态库报 plugin needed to handle lto object:用普通 ar 打包了 LTO 对象。修复:用 gcc-ar/gcc-ranlib,或关闭 LTO 重编。
  7. strip 后动态链接失败:误删 .dynsym。修复:用 strip --strip-debug 或 --strip-unneeded,不要手工删 .dynsym。
  8. -march=native 在老机器 SIGILL:二进制用了新指令集。修复:发布用基线 -march=x86-64-v2/-mcpu=generic,或运行时多版本分发(ifunc)。
  9. Rust release 仍然慢:codegen-units=16 限制跨单元内联,且没开 LTO。修复:codegen-units=1、lto="fat",再用 perf 验证。
  10. panic=abort 后 catch_unwind 不工作:abort 不展开栈。修复:库不要依赖 unwind;需要边界隔离用进程/Result。
  11. no_std 编译报 #[panic_handler] 缺失/重复:库和二进制各定义一次,或根本没定义。修复:只在最终二进制定义 panic handler;库用 #![no_std] 但不定义。
  12. FFI 结构体字段错位/崩溃:Rust 侧缺 #[repr(C)],或 C 侧 long 宽度不同。修复:#[repr(C)] + 固定宽度整数;用 bindgen/cbindgen 生成并在 CI 对比。

1.16.11 本章出口检查

检查项达标线没达标去哪
四阶段能说出每步的输入/输出、工具与可检查产物1.16.1
ELF能读 readelf -h/-S/-s/-r 并解释 UND/GLOBAL/WEAK1.16.2
库能构建 .a 与带 SONAME 的 .so,解释 GOT/PLT1.16.3
优化能对比 -O2/-O3 与 Rust opt-level 的汇编差异1.16.4
LTO能解释 -flto、--gc-sections、visibility 的收益与代价1.16.5
no_std能构建 no_std 静态库并被 C 调用1.16.6
FFI能双向调用,写 #[repr(C)]/extern "C"/build.rs1.16.7
实验能用 LD_DEBUG/nm/perf 找到证据1.16.8–1.16.9
即时自测 · 1.16

Q1 readelf -r 里的 R_X86_64_PLT32 和 R_X86_64_PC32 有什么区别?分别在什么场景出现?

Q2 为什么共享库要用 -fPIC?-fPIC 与 -fPIE 的差别是什么?

Q3 -flto 之后 nm 看不到某个函数,可能是哪些原因?如何区分「被内联」和「被节回收」?

Q4 Rust 的 no_std 静态库被 C 调用时,为什么还要 -C panic=abort?不设置会怎样?

答案

A1 PC32 是相对当前指令的 32 位偏移,常用于同文件数据/函数引用;PLT32 指向 PLT 桩,用于外部函数调用,链接器可把它解析成 PLT 或直接调用。PIC/PIE 下两者都不写绝对地址。

A2 -fPIC 生成位置无关代码,共享库可被加载到任意地址;-fPIE 用于可执行文件(PIE),语义类似但允许一些针对可执行文件的优化。共享库必须 PIC;可执行文件用 PIE 是加固默认。

A3 可能被内联、被 --gc-sections 删除、被 strip 删除,或变成了本地符号。区分:看 readelf -sW 是否还有 LOCAL 符号;看 objdump -d 是否在调用点展开;用 -ffunction-sections 时看节是否还在;strip 只删符号不改代码。

A4 静态库默认可能带 unwind 表/panic 运行时,C 链接器不一定提供对应符号;panic=abort 去掉展开路径,避免 eh_personality/unwind 依赖,也让 panic 不会跨 FFI 边界传播。不设置时,若库被 C 调用且发生 panic,行为未定义或链接失败。

母本对应

母本章节本书落点程度
C CSAPP第 7 章 链接(7.1–7.14)1.16.1–1.16.5完整:编译驱动、ELF、符号、重定位、动态链接、PIC;扩展 Rust
C CSAPP第 5 章 优化程序性能1.16.4背景:优化等级与性能证据
Rust Book第 19 章 unsafe、附录 Cargo/rustc1.16.6–1.16.7扩展:no_std、FFI、panic 策略
RustonomiconFFI、repr(C)、unwind 边界1.16.7参考:按 ABI 独立改写
rCore第 0 章 编译/链接、附录 D cargo asm1.16.1、1.16.9参考:只借工具链组织方式

下一站:1.17 Rust 系统编程 把这里的所有权、错误、FFI 与工具链放进文件、线程和 socket:同一套 ABI 规则,在真实系统调用上会以 EINTR、短写、Send/Sync 和生命周期错误的形式再次出现。