Rust unsafe 与 FFI 互操作生产级实战:把危险关进笼子的四道闸门

Rust unsafe 与 FFI 互操作生产级实战:把危险关进笼子的四道闸门

摘要导读 :Rust 的卖点是"内存安全",但真实项目里总有一部分代码必须跨出安全区------调用一个用了十年的 C 库、把一个 Rust 引擎暴露给 Python 或 Node、手写一个标准库没提供的数据结构。这时候 unsafe 就出场了。问题在于:很多团队把 unsafe 当成"给编译器盖个章就完事",结果编译通过、测试通过、上线三个月后收到一个 use-after-free 的高危漏洞。本文不讲"unsafe 很危险"这种正确的废话,而是给出一套可落地的四道闸门 方法论:把 unsafe 从"散落全项目的定时炸弹"压缩成"一个可审计、可验证、可回归测试的极小区域"。全程穿插 3 个真实事故复盘、可运行的 Rust 代码、以及 Miri / cargo-geiger / bindgen 的实战配置。

一、先看三个真实事故:unsafe 写错有多贵

在讲方法论之前,先看三个能追溯到公开来源的翻车现场。它们有一个共同特征:出事的地方全是 unsafe,但犯错的人写的是标注了 // SAFETY 注释的"认真代码"。

事故一:一句 unsafe impl Send 换来的 use-after-free

Hugging Face 的 tokenizers 是 Rust 生态里最流行的分词库,Python 侧的绑定用 PyO3 实现。它内部有个 RefMutContainer,包裹了一个 Arc<Mutex<Option<NonNull<T>>>> 之类的结构,用来持有指向 Python 侧对象的裸指针,并手工补上了 unsafe impl Sendunsafe impl Sync

问题出在:这个裸指针指向的内存是 Python 的 GC 管的 。当 Python 那边把对象回收掉,destroy() 会把 Option 置为 None,但已经有个别的线程拿到了旧指针准备解引用------这就是一个教科书级的 use-after-free(CWE-416),公开披露的严重性评级是 High(8.1)。

更值得深思的是触发条件:在传统的带 GIL 的 Python 里,这个竞态窗口被 GIL 挡住了;而一旦 PyO3 默认关闭 GIL、配合 free-threaded Python,窗口就暴露出来了。换句话说,这份 unsafe 代码"看起来一直是安全的",只是因为它一直在被外部机制保护着。

事故二:Send 实现里少写一个 trait bound

第二个案例来自 concread 这个 crate(一个并发缓存库)。它的 ARCache<K, V> 里有裸指针字段,所以必须手工实现 SendSync。作者当时的写法是:

rust 复制代码
// 出问题的版本(已修复,此处仅作反面教材)
unsafe impl<K, V> Send for ARCache<K, V> {}
unsafe impl<K, V> Sync for ARCache<K, V> {}

少了什么?少了 V: SendV: Sync 这两个约束。

这看起来是"漏了个 bound 的小疏忽",但后果极其严重:报告者(一位学术研究者,专门扫描 crates.io 找 soundness 问题)用纯 safe Rust 写出了一个复现程序。核心思路是:

  1. ARCache 里塞一个 Rc<i32>------Rc 既不是 Send 也不是 Sync
  2. 由于 ARCache 声称自己是 Send,把这个 Rc 顺着 Arc<ARCache> 分发到 5 个线程;
  3. 每个线程狂调 Rc::clone(),而 Rc 的引用计数是普通 usize,不是原子类型
  4. 多线程并发写同一个计数 → 计数错乱 → 要么内存泄漏,要么在引用还活着的时候把 Rc 释放掉 → 段错误。

复现程序的第一行是 #![forbid(unsafe_code)]

这是 unsafe 最阴险的一种失败模式:你自己一个 unsafe 都没写,但你的安全代码被依赖里的一个错误 unsafe impl 变成了 UB。 修复方式也很简单------把 V: Send + Sync 补上(历史修复是先加了 V: Send + 'static)。

事故三:FFI 边界上的 double-free

Dropbox 把同步引擎和文件索引逐步迁移到 Rust 时,官方复盘里提到一个早期教训:与既有 C++ 代码通过 FFI 协作时,出现过 double-free。原因不复杂------所有权在语言边界上归属不清 :Rust 侧以为 Box::into_raw() 已经把所有权交出去了,C++ 侧以为只是借用一个引用,两边都认为自己该负责释放。

数据本身很漂亮(同步引擎重写后,Smart Sync 的 CPU 占用降了 25%,文件索引延迟改善了约 30%~50%,覆盖 5 亿多台设备),但代价是团队必须在 FFI 边界上补一套严格的约定和测试。Dropbox 的结论原文大意是:Rust 的所有权模型不会跨语言边界生效

三个事故的共同结论

把三个案例摊在一起,规律非常清晰:

事故 表层原因 真正的根因
tokenizers UAF unsafe impl Send/Sync 覆盖了不安全的裸指针 把"外部机制提供的保护"误当成类型自身的保证
concread UB Send 实现漏了 V: Send 约束 unsafe impl 是一个对所有调用者的能力授予,不是注释
Dropbox double-free Box::into_raw 后所有权归属模糊 语言边界上没有单一的所有权契约

所以 unsafe 的真正危险不在"用没用",而在边界有没有划清。下面这套四道闸门的方法论,就是围绕"划边界"展开的。


二、unsafe 到底解锁了什么:五种能力与两层语义

2.1 五种能力,仅此而已

先把 unsafe 的能力范围钉死。翻 Rust 官方文档对 unsafe 关键字的说明,它解锁的恰恰是五件事:

  1. 解引用裸指针*const T / *mut T
  2. 调用 unsafe 函数
  3. 实现 unsafe trait
  4. 访问或修改可变静态变量 (含 extern 里的)
  5. 访问 union 的字段

一个重要澄清:unsafe 不会关掉借用检查器,也不会关掉类型系统。 它只解锁上面五件事,其余所有 Rust 规则照旧生效。很多人误以为"进了 unsafe 块就等于写 C",这是个昂贵误解。

光列出来不够直观,把五件事写在一个函数里看一眼(每处都标了它到底为什么需要 unsafe):

rust 复制代码
fn superpowers() {
    // ① 解引用裸指针
    let x = 42;
    let raw: *const i32 = &x;
    // SAFETY: raw 派生自 &x,指向有效的 i32
    let v = unsafe { *raw };
    assert_eq!(v, 42);

    // ② 调用 unsafe 函数(这里以分配器的 alloc 为例)
    let layout = std::alloc::Layout::new::<u32>();
    // SAFETY: layout 的尺寸非零,满足 alloc 的前置条件
    let mem = unsafe { std::alloc::alloc(layout) };
    assert!(!mem.is_null());
    // SAFETY: mem 来自同一次 alloc,layout 完全一致
    unsafe { std::alloc::dealloc(mem, layout) };

    // ③ 实现 unsafe trait ------ 见第七章 MyArc 的 Send/Sync

    // ④ 访问或修改可变静态变量
    static mut COUNTER: u32 = 0;
    // SAFETY: 本函数单线程执行,且没有其他访问路径
    unsafe { COUNTER += 1 };
    // SAFETY: 同上
    assert_eq!(unsafe { COUNTER }, 1);

    // ⑤ 访问 union 的字段
    union IntOrFloat {
        i: u32,
        f: f32,
    }
    let u = IntOrFloat { i: 0x3F80_0000 };
    // SAFETY: 0x3F800000 正是 f32 的 1.0,按位重解释是合法的
    let f = unsafe { u.f };
    assert_eq!(f, 1.0);
}

注意第 ⑤ 项为什么必须 unsafe:union 只保证"所有字段共享同一块内存",它不记录当前哪个字段是有效的 。上面这次访问恰好合法,因为 0x3F800000 就是 1.0f32 的位模式;但如果把它当 f32 读出来的位模式不构成合法 f32(比如产生了一个 NaN 之外的非法编码),那就是 UB。编译器没法帮你判断,所以这个判断责任被推给了你。

2.2 两层语义:声明契约 vs 履行契约

官方文档里有一段极其重要的区分,我建议每个写 FFI 的人都背下来:

  • unsafe fn / unsafe trait声明存在一个编译器无法检查的契约(告示牌)
  • unsafe {} / unsafe impl声明我已经检查过某个契约并保证它被满足(签字画押)

打个比方:unsafe fn 像是工地门口挂的"进入需戴安全帽"告示牌,它没给你安全帽,只是告诉你这里有条规则;unsafe {} 则是你自己戴上安全帽并签字确认。告示牌不产生任何保护,签字才产生责任。

这个区分在生产里有一个非常实际的推论:unsafe {} 块里的每一行都是"我承诺这条不变量成立",而不是"编译器放行了"。 所以它的正确写法必须配 // SAFETY: 注释,说清为什么 不变量成立------这不是代码风格洁癖,而是唯一能让 review 通过的东西(Clippy 的 undocumented_unsafe_blocks lint 就是为此而生)。

举个具体例子,同一个 unsafe fn 的两种调用方式:

rust 复制代码
/// 从缓冲区读取第 idx 个字节。
///
/// # Safety
///
/// 调用者必须保证 `idx < buf_len`。
unsafe fn read_byte(buf: *const u8, idx: usize, buf_len: usize) -> u8 {
    debug_assert!(idx < buf_len);
    // SAFETY: 调用者按上述契约保证 idx < buf_len
    unsafe { *buf.add(idx) }
}

/// ❌ 这是"假装签了字":unsafe 块里没有兑现任何契约。
fn read_wrong(data: &[u8], idx: usize) -> u8 {
    // idx 来自外部,没有任何东西保证它小于 data.len()
    unsafe { read_byte(data.as_ptr(), idx, data.len()) }
}

/// ✅ 这是真正签了字:先把契约兑现,再进 unsafe 块。
fn read_right(data: &[u8], idx: usize) -> Option<u8> {
    if idx < data.len() {
        // SAFETY: 上一行刚刚确认 idx < data.len(),契约成立
        Some(unsafe { read_byte(data.as_ptr(), idx, data.len()) })
    } else {
        None
    }
}

两个函数的区别,不在有没有写 unsafe,而在于**unsafe 块前面有没有那句检查**。

read_wrong 里那个 unsafe {} 块是"假的签字"------它只是在语法上满足了编译器的要求,但函数签名暴露的 idx 参数没有任何约束,调用者传 idx = 100(而 data.len() == 3)时就会越界读取。unsafe {} 不产生检查,它只是把你的承诺变成编译器的放行条。

read_right 才是正确的形态:先兑现契约(idx < data.len()),再进 unsafe 。这样一来,idx 的合法性就成了这个函数自己的责任,而不是推给调用者。

这里能提炼出一条可操作的 review 规则:

看到一个 unsafe {} 块,就问一句:"块里面的前置条件,是哪一行代码保证的?" 如果答案是"调用者保证的",那这个前置条件就应该出现在函数签名 里(unsafe fnunsafe 构造函数),而不是藏在一个安全的 fn 里。

2.3 unsafe 的"污染"是双向的

还有一个必须理解的机制:safe 函数的正确性依赖于它内部 unsafe 代码的正确性。看这个例子:

rust 复制代码
fn index(idx: usize, arr: &[u8]) -> Option<u8> {
    if idx < arr.len() {
        // SAFETY: 上面已经检查了 idx < arr.len(),
        // 所以 get_unchecked 的边界前置条件成立。
        unsafe { Some(*arr.get_unchecked(idx)) }
    } else {
        None
    }
}

函数的签名是 fn index(...),纯 safe,调用者不需要 unsafe。但如果边界检查写错成 idx <= arr.len(),这个safe 函数就会触发越界读取 UB。

这就是官方那句"Safe Rust 不能造成 UB"的真正含义------它说的是编译器能证明的 safe 代码 。一旦你把 unsafe 当积木嵌进 safe 抽象里,"安全"就变成了你自己维护的一个承诺,而不再是编译器给的保证。

用一张图表示 unsafe 边界的正确形态:
#mermaid-svg-Vfwf7B2F2dcclS0J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vfwf7B2F2dcclS0J .error-icon{fill:#552222;}#mermaid-svg-Vfwf7B2F2dcclS0J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vfwf7B2F2dcclS0J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vfwf7B2F2dcclS0J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vfwf7B2F2dcclS0J .marker.cross{stroke:#333333;}#mermaid-svg-Vfwf7B2F2dcclS0J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vfwf7B2F2dcclS0J p{margin:0;}#mermaid-svg-Vfwf7B2F2dcclS0J .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster-label text{fill:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster-label span{color:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster-label span p{background-color:transparent;}#mermaid-svg-Vfwf7B2F2dcclS0J .label text,#mermaid-svg-Vfwf7B2F2dcclS0J span{fill:#333;color:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J .node rect,#mermaid-svg-Vfwf7B2F2dcclS0J .node circle,#mermaid-svg-Vfwf7B2F2dcclS0J .node ellipse,#mermaid-svg-Vfwf7B2F2dcclS0J .node polygon,#mermaid-svg-Vfwf7B2F2dcclS0J .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vfwf7B2F2dcclS0J .rough-node .label text,#mermaid-svg-Vfwf7B2F2dcclS0J .node .label text,#mermaid-svg-Vfwf7B2F2dcclS0J .image-shape .label,#mermaid-svg-Vfwf7B2F2dcclS0J .icon-shape .label{text-anchor:middle;}#mermaid-svg-Vfwf7B2F2dcclS0J .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Vfwf7B2F2dcclS0J .rough-node .label,#mermaid-svg-Vfwf7B2F2dcclS0J .node .label,#mermaid-svg-Vfwf7B2F2dcclS0J .image-shape .label,#mermaid-svg-Vfwf7B2F2dcclS0J .icon-shape .label{text-align:center;}#mermaid-svg-Vfwf7B2F2dcclS0J .node.clickable{cursor:pointer;}#mermaid-svg-Vfwf7B2F2dcclS0J .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Vfwf7B2F2dcclS0J .arrowheadPath{fill:#333333;}#mermaid-svg-Vfwf7B2F2dcclS0J .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Vfwf7B2F2dcclS0J .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Vfwf7B2F2dcclS0J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vfwf7B2F2dcclS0J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vfwf7B2F2dcclS0J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vfwf7B2F2dcclS0J .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster text{fill:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J .cluster span{color:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Vfwf7B2F2dcclS0J .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vfwf7B2F2dcclS0J rect.text{fill:none;stroke-width:0;}#mermaid-svg-Vfwf7B2F2dcclS0J .icon-shape,#mermaid-svg-Vfwf7B2F2dcclS0J .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vfwf7B2F2dcclS0J .icon-shape p,#mermaid-svg-Vfwf7B2F2dcclS0J .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Vfwf7B2F2dcclS0J .icon-shape .label rect,#mermaid-svg-Vfwf7B2F2dcclS0J .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vfwf7B2F2dcclS0J .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Vfwf7B2F2dcclS0J .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Vfwf7B2F2dcclS0J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务代码(100% safe)

只调用安全 API
安全抽象层

pub fn,签名 safe
极小 unsafe 块

  • SAFETY 注释
    裸 FFI 声明

extern 块 / -sys crate
❌ 反面形态
unsafe 散落在业务逻辑里

谁都能改、没法测量、没法审计
一个改动 → 全链路 UB

核心目标只有一句话:让 unsafe 的暴露面小到可以用肉眼 review 完,并且小到可以被工具测量。 接下来的四道闸门,就是从四个不同维度往这个目标上收。


三、第一道闸门:边界收敛(two-crate 模式)

3.1 生态标准做法

Rust 生态早就沉淀出一个约定:把裸绑定和安全封装拆成两个 crate。

复制代码
openssl-sys  +  openssl
libgit2-sys  +  git2
libsqlite3-sys + rusqlite
sqlite3-sys  +  ...

命名规律是 xxx-sys(只放裸声明,不写逻辑)+ xxx(提供安全的 Rust 风格 API)。

为什么要拆两个包?三个理由,都很实际:

  1. -sys crate 可以只做一件事:忠实映射 C 的 ABI。它的代码几乎不需要改动,也因此几乎不需要重新审计。
  2. 版本协调 :多个上层库可能依赖同一个 -sys,拆开后 Cargo 能统一版本,避免符号冲突。
  3. unsafe 的可测量性cargo geiger 一跑,-sysunsafe 数量极高是预期行为(它就是干这个的),而业务 crate 的 unsafe 应该是 0。指标才有意义。

一个典型的仓库结构长这样:

复制代码
my-ffi/
├── Cargo.toml           # workspace
├── mylib-sys/           # ① 裸绑定
│   ├── build.rs         # cc::Build 编译 C 源码
│   ├── wrapper.h        # bindgen 入口
│   ├── csrc/            # vendored 的 C 源码
│   └── src/lib.rs       # include!(concat!(env!("OUT_DIR"), "/bindings.rs"))
└── mylib/               # ② 安全封装
    └── src/lib.rs       # 所有 pub API 都是 safe

注意 csrc/ 里把 C 源码 vendor 进来这个细节。它让构建变得可复现------不依赖开发机上碰巧装了什么版本的系统库。

3.2 关进笼子的三种手法

边界划好了,笼子怎么造?三种手法,覆盖 90% 的场景。

手法一:句柄 RAII(最常用)

C 库最常见的 API 形态是"创建句柄 → 用句柄干活 → 销毁句柄"。这正好对上 Rust 的 Drop

rust 复制代码
use std::ffi::CString;

// ① 裸 FFI 声明,放在私有模块里,绝不 pub
//    Rust 1.82+ 允许(2024 edition 要求)给 extern 块加 unsafe
mod ffi {
    use std::os::raw::{c_char, c_int, c_void};

    unsafe extern "C" {
        pub fn mylib_context_new(config: *const c_char) -> *mut c_void;
        pub fn mylib_context_free(ctx: *mut c_void);
        pub fn mylib_execute(ctx: *mut c_void, cmd: c_int) -> c_int;
    }
}

/// 安全封装:一个 C 上下文的 RAII 句柄。
///
/// 内部持有裸指针,但字段私有 ------ 外部代码拿不到它,
/// 也就不可能绕过这里的所有权约定。
pub struct Context {
    inner: *mut c_void, // 不变量:非空,且由 mylib 分配
}

impl Context {
    /// 创建上下文。失败时返回错误,绝不返回一个半成品句柄。
    pub fn new(config: &str) -> Result<Self, String> {
        // CString::new 会拒绝含 NUL 字节的输入,这是必须的:
        // 内嵌 NUL 会让 C 侧只读到一半,是个隐蔽的逻辑错误。
        let c_config = CString::new(config).map_err(|e| e.to_string())?;

        // SAFETY:
        // 1. c_config 在本行结束前一直存活,as_ptr() 返回的指针有效且 NUL 结尾;
        // 2. mylib_context_new 的契约是:返回 NULL 表示失败,
        //    非 NULL 表示一个由 mylib 拥有的有效上下文。
        let ptr = unsafe { ffi::mylib_context_new(c_config.as_ptr()) };

        if ptr.is_null() {
            Err("mylib: failed to create context".to_string())
        } else {
            Ok(Context { inner: ptr })
        }
    }

    /// 执行一次操作。C 侧返回 1 表示成功,其余为失败。
    pub fn execute(&mut self, cmd: i32) -> bool {
        // SAFETY: self.inner 由构造函数保证非空,
        // 且 &mut self 保证本方法独占访问,不会有并发调用。
        unsafe { ffi::mylib_execute(self.inner, cmd) == 1 }
    }
}

impl Drop for Context {
    fn drop(&mut self) {
        // SAFETY: self.inner 一定由 mylib_context_new 返回且未被释放过
        // (因为构造后没有任何路径会改动它,且 Drop 只会执行一次)。
        unsafe { ffi::mylib_context_free(self.inner) };
    }
}

// 用法:调用者看到的全是 safe API,unsafe 一个字都看不见
fn demo() -> Result<(), String> {
    let mut ctx = Context::new("mode=fast")?;
    assert!(ctx.execute(42));
    Ok(()) // ctx 在这里离开作用域,C 侧上下文被自动释放
}

这个模式把三个问题一次性解决了:

  • 生命周期Drop 保证不泄漏,也保证不会忘记释放。
  • 类型安全:字段私有 + 构造函数校验,外部拿不到裸指针。
  • 错误处理 :C 的返回值(NULL、错误码)被翻译成 Result

比喻一下:Context 就像一张门禁卡 。你拿到卡(new)才能进出大楼,刷卡干活(execute),走出大门时系统自动回收权限(drop)。你没法把卡复制给别人,也没法带走------因为卡片本身被封装在门禁系统里。

手法二:不透明类型代替裸指针

一个常见错误是直接 pub type Image = *mut RawImage; 把裸指针暴露成公开 API。这样做的后果是:调用方随时可以把它置空、悬垂、或者传给别的函数,你精心设计的不变量全部失效。

最要命的是------这种错误写法完全能编译通过,甚至能跑。 先看反面版:

rust 复制代码
// C 侧的不透明结构(Rust 只关心它的指针)
#[repr(C)]
pub struct RawImage {
    width: u32,
    height: u32,
}

unsafe extern "C" {
    fn raw_image_open(path: *const c_char) -> *mut RawImage;
    fn raw_image_resize(img: *mut RawImage, w: u32, h: u32) -> c_int;
    fn raw_image_free(img: *mut RawImage);
}

// ❌ 反面版:把裸指针直接暴露成公开 API ------ 它照样能编译通过
pub type BadImage = *mut RawImage;

fn bad_image_demo() {
    let c_path = CString::new("/tmp/a.png").unwrap();
    // SAFETY: 演示用
    let img = unsafe { raw_image_open(c_path.as_ptr()) };

    // ① 调用方可以随意置空。类型上完全合法,编译器拦不住
    let _null: BadImage = std::ptr::null_mut();

    // ② 更糟的是:裸指针是 Copy 的,所以这里编译器一声不响
    let img2 = img;
    // 现在 img 和 img2 都"指向"同一个资源,
    // 但所有权只有一份。对两者各 free 一次 → double free
}

问题就出在 pub type 上。类型别名不产生新类型 ,它只是给 *mut RawImage 起了个名字------所有裸指针的"特权"(可置空、可 Copy、可任意转换)一个都没少。

正确做法是套一层不透明结构体,用字段私有性把裸指针关进去:

rust 复制代码
/// 不透明句柄:外部拿不到内部指针,只能通过方法操作
pub struct SafeImage {
    inner: *mut RawImage, // 私有字段是关键
}

impl SafeImage {
    pub fn open(path: &str) -> Result<Self, String> {
        let c_path = CString::new(path).map_err(|e| e.to_string())?;
        // SAFETY: c_path 存活至本行结束,指针有效且 NUL 结尾
        let p = unsafe { raw_image_open(c_path.as_ptr()) };
        if p.is_null() {
            Err("raw_image_open failed".to_string())
        } else {
            Ok(SafeImage { inner: p })
        }
    }

    pub fn resize(&mut self, w: u32, h: u32) -> Result<(), String> {
        // SAFETY: inner 非空(构造函数已校验),&mut self 保证独占访问
        let rc = unsafe { raw_image_resize(self.inner, w, h) };
        if rc == 0 { Ok(()) } else { Err(format!("resize failed: {rc}")) }
    }
}

impl Drop for SafeImage {
    fn drop(&mut self) {
        // SAFETY: inner 由 raw_image_open 返回且未被释放过
        unsafe { raw_image_free(self.inner) };
    }
}

两个版本的行数差不多,能力却完全不同。对照着看:

BadImage(类型别名) SafeImage(不透明结构体)
能否置空 能,*mut T 天然允许 不能,构造函数拒绝空指针
能否复制 能,裸指针是 Copy 不能,SafeImage 不实现 Copy
能否重复释放 能,两次 free 就 double free 不能,Drop 只执行一次
能否忘记释放 会泄漏 自动释放
调用方需要写 unsafe 需要 完全不需要

一句话总结这张表:BadImage 把"如何安全使用"变成了调用者的口头约定;SafeImage 把它变成了类型系统的强制要求。 这就是"把不安全关进笼子"最直白的体现。

有一份生产复盘(一个图像处理团队把 12k 行遗留 C 库的绑定全部换成 bindgen 生成 + 安全封装)给出的数据是:手写绑定的 unsafe 代码从 210 行降到 32 行(降 85%),线上 FFI 相关的 on-call 事故从每月 14 起降到 0 起。他们的经验里有一条被反复强调:unsafe 行数是一个可以直接盯的工程指标。

手法三:unsafe 块最小化

这条规则讲起来最虚,但最好执行:unsafe 块缩到只包含"必须 unsafe 的那一次操作",而不是裹住整个函数体。

rust 复制代码
// ❌ 差:整个函数体都在 unsafe 里,边界检查、类型转换、日志全混在一起
pub fn process(&self, data: &[u8]) -> i32 {
    unsafe {
        let len = data.len();
        if len == 0 { return -1; }
        let ptr = data.as_ptr();
        mylib_process(ptr, len)  // 只有这一行真的需要 unsafe
    }
}

// ✅ 好:unsafe 只包住那一次跨边界调用
pub fn process(&self, data: &[u8]) -> i32 {
    if data.is_empty() {
        return -1;
    }
    // SAFETY:
    // 1. data.as_ptr() 指向一个有效、已初始化的字节缓冲区;
    // 2. 长度取自 data.len(),与缓冲区实际长度一致;
    // 3. mylib_process 的契约是"只读该缓冲区,不保存指针",
    //    因此调用返回后不再需要该内存存活。
    unsafe { mylib_process(data.as_ptr(), data.len()) }
}

区别在哪?第一种写法里,将来有人往 unsafe 块中间加一行"看起来很安全"的代码(比如打印日志),那行代码也就顺带失去了编译器检查。第二种写法杜绝了这种意外扩张。

3.3 小结:第一道闸门的产出物

做完边界收敛,你应该能得到:

  • 一个 -sys crate,unsafe 数量高但从不改动
  • 一个安全封装 crate,pub 出去的 API 全部是 safe 签名
  • 每个 unsafe 块都配上一句解释了"为什么这里安全"的 // SAFETY: 注释;
  • 一份"哪些 C 前置条件由谁负责"的书面清单(写进 doc comment 即可)。

四、第二道闸门:类型契约(repr、字符串、回调、panic)

边界收好了,接下来是数据怎么跨过去。这一节全是高频翻车点。

4.1 #[repr(C)]:不加就是玄学 bug

Rust 编译器有权重排结构体字段以优化内存布局(字段顺序不是 ABI 的一部分)。C 则严格要求字段按声明顺序排列,并遵守平台的填充规则。

如果两边布局不一致,你会在 C 侧读到错位的字节------程序不崩溃,只是返回错误的数字。这种 bug 连 Valgrind 都抓不到。

rust 复制代码
// ❌ 错:Rust 可能重排 x 和 y 的位置,C 侧按自己的布局解释 → 读到垃圾数据
struct Point {
    x: f64,
    y: f64,
}

// ✅ 对:强制采用 C 的内存布局
#[repr(C)]
struct Point {
    x: f64,
    y: f64,
}

// ✅ 也可以让编译器在编译期验证尺寸和字段偏移
const _: () = {
    assert!(std::mem::size_of::<Point>() == 16);
    assert!(std::mem::offset_of!(Point, y) == 8);
};

一个小技巧:bindgen 在生成绑定时默认带上布局断言(layout assertions),这就是它的价值之一------如果你的 Rust 侧结构体对不上,编译期就会报错,而不是等到运行时变成诡异的数值错误。

另外注意一个高频错误:不要在 #[repr(C)] 结构体里放 String&strOption<T>Vec<T> 。这些都带 Rust 私有布局(Vec(ptr, cap, len) 三元组,Option<&T> 有 niche 优化),C 侧完全无法理解。跨边界只用基础类型 + 裸指针 + 判空用的 Option<extern "C" fn>

4.2 类型映射速查表

Rust 类型 C 类型 关键注意点
i32 / u32 int32_t / uint32_t 固定宽度,跨平台安全
usize uintptr_t 平台相关 ,对外暴露接口优先用 u32/u64
f64 double 安全
*const T const T* 只读借用
*mut T T* 可写
std::ffi::CStr const char* 借用,NUL 结尾,UTF-8 不保证
std::ffi::CString char* 拥有,NUL 结尾,构造时校验 NUL 字节
std::ffi::c_void void 不透明指针
Option<extern "C" fn(..)> 函数指针 None 映射为 NULL
NonNull<T> T* 非空指针,比裸指针多一层保证

usize 那一行值得强调。有一个跨平台 FFI 的事故统计结论是:约三分之一的多平台 ABI 问题源于平台相关类型宽度差异------size_t 在 32 位 ARM 上是 4 字节,在 x86_64 上是 8 字节。如果你的 Rust 侧用 usize 而 C 侧的头文件里写的却是 uint32_t,在某个平台上就会静默错位。

4.3 字符串:三个必踩的坑

字符串是 FFI 里出错密度最高的数据类型。三个坑,按踩坑频率排序。

坑一:CString 的临时值悬垂
rust 复制代码
// ❌ 危险:CString 在本行结束时就被 drop 了,
//    返回的指针立刻变成悬垂指针
fn bad_get_ptr() -> *const c_char {
    CString::new("hello").unwrap().as_ptr() // dangling!
}

// ✅ 正确:让 CString 活到使用者用完为止
fn good_use() {
    let c_str = CString::new("hello").unwrap();
    let ptr = c_str.as_ptr();
    // SAFETY: c_str 在作用域内保持存活,ptr 有效且以 NUL 结尾
    unsafe { some_c_function(ptr) };
    // c_str 在这里才被释放
}

这个坑的可怕之处在于:它经常能"正常工作"。因为内存刚被释放还没归还给系统,那块字节内容还在。然后在某个加了日志、改了分配器的版本里突然崩溃。

这类"可能悬垂"的返回值,用 Rust 类型系统表达出来会更安全:

rust 复制代码
// 推荐:用 CStr 借用 + 生命周期把约束写进签名
fn process_str(s: &CStr) -> Result<(), Error> {
    // SAFETY: s 是有效的 CStr,其内部指针在调用期间保持有效
    let ret = unsafe { some_c_function(s.as_ptr()) };
    // ...
    Ok(())
}

&CStr 的参数类型会强迫调用者持有 一个 CString(或 &'static CStr),悬垂问题在编译期就被挡掉了。

坑二:NUL 字节

CString::new() 遇到输入里含 NUL 字节会返回 Err,这是好事,必须处理。如果你图省事用 unwrap(),那么当输入是用户可控的数据时,就会从"输入校验失败"变成"服务 panic"。

看这个对比------两个函数只差一个 map_err,线上表现却是"偶发 500"和"正常报错"的区别:

rust 复制代码
/// ❌ 用户可控输入 + unwrap = 服务 panic
pub fn set_name_unwrap(name: &str) -> bool {
    // 当 name 是 "a\0b" 时,这里会直接 panic
    let c = CString::new(name).unwrap();
    // SAFETY: c 存活至本行结束,指针有效且 NUL 结尾
    unsafe { mylib_set_name(c.as_ptr()) == 0 }
}

/// ✅ 把校验失败变成可处理的错误
pub fn set_name(name: &str) -> Result<bool, String> {
    let c = CString::new(name)
        .map_err(|_| "name must not contain a NUL byte".to_string())?;
    // SAFETY: c 存活至本行结束,指针有效且 NUL 结尾
    Ok(unsafe { mylib_set_name(c.as_ptr()) == 0 })
}

为什么内嵌 NUL 是个"隐蔽的逻辑错误"而不是"小瑕疵"?因为 C 字符串是靠 NUL 结尾来定界的。你传 "a\0b" 进去,C 侧读到的只是 "a"------函数返回成功了,但数据被悄悄截断了。 这比崩溃更难查,所以 CString 宁可报错也不让它过。

坑三:UTF-8 不保证

从 C 拿回来的 const char* 不能假定是合法 UTF-8。CStr::to_str() 会做校验,失败时返回 Errto_string_lossy() 会替换非法字节。两者都比 from_utf8_unchecked------后者在遇到非法 UTF-8 时直接制造 UB。

rust 复制代码
/// 读取 C 库的错误描述。注意:返回值可能是非 UTF-8 的任意字节。
pub fn error_message(code: i32) -> String {
    // SAFETY: 依据 C 库文档,返回值要么为 NULL,
    // 要么指向一个库内部持有的、NUL 结尾的有效字符串
    let p = unsafe { mylib_strerror(code) };
    if p.is_null() {
        return format!("unknown error {code}");
    }
    // SAFETY: 同上,p 有效且以 NUL 结尾
    let cstr = unsafe { CStr::from_ptr(p) };

    // ❌ 绝对不要这样写:遇到非法 UTF-8 就是 UB
    // let s = unsafe { std::str::from_utf8_unchecked(cstr.to_bytes()) };

    // ✅ 带校验的转换,非法字节降级为替换字符,而不是 UB
    cstr.to_string_lossy().into_owned()
}

这里有个容易忽略的细节:CStr::from_ptr 需要 unsafe(因为"指针有效且 NUL 结尾"这个前提编译器验证不了),而 to_string_lossy 不需要。也就是说------

unsafe 应当只覆盖"建立合法性"那一步(指针 → CStr),之后的纯 Rust 处理应该回到安全区。

这正是"最小 unsafe 块"的具体应用:把 unsafe 边界画在类型转换处,而不是整段函数。

4.4 回调:从 C 调回 Rust 的两个必答题

C 库注册回调的标准形态是"函数指针 + void* 用户数据",Rust 侧就是 extern "C" fn

rust 复制代码
use std::os::raw::c_void;

// 假设已在别处声明(示意):
// unsafe extern "C" {
//     fn mylib_start_long_task(
//         cb: extern "C" fn(*mut c_void, u64),
//         user_data: *mut c_void,
//     );
// }

struct ProgressState {
    total: u64,
    done: u64,
}

extern "C" fn on_progress(user_data: *mut c_void, delta: u64) {
    // SAFETY: 调用方保证 user_data 是我们注册时传入的
    // &mut ProgressState 的地址,且回调期间该对象一直存活。
    let state = unsafe { &mut *(user_data as *mut ProgressState) };
    state.done += delta;
}

pub struct Task {
    state: Box<ProgressState>, // Box 保证地址稳定,移动 Task 不会让指针失效
}

impl Task {
    pub fn run(&mut self) {
        let ptr = &mut *self.state as *mut ProgressState as *mut c_void;
        // SAFETY:
        // 1. ptr 来自 Box,地址稳定且对齐;
        // 2. 注册的回调不会 panic(见下文的安全版本),
        //    因此不会 unwind 穿越 C 栈帧;
        // 3. 回调期间 &mut self 独占借用,不会有别名访问。
        unsafe { mylib_start_long_task(on_progress, ptr) };
    }
}

两个必答题:

必答题一:为什么用 Box 因为要拿地址稳定。如果 ProgressState 直接存在栈上,你把 Task 一移动(赋值、放进 Vec、返回),地址就变了,而 C 侧还拿着旧地址------立刻 UB。Box 的内容在堆上,移动 Box 只移动指针本身。

必答题二:回调里能 panic 吗? 绝对不能。 Rust 的 panic 默认走 unwind,而 unwind 穿越 C 栈帧是 UB(C 没有这个机制)。所以在任何会被 C 调用的 Rust 函数里,必须拦住 panic:

rust 复制代码
use std::panic::{catch_unwind, AssertUnwindSafe};

extern "C" fn on_progress_safe(user_data: *mut c_void, delta: u64) {
    // catch_unwind 需要 UnwindSafe,而裸指针与 &mut 天然不满足,
    // 这里用 AssertUnwindSafe 明确承担"不跨越 unwind 的中间状态被观察到"的责任。
    let result = catch_unwind(AssertUnwindSafe(|| {
        // SAFETY: 同上一段代码
        let state = unsafe { &mut *(user_data as *mut ProgressState) };
        state.done = state.done.checked_add(delta).expect("counter overflow");
    }));
    if result.is_err() {
        // 降级处理:记日志、置错误标志,但绝不继续 unwind
        eprintln!("callback panicked; suppressed to avoid UB");
    }
}

这条规则要和"反过来"的方向区分开:Rust 调 C 之前也可能 panic (比如 CString::new().unwrap() 炸了),同样必须用 catch_unwind 包住,然后把 panic 翻译成 C 侧的返回码。

生产上还有个更干净的做法:abort 型 panic 策略

toml 复制代码
# Cargo.toml
[profile.release]
panic = "abort"

这样 panic 直接终止进程,从物理上排除了 unwind 穿越 C 栈帧的可能。代价是失去了 catch_unwind 的兜底能力------适合"跨 FFI 边界处宁可崩掉也不能 UB"的服务型场景。

4.5 所有权交接:Box::into_rawBox::from_raw

把 Rust 对象交给 C 托管时,用 into_raw;C 还回来时,用 from_raw 收编------两者必须严格配对,这是 Dropbox 那起 double-free 的核心教训。

rust 复制代码
#[repr(C)]
pub struct Handle {
    id: u32,
    // ...
}

#[unsafe(no_mangle)]
pub extern "C" fn handle_new() -> *mut Handle {
    // 交出所有权:Box 不再负责释放,责任转移给 C 侧
    Box::into_raw(Box::new(Handle { id: 1 }))
}

/// # Safety
/// `ptr` 必须是 `handle_new` 返回、且尚未被释放的指针。
#[unsafe(no_mangle)]
pub unsafe extern "C" fn handle_free(ptr: *mut Handle) {
    if !ptr.is_null() {
        // 收编所有权:重新变成 Box,函数结束时自动 drop
        // SAFETY: 调用者按契约保证 ptr 来自 handle_new 且未重复释放
        drop(unsafe { Box::from_raw(ptr) });
    }
}

要注意的两个细节:

  1. Box::from_raw 要求指针来自同一个分配器。 如果 C 侧用自己的 malloc 分配了内存却让 Rust 的 Box 去释放,同样是 UB。
  2. handle_free 必须是 unsafe fn 因为"指针有效且未被释放"这个前置条件编译器无法验证,它必须被写进类型签名里,而不是靠文档口头约定。这一点在 concread 的案例里被反证得非常清楚:凡是调用者能触发的 UB 前置条件,都必须出现在签名或构造函数的 unsafe 上。

4.6 动态大小类型的进阶处理

C 里有一种常见结构:"固定头部 + 柔性数组成员"(flexible array member):

c 复制代码
typedef struct {
    uint32_t name_len;
    char name[];   // 变长部分跟在后面
} Foo;

bindgen 会把它生成为 __IncompleteArrayField,直接往里写数据很容易踩到 copy_nonoverlapping requires aligned and non-null pointer 这类前置条件断言。

两种处理策略:

策略一(保守,推荐先用):定长缓冲,浪费一点内存换绝对安全。

rust 复制代码
const MAX_NAME_LEN: usize = 30;

#[repr(C)]
pub struct FooBuf {
    header: Foo,
    payload: [std::os::raw::c_char; MAX_NAME_LEN + 1], // +1 给 NUL
}

impl FooBuf {
    /// 名字超过 MAX_NAME_LEN 会 panic ------ 这是刻意的:
    /// 与其静默接受一个放不下的输入,不如在测试阶段就炸出来。
    pub fn new(name: &[std::os::raw::c_char]) -> Self {
        assert!(name.len() <= MAX_NAME_LEN);
        // 组装具体实现略
        todo!()
    }
}

// 交给 C 时:Box 起来再交出指针
pub fn register(name: &[std::os::raw::c_char]) -> u32 {
    let foo = Box::new(FooBuf::new(name));
    // SAFETY: FooBuf 是 Sized 且 #[repr(C)],
    // 指针类型转换到 *mut Foo 在布局上成立(header 是第一个字段)。
    unsafe { mylib_register_foo(Box::into_raw(foo) as *mut Foo) }
}

策略二(激进):用 DST([c_char] 尾字段)+ 手工构造胖指针。 这条路能省内存,但需要借助 std::ptr::slice_from_raw_parts_mut 把"瘦指针 + 元数据"拼成胖指针,并且要非常小心 provenance(来源合法性)。这条路线属于"必须配 Miri 验证"的范畴,团队没有 Miri 流程的话不建议走。

如果你确实需要走这条路,核心代码长这样:

rust 复制代码
#[repr(C)]
pub struct FooHeader {
    name_len: u32,
}

#[repr(C)]
pub struct FooDst {
    header: FooHeader,
    payload: [c_char], // 尾字段:长度不写在类型里,而是记在指针的元数据里
}

impl FooDst {
    /// 把"瘦指针"(指向 header)还原成带元数据的"胖指针"。
    ///
    /// Rust 的胖指针 = 数据地址 + 元数据。对 DST 来说,元数据就是尾切片的长度。
    /// slice_from_raw_parts_mut 只负责把这两样拼装起来,它不解引用任何内存。
    ///
    /// # Safety
    ///
    /// - `header` 必须指向一个 `FooDst` 布局的对象,且 `name_len` 已正确初始化;
    /// - `header` 必须具备覆盖 `size_of::<FooHeader>() + name_len + 1` 字节的
    ///   可写 provenance。若它派生自 `&FooHeader` 引用,则**不满足**该条件。
    pub unsafe fn from_header(header: *mut FooHeader) -> *mut FooDst {
        // SAFETY: 调用者保证 header 有效,故可读取 name_len
        let name_len = unsafe { (*header).name_len } as usize;
        // 数据地址取 header 的地址,元数据取 name_len + 1(含 NUL 结尾)
        std::ptr::slice_from_raw_parts_mut(header as *mut c_char, name_len + 1) as *mut FooDst
    }
}

然后是这样用的------这里有一个非常容易踩的坑

rust 复制代码
fn dst_usage() -> usize {
    const N: usize = 8;
    let total = std::mem::size_of::<FooHeader>() + N + 1;

    // 用 Vec 作为后端内存,保证 provenance 覆盖整块分配
    let mut backing = vec![0u8; total];
    let raw = backing.as_mut_ptr() as *mut FooHeader;

    // SAFETY: backing 至少 total 字节,足够容纳 header + 名字 + NUL
    unsafe {
        (*raw).name_len = N as u32;
        let dst = FooDst::from_header(raw);

        // ⚠️ 坑在这里:不能直接把 *mut FooDst 强转回 *mut [c_char]!
        //    那样数据地址会停在结构体起始处,而不是 payload 字段。
        //    let bad = &mut *(dst as *mut [c_char]);  // ← 读到的不是名字,是 header

        // ✅ 正确做法:用 addr_of_mut! 让编译器算好字段偏移
        let payload: *mut [c_char] = std::ptr::addr_of_mut!((*dst).payload);
        // SAFETY: 地址与长度元数据都由 from_header 的契约保证有效
        let payload: &mut [c_char] = &mut *payload;

        let len = payload.len(); // == N + 1,含 NUL 结尾
        payload[0] = b'a' as c_char;
        len
    }
}

这段代码想说明三件事:

  1. 胖指针的元数据是可以"手工造出来"的 ,这是 slice_from_raw_parts_mut 的用途,也是它危险的原因------你造错了长度,编译器一声不响。
  2. 注意 payload.len()N + 1 而不是 N,因为元数据里包含了 NUL 结尾。这种差一位的错误在 DST 场景里极易发生,而且往往表现为"最后一个字节不对"这种诡异症状。
  3. 格式化转换必须走 addr_of_mut! ,不能靠指针强转。原因是 *mut FooDst*mut [c_char] 虽然都是胖指针,但前者的数据地址指向结构体起始处,后者的数据地址必须指向尾字段。直接 as 转换不会帮你调整偏移------这类 bug 特别隐蔽,因为类型检查完全通过。

最后再强调一遍那两条硬约束:provenance 必须覆盖整块内存 (所以上面的 raw 必须派生自 backing.as_mut_ptr(),而不能是 &header 的地址),以及这条路必须用 Miri 验证

顺带解释一下上面那个"provenance"到底在说什么。Rust 的内存模型里,指针不只是"一个地址",它还带着"我有权访问哪块分配"的信息------这就是 provenance。backing.as_mut_ptr() 拿到的指针,权限覆盖整个 total 字节,所以写尾巴是合法的;而如果你写 &mut header as *mut FooHeader,这个指针的权限就只覆盖 header 那 4 个字节,写尾巴就是越权。这也是为什么在 4.1 节我说"布局错误编译器能靠断言抓,但 provenance 错误只有 Miri 能抓"。


五、第三道闸门:语法契约(Rust 2024 的新规矩)

前两道闸门讲的是"怎么写",这一道讲的是"写成什么语法形态"。如果你最近升级过 edition,可能会发现一批 FFI 代码突然报错------这不是编译器找麻烦,而是Rust 官方在语法层面把 FFI 的隐性契约显性化了

5.1 unsafe extern 块:把责任写进语法

历史遗留问题是这样的:extern 块里的函数声明不需要写 unsafe,但从里面调用任何东西都是 unsafe 的。这就模糊了一个关键问题------

如果 extern 块里的函数签名写错了(比如 C 侧其实是 int,你写成了 long),那么 UB 的责任在谁?写 extern 块的人,还是调用它的人?

Rust 团队的结论(RFC 3484):责任在写 extern 块的人。因为写声明的人才有能力去核对头文件。

于是从 Rust 1.82 开始,允许(并在 2024 edition 起要求 )写成 unsafe extern。而且块内的每一项都可以单独标注 safeunsafe

rust 复制代码
unsafe extern "C" {
    // 任何 f64 输入都合法,标为 safe ------ 调用者不需要 unsafe 块
    pub safe fn sqrt(x: f64) -> f64;

    // 需要有效指针,标为 unsafe ------ 契约必须在调用处兑现
    pub unsafe fn strlen(p: *const std::ffi::c_char) -> usize;

    // 不标注则保守地按 unsafe 处理
    pub fn free(p: *mut std::ffi::c_void);

    // 静态变量也可以标 safe 或 unsafe
    pub safe static IMPORTANT_BYTES: [u8; 256];
}

这个改动的工程价值很直接:以前整个 extern 块是一团"全黑",现在可以按风险分级。 那些确实无前置条件的纯函数(数学函数、版本号查询)标成 safe,调用处就干净了一大截;真正危险的指针操作继续留 unsafe,一眼可见。

5.2 #[unsafe(no_mangle)]:能造成 UB 的属性

no_mangleexport_namelink_section 这三个属性可以在没有任何 unsafe的情况下制造 UB,因为它们影响符号名和链接行为。而所有链接库共享一个全局符号命名空间。

版本指南里给了一个触目惊心的例子:在旧 edition 里,下面这段纯 safe 的代码会在多数 Unix 平台上直接崩溃:

rust 复制代码
fn main() {
    println!("Hello, world!");
}

#[export_name = "malloc"]   // 把 malloc 的实现给劫持了
fn foo() -> usize { 1 }

你劫持了 malloc,然后 println! 内部要分配内存......后果自负。

从 Rust 1.82 开始这些属性可以在任何 edition 里写成 unsafe(...) 形式;从 2024 edition 起是强制的

rust 复制代码
// 2021 edition(旧写法,2024 edition 下是硬错误)
#[no_mangle]
pub extern "C" fn my_export() -> i32 { 42 }

// 2024 edition(新写法)
// SAFETY: 这是本 crate 唯一导出该符号的地方,不存在命名冲突
#[unsafe(no_mangle)]
pub extern "C" fn my_export() -> i32 { 42 }

5.3 unsafe_op_in_unsafe_fn:unsafe 函数内部的显式边界

旧规矩里,unsafe fn 的函数体自动 被当成一个 unsafe 块。这意味着 unsafe fn 承担了两个角色:

  1. 告诉调用者"调用我需要兑现契约";
  2. 顺便让函数体内的所有 unsafe 操作全部放行。

Rust 团队判断第二个角色风险过高 (RFC 2585),于是引入了 unsafe_op_in_unsafe_fn lint,并在 2024 edition 下默认 warn 。正确写法是在 unsafe fn 内部照样显式写 unsafe {}

rust 复制代码
// 2021 风格(2024 edition 下会警告)
unsafe fn get_unchecked_slice<T>(x: &[T], i: usize) -> &T {
    x.get_unchecked(i)
}

// 2024 风格:函数体内的 unsafe 操作仍需显式块
unsafe fn get_unchecked_slice<T>(x: &[T], i: usize) -> &T {
    // SAFETY: 调用者按本函数的 # Safety 契约保证 i < x.len(),
    // 因此 get_unchecked 的前置条件成立。
    unsafe { x.get_unchecked(i) }
}

好处是:你能一眼看出 unsafe fn 内部到底哪几行是真正危险的操作,而不是"整个函数体都在黑箱里"。

5.4 &raw const / &raw mut 取代 addr_of!

Rust 1.82 起提供原生的裸指针创建语法,语义更清晰:

rust 复制代码
static mut COUNTER: u32 = 0;

fn take_address() {
    // 旧写法
    let p1 = std::ptr::addr_of_mut!(COUNTER);

    // 新写法(1.82+)
    let p2 = &raw mut COUNTER;

    // 注意:这只是"取地址",不是读写,本身不需要 unsafe
    // 但后续解引用需要 unsafe
    // SAFETY: ...
    unsafe { *p2 = 1 }
}

顺带一个 2024 edition 的破坏性变更:static_mut_refs lint 在 2024 下默认 deny 。也就是说 let r = &mut COUNTER; 这种"对可变静态变量取引用"的写法被禁止了。理由很充分------&mut 会承诺独占,而 static mut 是多线程下可被任意访问的,这个承诺根本无法兑现。正确替代方案就是改用 &raw mut 拿指针,或者干脆用 AtomicU32 / Mutex 这类真正安全的容器。

5.5 一张迁移对照表

项目 旧写法(≤2021) 新写法(2024 edition 要求)
extern 块 extern "C" { fn f(); } unsafe extern "C" { fn f(); }(可标 safe/unsafe
符号导出 #[no_mangle] #[unsafe(no_mangle)]
段属性 #[link_section = ".."] #[unsafe(link_section = "..")]
符号名 #[export_name = ".."] #[unsafe(export_name = "..")]
unsafe fn 内操作 隐式继承 需显式 unsafe {} 块(lint 默认 warn)
static mut 取引用 &mut STATIC deny;改 &raw mut STATIC 或换原子类型
取地址 addr_of_mut!(x) &raw mut x(1.82+ 原生语法)

迁移的工具链支持已经就位:

bash 复制代码
# 自动补齐 unsafe extern 和 unsafe 属性(但不会帮你核对签名正确性)
cargo fix --edition

注意最后半句 :自动化迁移只能加关键字,无法验证 extern 块里的签名是否与 C 侧一致。那部分仍然是你自己的责任。这也是 RFC 3484 想传达的态度------语法在提醒你负责,而不是替你负责。


六、第四道闸门:自动化验证(bindgen / Miri / 供应链三件套)

前三道闸门靠的是纪律。但人的纪律会疲劳,所以最后一道闸门交给工具。

6.1 bindgen:让机器写绑定,但别指望它给你安全

bindgen 的工作是读 C 头文件,生成 Rust 的 extern 声明。它的定位有一句极其精辟的表述:

bindgen 是"描述性"的,不是"安全性"的。

翻译一下:它会忠实 地把 C 的签名翻译成 Rust------包括那些你根本不该直接调用的函数。它生成的 *const c_char 参数照样要求一个有效的 NUL 结尾指针,这一点 bindgen 一点都不会替你保证。

五大坑(按踩坑频率)
# 症状 解法
1 unsafe 块外调用生成的函数 硬编译错误 error[E0133] 包一层 safe wrapper,unsafe 只包住调用
2 忘了 cargo:rerun-if-changed 改了头文件但绑定没重新生成,静默上下游不一致 头文件和 C 源文件都显式列出
3 指向"啥都 include"的头文件 生成上千行无关绑定,编译奇慢 allowlist_function / allowlist_type 正则白名单
4 以为 bindgen 让代码变安全了 运行时 UB 认清它只是生成声明,前置条件全靠你
5 找不到 libclang 构建失败 装 clang/LLVM 开发包,必要时设 LIBCLANG_PATH

第 3 个坑的解法值得展开,这是性能与可读性的关键:

rust 复制代码
// build.rs
fn main() {
    // 显式声明依赖,避免绑定"静默过期"
    println!("cargo:rerun-if-changed=wrapper.h");
    println!("cargo:rerun-if-changed=csrc/temp.c");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        // 只生成 temp_ 开头的 API,其余一律不要
        .allowlist_function("temp_.*")
        .allowlist_type("temp_.*")
        .allowlist_var("TEMP_.*")
        // 默认开启:生成结构体布局断言,把布局错误提前到编译期
        .layout_tests(true)
        // 让生成文件内部带上 allow,避免命名风格警告刷屏
        .raw_line("#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)]")
        .generate()
        .expect("Unable to generate bindings");

    let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings");
}
枚举的一个隐藏陷阱

bindgen 默认把 C 枚举生成为常量pub const Foo_A: Foo = 0;),这是安全的。但有个诱人的选项 .rustified_enum() 会把它生成为真正的 Rust enum,用起来更顺手------

只对你完全掌控、且取值可信的枚举用它。 因为 Rust enum 有一个硬约束:判别式必须是合法的变体之一 。如果 C 侧某天返回了一个你没定义的值(新版本 C 库加了个状态码、或者内存被踩了),那么把它当 Rust enum 使用就是立即 UB------这是那种"编译器认为不可能发生所以直接优化掉你的检查"的 UB,极难 debug。

跨边界传回来的枚举,老老实实用默认的常量形式或 .newtype_enum()

把两种生成结果放一起对比,问题就一目了然了:

rust 复制代码
// 假设 C 头文件里写的是:
//   typedef enum { TEMP_CELSIUS = 0, TEMP_FAHRENHEIT = 1 } TempUnit;

// ✅ bindgen 默认生成的形式:一组常量
pub type TempUnit = c_uint;
pub const TempUnit_TEMP_CELSIUS: TempUnit = 0;
pub const TempUnit_TEMP_FAHRENHEIT: TempUnit = 1;

/// 常量形式允许你写兜底分支 ------ 这个分支真的会被执行到
pub fn unit_name(raw: TempUnit) -> &'static str {
    match raw {
        TempUnit_TEMP_CELSIUS => "Celsius",
        TempUnit_TEMP_FAHRENHEIT => "Fahrenheit",
        _ => "unknown", // C 侧哪天加了状态码 2,这里能兜住
    }
}
rust 复制代码
// ⚠️ 改用 .rustified_enum("TempUnit") 之后,生成的是真正的 Rust enum:
#[repr(u32)]
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum TempUnit {
    TEMP_CELSIUS = 0,
    TEMP_FAHRENHEIT = 1,
}

/// ❌ 危险:把 C 返回的原始数值"当成" Rust enum
///
/// # Safety
/// `raw` 必须是 0 或 1 ------ 但 C 侧没有任何机制保证这一点。
pub unsafe fn to_unit_bad(raw: u32) -> TempUnit {
    // 这一步就是 UB 的引爆点:如果 raw 是 2,
    // 这里就凭空构造了一个判别式非法的 enum
    unsafe { std::mem::transmute::<u32, TempUnit>(raw) }
}

区别不止"能不能写 _ 分支"这么简单。真正的危险在于:TempUnit 这个类型在编译期就向全世界承诺"我只可能是 0 或 1",编译器会据此优化。于是:

  • 你在别处写的 match unit { CELSIUS => .., FAHRENHEIT => .., _ => 兜底处理 } 里的兜底分支,可能被整个优化掉------因为编译器"证明"了不可能有其他值;
  • 一旦 C 侧真的传回一个 2(换了新版本的头文件、或者上游内存越界),程序不会走到你的兜底分支,而是带着一个非法判别式继续跑,进入 UB 状态。

这就是"静态类型带来的保证"在跨越语言边界时会失效的典型样子 :Rust 的 enum 保证依赖"值只能由合法的构造路径产生",而 C 侧可以通过一个 u32 直接从背后塞进来任何值。

所以结论很干脆:跨边界传回来的枚举,永远用常量形式(或 newtype_enum)加显式兜底分支处理。 rustified_enum 只留给"这个枚举从 Rust 侧产生、C 侧只读不回传"的场景。

反向:cbindgen

反方向(Rust → C)用 cbindgen 生成 C 头文件。这里有个非常容易被忽略的 CI 步骤 :生成的 .h 必须用 C 编译器语法检查一遍。

bash 复制代码
# CI 中的一步:用 C 编译器验证生成的头文件
gcc -std=c99 -Wall -Werror -fsyntax-only target/mylib.h

依据一份团队实践复盘,这一步能在进入生产前拦掉约 87% 的头文件兼容性问题(缺 include、类型不匹配、语法错误)。成本几乎为零,收益极高------强烈建议加进 CI

另外用 cbindgen 时要留意平台差异:对外的 Rust 类型优先用 u32/u64 而不是 usize,因为 usize 在 32 位和 64 位目标上宽度不同,会在某个平台上静默错位。

6.2 Miri:能查出"看起来正常"的 UB

Miri 是 Rust 的 MIR 解释器,它逐条指令 执行程序,并在每次内存访问时做完整检查。和 sanitizer 相比,它最大的价值是能发现那些在你机器上恰好没崩的 UB。

bash 复制代码
# Miri 是 nightly 组件
rustup +nightly component add miri

# 跑整个测试套件
cargo +nightly miri test

# 只跑含 unsafe 的那个 crate
cargo +nightly miri test --package my-ffi-crate

# 开启更严格的 provenance 检查
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test

# 顺便查内存泄漏
cargo +nightly miri test -- -Zmiri-find-leaks
它能查什么、不能查什么

这张表是使用 Miri 前必须记住的------误以为 Miri 通过就万事大吉,是另一种危险

Miri 检测 典型例子
越界访问 ptr.add(100).read() 越过了分配边界
use-after-free 通过裸指针读已 drop 的 Box
double free 调两次 drop_in_place
未对齐访问 在奇数地址上 (ptr as *const u32).read()
读取未初始化内存 MaybeUninit::uninit().assume_init()
非法类型值 transmute::<u8, bool>(2)
悬垂引用 &*ptr,而 ptr 已释放
数据竞争 两线程无同步地写同一位置
Stacked Borrows / Tree Borrows 违规 &mut 与派生裸指针的别名冲突
Miri 不能检测 原因
逻辑错误 它查内存安全,不查业务正确性
死锁 查数据竞争,不查活锁/死锁
性能问题 解释执行比原生慢 10~100 倍
系统调用 / 硬件交互 无法模拟 syscall 和设备 I/O
所有 FFI 调用 它只能解释 Rust MIR,读不懂 C 代码
穷尽路径覆盖 只跑你测试用例覆盖到的路径

加粗那一行是本文里最关键的限制。 正因为 Miri 无法解释 C 代码,所以:

  1. Miri 无法验证 C 库本身的行为,只能验证 Rust 侧在你给出的输入下没有违反 Rust 的内存模型
  2. 有两条出路------要么把 C 库的行为用纯 Rust 的 mock 实现替换掉,让 Miri 跑一条"影子路径"(这是很有效的一招);要么接受"Miri 只能覆盖 Rust 侧边界"这个天花板,再补 Valgrind / ASan 做运行时验证。
一个能立刻用上的坑:栈上的非法值

Miri 最擅长的场景之一是在 UI 层伪造 C 返回值

rust 复制代码
#[cfg(miri)]
mod mock {
    // 在 Miri 下,用一个纯 Rust 实现替代 C 函数的角色,
    // 返回值可以故意设置成"边界情况"来测试 Rust 侧的处理是否正确
    pub unsafe extern "C" fn temp_conversion_count() -> u32 { 0 }
}

这样 Rust 侧的边界处理逻辑(返回 0 怎么办、返回超大值怎么办)就能在 Miri 下被检查无数次,而不需要真的跑 C 代码。

Stacked Borrows:为什么这段代码是 UB

理解 Miri 的检查规则,看这个典型的踩坑样本:

rust 复制代码
fn main() {
    let mut x = 42;
    let r1 = &mut x;
    let raw = r1 as *mut i32;   // 从 r1 派生出裸指针
    let r2 = &mut *r1;          // ⚠️ 再借一次,栈上压入新的 tag
    *r2 = 7;
    // SAFETY: ← 这句注释在这里是错的!
    unsafe { *raw = 13; }       // UB:raw 的 tag 已经被 r2 顶掉了
    println!("{}", x);
}

按 Stacked Borrows 模型逐步看:

  1. let r1 = &mut x;r1 拿到一个唯一 tag,位于借用栈顶;
  2. let raw = r1 as *mut i32;raw 继承 r1 的 tag,但裸指针不保持借用存活
  3. let r2 = &mut *r1; → 从 r1 再借出,压入新 tagr1raw 都被标为失效;
  4. *r2 = 7; → 合法,r2 是当前活跃引用;
  5. *raw = 13;用失效的 tag 写内存 → UB

Miri 会立刻报出来:

复制代码
error: Undefined Behavior: attempting a write access using <tag> at alloc149[0x0],
but that tag does not exist in the borrow stack for this location

这段代码在 release 下"通常能跑对",因为编译器只是恰好没利用这个 UB 做优化。这就是 Miri 存在的意义:把"通常能跑对"变成"确定没违反规则"。

6.3 供应链三件套:谁管什么

FFI 项目通常依赖一堆 -sys crate,unsafe 面积天然大。这时需要三个各有分工的工具:

工具 回答的问题 关注对象 输出性质
cargo-audit 有没有已知漏洞? RustSec 公告库 通过 / 不通过
cargo-deny 许可证、来源、版本策略合规吗? 策略文件 deny.toml 通过 / 不通过
cargo-geiger unsafe 代码在哪儿、有多少? 全部依赖树源码 信息,不是判决

cargo-geiger 的用法和输出都值得单独说。它给出的 x/y 格式含义是:x = 本次构建实际用到的 unsafe 数量,y = 该 crate 里存在的 unsafe 总数。这个区分很有用------它能把"死代码里的 unsafe"和"真的被链接进来的 unsafe"分开。

复制代码
Crate              Safe    Unsafe        UFn     UImpl   UTrait
libc               ✓✓✓     ✓             3       0       0
memchr             ✓✓✓     ✓             0       0       0
crossbeam-utils    ✓✓      ✓             12      1       0

三个符号的含义:

  • 🔒 无 unsafe,且声明了 #![forbid(unsafe_code)]
  • ❓ 无 unsafe,但没声明 forbid
  • ☢️ 含 unsafe 代码

关键心态cargo-geiger 的输出是一张表,不是通行证。libc 里有几十处 unsafe完全正常且必要 的------它的工作就是包系统调用。真正要警惕的是那种"名气不大、职责单一、却塞了几百处 unsafe"的包。

配套的一个技巧:对自己的一手代码,加上这道硬保险------

rust 复制代码
// 在不需要 unsafe 的 crate 根部声明
#![forbid(unsafe_code)]

forbiddeny 的区别是 forbid 无法被内层 allow 覆盖 ,所以它是一道编译器强制执行的物理边界。cargo geiger --forbid-only 检查的就是这一点,速度很快,适合放进每次提交的 CI。

一个务实的 CI 分层方案
yaml 复制代码
# 每次 push / PR:快速门禁
- run: cargo deny check                    # 漏洞 + 许可证 + 版本 + 来源,四项一起
- run: cargo audit                         # 独立第二道漏洞扫描(同库双扫,刻意的冗余)
- run: cargo geiger --forbid-only          # 快速检查 forbid 声明

# 每周定时:慢速深检
- run: cargo +nightly miri test            # 只跑含 unsafe 的 crate
- run: cargo geiger --all-features         # 审阅 unsafe 面积的漂移

# 发布前
- run: gcc -std=c99 -Wall -Werror -fsyntax-only target/mylib.h   # 校验生成的头文件

注意其中的编排逻辑:Miri 跑得慢(10~50 倍),不要放进每次提交 。 有团队的做法是只在 nightly 定时任务或"改动了含 unsafe 的模块"时才触发。

yaml 复制代码
# 只在相关模块变动时触发 Miri(GitHub Actions 的路径过滤)
on:
  push:
    paths:
      - 'crates/my-ffi/**'
      - 'crates/my-ffi-sys/**'

七、最凶的坑:手写 unsafe impl Send/Sync

前面六章讲的坑,多数是"写错会崩"。这一章讲的是另一类:写错会让你的整个项目失去 Rust 的核心保证,而且错的人可能根本不知道自己碰了 unsafe。

7.1 为什么 Send/Syncunsafe trait

因为它们的错误实现会导致数据竞争,而数据竞争在 Rust 的内存模型里是 UB------不是"结果有点怪",是编译器可以合法地做任何事。

编译器默认的策略是保守自动推导 :包含裸指针的类型自动 !Send、包含 Rc 的类型自动 !Send、包含 Cell 的类型自动 !Sync。这是安全的默认值。

但当你写 unsafe impl Send for MyType {} 时,你是在对所有调用者授予一项能力 :告诉他们"这个类型可以随便送到别的线程去"。这个授予是不可撤销、不加条件的------它不是注释,是能力发放。

concread 的案例就是这句话最直白的注脚。

7.2 事故完整复盘:少了一个 V: Send

出问题时的代码形态(已修复,此处作为反面教材):

rust 复制代码
pub struct ARCache<K, V> {
    inner: *mut RawNode,   // 裸指针 → 自动推导为 !Send + !Sync
    // ...
}

// ❌ 问题代码:无条件地声称整个类型可以跨线程
unsafe impl<K, V> Send for ARCache<K, V> {}
unsafe impl<K, V> Sync for ARCache<K, V> {}

正确的写法应该带上约束:

rust 复制代码
// ✅ 只有当 K、V 本身可以跨线程时,ARCache 才可以
unsafe impl<K: Send, V: Send> Send for ARCache<K, V> {}
unsafe impl<K: Sync, V: Sync> Sync for ARCache<K, V> {}
// 或按实际实现约定用更严格的 bound(历史修复采用了 V: Send + 'static)

报告者写出了一份完全不含 unsafe 的复现程序,攻击链条是这样的:

rust 复制代码
use concread::arcache::ARCache;
use std::rc::Rc;
use std::sync::Arc;

fn main() {
    // ① 造一个既不是 Send 也不是 Sync 的值
    let non_sync_item = Rc::new(0);
    assert_eq!(Rc::strong_count(&non_sync_item), 1);   // Rc 的引用计数 = 1

    // ② 塞进声称自己 Send 的缓存里(这步就已经违背了类型系统的原意)
    let cache: ARCache<usize, Rc<i32>> = ARCache::new_size(5, 5);
    let mut writer = cache.write();
    writer.insert(0, non_sync_item);
    writer.commit();

    // ③ 顺着 Arc 分发到 5 个线程
    let arc_parent = Arc::new(cache);
    let mut handles = vec![];
    for _ in 0..5 {
        let arc_child = arc_parent.clone();
        handles.push(std::thread::spawn(move || {
            let reader = arc_child.read();
            let smuggled_rc = reader.get(&0).unwrap();   // 拿到了那个 Rc
            for _ in 0..1000 {
                // ④ 每个线程疯狂 clone ------ Rc 的计数是普通 usize,不是原子类型!
                let _dummy_clone = Rc::clone(&smuggled_rc);
            }
        }));
    }
    for h in handles { h.join().expect("failed to join child thread"); }

    // ⑤ 引用计数已经错乱
    assert_eq!(Rc::strong_count(arc_parent.read().get(&0).unwrap()), 1);
}

结果有几种可能,每一种都是坏消息

  • strong_count > 1 → 内存泄漏,断言失败;
  • strong_count == 0Rc 在引用还活着的时候被释放 → 直接段错误(在 Ubuntu 18.04 上表现为 Illegal Instruction + core dump,因为 Rc 的数据被回收后又被 clone);
  • strong_count == 1 → 理论上有可能,但概率极低。

在 debug 模式下运行这个程序,你会看到进程直接崩掉。

7.3 为什么这类 bug 极难自我发现

三个原因,每个都值得警惕:

  1. 测试覆盖不到 :单元测试通常是单线程的。Send/Sync 的错误只在多线程并发下才暴露,而并发时序问题往往"跑 100 次都是绿的"。
  2. 代码审查看不出来unsafe impl Send for X {} 这一行看起来太无害了------既没有指针解引用,也没有 mem::transmute,review 的人很容易滑过去。
  3. 注释不能提供保护 :这是最重要的一点。写个 // SAFETY: 调用者必须只在一个线程里用它 完全没用 ------因为你可以用安全代码自由创建、跨线程移动这个类型,而"调用者必须......"这个约束没有任何机制在执行。unsafe impl Send 一旦写下去,能力就发放出去了。

报告者的修复思路(加 V: Send + 'static)之所以成立,是因为它把约束写进了类型系统Rc 不是 Send,所以带 RcARCache 不再是 Send,第 ③ 步的 Arc::new + 跨线程移动就会编译失败。约束从"文档"变成了"编译期强制"。

7.4 判断框架:三个必须回答的问题

每次要写 unsafe impl SendSync 时,先回答这三个问题。任何一个是"不确定",就不要写。
#mermaid-svg-75t7Y8okGIsyOjFY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-75t7Y8okGIsyOjFY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-75t7Y8okGIsyOjFY .error-icon{fill:#552222;}#mermaid-svg-75t7Y8okGIsyOjFY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-75t7Y8okGIsyOjFY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-75t7Y8okGIsyOjFY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-75t7Y8okGIsyOjFY .marker.cross{stroke:#333333;}#mermaid-svg-75t7Y8okGIsyOjFY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-75t7Y8okGIsyOjFY p{margin:0;}#mermaid-svg-75t7Y8okGIsyOjFY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-75t7Y8okGIsyOjFY .cluster-label text{fill:#333;}#mermaid-svg-75t7Y8okGIsyOjFY .cluster-label span{color:#333;}#mermaid-svg-75t7Y8okGIsyOjFY .cluster-label span p{background-color:transparent;}#mermaid-svg-75t7Y8okGIsyOjFY .label text,#mermaid-svg-75t7Y8okGIsyOjFY span{fill:#333;color:#333;}#mermaid-svg-75t7Y8okGIsyOjFY .node rect,#mermaid-svg-75t7Y8okGIsyOjFY .node circle,#mermaid-svg-75t7Y8okGIsyOjFY .node ellipse,#mermaid-svg-75t7Y8okGIsyOjFY .node polygon,#mermaid-svg-75t7Y8okGIsyOjFY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-75t7Y8okGIsyOjFY .rough-node .label text,#mermaid-svg-75t7Y8okGIsyOjFY .node .label text,#mermaid-svg-75t7Y8okGIsyOjFY .image-shape .label,#mermaid-svg-75t7Y8okGIsyOjFY .icon-shape .label{text-anchor:middle;}#mermaid-svg-75t7Y8okGIsyOjFY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-75t7Y8okGIsyOjFY .rough-node .label,#mermaid-svg-75t7Y8okGIsyOjFY .node .label,#mermaid-svg-75t7Y8okGIsyOjFY .image-shape .label,#mermaid-svg-75t7Y8okGIsyOjFY .icon-shape .label{text-align:center;}#mermaid-svg-75t7Y8okGIsyOjFY .node.clickable{cursor:pointer;}#mermaid-svg-75t7Y8okGIsyOjFY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-75t7Y8okGIsyOjFY .arrowheadPath{fill:#333333;}#mermaid-svg-75t7Y8okGIsyOjFY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-75t7Y8okGIsyOjFY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-75t7Y8okGIsyOjFY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-75t7Y8okGIsyOjFY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-75t7Y8okGIsyOjFY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-75t7Y8okGIsyOjFY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-75t7Y8okGIsyOjFY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-75t7Y8okGIsyOjFY .cluster text{fill:#333;}#mermaid-svg-75t7Y8okGIsyOjFY .cluster span{color:#333;}#mermaid-svg-75t7Y8okGIsyOjFY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-75t7Y8okGIsyOjFY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-75t7Y8okGIsyOjFY rect.text{fill:none;stroke-width:0;}#mermaid-svg-75t7Y8okGIsyOjFY .icon-shape,#mermaid-svg-75t7Y8okGIsyOjFY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-75t7Y8okGIsyOjFY .icon-shape p,#mermaid-svg-75t7Y8okGIsyOjFY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-75t7Y8okGIsyOjFY .icon-shape .label rect,#mermaid-svg-75t7Y8okGIsyOjFY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-75t7Y8okGIsyOjFY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-75t7Y8okGIsyOjFY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-75t7Y8okGIsyOjFY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 会 / 不确定
不会
没走 / 不确定
走了
不是(裸指针 / Rc / 非线程安全的 C 句柄)

没有
写了
我要写 unsafe impl Send 或 Sync
问题1:多线程并发访问它,

会不会出现无同步的写?
❌ 不要写

改用 Mutex / RwLock / 原子类型
问题2:内部可变性有没有

走原子或锁?
❌ 不要写

把 UnsafeCell 的访问包进同步原语
问题3:被包裹的载荷

本身是 Send / Sync 吗?
❌ 不要写

去掉手写 impl,让类型保持 !Send/!Sync
约束写进类型签名了吗?

(带 bound,不是只写在注释里)
❌ 补齐 bound:

unsafe impl Send for C
✅ 可以写

并在 impl 上加 // SAFETY 说明每条不变量

对"问题三"补一句最重要的判断原则

unsafe impl Send/Sync 的合法性,不取决于你怎么写注释,而取决于你的类型的公开 API 是否在每条安全路径上都维护了这个不变量。

三种合法的形态:

  • 构造函数本身是 unsafe fn → 义务转移到调用者(比如 pub unsafe fn from_raw_parts(...));
  • 所有访问都经过你拥有的 Mutex/RwLock/原子 → 同步机制是你这个类型内部提供的;
  • 载荷本身在所有字段上都满足 Send/Sync → 手写 impl 其实是冗余的(那就干脆删掉,让编译器自动推导,更安全)。

7.5 正确示范

一个真正需要手写 Send/Sync 的合理场景:你自己实现的一个 Arc 变体(原子引用计数 + 非空指针)。

rust 复制代码
use std::ptr::NonNull;
use std::sync::atomic::{AtomicUsize, Ordering};

struct MyArcInner<T> {
    count: AtomicUsize,   // ✅ 原子计数,这是关键
    data: T,
}

pub struct MyArc<T> {
    ptr: NonNull<MyArcInner<T>>,   // 裸指针形式 → 编译器无法自动推导
}

// SAFETY:
// 1. 引用计数是 AtomicUsize,clone / drop 全部走 fetch_add / fetch_sub,
//    并发增减不会造成计数错乱;
// 2. data 的访问受引用计数保护:只有当计数归零时才会真正释放,
//    也就是"最后一个持有者释放",不存在悬垂访问;
// 3. T: Send 保证数据本身可以跨线程转移;T: Sync 保证 &T 可以共享。
unsafe impl<T: Send + Sync> Send for MyArc<T> {}
unsafe impl<T: Send + Sync> Sync for MyArc<T> {}

impl<T> Clone for MyArc<T> {
    fn clone(&self) -> Self {
        // SAFETY: ptr 由构造时的 Box 保证有效;
        // 只要本对象存活,target 一定未被释放(引用计数 >= 1)
        let inner = unsafe { self.ptr.as_ref() };
        inner.count.fetch_add(1, Ordering::Relaxed);
        MyArc { ptr: self.ptr }
    }
}

对比一下 concread 的问题代码,差别只有两处:

  1. 计数用了 AtomicUsize(不是 usize);
  2. unsafe impl 带上了 T: Send + Sync 的 bound。

就这两处,决定了这个类型是"安全的抽象"还是"一个能被 safe 代码触发的 UB 开关"。


八、什么时候该换 cxx:Rust--C++ 三角模型

到这里,bindgen 路线已经讲完了。但如果你的目标是一个惯用的 C++ API (有类、有 unique_ptr、有异常、有继承),那么 bindgen 会变得相当别扭。这时候该认识一下 cxx

8.1 三角形的直觉

cxx 官方文档给了一个我觉得最有助于建立直觉的心智模型:把 Rust、C、C++ 想成三角形的三个顶点,边长代表两种语言在库设计上的相似度
#mermaid-svg-fxIzhFqnEvTB3Y66{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fxIzhFqnEvTB3Y66 .error-icon{fill:#552222;}#mermaid-svg-fxIzhFqnEvTB3Y66 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fxIzhFqnEvTB3Y66 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .marker.cross{stroke:#333333;}#mermaid-svg-fxIzhFqnEvTB3Y66 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fxIzhFqnEvTB3Y66 p{margin:0;}#mermaid-svg-fxIzhFqnEvTB3Y66 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster-label text{fill:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster-label span{color:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster-label span p{background-color:transparent;}#mermaid-svg-fxIzhFqnEvTB3Y66 .label text,#mermaid-svg-fxIzhFqnEvTB3Y66 span{fill:#333;color:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .node rect,#mermaid-svg-fxIzhFqnEvTB3Y66 .node circle,#mermaid-svg-fxIzhFqnEvTB3Y66 .node ellipse,#mermaid-svg-fxIzhFqnEvTB3Y66 .node polygon,#mermaid-svg-fxIzhFqnEvTB3Y66 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .rough-node .label text,#mermaid-svg-fxIzhFqnEvTB3Y66 .node .label text,#mermaid-svg-fxIzhFqnEvTB3Y66 .image-shape .label,#mermaid-svg-fxIzhFqnEvTB3Y66 .icon-shape .label{text-anchor:middle;}#mermaid-svg-fxIzhFqnEvTB3Y66 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .rough-node .label,#mermaid-svg-fxIzhFqnEvTB3Y66 .node .label,#mermaid-svg-fxIzhFqnEvTB3Y66 .image-shape .label,#mermaid-svg-fxIzhFqnEvTB3Y66 .icon-shape .label{text-align:center;}#mermaid-svg-fxIzhFqnEvTB3Y66 .node.clickable{cursor:pointer;}#mermaid-svg-fxIzhFqnEvTB3Y66 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .arrowheadPath{fill:#333333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fxIzhFqnEvTB3Y66 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fxIzhFqnEvTB3Y66 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fxIzhFqnEvTB3Y66 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster text{fill:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 .cluster span{color:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fxIzhFqnEvTB3Y66 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fxIzhFqnEvTB3Y66 rect.text{fill:none;stroke-width:0;}#mermaid-svg-fxIzhFqnEvTB3Y66 .icon-shape,#mermaid-svg-fxIzhFqnEvTB3Y66 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fxIzhFqnEvTB3Y66 .icon-shape p,#mermaid-svg-fxIzhFqnEvTB3Y66 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fxIzhFqnEvTB3Y66 .icon-shape .label rect,#mermaid-svg-fxIzhFqnEvTB3Y66 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fxIzhFqnEvTB3Y66 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fxIzhFqnEvTB3Y66 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fxIzhFqnEvTB3Y66 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 最短边

所有权 / 不可失败性 / 集合类型

概念高度对应
长边:C++ 降到 C

手动处理内存布局、异常、析构
长边:C 升到 Rust

大量 unsafe + 裸指针
bindgen 走的路(最长两条边)
惯用 C++ API
手写 C 风格包装

裸指针 + 手工布局
bindgen 生成的裸声明
再手写一遍 safe 封装
Rust
C++
C

关键洞察在这里:当你用 bindgen 去绑定一个惯用 C++ API 时,你实际上是在走三角形的两条最长边------先从 C++ 沉到 C,再从 C 升回 Rust。 而这两条边全是 unsafe 地带。

而且 bindgen 对 C++ 的支持是"尽力而为"的(官方话说得比较直接:它想为所有东西生成声明,所以任何它没实现的 C++ 细节,运气好就是崩溃,运气不好是静默地生成一个不兼容的签名,然后在运行时做任意内存不安全的事)。

文档给的具体例子很有代表性:std::unique_ptr 虽然"就是个指针",但它的 ABI 不等同于一个指针或"含指针的 C 结构体",它无法用 Rust 直接表达。于是你会遇到"程序有时会 segfault"这种最难查的 bug。

8.2 cxx 的做法:把共享认知抬到"惯用 API"层

cxx 的思路完全不同:它不经过 C,而是直接在 Rust 和 C++ 之间架桥,并且用静态分析保证两侧签名一致。

rust 复制代码
#[cxx::bridge]
mod ffi {
    // Rust 侧是"事实来源"
    extern "Rust" {
        type MultiBuf;
        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }

    // C++ 侧是"事实来源"
    unsafe extern "C++" {
        include!("example/include/blobstore.h");

        type BlobstoreClient;
        fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
        fn put(self: &BlobstoreClient, buf: &mut MultiBuf) -> Result<()>;
    }
}

这个 bridge 模块最有意思的地方是:blobstore.cc 里是 100% 正常的 C++ 代码,main.rs 里是 100% 正常的 Rust 代码 ------没有 extern "C"、没有 #[no_mangle]、没有裸指针(cxx 会在需要的地方自动插入 shim)。

代价是什么?签名写两遍(一遍在实现处,一遍在 bridge 里)。但因为 bridge 里的声明会被编译期静态断言校验,两边永远不会静默漂移。用一点"重复"换掉了"全程 unsafe",这笔交易通常划算。

cxx 还内建了标准库类型的绑定(StringVecBoxunique_ptrCxxString 等),并且承诺零或可忽略的开销:不复制、不序列化、不额外分配、不需要运行时检查。

8.3 选型对照

维度 bindgen + cbindgen cxx
适用目标 本质上是 C 的代码(SQLite、OpenSSL 这类) 惯用的 C++ API(类、模板、智能指针)
签名来源 从 C 头文件生成,单一事实来源 手写 bridge,两侧静态断言校验
危险面积 大(C 风格签名全程 unsafe) 小(常见 FFI 场景可以 100% 安全代码)
典型踩坑 布局不符、unique_ptr ABI 不兼容 → 静默错误 表达能力有限制(设计上有意保守),部分签名无法表达
混合使用 --- 官方明确认可:95% 走 cxx,剩下几个"怪签名"仍用 bindgen 处理

选型的判断法则很简单:看你要绑的代码本质上是 C 还是 C++。

  • 源码是 .c,头文件里只有函数、结构体、枚举 → bindgen 足够;
  • 头文件里有 class、模板、继承、std::unique_ptr、异常 → 认真考虑 cxx

关于 cxx 有一个透明说明:它的设计是刻意保守和固执的 。你会遇到"这个签名 cxx 表达不了"的情况。这时候不要试图绕过去,按官方建议混用即可------大部分走 cxx,少数怪异的走传统 bindgen 路线。


九、真实生产数据:Rust FFI 的收益账

方法论讲完了,最后上数据。以下案例都可以追溯到公开来源(链接见文末),这里只提炼数字和可复用的经验

组织 / 场景 做法 量化收益 可复用的经验
Dropbox 桌面同步引擎 Python + C++ 的部分模块用 Rust 重写,保留下沉到 FFI 的集成方式 Smart Sync CPU 占用 -25% ;文件索引延迟改善约 30%~50% ;覆盖 5 亿+ 设备;900k 行规模下内存相关 bug -70% 从性能瓶颈最明显、基础设施成本最高的模块下手,不要全面重写
Dropbox Capture 桌面端 自家 Rust 库替换一批 shell 调用的第三方库,经 Neon 绑定给 TypeScript 用 消除进程启动开销;macOS 上省掉一个约 17 MB 的 Swift 库;错误处理从"解析 stderr"变成结构化返回 "解析子进程 stdout 判断成功失败"这种反模式,是 FFI 化的高价值目标
Discord Read States 服务 Go → Rust 重写 消除了 Go GC 每约 2 分钟造成的延迟尖峰,延迟曲线变平坦,CPU 占用大幅下降 对延迟敏感的服务,GC 的抖动往往比平均延迟更致命
1Password 密码学与同步引擎用 Rust;一套代码经 FFI 用于 iOS/Android/Windows/macOS/Linux 安全逻辑写一次、全平台复用 安全敏感的共享逻辑是 FFI 最佳落点:既统一了实现,也统一了审计面
某图像处理团队(12k 行遗留 C 库绑定) 手写绑定 → bindgen 生成 + 安全封装;cbindgen 反向导出;CI 加 Miri p99 从 2.4s → 120ms (-95%);FFI 相关 on-call 事故 14 起/月 → 0 起unsafe 代码 210 行 → 32 行 (-85%);C API 变更适配时间 6 小时 → 8 分钟 unsafe 行数是个可管理的工程指标;自动化生成绑定顺带解决了"适配 C API 变更"这个长期成本
Amazon Firecracker Rust 实现的 microVM 约 5 万行代码,亚秒级启动 内存安全直接等于更小的攻击面,这对安全边界组件是本质收益
Google Android 逐步用 Rust 重写内存不安全组件 内存安全类漏洞占比从 2019 年的 74% 降到 2024 年的 24% 大规模代码库的改造是长周期投入,量级收益需要多年累积

有几个数据我要额外加一句风险提示,这比数字本身更重要:

  1. Dropbox 明确记录了 double-free 的早期事故。收益是真的,"FFI 要用纪律换"也是真的。这两件事不矛盾。
  2. Parity 的以太坊客户端(约 100 万行 Rust)有一个著名的反面教训unsafe 使用过度导致了 double-free 等严重漏洞,当时的事故根源就是"裸指针绕过了所有权模型"。它的结论被总结成一句话,我完全同意:能用 safe Rust 完成的任务,就不要写 unsafe
  3. 数字要看清口径。上表里有的是"CPU 占用",有的是"延迟",有的是"事故数"。不要混着比较,也不要拿去做跨案例的加权平均------它们的分母完全不同。

从这些案例里,能提炼出三条真正可迁移的判断:

一、收益最大的位置是"性能瓶颈 + 已有正确实现"的交集。 Dropbox 从同步引擎下手、Capture 从 shell 调用下手,都不是因为"Rust 很酷",而是因为那里既有明确的成本,又有可参照的正确行为。

二、FFI 化真正的成本不在写代码,而在边界契约。 上表里耗时最长的环节全是"搞清楚谁负责释放"、"对齐结构体布局"、"核对错误码语义"。这些工作不做,收益会以事故的形式还回去。

三、可测量的东西才管得住。 unsafe 行数、FFI 事故数、Miri 通过率、头文件语法检查通过率------这些都是能进 CI 的指标。凡是进不了 CI 的纪律,三个月后就会失效。

9.1 把上面那张表里的"反模式"具体化

表里 Dropbox Capture 那行提到的"解析子进程 stdout 判断成功失败",是很多桌面端 / 客户端项目的历史包袱。它到底坑在哪?看两段代码就清楚了。

改造前:靠 shell 调用 + 解析文本

rust 复制代码
/// ❌ 反面:每次截图都 fork 一个进程,然后靠文本判断结果
fn screenshot_shell(display: u32) -> Result<Vec<u8>, String> {
    let output = std::process::Command::new("screenshot-tool")
        .arg("--display")
        .arg(display.to_string())
        .output()
        .map_err(|e| e.to_string())?;

    // 问题一:每次调用都要 fork + exec,这个固定开销躲不掉
    // 问题二:靠 stderr 里有没有某个英文短语来判断失败类型
    if !output.status.success() {
        let msg = String::from_utf8_lossy(&output.stderr).to_string();
        if msg.contains("no such display") {
            return Err("DISPLAY_NOT_FOUND".to_string());
        }
        return Err(format!("SCREENSHOT_FAILED: {msg}"));
    }
    Ok(output.stdout)
}

这段代码有四个问题,而且是叠加的:

  1. 固定开销 :每调用一次就 fork + exec 一次。截图这种高频操作,光进程启动就吃掉可观的时间。
  2. 错误靠字符串匹配msg.contains("no such display") 依赖工具的输出文案。工具升级改了措辞、或者系统语言变了,你的错误分支就静默失效------失败会被误判成成功
  3. 错误类型被拍平成 String :调用方拿到的是一段自由文本,没法做 match,只能继续 contains。错误处理在层层向上传递中被不断劣化。
  4. 潜在注入风险 :这里因为 display 是数字还好,但用 shell 拼接参数的模式一旦引入字符串,很快就会变成注入漏洞。

改造后:常驻库调用 + 结构化错误码

rust 复制代码
/// ✅ 正面:错误码是契约,不是文案
#[derive(Debug, PartialEq)]
pub enum CaptureError {
    DisplayNotFound,
    PermissionDenied,
    Other(i32),
}

unsafe extern "C" {
    /// 返回值约定:0 成功;-1 显示器不存在;-2 权限不足;其他为未知错误
    fn capture_shot(display: u32, out: *mut c_uchar, out_len: usize) -> c_int;
}

pub fn capture(display: u32, buf: &mut [u8]) -> Result<(), CaptureError> {
    // SAFETY: buf.as_mut_ptr() 指向 buf.len() 字节的可写缓冲区,
    // 且调用期间缓冲区独占(&mut 借用)
    let rc = unsafe { capture_shot(display, buf.as_mut_ptr() as *mut c_uchar, buf.len()) };
    match rc {
        0 => Ok(()),
        -1 => Err(CaptureError::DisplayNotFound),
        -2 => Err(CaptureError::PermissionDenied),
        other => Err(CaptureError::Other(other)),
    }
}

对照一下收益来自哪里:

shell 调用 FFI 库调用
调用开销 每次 fork + exec 一次普通函数调用
失败判定 匹配 stderr 英文文案 数字错误码,与文案无关
错误类型 拍平成 String 强类型 enum,可 match
依赖 系统里必须装那个 CLI 工具 只依赖链接进来的库
可测试性 要 mock 子进程 可以替换成纯 Rust 的 mock 实现

注意最后一行,这和第六章的 Miri 是同一个技巧 :因为 FFI 边界被收敛成了一两个函数,你可以在测试里把 capture_shot 换成一个纯 Rust 的替身,然后把所有错误分支(-1、-2、-99)都跑一遍。而 shell 版本要做到这一点,得往 PATH 里塞一个假的可执行文件。

再回到 Dropbox 那组数据:macOS 上省掉约 17 MB 的 Swift 库、消除进程启动开销、错误处理从"解析 stderr"变成结构化返回------这三件事不是三个独立收益,而是"把边界从进程降到函数"这一个动作的三个侧面。 这也印证了第一条判断:收益最大的位置,是那些"已经有正确行为、但实现方式代价很高"的地方。


十、总结思考

回到开头三个事故。它们的共同点是:出问题的都不是"写了 unsafe",而是"边界没划清"------tokenizers 把外部机制的保护当成了类型的保证,concread 把能力授予写成了注释,Dropbox 早期让所有权在边界上悬空。

四道闸门,本质上是从四个维度反复收窄这个边界:
#mermaid-svg-SUC7ioO1NKDHJZk2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SUC7ioO1NKDHJZk2 .error-icon{fill:#552222;}#mermaid-svg-SUC7ioO1NKDHJZk2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SUC7ioO1NKDHJZk2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .marker.cross{stroke:#333333;}#mermaid-svg-SUC7ioO1NKDHJZk2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SUC7ioO1NKDHJZk2 p{margin:0;}#mermaid-svg-SUC7ioO1NKDHJZk2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster-label text{fill:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster-label span{color:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster-label span p{background-color:transparent;}#mermaid-svg-SUC7ioO1NKDHJZk2 .label text,#mermaid-svg-SUC7ioO1NKDHJZk2 span{fill:#333;color:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .node rect,#mermaid-svg-SUC7ioO1NKDHJZk2 .node circle,#mermaid-svg-SUC7ioO1NKDHJZk2 .node ellipse,#mermaid-svg-SUC7ioO1NKDHJZk2 .node polygon,#mermaid-svg-SUC7ioO1NKDHJZk2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .rough-node .label text,#mermaid-svg-SUC7ioO1NKDHJZk2 .node .label text,#mermaid-svg-SUC7ioO1NKDHJZk2 .image-shape .label,#mermaid-svg-SUC7ioO1NKDHJZk2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-SUC7ioO1NKDHJZk2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .rough-node .label,#mermaid-svg-SUC7ioO1NKDHJZk2 .node .label,#mermaid-svg-SUC7ioO1NKDHJZk2 .image-shape .label,#mermaid-svg-SUC7ioO1NKDHJZk2 .icon-shape .label{text-align:center;}#mermaid-svg-SUC7ioO1NKDHJZk2 .node.clickable{cursor:pointer;}#mermaid-svg-SUC7ioO1NKDHJZk2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .arrowheadPath{fill:#333333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SUC7ioO1NKDHJZk2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SUC7ioO1NKDHJZk2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SUC7ioO1NKDHJZk2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster text{fill:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 .cluster span{color:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SUC7ioO1NKDHJZk2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SUC7ioO1NKDHJZk2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-SUC7ioO1NKDHJZk2 .icon-shape,#mermaid-svg-SUC7ioO1NKDHJZk2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SUC7ioO1NKDHJZk2 .icon-shape p,#mermaid-svg-SUC7ioO1NKDHJZk2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SUC7ioO1NKDHJZk2 .icon-shape .label rect,#mermaid-svg-SUC7ioO1NKDHJZk2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SUC7ioO1NKDHJZk2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SUC7ioO1NKDHJZk2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SUC7ioO1NKDHJZk2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 边界收敛

two-crate + RAII 句柄

→ unsafe 位置固定
② 类型契约

repr(C) / CString / 回调 / panic

→ 数据形态可预测
③ 语法契约

unsafe extern / unsafe(no_mangle)

→ 责任写进签名
④ 自动验证

bindgen / Miri / geiger

→ 不靠人眼
可审计的 unsafe 区域

小、固定、可测量、可回归

提炼成五条能带走的行动准则:

1. unsafe 的合法目标只有一个:造一个 safe 的抽象。 判断一段 unsafe 写得对不对,看的不是它有没有 // SAFETY 注释,而是------它的公开 API 是否在所有安全路径上都维护了那条不变量 。如果一条不变量只能靠"调用者承诺"来维持,那它就必须出现在 unsafe fn 的签名或 unsafe 的构造函数上,而不是文档里。

2. 用类型系统承载约束,不要用注释承载约束。 concread 的 bug 最后是怎么修的?加一个 V: Send bound------这不是"补文档",而是让编译器承担起检查责任。CStr 参数、NonNullResult 返回值,全都是同一个思路:把"我希望调用者这么做"变成"编译器不允许他不这么做"。

3. unsafe impl Send/Sync 是能力授予,不是注释。 写之前跑一遍那个三问决策树。特别是:如果某个类型同时拥有"可以从安全代码自由构造"和"手写 Send"这两个特征,那基本可以判定为 soundness bug,除非同步机制是你这个类型自己提供的。

4. 工具要装到位,但要知道边界。 Miri 能查出"在你机器上恰好没崩"的 UB,这是别的工具做不到的;但它读不懂 C 代码 ,所以 FFI 项目里它只能覆盖 Rust 侧。cargo-geiger 给的是信息不是判决,libc 里一堆 unsafe 完全正常。把工具的适用范围记清楚,比把工具跑起来更重要。

5. 从能测量的地方开始。 别一上手就想着"全项目零 unsafe"。先跑一次 cargo geiger,看清楚现状;给自己的 crate 加上 #![forbid(unsafe_code)] 把那道物理边界立起来;把 unsafe 行数、Miri 通过率、生成头文件的语法检查写进 CI。指标一旦可测量,治理就变成了工程问题而不是意志力问题。

最后说一句可能有点反直觉的话:Rust 的 unsafe 不是这个语言的失败,恰恰是它最诚实的设计。 它把"这里有一段逻辑,编译器没法证明它安全"这件事明确标注出来,而不是像 C 那样让整个项目都处在"编译器什么都不保证"的状态。所以真正该追求的目标从来不是"零 unsafe",而是------让每一处 unsafe 都小到能用肉眼看完,都固定到不会随手改动,都被工具覆盖到能被回归测试。

做到这三点,unsafe 就从一个定时炸弹,变回了它本该扮演的角色:一个被关在笼子里、但确实是必要的工具。

相关推荐
步行cgn2 小时前
Spring Boot 将配置绑定到第三方对象详解
spring boot·后端·python
傻啦嘿哟9 小时前
某招聘平台爬虫:爬取招聘岗位数据,分析各城市薪资水平
开发语言·爬虫·python
2501_933670799 小时前
2026秋招量化分析岗技能栈:Python、SQL、统计建模、回测项目怎么准备
开发语言·python·sql
传奇开心果编程10 小时前
【xilem0.4基础语法学与练】第13课:Xilem 0.4 最简短代码体现“一切皆设计图“
学习·rust·前端框架
李少兄10 小时前
JavaScript 数据类型完全指南
开发语言·javascript·ecmascript
码事漫谈10 小时前
多人共用一个 key,缓存命中率会不会因此降低?
后端
Seoyoneh11 小时前
Agentic Workflow编排架构:云客服从“被动响应”迈向“主动执行”的技术实现
java·开发语言·架构
Tangyuewei11 小时前
Java 写 Agent:模型只是组件
java·开发语言
考虑考虑11 小时前
docker compose环境变量替换
运维·后端·自动化运维