Rust 编译器与运行时揭秘

第13章 FFI:与 C 世界的桥梁

作者 杨艺韬 · 15,673 字 · 发布于 · 更新于

ABI 是两种语言之间的握手协议——参数怎么传、返回值放哪里、谁来清理栈、谁来释放内存。

本章要点

  • extern "C" 告诉编译器使用 C ABI 调用约定:参数通过特定寄存器和栈传递,遵循平台标准
  • repr(C) 让 struct 按平台 C 的布局规则排布、与 C 编译器一致——字段顺序、对齐、padding 都可预测(bitfield 等 Rust 表达不了的结构除外,见 §13.13.6)
  • Rust 的 String/&str 是 (ptr, len) 胖指针,C 的字符串是 null 结尾的 char*——需要 CStr/CString 转换
  • #[no_mangle] 阻止编译器对函数名进行 name mangling,使 C 代码可以通过原始名称链接
  • 回调函数通过函数指针 + void* 上下文的模式在 Rust 与 C 之间传递
  • FFI 边界的内存管理遵循谁分配谁释放原则——不要混用分配器
  • 安全封装(Safe Wrapper Pattern)是将 unsafe FFI 调用包装为安全 Rust API 的标准做法

13.1 为什么需要 FFI

Rust 诞生在一个以 C 为基础的世界中。操作系统内核用 C 写成,数十年积累的高质量库——从 SQLite 到 OpenSSL——都以 C API 的形式存在。FFI(Foreign Function Interface)解决三个核心需求:

调用 C 库。 当你需要使用 SQLite、OpenSSL 或任何已有的 C 库时,FFI 让 Rust 直接调用这些函数,无需重写。

调用系统 API。 POSIX API、Windows API 几乎全部以 C ABI 暴露。Rust 标准库在 library/std/src/sys/ 目录下大量使用 FFI 调用底层系统函数——文件系统、网络 I/O、线程、随机数生成,底层都落到 extern 块声明的外部函数上(Unix 上多经 libc crate,Windows 上多为 extern "system")。

将 Rust 嵌入其他语言。 Python、C/C++、Go、Ruby 都可以通过 C ABI 调用 Rust 编写的共享库。

C ABI 是系统编程世界的通用语言。 几乎所有语言都能生成或调用符合 C ABI 的代码,Rust 选择以此作为与外部世界交互的桥梁。

13.2 extern "C" 与 ABI 规范:编译器做了什么

ABI(Application Binary Interface)定义了二进制层面的交互规则:参数放哪些寄存器、返回值怎么传、谁清理栈、哪些寄存器需要保存。

// Rust ABI——编译器自由优化,不保证跨版本兼容
fn rust_abi(a: i32, b: i64) -> i32 { a + b as i32 }

// C ABI——遵循平台标准,可以被 C/Python/Go 调用
extern "C" fn c_abi(a: i32, b: i64) -> i32 { a + b as i32 }

在 compiler/rustc_target/src/spec/abi_map.rs 中,AbiMap 负责将源码中的 ABI 标注映射为规范化的 CanonAbi:

// 来自 compiler/rustc_target/src/spec/abi_map.rs(简化)
pub fn canonize_abi(&self, extern_abi: ExternAbi, has_c_varargs: bool) -> AbiMapping {
    match (extern_abi, arch) {
        (ExternAbi::C { .. }, _) => CanonAbi::C,
        (ExternAbi::Rust | ExternAbi::RustCall, _) => CanonAbi::Rust,
        (ExternAbi::System { .. }, ArchKind::X86)
            if os == OsKind::Windows && !has_c_varargs =>
        {
            CanonAbi::X86(X86Call::Stdcall)  // Windows x86 上 System = Stdcall
        }
        (ExternAbi::System { .. }, _) => CanonAbi::C, // 其他平台 System = C
        // ...
    }
}

compiler/rustc_target/src/callconv/mod.rs 中的 FnAbi 结构描述函数在特定 ABI 下的完整调用信息,每个参数的传递方式由 PassMode 枚举描述:

// 来自 compiler/rustc_target/src/callconv/mod.rs(省略 doc 注释,中文注释为本专栏所加)
pub enum PassMode {
    Ignore,                                         // ZST,忽略
    Direct(ArgAttributes),                          // 直接传(Scalar/Vector)
    Pair(ArgAttributes, ArgAttributes),              // ScalarPair,拆成两个参数
    Cast { pad_i32: bool, cast: Box<CastTarget> },  // 类型转换后传递
    Indirect { attrs: ArgAttributes, meta_attrs: Option<ArgAttributes>, on_stack: bool },
}
flowchart TD
    subgraph "编译器 ABI 处理流水线"
        A["源码: extern &quot;C&quot; fn foo(a: i32, b: MyStruct)"]
        B["AbiMap::canonize_abi() → CanonAbi::C"]
        C["FnAbi 构建: 为每个参数确定 PassMode"]
        D["adjust_for_foreign_abi(): 平台特定调整"]
        E["x86-64 SysV 下(设 MyStruct 为 24 字节): a → Direct(edi);b → Indirect(on_stack=true) 按值拷到栈上;ret → Direct(eax)"]
    end
    A --> B --> C --> D --> E
    style A fill:#2563eb,color:#fff,stroke:none
    style B fill:#10b981,color:#333,stroke:none
    style E fill:#f59e0b,color:#333,stroke:none

在 x86-64 上,classify_arg 按 System V ABI 规则将参数分为 Int(通用寄存器 rdi, rsi, rdx, rcx, r8, r9)和 Sse(SSE 寄存器 xmm0-xmm7)两类。超出寄存器容量的参数通过栈传递。

13.2.1 AbiMapping 的 Deprecated 三态与 "force listing" 自文档化设计

上面的 canonize_abi 伪代码把返回值暗示成普通的 CanonAbi。但真实签名是——AbiMapping——一个 3 变体 enum(compiler/rustc_target/src/spec/abi_map.rs:17):

pub enum AbiMapping {
    /// this ABI is exactly mapped for this platform
    Direct(CanonAbi),
    /// we don't yet warn on this, but we will
    Deprecated(CanonAbi),
    /// ABI we do not map for this platform: it must not reach codegen
    Invalid,
}

Deprecated 这一档非常关键——它代表"当前还能编译、未来将变成错误"的 ABI 标注。按 canonize_abi 的真实分支,走到 Deprecated(CanonAbi::C) 的只有三种情况:extern "cdecl" 出现在非 x86 架构上、以及 extern "stdcall" / extern "fastcall" 出现在Windows 但非 x86的目标上(如 aarch64-pc-windows-msvc)。而 extern "stdcall" 在非 Windows 的非 x86 上、extern "win64" 在 x86 上,直接就是 Invalid。这些历史上被接受但语义模糊的 ABI、rustc 决定慢慢收紧:

  • 当前版本:Deprecated(CanonAbi) 返回规范化 ABI,继续能编译,但 check_abi 会报默认 warn 的 future-incompat lint UNSUPPORTED_CALLING_CONVENTIONS(compiler/rustc_hir_analysis/src/check/check.rs:82-89;1.96.0 实测 x86_64 Linux 上 extern "cdecl" fn 报 warning,extern "stdcall" fn 直接 E0570)
  • 未来版本:这些 case 会升级成硬 error

枚举上的源码注释 "we don't yet warn on this, but we will" 写于 lint 落地之前,已经落后于实现——但它点明了这一档的定位:ABI 生态演化的明示缓冲带。对跨语言项目维护者有实用价值:升级 rustc 时 Invalid 突然出现意味着你依赖的 extern "..." 被禁用了、需要改代码。而 Deprecated 阶段只是信号、可以从容准备。

AbiMap 的 struct 注释本身(line 7-8)反映了 rustc 作者的自我认知:

/// A maybe-transitional structure circa 2025 for hosting future experiments in
/// encapsulating arch-specific ABI lowering details to make them more testable.

"maybe-transitional" + "circa 2025" + "future experiments" 三个词串在一起——rustc 源码里这么自谦的注释不多见。作者明确标注这个结构可能还会大改。读者看到这种注释要知道:AbiMap 是 WIP、将来可能被重构;不要在外部代码里依赖它的具体形状。这也是读 rustc 源码时的一条通用规则——找这种 "transitional" / "experimental" 标记能帮你判断哪些 API 相对稳定、哪些处于"改它不算 breaking change"的状态。

from_target 里的"force listing"自文档化设计(line 49):

// compiler/rustc_target/src/spec/abi_map.rs:48
pub fn from_target(target: &Target) -> Self {
    // the purpose of this little exercise is to force listing what affects these mappings
    let arch = match target.arch {
        Arch::AArch64 => ArchKind::Aarch64,
        Arch::AmdGpu => ArchKind::Amdgpu,
        Arch::Arm => ArchKind::Arm(...),
        Arch::Avr => ArchKind::Avr,
        Arch::LoongArch32 | Arch::LoongArch64 => ArchKind::LoongArch,
        Arch::Msp430 => ArchKind::Msp430,
        Arch::Nvptx64 => ArchKind::Nvptx,
        Arch::RiscV32 | Arch::RiscV64 => ArchKind::Riscv,
        Arch::X86 => ArchKind::X86,
        Arch::X86_64 => ArchKind::X86_64,
        _ => ArchKind::Other,
    };

源码注释 "the purpose of this little exercise is to force listing what affects these mappings" 点破了这段设计意图——这段 match 不是简单翻译、而是有意做自文档:把"哪些 target arch 影响 ABI 决定"显式列出来。不过它末尾有 _ => ArchKind::Other 兜底,并不是穷举匹配,新增 arch 时编译器不会在这里报错;只有将来去掉兜底分支,才会强迫贡献者思考新架构是否影响 ABI。

这和第 2 章 elaborate_drops 里 debug_assert_ne! 捕获 rustc bug、第 6 章 CollectionMode 双模式用 enum exhaustive 防止未来漏处理出于相近的考虑——尽量把"将来别忘了"这件事写进代码结构,而不是只留在人脑里;区别在于,这里的显式列表加注释只是提醒,真正由编译器强制的是后两者那样的断言和穷举匹配。这也解释了为什么 rustc 源码看起来"啰嗦"——大量 match 和显式列表的代码,其实是在给将来的修改者留检查点。

13.3 类型映射:Rust 类型与 C 类型

library/core/src/ffi/primitives.rs 中定义了与 C 类型精确对应的类型别名:

C 类型 Rust FFI 类型 说明
char c_char 平台相关:ARM Linux 为 u8,x86 Linux 为 i8
int / unsigned int c_int / c_uint 通常 i32 / u32
long c_long Linux 64 位: i64, Windows: i32
double c_double 通常 f64
void* *mut c_void 通用指针
size_t usize 指针大小的无符号整数

c_char 的符号性差异值得注意——primitives.rs 的 c_char_definition 模块里有大段注释引用各平台的 ABI 文档。Apple 平台即使在 ARM 上也强制 signed char,而 ARM Linux 默认 unsigned char。

字符串转换:CStr 与 CString

Rust 标准库 library/std/src/ffi/mod.rs 中详细说明了两种字符串体系的差异:Rust 字符串保证 UTF-8、存储长度、可含内部 \0;C 字符串无编码保证、以 \0 结尾、不可含内部 \0。

graph TB
    subgraph "Rust → C"
        R1["&str / String"] -->|"CString::new()"| R2["CString"]
        R2 -->|".as_ptr()"| R3["*const c_char"]
    end
    subgraph "C → Rust"
        C1["*const c_char"] -->|"CStr::from_ptr()"| C2["&CStr"]
        C2 -->|".to_str()"| C3["&str"]
    end
    style R1 fill:#2563eb,color:#fff,stroke:none
    style R2 fill:#10b981,color:#333,stroke:none
    style R3 fill:#f59e0b,color:#333,stroke:none
    style C1 fill:#f59e0b,color:#333,stroke:none
    style C2 fill:#10b981,color:#333,stroke:none
    style C3 fill:#2563eb,color:#fff,stroke:none
use std::ffi::{CStr, CString, c_char};

extern "C" { fn puts(s: *const c_char) -> i32; }

fn rust_to_c() {
    let c_string = CString::new("Hello, C!").expect("包含内部 \\0");
    unsafe { puts(c_string.as_ptr()); }
}

unsafe fn c_to_rust(c_buf: *const c_char) {
    let c_str = CStr::from_ptr(c_buf);
    // to_str() 做 UTF-8 验证;to_string_lossy() 做有损转换
    match c_str.to_str() {
        Ok(s) => println!("{}", s),
        Err(_) => println!("{}", c_str.to_string_lossy()),
    }
}

// Rust 1.77+ 的 C 字符串字面量——编译期保证末尾 \0 和无内部 \0
const GREETING: &CStr = c"Hello, C world!";

CString::new 返回 Result 是因为内部 \0 会导致 C 截断字符串——这是编译器无法静态检查的运行时约束。

13.3.1 源码核对:CStr::from_ptr 的 strlen + from_raw_parts 双步实现

§13.3 给了字符串转换的概念。打开 library/core/src/ffi/c_str.rs:253 的 CStr::from_ptr 真实代码:

#[inline] // inline is necessary for codegen to see strlen.
#[must_use]
#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_const_stable(feature = "const_cstr_from_ptr", since = "1.81.0")]
pub const unsafe fn from_ptr<'a>(ptr: *const c_char) -> &'a CStr {
    // SAFETY: The caller has provided a pointer that points to a valid C
    // string with a NUL terminator less than `isize::MAX` from `ptr`.
    let len = unsafe { strlen(ptr) };

    // SAFETY: The caller has provided a valid pointer with length less than
    // `isize::MAX`, so `from_raw_parts` is safe. The content remains valid
    // and doesn't change for the lifetime of the returned `CStr`. This
    // means the call to `from_bytes_with_nul_unchecked` is correct.
    //
    // The cast from c_char to u8 is ok because a c_char is always one byte.
    unsafe { Self::from_bytes_with_nul_unchecked(slice::from_raw_parts(ptr.cast(), len + 1)) }
}

5 个值得读懂的细节:

1、#[inline] // inline is necessary for codegen to see strlen.——引入这行注释的提交(e94ba4ae78bb,"Inline strlen_rt in CStr::from_ptr")说明了动机:内联之后,调用点的代码里直接出现对 strlen 的调用,LLVM 就能把它当作 strlen 来优化(例如对编译期已知的字符串直接折叠出长度),而不必开启 std 参与的 LTO。#[inline] 在这里是为了让优化器看见 strlen 的语义——值得记住的 Rust idiom。

2、pub const unsafe fn——同时是 const fn + unsafe fn。const 让它能在常量上下文求值(编译期 strlen);unsafe 因为调用者必须保证 ptr 是 valid C string。#[rustc_const_stable(feature = "const_cstr_from_ptr", since = "1.81.0")] 显示这条 const 化在 1.81 stable(2024 年)——比函数本身(1.0 stable,2015 年)晚了 9 年。慢慢扩展 const 能力的过程反映 Rust const eval 引擎的逐年成熟。

3、'a lifetime 是任意的——caller 决定。这条函数签名最危险的部分——返回的 &'a CStr 的 lifetime 完全由调用者选择、Rust 不验证。如果你写 'static 但 ptr 实际只 valid 几秒、之后 use after free。这是 unsafe FFI 的根本性陷阱——文档明确要求 caller 保证 lifetime 正确。

4、strlen(ptr) + from_raw_parts(ptr.cast(), len + 1) 两步——先 O(N) 扫一遍找 NUL、再用 len+1 构造 byte slice(包含 NUL)。N 是字符串长度——所以 from_ptr 是 O(N) 而不是 O(1)。如果你已经知道 length(来自其他 source)——用 from_bytes_with_nul_unchecked 直接 O(1) 构造、避免扫描。

5、The cast from c_char to u8 is ok because a c_char is always one byte——注释里的小确认。c_char 在不同平台是 i8 或 u8 但总是 1 字节——所以 ptr.cast() 是安全的。这种"看起来 obvious 但不写出来未来 contributor 可能踩坑"的注释贯穿 rustc 源码——保护未来自己的工程纪律。

13.3.2 源码核对:from_bytes_until_nul 的 memchr 优化

CStr 还有一个有趣 API——from_bytes_until_nul(library/core/src/ffi/c_str.rs:298,省略了属性与 SAFETY 注释):

pub const fn from_bytes_until_nul(bytes: &[u8]) -> Result<&CStr, FromBytesUntilNulError> {
    let nul_pos = memchr::memchr(0, bytes);
    match nul_pos {
        Some(nul_pos) => {
            // FIXME(const-hack) replace with range index
            let subslice = unsafe { crate::slice::from_raw_parts(bytes.as_ptr(), nul_pos + 1) };
            Ok(unsafe { CStr::from_bytes_with_nul_unchecked(subslice) })
        }
        None => Err(FromBytesUntilNulError(())),
    }
}

应用场景:你从一个 stack buffer 读 C string:

let mut buf = [0u8; 256];
read_into(&mut buf);
let c = CStr::from_bytes_until_nul(&buf)?;

这比 from_ptr 安全(不依赖外部 lifetime),也比 from_bytes_with_nul 灵活(不需要 buffer 末尾恰好是 NUL)。

memchr::memchr(0, bytes) 是 core 内部的实现(library/core/src/slice/memchr.rs,移植自 rust-memchr),不是逐字节循环:短于两个 usize 的切片逐字节比较,更长的切片每次读两个 usize 字,用位运算技巧(SWAR)判断其中是否含零字节。它不用 SIMD 指令,但长 buffer 上通常快于逐字节手写 for b in bytes { if *b == 0 ... }(具体倍数取决于目标 CPU 与字符串长度,这里不给固定数字)。

FIXME(const-hack) 注释暗示——bytes[..nul_pos+1] 这种 range index 在 const fn 里还不支持(const fn 限制持续放宽中)——所以用 from_raw_parts 绕过去。未来某个 Rust 版本里这条 FIXME 会被删、代码改成更直观的 &bytes[..nul_pos+1]。

读 rustc/std 源码时遇到 FIXME(const-hack) 这种 tag 你能立刻知道——这是 const fn 限制还没解决前的临时绕路——未来会消失、不算技术债。

13.4 repr(C):确保 C 兼容的内存布局

repr(C) 强制使用 C 的布局规则:字段按声明顺序排列,对齐遵循 C ABI 标准。

#[repr(C)]
struct Misaligned {
    a: u8,     // 偏移 0
    // 3 字节 padding
    b: u32,    // 偏移 4
    c: u8,     // 偏移 8
    // 3 字节 padding
}
// 总大小 12 字节(6 字节 padding)

#[repr(C)]
enum Color { Red = 0, Green = 1, Blue = 2 }
// 等价于 C 的 enum,通常 4 字节

#[repr(u8)]
enum SmallColor { Red = 0, Green = 1, Blue = 2 }
// 指定底层类型为 u8,大小 1 字节
// 注意:无字段枚举写 #[repr(C, u8)] 会报 E0566(conflicting representation hints)

注意:repr(C) 不允许编译器重排字段——如果你需要减少 padding,必须手动调整字段顺序(将大对齐字段放前面)。

13.4.1 源码核对:CString::into_raw 与 from_raw 的所有权转移协议

CString::into_raw(library/alloc/src/ffi/c_str.rs:455)只有 3 行:

#[inline]
#[must_use = "`self` will be dropped if the result is not used"]
#[stable(feature = "cstr_memory", since = "1.4.0")]
pub fn into_raw(self) -> *mut c_char {
    Box::into_raw(self.into_inner()) as *mut c_char
}

但这 3 行藏着 FFI 内存管理最重要的一条契约——所有权完整转移给 raw pointer:

#[must_use = "...will be dropped if not used"]——一个救命的 lint。如果用户写 cs.into_raw();(忽略返回值)——CString 的 Box 会被 leak、内存永远释放不了。must_use 让 rustc 至少 warn 用户。ownership transfer 函数的 must_use 是 Rust API 设计的标准 idiom——你写自己的 unsafe FFI binding 时也该加。

Box::into_raw 是真正的"放手"操作——Box 不再 drop 内部数据、所有权转给 raw pointer。对应的 CString::from_raw 才会重新接管:

"This should only ever be called with a pointer that was earlier obtained by calling CString::into_raw, …… Other usage (e.g., trying to take ownership of a string that was allocated by foreign code) is likely to lead to undefined behavior or allocator corruption."

只有 from into_raw 出来的 pointer 才能传 from_raw——绝不能用 C 代码 malloc 的指针!为什么?因为 Rust 的 Box 用全局 GlobalAlloc(默认是 system malloc,但用户可以换)——如果你用 C 的 malloc 分配、用 Rust 的 Box drop(内部走 dealloc)、可能导致 allocator state corruption。这条规则是 §13.9 "谁分配谁释放"的源码层证据——不是 best practice、是必要约束。

13.4.2 源码核对:CString::into_string 的失败回退路径

CString::into_string(library/alloc/src/ffi/c_str.rs:478)展示了 Rust API 设计里失败时不丢数据的典型 pattern:

pub fn into_string(self) -> Result<String, IntoStringError> {
    String::from_utf8(self.into_bytes()).map_err(|e| IntoStringError {
        error: e.utf8_error(),
        inner: unsafe { Self::_from_vec_unchecked(e.into_bytes()) },
    })
}

如果 CString 内容不是合法 UTF-8——返回 IntoStringError、但 inner 字段把原 CString 还回去。用户可以:

match cstring.into_string() {
    Ok(s) => use_string(s),
    Err(err) => {
        let original_cstring = err.into_cstring();  // 拿回原对象
        // ... 用其他方式处理(比如保留 raw bytes)
    }
}

Rust 的 Result 本身只能携带 Ok/Err 两种值——错误时输入数据通常丢失。IntoStringError 这种"错误对象 carry 原数据"的 pattern 让用户能从失败中恢复——比只抛一个 IllegalArgumentException("...")、把输入数据吞掉的做法友好得多。

这条 pattern 在 std 里反复出现:String::from_utf8 失败时用 FromUtf8Error carry 原 Vec<u8>(into_bytes() 取回)、CString::new 失败时用 NulError carry 原 Vec<u8>(into_vec() 取回)。Rust API 设计里"never lose data on failure"是显式追求——读完 std 你会感受到这条规则的一致性。

repr(C) 联合体

FFI 中经常需要联合体来表示 C 的 union:

#[repr(C)]
union Value {
    i: i32,
    f: f32,
    b: bool,
}
// 等价于 C 的 union Value { int i; float f; bool b; };
// 大小 = max(4, 4, 1) = 4 字节

// 访问联合体字段是 unsafe 的——编译器无法追踪当前活跃的字段
let v = Value { i: 42 };
let i = unsafe { v.i }; // OK,因为我们知道是 i 被设置的

13.4.3 源码核对:repr(C) 关闭字段重排——inhibit_struct_field_reordering

§13.4 给了 repr(C) 内存布局保证的高层概念。打开 compiler/rustc_abi/src/layout.rs:1177 能看到具体实现:

let optimize_field_order = !repr.inhibit_struct_field_reordering();

inhibit_struct_field_reordering() 在 repr(C) 时返回 true——意味着 optimize_field_order = false——字段保持源码顺序、不被编译器重排。

普通 Rust struct(不带 repr)默认走 univariant_biased 的 optimize_field_order = true 路径——rustc 会按字段对齐度等排序 key 重排字段、最小化 padding。比如:

struct Foo {
    a: u8,    // 1 byte
    b: u64,   // 8 bytes
    c: u8,    // 1 byte
}

普通 Rust:编译器重排为 b, a, c + 6 byte padding——total 16 bytes。 repr(C):保持 a, b, c + 7+7=14 byte padding——total 24 bytes。

多 50% 内存代价换 C 兼容性——这是 repr(C) 在字段顺序不佳时的真实成本(1.96.0 实测:普通版 16 字节、b 在偏移 0;repr(C) 版 24 字节)。

注意它的真实判据(compiler/rustc_abi/src/lib.rs:227)是 self.flags.intersects(ReprFlags::FIELD_ORDER_UNOPTIMIZABLE) || self.int.is_some(),而 FIELD_ORDER_UNOPTIMIZABLE = IS_C | IS_SIMD | IS_SCALABLE | IS_LINEAR。也就是说触发 inhibit 的是 repr(C)、repr(simd)、repr(scalable)、内部标记 IS_LINEAR,外加给枚举指定判别式类型的 repr(u8) 之类(int.is_some())。repr(packed) 并不在其中——packed struct 依然会被重排,pack 只是在上面 alignment_group_key 的第一个分支里改变排序 key。

13.4.4 源码核对:-Z randomize-layout 的 RNG 字段重排——unsafe 假设的 fuzzing 工具

compiler/rustc_abi/src/layout.rs:1186-1202 有段非常有趣的代码:

if optimize_field_order && fields.len() > 1 {
    // If `-Z randomize-layout` was enabled for the type definition we can shuffle
    // the field ordering to try and catch some code making assumptions about layouts
    // we don't guarantee.
    if repr.can_randomize_type_layout() && cfg!(feature = "randomize") {
        #[cfg(feature = "randomize")]
        {
            use rand::SeedableRng;
            use rand::seq::SliceRandom;
            let mut rng = rand_xoshiro::Xoshiro128StarStar::seed_from_u64(
                field_seed.wrapping_add(repr.field_shuffle_seed).as_u64(),
            );
            // Shuffle the ordering of the fields.
            optimizing.shuffle(&mut rng);
        }
        // Otherwise we just leave things alone and actually optimize the type's fields
    } else {
        ...
    }
}

-Z randomize-layout 是一个 nightly compiler flag——启用后普通 Rust struct 的字段顺序会被随机洗牌。目的是:测试用户的 unsafe 代码是否错误地假设了字段顺序。

如果你的 unsafe 代码做 *(ptr as *const u8).add(8) 假设 8 字节偏移是某个字段——randomize-layout 可能让那个偏移指向另一个字段——你的测试就可能挂掉。这是 rustc 给 unsafe Rust 用户的 fuzzing 工具——主动制造混乱让你发现自己的 implicit assumption。

Xoshiro128StarStar 是个高质量 PRNG——速度快、统计性好、有完整 test suite。rand_xoshiro crate 提供它的实现——rustc 自己用、应用代码也能用。

field_seed.wrapping_add(repr.field_shuffle_seed)——种子是两部分组合:基于字段内容的哈希 + 类型级 seed。这让同一个 type 在多次编译里得到一致的 shuffle——不会"昨天编译跑过、今天编译挂"。Reproducibility 是 fuzzing 工具的基础。

这条 randomize-layout 是 Rust 团队对 unsafe 生态的主动质量投入——你写 unsafe 代码时建议跑一次 nightly + -Z randomize-layout 看测试是否还过。这是 rustc 给 unsafe 工程的"安全网"——比每个 unsafe 库各自造 fuzzing 轮子省力得多。

13.4.5 源码核对:alignment_group_key 的字段排序启发——niche-aware 优化

继续读 compiler/rustc_abi/src/layout.rs:1219-1260 的 alignment_group_key——它是字段重排算法的"排序 key":

let alignment_group_key = |layout: &F| {
    if let Some(pack) = pack {
        // Return the packed alignment in bytes.
        layout.align.abi.min(pack).bytes()
    } else {
        // Returns `log2(effective-align)`.
        let align = layout.align.bytes();
        let size = layout.size.bytes();
        let niche_size = layout.largest_niche.map(|n| n.available(dl)).unwrap_or(0);
        // Group [u8; 4] with align-4 or [u8; 6] with align-2 fields.
        let size_as_align = align.max(size).trailing_zeros();
        let size_as_align = if largest_niche_size > 0 {
            match niche_bias {
                NicheBias::Start => { ... }
                NicheBias::End => { ... }
            }
        };
        ...
    }
};

四条精巧设计:

1、按 alignment 分组——同 align 的字段放一起、避免重新对齐 padding。把 (u8, u64, u8) 重排成 (u64, u8, u8)——u64 align 8、两个 u8 后面只需 6 字节 padding(不是 7+7=14)。

2、size_as_align 处理 [u8; N] 这种 array——array 的 align 是 element align 但 size 大、按 size 排序更合理。代码用 align.max(size).trailing_zeros()——把 align 和 size 取 max 再取 log2、统一编码进一个数字。

3、niche-aware 排序——niche("未使用的 bit pattern")让 Option<T> 等 enum 能 0 成本表达。Niche 通常在字段的开头或结尾——niche_bias 控制 niche 在前还是在后。niche bias 影响字段排序选择——为了让 enum discriminant 能 reuse niche、字段位置要配合。

4、注释里的具体例子——"Given A(u8, [u8; 16]) and B(bool, [u8; 16]) we want to bump the array to the front in the first case ... but keep the bool in front in the second case for its niches"——bool 有 niche(不是 0 也不是 1 的 byte 都不可能、能塞 enum discriminant)、u8 没 niche——所以 B 的 bool 必须放前面、A 的 u8 可以放后面。

这条字段排序算法是 Rust 编译器几年优化的结晶——各种 niche optimization、size optimization、alignment grouping 都纠缠在一起。让 Rust 的 enum 能做到 zero-cost optimization 比 C++ 强——C++ 没有 niche optimization、std::optional<bool> 至少 2 字节、Rust Option<bool> 可以 1 字节。

13.5 从 Rust 调用 C:extern 块、链接与 bindgen

use std::ffi::{c_int, c_double, c_void};

extern "C" {
    fn abs(x: c_int) -> c_int;
    fn sqrt(x: c_double) -> c_double;
    fn malloc(size: usize) -> *mut c_void;
    fn free(ptr: *mut c_void);
}

fn main() {
    unsafe {
        assert_eq!(abs(-42), 42);
        println!("sqrt(2) = {}", sqrt(2.0));
    }
}

版次差异

本章代码按 2021 版次书写。在 2024 版次下这两条写法都会变成硬错误(见 compiler/rustc_lint_defs/src/builtin.rs 中 MISSING_UNSAFE_ON_EXTERN 与 UNSAFE_ATTR_OUTSIDE_UNSAFE,二者都标注了 EditionError 2024,默认级别是 Allow、到 2024 版次升级为 error):

  • extern "C" { ... } 必须写成 unsafe extern "C" { ... }
  • #[no_mangle] 必须写成 #[unsafe(no_mangle)](#[link_section]、#[export_name] 同理)

通过 build.rs 控制链接:

// build.rs
fn main() {
    println!("cargo:rustc-link-lib=static=foo");       // 静态链接 libfoo.a
    println!("cargo:rustc-link-search=native=/path");
    println!("cargo:rustc-link-lib=dylib=bar");         // 动态链接 libbar.so
}

bindgen 从 C 头文件自动生成 Rust 绑定,在 build.rs 中集成后每次构建自动更新:

// build.rs
fn main() {
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .generate().expect("生成绑定失败");
    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings.write_to_file(out.join("bindings.rs")).unwrap();
}

13.6 从 C 调用 Rust:#[no_mangle]、extern "C" fn 与 cdylib

#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 { a + b }

编译器源码 compiler/rustc_symbol_mangling/src/lib.rs 中记录了 #[no_mangle] 的处理逻辑:

// 来自 compiler/rustc_symbol_mangling/src/lib.rs(简化)
fn compute_symbol_name(tcx, instance, ...) -> String {
    let attrs = tcx.codegen_instance_attrs(instance.def);
    if let Some(name) = attrs.symbol_name { return name.to_string(); }
    if attrs.flags.contains(CodegenFnAttrFlags::NO_MANGLE) {
        return tcx.item_name(def_id).to_string();  // 保留原始名称
    }
    // 否则按 mangling 版本编码 crate 路径、泛型参数等:
    // stable 编译器默认 legacy 方案,nightly 默认 v0(compiler/rustc_session/src/config.rs:1485-1490)
}

没有 #[no_mangle],rust_add 在 1.96.0 stable 上会变成类似 _ZN8my_crate8rust_add17h746b749a962da218E 的符号名(legacy 方案;nightly 或 -C symbol-mangling-version=v0 下则是 _RNv... 开头的 v0 符号)——C 链接器按 rust_add 找不到它。

要构建 C 可用的库,在 Cargo.toml 中指定:

[lib]
crate-type = ["cdylib"]    # .so (Linux) / .dylib (macOS) / .dll (Windows)
# 或者
crate-type = ["staticlib"]  # .a (Unix) / .lib (Windows)

编译后,C 代码通过头文件声明函数签名并链接:

// 声明 Rust 函数
extern int rust_add(int a, int b);
int main() { return rust_add(3, 4); }  // 链接: gcc main.c -L target/release -lmy_lib

13.7 回调函数:将 Rust 闭包传递给 C

C 库常接受函数指针回调——排序的比较器、事件循环的处理器、遍历操作的访问器。好的 C API 遵循函数指针 + void* 上下文的模式,void* 让调用者传递任意数据给回调函数,避免全局变量。

无状态回调

最简单的情况——回调不需要访问任何外部状态:

type CCallback = extern "C" fn(event: i32, user_data: *mut std::ffi::c_void);
extern "C" { fn register_callback(cb: CCallback, user_data: *mut std::ffi::c_void); }

extern "C" fn simple_callback(event: i32, _data: *mut std::ffi::c_void) {
    println!("收到事件: {}", event);
}

fn register_simple() {
    unsafe { register_callback(simple_callback, std::ptr::null_mut()); }
}

有状态回调:通过 void* 传递上下文

struct Context { name: String, count: u32 }

extern "C" fn stateful_callback(event: i32, user_data: *mut std::ffi::c_void) {
    let ctx = unsafe { &mut *(user_data as *mut Context) };
    ctx.count += 1;
    println!("[{}] 事件 {} (第 {} 次)", ctx.name, event, ctx.count);
}

fn register_stateful() {
    let ctx = Box::new(Context { name: "监控".into(), count: 0 });
    let raw = Box::into_raw(ctx); // 转移所有权,防止被 drop
    unsafe { register_callback(stateful_callback, raw as *mut std::ffi::c_void); }
    // 重要:稍后需要用 Box::from_raw(raw) 回收内存!
}

传递闭包:trampoline 模式

Rust 闭包捕获了环境变量,不是裸函数指针。通过泛型 trampoline 函数实现转换:

extern "C" fn closure_trampoline<F: FnMut(i32)>(event: i32, data: *mut std::ffi::c_void) {
    let closure = unsafe { &mut *(data as *mut F) };
    closure(event);
}

fn register_closure<F: FnMut(i32) + 'static>(closure: F) {
    let boxed = Box::new(closure);
    let raw = Box::into_raw(boxed);
    unsafe {
        register_callback(closure_trampoline::<F>, raw as *mut std::ffi::c_void);
    }
}

// 使用
let mut counter = 0;
register_closure(move |event| {
    counter += 1;
    println!("闭包: 事件 {}, 计数 {}", event, counter);
});

trampoline 函数是泛型的,Rust 为每种闭包类型生成特化版本——利用单态化保证类型安全。关键注意点:通过 void* 传递的上下文对象必须在回调期间保持存活,通常用 Box::into_raw 延长生命周期。

13.8 跨 FFI 边界的错误处理

Rust 的 Result 和 C 的错误码需要精心桥接。最关键的原则:panic 不能跨越 FFI 边界。 想把 panic 转成错误码,就要在边界内用 catch_unwind 接住:

#[no_mangle]
pub extern "C" fn safe_parse(input: *const std::ffi::c_char, result: *mut i32) -> i32 {
    std::panic::catch_unwind(|| {
        if input.is_null() || result.is_null() { return -1; }
        let s = unsafe { std::ffi::CStr::from_ptr(input) };
        match s.to_str().ok().and_then(|s| s.parse::<i32>().ok()) {
            Some(n) => { unsafe { *result = n; } 0 }
            None => -2,
        }
    }).unwrap_or(-99) // panic 被捕获,返回错误码
}

推荐的完整模式:定义 #[repr(C)] 错误码枚举,配合线程局部的错误消息缓冲区(类似 errno + strerror):

/// FFI 错误码
#[repr(C)]
pub enum ErrorCode { Success = 0, NullPointer = -1, InvalidUtf8 = -2, InternalError = -3 }

thread_local! {
    static LAST_ERROR: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
}

fn set_last_error(msg: String) {
    LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg));
}

/// C 端调用获取错误详情
#[no_mangle]
pub extern "C" fn get_last_error(buf: *mut u8, buf_len: i32) -> i32 {
    LAST_ERROR.with(|e| {
        match e.borrow().as_ref() {
            None => 0,
            Some(msg) => {
                let bytes = msg.as_bytes();
                if buf.is_null() || buf_len <= 0 || bytes.len() + 1 > buf_len as usize { return -1; }
                unsafe {
                    std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, bytes.len());
                    *buf.add(bytes.len()) = 0;
                }
                bytes.len() as i32
            }
        }
    })
}

C 端可以这样使用:先调用业务函数检查返回值,失败时调用 get_last_error 获取详细信息。这个模式在 Rust 生态的 C 绑定库中被广泛采用。

如果不接住会怎样?panic 若从 extern "C" 函数逸出,进程会直接 abort,并打印 library/core/src/panicking.rs:337 定义的 "panic in a function that cannot unwind"——本专栏用 C 程序调用一个会 panic 的 extern "C" fn 实测正是如此。这是 1.81.0 起的行为(c_unwind 特性在该版本稳定,见 compiler/rustc_feature/src/accepted.rs:96)。确实需要让 panic 展开穿过边界时,两侧都得改用 extern "C-unwind"。

13.9 跨 FFI 的内存管理:谁分配,谁释放

核心原则:谁分配,谁释放。 Rust 和 C 可能使用不同的分配器(Rust 的全局分配器可以替换,C 库也可能自带分配器),混用是未定义行为。

flowchart LR
    subgraph "正确"
        A["Rust Box::new()"] -->|"Box::into_raw()"| B["C 使用"]
        B -->|"传回"| C["Rust Box::from_raw()"]
        D["C malloc()"] -->|"传给 Rust"| E["Rust 使用"]
        E -->|"传回"| F["C free()"]
    end
    subgraph "错误"
        G["Rust Box::new()"] -->|"传给 C"| H["C free() → UB"]
        I["C malloc()"] -->|"传给 Rust"| J["Rust drop → UB"]
    end
    style A fill:#2563eb,color:#fff,stroke:none
    style C fill:#2563eb,color:#fff,stroke:none
    style D fill:#f59e0b,color:#333,stroke:none
    style F fill:#f59e0b,color:#333,stroke:none
    style H fill:#dc2626,color:#fff,stroke:none
    style J fill:#dc2626,color:#fff,stroke:none

模式一:Rust 分配,Rust 释放(不透明句柄)

最安全的模式。C 代码持有不透明指针,创建和销毁都由 Rust 完成:

pub struct Database { /* Rust 内部结构 */ }

#[no_mangle]
pub extern "C" fn db_open() -> *mut Database {
    Box::into_raw(Box::new(Database { /* ... */ }))
}

#[no_mangle]
pub unsafe extern "C" fn db_close(db: *mut Database) {
    if !db.is_null() { let _ = Box::from_raw(db); } // 重建 Box 触发 drop
}

模式二:C 分配,C 释放

使用 C 库返回的内存时,必须用 C 库提供的释放函数:

extern "C" {
    fn strdup(s: *const std::ffi::c_char) -> *mut std::ffi::c_char;
    fn free(ptr: *mut std::ffi::c_void);
}

unsafe {
    let copy = strdup(c"hello".as_ptr());
    // copy 是 C 的 malloc 分配的,必须用 free 释放
    free(copy as *mut std::ffi::c_void);
}

模式三:调用者分配缓冲区

由调用方分配缓冲区,被调用方填充数据——避免了跨边界的所有权转移:

#[no_mangle]
pub extern "C" fn format_number(val: i64, buf: *mut u8, buf_len: i32) -> i32 {
    if buf.is_null() || buf_len <= 0 { return -1; }
    let s = format!("{}", val);
    if s.len() + 1 > buf_len as usize { return -1; }
    unsafe {
        std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
        *buf.add(s.len()) = 0;
    }
    s.len() as i32
}

13.10 安全封装:将 C API 包装为安全的 Rust API

flowchart TB
    subgraph "用户代码(安全)"
        U["let mut store = KvStore::open(path)?;<br/>store.set(key, value)?;<br/>// 自动 drop"]
    end
    subgraph "安全封装层"
        S["struct KvStore(NonNull&lt;ffi::kv_store&gt;)<br/>impl Drop: 调用 ffi::kv_close<br/>所有方法返回 Result"]
    end
    subgraph "底层 FFI(unsafe)"
        F["extern &quot;C&quot; { fn kv_open/close/set/get... }"]
    end
    U --> S --> F
    style U fill:#10b981,color:#333,stroke:none
    style S fill:#2563eb,color:#fff,stroke:none
    style F fill:#f59e0b,color:#333,stroke:none

安全封装的要素:

mod ffi {
    #[repr(C)]
    pub struct kv_store { _opaque: [u8; 0] }  // 不透明类型

    extern "C" {
        pub fn kv_open(path: *const std::ffi::c_char) -> *mut kv_store;
        pub fn kv_close(store: *mut kv_store);
        pub fn kv_set(s: *mut kv_store, k: *const std::ffi::c_char, v: *const std::ffi::c_char) -> i32;
        pub fn kv_get(s: *mut kv_store, k: *const std::ffi::c_char) -> *const std::ffi::c_char;
    }
}

pub struct KvStore { inner: std::ptr::NonNull<ffi::kv_store> }

impl KvStore {
    pub fn open(path: &str) -> Result<Self, KvError> {
        let c_path = std::ffi::CString::new(path).map_err(|_| KvError::InvalidPath)?;
        let ptr = unsafe { ffi::kv_open(c_path.as_ptr()) };
        std::ptr::NonNull::new(ptr).map(|p| KvStore { inner: p }).ok_or(KvError::OpenFailed)
    }

    pub fn get(&self, key: &str) -> Result<String, KvError> {
        let c_key = std::ffi::CString::new(key).map_err(|_| KvError::InvalidKey)?;
        let ptr = unsafe { ffi::kv_get(self.inner.as_ptr() as *mut _, c_key.as_ptr()) };
        if ptr.is_null() { return Err(KvError::NotFound); }
        // 关键:立即复制,因为 C 指针可能在下次调用后失效
        let s = unsafe { std::ffi::CStr::from_ptr(ptr) };
        s.to_str().map(|s| s.to_string()).map_err(|_| KvError::InvalidUtf8)
    }
}

impl Drop for KvStore {
    fn drop(&mut self) { unsafe { ffi::kv_close(self.inner.as_ptr()); } }
}

检查清单: (1) 所有 FFI 调用集中在 ffi 模块;(2) RAII 管理 C 资源;(3) NonNull 代替裸指针;(4) 立即复制借用的 C 数据;(5) C 错误码转 Result;(6) 字符串转换处理 \0 和 UTF-8;(7) 考虑线程安全性。

13.11 平台特定代码:cfg(target_os) 与条件编译

FFI 代码天然与平台绑定。Rust 的条件编译系统为不同平台编写不同绑定:

#[cfg(target_os = "linux")]
extern "C" { fn epoll_create1(flags: i32) -> i32; }

#[cfg(target_os = "macos")]
extern "C" { fn kqueue() -> i32; }

#[cfg(target_os = "windows")]
extern "system" { fn CreateIoCompletionPort(/* ... */) -> *mut std::ffi::c_void; }

注意 Windows 使用 extern "system" 而非 extern "C"——编译器会将其映射为 Stdcall(x86)或 C ABI(x86-64)。

Rust 标准库在 library/std/src/sys/ 下按平台分离 FFI 实现:pal/(Platform Abstraction Layer)放各平台的基础设施,fs/、net/ 等功能模块内部再按平台分发,对上层暴露统一接口:

library/std/src/sys/
├── pal/           # 平台抽象层
│   ├── unix/      # Unix (Linux, macOS, BSDs...)
│   ├── windows/   # Windows
│   └── ...
├── fs/            # 文件系统(内部按平台分发)
├── net/           # 网络
├── thread/        # 线程
├── sync/          # 同步原语
└── random/        # 随机数

这种架构模式值得学习:底层按平台分离 FFI 绑定,上层通过统一的模块接口(cfg 选出同名实现)屏蔽差异。 用户代码只面对统一的 Rust API,无需关心底层是 epoll、kqueue 还是 IOCP。

常用的 cfg 条件:

cfg 条件 说明 示例值
target_os 操作系统 "linux", "macos", "windows"
target_arch CPU 架构 "x86_64", "aarch64"
target_family 平台家族 "unix", "windows"
target_env 工具链环境 "gnu", "musl", "msvc"
target_pointer_width 指针宽度 "32", "64"

13.12 实战示例:封装 C 加密库

综合运用前面所有知识点,假设封装一个 C 加密库:

// ffi 层
mod ffi {
    #[repr(C)]
    pub struct crypto_ctx { _opaque: [u8; 0], _marker: std::marker::PhantomData<*mut ()> }

    extern "C" {
        pub fn crypto_new() -> *mut crypto_ctx;
        pub fn crypto_free(ctx: *mut crypto_ctx);
        pub fn crypto_set_key(ctx: *mut crypto_ctx, key: *const u8, len: usize) -> i32;
        pub fn crypto_encrypt(ctx: *mut crypto_ctx, input: *const u8, in_len: usize,
                              out: *mut u8, out_len: *mut usize) -> i32;
        pub fn crypto_error_msg(code: i32) -> *const std::ffi::c_char;
    }
}

// 安全封装层
pub struct CryptoContext { inner: std::ptr::NonNull<ffi::crypto_ctx> }

impl CryptoContext {
    pub fn new() -> Result<Self, CryptoError> {
        let ptr = unsafe { ffi::crypto_new() };
        std::ptr::NonNull::new(ptr).map(|p| Self { inner: p }).ok_or(CryptoError::InitFailed)
    }

    pub fn set_key(&mut self, key: &[u8]) -> Result<(), CryptoError> {
        let code = unsafe { ffi::crypto_set_key(self.inner.as_ptr(), key.as_ptr(), key.len()) };
        if code == 0 { Ok(()) } else { Err(CryptoError::from_code(code)) }
    }

    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
        let mut out = vec![0u8; plaintext.len() + 256];
        let mut out_len = out.len();
        let code = unsafe {
            ffi::crypto_encrypt(self.inner.as_ptr() as *mut _, plaintext.as_ptr(),
                                plaintext.len(), out.as_mut_ptr(), &mut out_len)
        };
        if code != 0 { return Err(CryptoError::from_code(code)); }
        out.truncate(out_len);
        Ok(out)
    }
}

impl Drop for CryptoContext {
    fn drop(&mut self) { unsafe { ffi::crypto_free(self.inner.as_ptr()); } }
}

// 用户代码——零 unsafe
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ctx = CryptoContext::new()?;
    ctx.set_key(b"my-32-byte-secret-key-here!!!!!!")?;
    let encrypted = ctx.encrypt(b"Hello, secure world!")?;
    println!("密文 {} 字节", encrypted.len());
    Ok(())
}

13.12.1 源码核对:System V x86_64 ABI 的 classify_arg 算法

§13.2 提到 x86_64 上参数走 Int/Sse 寄存器。打开 compiler/rustc_target/src/callconv/x86_64.rs:29 看真实实现(简化,省略了枚举变体按 union 成员处理等分支):

fn classify_arg<'a, Ty, C>(...) -> Result<[Option<Class>; MAX_EIGHTBYTES], Memory> {
    fn classify(cx, layout, cls, off) -> Result<(), Memory> {
        if !off.is_aligned(layout.align.abi) {
            if !layout.is_zst() {
                return Err(Memory);   // ← 未对齐 → 走栈
            }
            return Ok(());
        }

        let mut c = match layout.backend_repr {
            BackendRepr::Scalar(scalar) => match scalar.primitive() {
                Primitive::Int(..) | Primitive::Pointer(_) => Class::Int,
                Primitive::Float(_) => Class::Sse,
            },
            BackendRepr::SimdVector { .. } => Class::Sse,
            BackendRepr::ScalarPair(..) | BackendRepr::Memory { .. } => {
                // 复合类型:递归 classify 每个字段
                for i in 0..layout.fields.count() {
                    classify(cx, layout.field(cx, i), cls, off + layout.fields.offset(i))?;
                }
                ...
                return Ok(());
            }
        };

        // 把 cls[first..=last] 填上分类
        let first = (off.bytes() / 8) as usize;
        let last = ((off.bytes() + layout.size.bytes() - 1) / 8) as usize;
        for cls in &mut cls[first..=last] {
            *cls = Some(cls.map_or(c, |old| old.min(c)));   // 类已经定就取较小者
            if c == Class::Sse {
                c = Class::SseUp;   // 后续 eightbyte 是 upper half
            }
        }
        Ok(())
    }
    ...
}

这条算法实现了 System V AMD64 ABI 的"eightbyte classification"——把每个参数按 8 字节切分、每个 8 字节属于 Int / Sse / SseUp 类——决定用哪些寄存器。

四条值得读懂的细节:

1、字段偏移未对齐 → Memory(走栈)——检查的是字段偏移是否满足该字段自身的 ABI 对齐(典型场景是 repr(packed) 结构体)。只要有一个非 ZST 字段落在未对齐的偏移上,整个参数就降级走栈、不能用寄存器。

2、Pointer = Class::Int——Rust 指针在 x86_64 ABI 里走整数寄存器(rdi/rsi/...)、不是 SSE。这条让 Box<T> 作为参数传递时占用整数寄存器、和 C 的 pointer 一样。

3、ScalarPair / Memory 递归 classify 字段——比如 #[repr(C)] struct { a: u64, b: *const u8 } 这种两个 8 字节标量字段的结构体是 ScalarPair——递归 classify 后每个字段独立分类。两个字段都是 Int 时,它占 2 个 Int 寄存器传递(若是第一个参数,就是 rdi + rsi)。(Option<&T> 则不是 ScalarPair:借助空指针 niche,它就是一个 8 字节的 Scalar。)

4、cls.map_or(c, |old| old.min(c))——同一个 eightbyte 槽位多个 sub-field 时取较小者。Class 的声明顺序是 Int < Sse < SseUp(源码注释:"from general to specific")。这个 min 操作对应 SysV 规范的合并规则——"同一个 eightbyte 里只要有 Int,这个 eightbyte 就走整数寄存器"。

MAX_EIGHTBYTES 定义为 LARGEST_VECTOR_SIZE / 64,而 LARGEST_VECTOR_SIZE = 512(当前支持的最大向量宽度 AVX-512),所以它恰好是 8(compiler/rustc_target/src/callconv/x86_64.rs:26-27)——64 字节只是 SIMD 向量类型(如 __m512)能走寄存器的上限;普通结构体超过 16 字节就会被 §13.12.2 的规则降级为 Memory。

注意这套分类只作用于 extern "C" 等外部 ABI(compiler/rustc_ty_utils/src/abi.rs 的 fn_abi_adjust_for_abi 里,Rust ABI 走 adjust_for_rust_abi,外部 ABI 才走 adjust_for_foreign_abi)。1.96.0 实测同一个 24 字节 repr(C) 结构体:extern "C" fn 里是 Indirect { on_stack: true }(按值拷到栈上),普通 Rust fn 里是 Indirect { on_stack: false }(传一个指针)。

读懂这条算法你能解释为什么某些 extern "C" 函数签名比看起来快/慢——参数小且对齐 → 寄存器传递、参数大 → 栈传递。至于返回值,超出寄存器容量的大 struct 走 sret:调用方预留内存、把地址作为隐藏参数传进去,并不需要堆分配;改成返回 Box<HugeStruct> 虽然只返回 8 字节指针,却多了一次堆分配,未必更快。

13.12.2 源码核对:MAX_EIGHTBYTES 上限 + Memory 降级回退路径

compiler/rustc_target/src/callconv/x86_64.rs:100-128 的算法主体处理 eightbyte > 2 的情况:

let n = arg.layout.size.bytes().div_ceil(8) as usize;
if n > MAX_EIGHTBYTES {
    return Err(Memory);    // ← 太大、走栈
}

let mut cls = [None; MAX_EIGHTBYTES];
classify(cx, arg.layout, &mut cls, Size::ZERO)?;
if n > 2 {
    // 大于 16 字节的复合类型:必须开头是 Sse 才能 vectorize 走 SSE 寄存器
    if cls[0] != Some(Class::Sse) {
        return Err(Memory);
    }
    if cls[1..n].iter().any(|&c| c != Some(Class::SseUp)) {
        return Err(Memory);
    }
} else {
    // 1-16 字节:normalize SseUp 序列
    let mut i = 0;
    while i < n {
        if cls[i] == Some(Class::SseUp) {
            cls[i] = Some(Class::Sse);   // 孤立的 SseUp 升级成 Sse
        } else if cls[i] == Some(Class::Sse) {
            i += 1;
            while i != n && cls[i] == Some(Class::SseUp) {
                i += 1;
            }
        } else {
            i += 1;
        }
    }
}

两条规则:

1、超过 16 字节的类型,除非是单个 SIMD 向量 → Memory——n > 2 时要求 cls[0] 是 Sse、其余全是 SseUp,只有 __m256/__m512 这类向量满足;像 #[repr(C)] struct { a: u64, b: u64, c: f32 }(24 字节)这样的结构体,哪怕全是浮点字段(每个标量各占一个 Sse 槽,不会连成 SseUp),也一律降级走栈。作为返回值时则走 sret(调用方预留内存、传隐藏指针)。

2、SseUp 的孤立处理——SseUp 是"前一个 Sse 的 upper half"概念。如果 cls 里有孤立的 SseUp(前面没有 Sse),算法把它升级成 Sse。这是为了处理某些特殊的复合类型布局。

这套算法实现的是 System V AMD64 ABI 规范的 classification 规则——文件顶部注释写明,这段分类代码移植自 clay 语言的实现(compiler/rustc_target/src/callconv/x86_64.rs:1-2)。Rust 调用 C 库要走完全相同的 ABI——一个 byte 不对都会出现 ABI 不匹配的诡异 bug(参数错位、栈崩溃)。

读这段代码让你理解一条工程哲学——协议级实现必须严格按 spec、不能 "差不多就行"。x86_64 SysV ABI 这种文档化协议把规则写得足够精确,rustc 才能直接借用已有实现——站在巨人肩膀上。

13.13 cbindgen:从 Rust 代码生成 C 头文件

bindgen 将 C 头文件转为 Rust 绑定,cbindgen 做相反的事——从 Rust 代码生成 C/C++ 头文件。

cargo install cbindgen
cbindgen --lang c --output include/my_lib.h

给定以下 Rust 代码:

#[repr(C)]
pub struct Point { pub x: f64, pub y: f64 }

#[no_mangle]
pub extern "C" fn point_distance(a: *const Point, b: *const Point) -> f64 {
    if a.is_null() || b.is_null() { return -1.0; }
    let (a, b) = unsafe { (&*a, &*b) };
    ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}

cbindgen 生成:

#ifndef MY_LIB_H
#define MY_LIB_H
#include <stdint.h>

typedef struct Point { double x; double y; } Point;

double point_distance(const Point *a, const Point *b);

#endif

在 build.rs 中集成后,每次 cargo build 时头文件自动更新:

// build.rs
fn main() {
    cbindgen::Builder::new()
        .with_crate(std::env::var("CARGO_MANIFEST_DIR").unwrap())
        .with_language(cbindgen::Language::C)
        .generate().expect("cbindgen failed")
        .write_to_file("include/my_lib.h");
}

配置文件 cbindgen.toml 可以控制命名风格、包含守卫、自动生成警告等细节,保证 Rust 与 C 接口始终同步。

13.13.1 写 Rust FFI 的 5 个常见心理陷阱

总结读完本章后你应该建立的几个心理纠正——FFI 学习者最容易陷入的认知陷阱:

1、"看起来工作 = 正确"——FFI 代码可能在你机器上跑 100 次都没问题、生产 4 小时崩。unsafe 代码的正确性是逻辑证明问题——不是测试问题。任何 unsafe 块都该有详细 SAFETY 注释解释为什么这条规则成立。

2、"加 unsafe 就能编译就能用"——unsafe 是契约、不是免罪牌。每个 unsafe 函数有 caller 必须遵守的前提条件——文档要明确写、否则用户必踩坑。

3、"性能优化就该用 transmute"——transmute 是 Rust 里最危险的操作。绝大多数"用 transmute 加速"的场景都有更安全的替代(unwrap_unchecked、from_raw、指针 cast)。transmute 是最后手段。

4、"C 库说支持多线程我就放心多线程调用"——很多 C 库的"thread-safe"是 conditional 的(比如要每线程独立 init)。用 Send/Sync 包装时仔细读 C 库文档——错了就是 race condition。

5、"FFI 错误就是 SIGSEGV"——unsafe 错误的真实表现千奇百怪:silent data corruption、看似随机的逻辑错误、几小时后 panic、memory leak、performance degradation。永远先怀疑 unsafe 代码——sound Rust 代码不会出这种问题。

这 5 个陷阱都是从踩坑经验提炼出来的——记住它们能显著减少写 FFI 时的返工。

13.13.2 异步 FFI:tokio + io_uring/IOCP 的特殊难点

读完本章 sync FFI 后再考虑 async FFI——会遇到一些独特挑战:

1、async runtime 不能 block 在 FFI 调用上——如果 C 函数会阻塞(比如 fopen 一个慢盘文件)、整个 worker thread 卡住、其他 task 饿死。两条解法:

  • 把 sync C call 包在 tokio::task::spawn_blocking 里——专用 blocking thread pool 跑
  • 用纯 async 的底层 API(io_uring、IOCP)——天生异步、不阻塞

2、callback-based async C API 的 Rust 包装很难——比如 libuv 的"注册一个 callback、io 完成时调用"模型——需要把 Rust Future 转成 callback。常用 pattern:用 oneshot channel——callback 里 send、Future 里 await receive。

3、Pin 安全——FFI 函数可能保存 self pointer 用于回调(比如把 C lib 的"on_complete"hook 指向 Rust struct)——这要求 Rust struct 不能被 move。需要 Pin<Box<MyStruct>>、构造时立刻 Pin。

4、cancellation 难传播——Rust task drop 时希望 cancel 进行中的 FFI call——但很多 C API 没有 cancel 接口。最佳实践:尽量用支持 cancel 的 API(io_uring 的 IORING_OP_ASYNC_CANCEL);不行就 detach(task drop 但 FFI call 继续跑、结果丢弃)。

这些 pattern 在 tokio-uring、quinn、deno_core 等项目里都能看到——是 async Rust + FFI 的进阶内容。

13.13.3 真实生态里的 Rust FFI 大型项目

读完本章后想看 production-grade FFI 代码——下面 5 个开源项目都值得读:

1、tokio-uring:基于 io_uring 的 Rust 封装。完整展示了 syscall 级 FFI——SQE/CQE 的内存共享、kernel/user space 的 ring buffer、submit_and_wait 的 syscall 调用。学 Rust ↔ Linux kernel 互操作的最佳教材。

2、wgpu:WebGPU 的 Rust 实现——内部封装 Vulkan/Metal/DX12 三套 native graphics API。每个 backend 都是几千行 FFI——展示怎么把 stateful C API(GPU command buffer)封装成 type-safe Rust API。

3、rustls:Rust 实现的 TLS——但底层加密算法(AES-NI、AVX-512 ChaCha20)通过 FFI 调 ring/aws-lc。展示Rust 怎么和高度优化的 C 加密库共存。

4、polars:Rust 数据分析框架——通过 PyO3 暴露给 Python。展示怎么把复杂 Rust API(DataFrame)映射到 Python 用户友好的 API。

5、deno_core:Deno 的核心运行时,建立在 rusty_v8(v8 crate)提供的 V8 绑定之上,V8 ↔ Rust 的交互大量经过 FFI。对 unsafe 代码组织的工业级示范。

5 个项目代码量从几千到几十万行——按你的兴趣选 1-2 个深入。读优秀 FFI 代码比写自己的更有助于建立直觉——很多 best practice 只在大型项目里才能看到。

13.13.4 与本专栏其他章节的呼应

本章 FFI 看似独立、实则与其他各章深度咬合:

与第 1 章(编译流水线)的关系:本章的 extern "C" 作为函数签名(FnSig)里的 ABI 字段一路穿过 HIR 和 MIR,直到代码生成时才由 fn_abi_of_instance(背后是 fn_abi_of_instance_raw 等查询)算出 FnAbi、定下每个参数的 PassMode。没有第 1 章讲的 IR 分层,本章的 ABI map 无处应用。

与第 6 章(单态化)的关系:泛型函数在单态化后才拿到具体类型,FnAbi 也才能为每个参数定出 PassMode。单态化和 ABI 计算前后咬合。

与第 12 章(unsafe)的关系:FFI 几乎全是 unsafe——本章的 extern 块、CStr::from_ptr 都需要 unsafe 上下文。第 12 章是本章的语言基础。

与第 15/16 章(MIR 优化、LLVM codegen 与链接)的关系:FFI 函数的符号最终通过 linker 解析——#[no_mangle] 影响符号名、#[link(...)] 影响 linker 行为。第 16 章讲后端与链接是怎么工作的、本章告诉你 FFI 怎么 hook 进去。

与第 5 章(内存布局)的关系:本章 §13.4.3-13.4.5 的字段重排、niche、-Z randomize-layout 正是第 5 章布局算法在 FFI 场景下的落地。

与第 14 章(宏)的关系:bindgen/cbindgen 属于 build 期代码生成,与第 14 章讲的过程宏是同一类"编译期生成代码"的工程手段,只是一个在 cargo build script 里、一个在编译器内部。

读完本章 + 这几个接口、你就能把 rustc + std + ABI + linker 串成一个完整心智模型——Rust 程序从源码到 OS syscall 的全链路。这是从"会写 Rust"到"懂 Rust"的最后一公里。

13.13.5 写自己的 cdylib + Python 调用的 50 行实操

读完本章想立即上手——下面是一个 50 行能跑的 demo:

Cargo.toml:

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

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

src/lib.rs:

use std::ffi::{CStr, CString, c_char};

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
    let name = unsafe {
        if name.is_null() { return std::ptr::null_mut(); }
        match CStr::from_ptr(name).to_str() {
            Ok(s) => s,
            Err(_) => return std::ptr::null_mut(),
        }
    };
    let msg = format!("Hello, {}!", name);
    CString::new(msg).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
}

#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
    if s.is_null() { return; }
    unsafe { let _ = CString::from_raw(s); }
}

Python 调用:

import ctypes
lib = ctypes.CDLL("./target/release/libmylib.dylib")   # macOS;Linux 上是 libmylib.so
lib.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib.add.restype = ctypes.c_int
print(lib.add(2, 3))   # → 5

lib.greet.argtypes = [ctypes.c_char_p]
lib.greet.restype = ctypes.c_void_p   # 注意是 void_p 不是 c_char_p(c_char_p 会把结果转成 bytes、丢掉原指针,之后就没法 free)
lib.free_string.argtypes = [ctypes.c_void_p]
ptr = lib.greet(b"World")
print(ctypes.cast(ptr, ctypes.c_char_p).value)   # → b'Hello, World!'
lib.free_string(ptr)   # 必须主动 free!

50 行内涵盖了本章讲过的所有核心 pattern——#[no_mangle] + extern "C" + CStr/CString + 所有权转移 + 释放函数。自己跑通这个 demo 比读 5 遍本章更有帮助——很多隐藏的细节(比如 ctypes 的 c_char_p 丢指针、导致泄漏的陷阱)只有动手才能感受到。

加分练习:让 Python 传一个 callback 给 Rust——practice §13.7 的 trampoline 模式。

13.13.6 几条 FFI 反直觉但真实的事实

读完本章你应该获得几条反直觉但真实的事实——这些在普通 FFI 教程里不会讲:

1、#[repr(C)] 不保证和 C 编译器完全一致——它保证的是按平台 C 的字段顺序和对齐规则排布。但 C 那边还有 bitfield、编译器扩展(如 packed/aligned 属性)等 Rust 无法直接表达的东西。遇到这类结构,最好用 bindgen 从头文件生成、不要手写。

2、extern "C" 在不同平台 ABI 不同——x86_64 Linux 是 SysV ABI、x86_64 Windows 是 MS ABI、ARM 是 AAPCS——同一份 Rust 代码在不同平台编译出的函数调用约定不同。这就是 §13.2 讲过的 canonize_abi 要做的事——把 source-level "C" 映射到 target-specific canonical ABI。

3、size_of::<&str>() == size_of::<*const u8>() * 2——Rust 的 &str 是胖指针 (ptr, len),64 位平台上共 16 字节、不是普通指针。跨 FFI 必须 destructure 成两个参数——写成 extern "C" fn foo(s: &str) 虽然能编译,但 rustc 会报 improper_ctypes_definitions 警告:"string slices have no C equivalent"(compiler/rustc_lint/src/types/improper_ctypes.rs:610),C 端也没有对应的类型可以接。

4、bool 在 ABI 上是 1 字节但只能是 0 或 1——其他值是 UB。如果你 transmute::<u8, bool>(2)——Rust 编译器假设 bool 永远是 0/1、可能优化出诡异行为(比如同一个值在不同判断里得出互相矛盾的结果)。C 端若用 int/uint8_t 表示布尔值,接收时要 match v { 0 => false, _ => true } 而不是 transmute。

5、extern "C" fn 类型不等于 unsafe extern "C" fn 类型——两者是不同的函数指针类型:safe 的可以隐式转成 unsafe 的,反过来不行(E0308,"unsafe functions cannot be coerced into safe function pointers")。注意你定义的 callback 签名要和 C 库期望的精确匹配。

6、#[no_mangle] 函数在 LTO 时可能被 inline——despite 名字里有"no mangle"。#[no_mangle] 只保证符号名不被改、不阻止其他 LTO 优化。如果想阻止 inline 加 #[inline(never)]——是另一个 attribute。

这 6 条事实里至少 3 条在你写 FFI 代码时会遇到——理解它们能避免最痛苦的 debug 之夜。

13.13.7 ABI 演进的 4 个时间节点

读完本章 ABI 部分你应该理解 ABI 不是一个静态规范、而是有明确的演化历史:

1980s-1990s - C ABI 确立:System V i386 ABI 把"参数怎么排、谁清栈、返回值放哪"写成公开文档(i386 上整数参数全部走栈)。真正定下"前 6 个整数参数走 rdi/rsi/rdx/rcx/r8/r9"的是 2000 年前后制定的 System V AMD64 ABI——compiler/rustc_target/src/callconv/x86_64.rs 实现的就是它的 classification 规则(顶部注释写明代码移植自 clay 语言的实现)。这套规则成为后续所有语言互操作的"通用底"。

2000s - Windows 引入差异:Microsoft 随 x64 版 Windows 自己定义了 Windows x64 ABI(x86 时代就已有 stdcall/fastcall 的分歧,到 x64 被固化成一套完整规范)——前 4 个参数走 rcx/rdx/r8/r9(不是 6 个)、shadow stack space 32 字节(SysV 没有)。两套 ABI 的存在是 Windows 上跨平台代码痛苦的根源。

2010s - Rust 选择不固定 ABI:Rust 的 "Rust ABI"(不是 extern "C")至今没有 stable 化——rustc 可以自由优化 layout、寄存器使用。这是 Rust 性能优势的来源(编译器有完全自由)、也是 dynamic linking Rust 库困难的原因。

2020s - WebAssembly Component Model 出现:把"语言无关 ABI"做到 wasm 层——编译到 wasm 组件的语言都通过同一套接口类型和 canonical ABI 互相调用。这可能是 Rust 之后最重要的 ABI 演化——为跨语言组件的组合提供了一条新路。

理解这 4 个节点让你看到 ABI 不是天上掉下来的——是几代工程师在不同时代的工程妥协累积。rustc 团队对 ABI 的处理保守而精确——既要兼容 SysV/Windows ABI 让 Rust 能调 C 库、又要让 Rust 自己有优化自由——这是 §13.2.1 讲的 AbiMapping 三态设计的根本动机。

13.13.8 几个真实生产 FFI bug 的 root cause

读完本章源码细节后,下面 5 个典型 FFI bug 都能用本章知识精确定位:

Bug 1:调 C 库返回 corrupt struct——repr 没标 C

  • 症状:C 函数返回的 struct 字段值不对、看起来像内存被踩
  • 根因:Rust 端定义对应 struct 没加 #[repr(C)]、字段顺序被编译器重排
  • 本章定位:§13.4.3 的 inhibit_struct_field_reordering——加上 #[repr(C)] 立刻好

Bug 2:cstring 传给 C 函数后 C 端读到乱码

  • 症状:传 "hello" C 端打印出 "" 或乱字符
  • 根因:用 &str.as_ptr() 直接传——Rust 字符串没有 NUL 终结符
  • 本章定位:§13.3.1——必须用 CString 或 c"hello" 字面量

Bug 3:调 C library 闭包 callback 段错误

  • 症状:注册 Rust 闭包给 C 库做 callback、callback 触发时 SIGSEGV
  • 根因:闭包不是普通 fn pointer——需要 trampoline 模式 + void* 上下文
  • 本章定位:§13.7——用泛型 extern "C" fn trampoline + 把 closure boxed 进 void*

Bug 4:Rust string 跨 FFI 边界后变 corrupt

  • 症状:Rust 端 println! 看到的字符串和 C 端 printf 看到的不一致
  • 根因:String 是 (ptr, cap, len) 三个字的结构体(字段顺序不保证),不能直接跨 FFI、必须先转成 CString(再 as_ptr 或 into_raw)
  • 本章定位:§13.3——String 的内存 layout 不是 ABI stable

Bug 5:用 C malloc 分配的内存被 Box::from_raw 释放

  • 症状:Rust 程序运行几十分钟后随机崩——free(): invalid pointer 或 heap corruption
  • 根因:分配器不匹配——Rust 的 GlobalAlloc 默认是 system malloc 但用户可换,C 库也可能用自己的分配器
  • 本章定位:§13.4.1 + §13.9——所有权完整转移、不要混用分配器

这 5 个 bug 的共同点——没有读过 ABI 源码很难定位——segfault / corrupt data 这些症状都很模糊、但根因都在本章源码里。读懂本章后再遇到这种问题、能直接定位到具体源码片段。

13.13.9 给 FFI 学习者的进阶路径

读完本章后想继续深入——5 条值得走的进阶路径:

1、读 SQLite Rust binding(rusqlite):rusqlite 是 sqlite3 C API 的 Rust 安全封装,把大量 C 函数包成安全 Rust API。读一遍能学到 unique 的 trick——比如 prepared statement 的 lifetime 管理、blob 的零拷贝读取。

2、读 Tokio 的 mio 子系统:mio 是 epoll/kqueue/IOCP 的跨平台抽象。底层大量 unsafe FFI——但封装得用户感受不到。学怎么把不同 OS 的 IO multiplexing API 抽象成统一 trait。

3、写一个自己的 C 库的 Rust binding:选一个你熟悉的 C 库(比如 libcurl、libpng)——用 bindgen 生成 raw bindings、再手写一层 safe wrapper。实践是检验对 FFI 理解的唯一标准。

4、读 wasmtime 的 wasi 实现:wasmtime 是 wasm runtime、需要把 wasm 程序的 ABI 调用映射到宿主 OS 调用。完整的 ABI 映射教程——从 wasm 类型到宿主系统调用的映射都覆盖。

5、读 compiler/rustc_abi/src/extern_abi.rs 的 ExternAbi 枚举:它是所有可写在 extern "..." 里的 ABI 字符串的唯一真源——1.96.0 里共 28 个变体,从 C/Rust 到 AvrInterrupt、GpuKernel、CmseNonSecureCall。配合《Rust Reference》的 "External blocks" 一章读,能看懂 rustc 源码里很多 enum variant 的命名缘由。

5 条路径有不同时间预算——按你的兴趣深度选。

13.13.10 FFI 的成本来自哪里(按数量级理解,不背具体数字)

这里只给成本的来源与相对量级,不给任何"多少纳秒"的伪精确数字——真实数字随 CPU、分配器、编译选项差异极大,需要自己用 criterion 之类的工具在目标机器上测。

调用本身:extern "C" 调用在机器码层面就是一条普通 call——和 Rust 内部调用同一个量级。真正的差别是它默认不能被跨语言内联:extern "C" 函数(尤其是来自另一个 .so 的)对 LLVM 是不透明的(静态链接时可用 -C linker-plugin-lto 做跨语言 LTO 缓解),因此损失的是内联及内联带来的连锁优化,而不是"调用指令更贵"。通过函数指针的间接调用还要多付一次间接跳转与可能的分支预测失败。

字符串转换:CStr::from_ptr 内部是 strlen,是 O(N)(§13.3.1)——短字符串可忽略,长 buffer 上就会成为热点;CString::new 还要额外做一次堆分配和一次内部 NUL 扫描。热路径上重复做这两件事是 FFI 里最常见的性能问题。

参数传递:§13.12.1-13.12.2 的 classification 决定了参数走寄存器还是栈——小而对齐的类型走寄存器,超过 16 字节的类型(单个 SIMD 向量除外)会被降级成 Memory(走栈)。所以按值传/返大 struct 的代价明显高于传一个指针。

与带运行时的语言相比:Go 的 cgo、Java 的 JNI 需要在调用前后做运行时状态切换(保存/恢复 goroutine 状态、切换栈,或进入/退出 JNI 帧),这部分开销 Rust 完全没有——Rust 的 extern "C" 不需要任何运行时切换。这是结构性差异,不是优化技巧能弥补的;但具体差多少,仍然要自己测。

13.13.11 FFI 在不同生态里的对比

读完 Rust FFI 后看其他语言的对应实现——对比让你看到 Rust 的设计取舍:

Python ctypes:纯运行时——通过 dlopen + ABI 函数签名解析在运行时调 C 函数。慢但灵活——能动态调任意 C 库不需要重编译;代价是每次调用都要在运行时做类型编解码,量级远高于编译期就定好调用约定的 Rust。

Go cgo:编译时检测 C 调用、生成 wrapper。但 wrapper 要保存 goroutine state、切到 C 栈——开销大。Go 社区流行"cgo is not Go"的说法——能用纯 Go 解决就别用 C。

Java JNI:通过 javac -h(JDK 10 之前是 javah)生成 .h 文件、C 函数命名约定(Java_ClassName_methodName)。调用要进出 JNI 帧,和 Go cgo 一样有额外的运行时开销。

Node.js N-API:通过 napi 提供稳定 ABI 让 native module 不依赖 V8 内部。Rust 的 napi-rs 让 Rust + Node 互操作变得简单——编译 Rust 成 Node 加载的 .node 文件。

WebAssembly:通过 Component Model 提供"语言无关 ABI"——Rust/C++/Swift 编译到 wasm 后能互相调。有人把 WebAssembly 看作"未来的 C ABI"——rustc 已经把 wasm32 系列作为 Tier 2 target 支持。

5 种生态对比让你看到一条规律:Rust 的 extern "C" 没有运行时切换开销,又能用安全封装把 unsafe 收拢起来——在 FFI 的性能与安全之间取得了很好的平衡。

13.14 本章源码定位索引

为便于按图索骥(基于本专栏基准版本 Rust 1.96.0):

主题 源文件 关键行号/位置
AbiMapping enum compiler/rustc_target/src/spec/abi_map.rs 17
AbiMap::from_target 同上 49+
canonize_abi 同上 关键映射逻辑
FnAbi + PassMode enum compiler/rustc_target/src/callconv/mod.rs 主体
classify_arg x86_64 compiler/rustc_target/src/callconv/x86_64.rs 29-131
CStr::from_ptr library/core/src/ffi/c_str.rs 253-265
CStr::from_bytes_until_nul 同上 298-311
CString::into_raw library/alloc/src/ffi/c_str.rs 455-457
CString::into_string 同上 478-483
LayoutCalculator::univariant_biased compiler/rustc_abi/src/layout.rs 1160+
inhibit_struct_field_reordering compiler/rustc_abi/src/lib.rs ReprOptions impl
-Z randomize-layout compiler/rustc_abi/src/layout.rs 1186-1202
alignment_group_key 同上 1219-1260

源码版本:Rust 1.96.0(tag 1.96.0)。

13.15 读完本章能回答的具体问题清单

作为本章自测:

  1. AbiMapping::Deprecated 是什么、和 Invalid 差在哪?(§13.2.1——能编译但未来会成 Invalid,明示缓冲带)
  2. rustc 源码里 "force listing" 注释是什么意图?(§13.2.1——显式列出影响 ABI 的 arch,提醒贡献者思考新 arch 的 ABI 影响;末尾有 _ 兜底,并非编译器强制)
  3. CStr::from_ptr 为什么需要 #[inline]?(§13.3.1——让 LLVM 在调用点看到 strlen 调用、按 strlen 语义优化,不必开 std 参与的 LTO)
  4. CStr::from_ptr 和 from_bytes_until_nul 的本质差别?(§13.3.1-2——前者依赖外部 lifetime 不安全、后者从 byte slice 安全构造)
  5. CString::into_raw 后该用哪个函数 reclaim?(§13.4.1——只能 from_raw、不能用 C 的 free)
  6. CString::into_string 失败时数据丢了吗?(§13.4.2——没丢、IntoStringError carry 原对象)
  7. repr(C) 的字段顺序保证是怎么实现的?(§13.4.3——inhibit_struct_field_reordering 让 optimize_field_order=false)
  8. -Z randomize-layout 是干嘛的?(§13.4.4——主动洗牌字段顺序帮 unsafe 用户发现 implicit assumption)
  9. Option<bool> 和 Option<u8> 的内存大小一样吗?(§13.4.5——bool 有 niche、Option<bool> = 1 字节;u8 没 niche、Option<u8> = 2 字节)
  10. x86_64 上一个 Box<HugeStruct> 走寄存器还是栈?(§13.12.1——Box 是 8 字节 pointer 走 Int 寄存器)
  11. 多大的复合类型会被强制走栈?(§13.12.2——extern "C" 下超过 16 字节的类型,单个 SIMD 向量除外 → Memory)
  12. System V ABI 的 eightbyte classification 中 Int + Sse 混合时怎么决策?(§13.12.1——min 取 Int 优先)

能答 8 条以上——你对 Rust FFI 在编译器层面的实现细节理解已经超越大多数应用 Rust 开发者。这种深度让你能 debug "Rust 调 C 库参数错位"、"Rust 给 C 写 callback 段错误" 等 ABI 级 bug。

13.16 给 FFI 工程作者的 8 条工程启示

1、永远先写 safe wrapper、再暴露:用户用 unsafe FFI 出 bug 是你的责任——不是用户的。每个 unsafe FFI 函数都该有 safe wrapper。

2、#[must_use] 标在所有"忘了用就内存泄露"的函数上:CString::into_raw 是模板。

3、错误时不丢数据:CString::into_string 失败 carry 原对象——你的 API 也该这样。

4、内存所有权契约写进文档第一段:from_raw 的"only call with from into_raw"是必须的。模糊的 ownership 文档让用户必踩坑。

5、ABI 边界永远走 repr(C) + 简单类型:跨 FFI 别用 Vec/String——它们的 layout 不稳定。把它们 destructure 成 (ptr, len) pair 再传。(T: Sized 时的 Box<T> 例外:std 文档保证它与 C 的 T* ABI 兼容,见 library/alloc/src/boxed.rs:71-73。)

6、回调用 fn pointer + void* 模式:本章 §13.7 讲过的 trampoline 模式是教科书。

7、bindgen 生成的代码当作起点、不要原样用:bindgen 默认很保守、很多 unsafe 没必要。看一遍 generated 代码、能精简的精简。

8、cbindgen 配合 cdylib 让 Rust 库被 C 用:本章 §13.13 介绍——比手写 .h 更不容易和 Rust 侧签名脱节。

这 8 条加上前面的 12 题、能让你在 FFI 这个 Rust 最危险的领域里安全工作。FFI 上绝大多数 bug 都来自不遵守上面这些规则——掌握它们就掌握了 Rust 跨语言开发的核心能力。

13.17 FFI 安全性总结

FFI 边界是 Rust 安全保证的断裂带。以下不变量需要手动保证:

不变量 常见错误
类型大小和对齐匹配 用 i32 对应 C 的 long(64 位 Linux 上是 8 字节)
内存所有权清晰 用 Rust 的 drop 释放 C malloc 的内存
字符串正确转换 把 &str 直接转为 *const c_char(缺少 \0)
空指针检查 未检查 C 返回值是否为 NULL
panic 不跨越 FFI 在 extern "C" fn 中未捕获 panic
指针有效性 使用已被 C 释放的指针
bool/enum 有效值 C 传递了无效的 enum 判别值

掌握了 FFI 的全部机制,我们看到编译器在语言边界处的退让——从完全验证退化为"信任程序员"。unsafe 块是编译器对程序员说:"我检查不了了,你来保证正确性。" 安全封装层的意义在于将这种信任压缩到尽可能小的代码范围内。

下一章我们将回到编译器大展身手的领域:宏展开。声明宏和过程宏在编译流水线的哪个阶段被处理?token tree 到底是什么?编译器如何保证宏生成的代码不会意外地与使用者的代码发生命名冲突?