第十五章 unsafe 与 FFI:编译器把钥匙交还给你时说了什么

你有没有这样一种时刻:明知道脚下那一步是安全的,编译器却死活不让你迈——因为它没法替你证明。这时候你是希望它永远替你挡着,还是希望它能说一句”行,这步我信你,钥匙给你,但出了事你得自己担”?

我是墨叔,机房里泡了二十年。Rust 最容易被误读的两个词,一个是”所有权”,另一个就是”unsafe”。太多人把 unsafe 当成”终于可以关掉那个烦人的编译器了”,于是大笔一挥,把整段逻辑都裹进 unsafe 块里,仿佛拿到了免死金牌。错得离谱。

这一章我们要把 unsafe 的真面目扒开。它既不是”编译器下班了”,也不是”规则失效了”。它是一份非常具体的、范围极窄的”担保书”:你向编译器保证,下面这几行里,那些编译器自己无法验证的 invariants(不变量),你手动验过了。编译器信你这一回,但它从没真的闭上眼睛。

一、先想清楚:为什么 Rust 还需要 unsafe

有人会问:你不是说 Rust 编译期就能保证内存安全吗?那还要 unsafe 干啥?这不是自己打脸?

别急,咱们把话摊开。Rust 的保证有一个前提:它只对”它看得懂的代码”做保证。问题是,真实世界的程序,有三种情况编译器永远没法替你证明:

第一种,是和硬件、操作系统打交道。比如你要写一个往特定内存地址写数据的驱动,或者操作一块由外部设备映射进来的寄存器。那个地址里有什么、什么时候能写、写完会不会炸,编译器一无所知——它连那块内存的存在都不知道,怎么证明安全?

第二种,是和外部代码(C 库、系统调用)交互。你调用一个 C 写的函数,传进去一个指针。C 函数内部干了什么,Rust 编译器看不见、也不懂。它没法验证”C 那边会不会在我还用着的时候把内存释放了”。这个信任缺口,只能由你人来填。

第三种,是为了极致的性能或表达力,做一些编译器不敢自动做、但你知道没问题的底层操作。比如在某些 hot path 上,你明确知道两个引用指向的不是同一块内存,于是想跳过借用检查器的别名分析,直接上手。

你看,这三种情况有一个共同点:它们不是”我懒得写安全代码”,而是”这件事的安全性的确超出了静态分析的能力边界”。Rust 没打算假装自己全知全能。它坦诚地留了一道门——unsafe——但门上写着”进门者自负”。

打个比方。Rust 的编译期检查像是地铁的安检:所有人、所有行李都要过一遍,绝大多数危险被挡在外面。但安检员也有看不透的东西,比如某个密封的工业集装箱,里面按规定是合规设备,安检员没法拆开逐件查。这时候他要你签个字——“我确认这箱子里没有违禁品”——然后放行。签字不等于免检,只是责任转移到了你头上。你签了字还往里塞炸药,出事的是你,不是安检员。

所以 unsafe 的第一性原理,墨叔给你一句话:unsafe 不取消任何规则,它只是把”维持这些规则的证明责任”从编译器肩上,临时挪到了你肩上。

二、unsafe 到底给你哪五种”超能力”

很多人以为 unsafe 块里”啥都能干”。其实不然。Rust 把 unsafe 能突破的检查,精确列成了五类。除了这五类,unsafe 块里你照样受所有正常规则约束——你依然不能写出类型不匹配的表达式,依然不能凭空制造一个不存在的值。

哪五类?一个一个拆。

1. 解引用裸指针(raw pointer)

*const T 和 *mut T 就是裸指针。它们和 &T/&mut T 不同:裸指针没有任何借用规则保护,你可以同时持有多个指向同一处的 *mut T,编译器不管。但解引用它,必须在 unsafe 里进行。

let mut x = 42;
let r: *mut i32 = &mut x;          // 创建裸指针本身是安全的
// *r = 100;                       // 编译不过:解引用裸指针是 unsafe 操作
unsafe {
    *r = 100;                      // 在 unsafe 块里才允许
    println!("x = {}", *r);
}

注意一个微妙点:创建裸指针这步本身是 safe 的。危险的是”解引用”这个动作——因为你没法保证 r 指向的内存还合法、还没被别人释放、没有被别的写者同时改。这些,编译器交给你自己担保。

2. 调用 unsafe 函数或方法

有些函数,光看签名看不出危险,但它的契约(contract)里藏着前提条件。比如 std::slice::from_raw_parts,它要求你给一个指针和一个长度,然后凭空造出一个切片。

use std::slice;
 
fn build_slice() {
    let data = vec![1, 2, 3, 4];
    let ptr = data.as_ptr();
    let len = data.len();
    unsafe {
        // 前提:ptr 必须指向 len 个有效、已初始化的 T,且生命周期至少覆盖返回的切片
        let s: &[i32] = slice::from_raw_parts(ptr, len);
        println!("{:?}", s);
    }
}

你调用它之前,必须确保那些隐藏前提成立。函数头上写着 unsafe fn,就是在喊:“想用我?先答应我那几个条件。“应答的方式,就是把调用包进 unsafe。

3. 实现一个 unsafe trait

有些 trait 的契约,光靠类型系统保证不了,必须靠实现者的良知。最典型的就是标准库里的 Send 和 Sync——它们规定”我这类型能不能安全地跨线程传 / 共享”。大部分类型编译器能自动推导,但少数类型(比如内部包了裸指针的自定义结构)需要你手动 unsafe impl。

// 假设你写了一个裸指针包装,且你确信它跨线程是安全的
struct MyPtr(*mut i32);
 
unsafe impl Send for MyPtr {}   // 我向编译器担保:跨线程移动它是安全的
unsafe impl Sync for MyPtr {}   // 我向编译器担保:多线程共享引用它是安全的

这里的风险极高:你一旦担保错了,数据竞争就回来了,而且发生在运行时,编译期毫无察觉。所以 unsafe impl 是最该谨慎对待的一处——你其实是在对编译器的类型系统”撒一个善意的谎”,而它选择信你。

4. 访问或修改可变静态变量(mutable static)

static mut 是全局可变状态。在 Rust 眼里,全局可变 = 所有人的共享可变状态 = 数据竞争的温床。所以读写它必须进 unsafe。

static mut COUNTER: u32 = 0;
 
unsafe {
    COUNTER += 1;               // 读改写全局变量,必须 unsafe
    println!("count = {}", COUNTER);
}

墨叔劝你一句:能不用 static mut 就别用。它本质是把并发安全的所有包袱都甩给自己。现代 Rust 更推荐 lazy_static / OnceCell / atomic 这类东西来替代——它们在内部用 unsafe 把危险封死,对外只暴露安全接口。

5. 访问 union 的字段

union 是多个字段共用同一块内存(像 C 的 union)。你读哪个字段,编译器不知道那块内存当前”真正存的是不是那个类型”,这 unsafe。

union Number {
    i: i32,
    f: f32,
}
 
let mut n = Number { i: 0 };
unsafe {
    n.f = 3.14;                 // 写 f 字段
    println!("as i32: {}", n.i); // 读 i 字段:结果是 3.14 的字节 reinterpret,无意义但合法
}

union 基本只在和 C 结构做 FFI 打交道、或在做底层位操作时才用。日常业务代码你几乎不会碰它。

三、unsafe 不是”关掉检查”,是”我向你保证”

把上面五条合起来看,你会发现一件重要的事:unsafe 块内外,Rust 的正常规则一条都没少。你依然不能写出悬垂引用、不能类型乱配、不能凭空构造值。unsafe 仅仅解锁了那五件编译器无法静态验证的事,而且每一件都附带一个”你先保证”的清单。

这就是为什么我说”关掉检查”是天大的误解。真要关掉检查,应该是”怎么写都行”。但 Rust 里,你把整个文件都改成 unsafe,该报的借用错误照样报。unsafe 解锁的是特定维度的信任,不是全面放水。

再换个比喻。你去银行金库,门禁系统平时只让持授权的员工进。某天你拿到了一次性的”陪同进入许可”(unsafe),保安陪你进去。进了金库,你依然不能去砸保险柜、不能把不属于你的金条装兜里——那些是法律(语言规则)管的,保安从来没说”进了门就法外之地”。许可只意味着”这个特定动作(进入金库)本次被允许”,职责边界清清楚楚。

所以写 unsafe 时,墨叔建议你脑子里始终悬着一句话:我不是在绕过规则,我是在为规则覆盖不到的盲区,亲手背书。

四、把 unsafe 圈在最小范围,用安全抽象封装

光知道 unsafe 是什么还不够,关键是”怎么用才不给自己埋雷”。这里有一条铁律,请你刻进肌肉记忆:

unsafe 代码的总量要尽可能小;所有 unsafe 都要被一层安全的、文档完备的接口包起来,对外只暴露 safe API。

为什么?因为 unsafe 的”保证”是靠人维持的。人写的保证,范围越大越容易出错。你把一整页逻辑都塞进 unsafe,就等于把整页都交给”我担保”,担保面积越大,漏检的概率越高。反过来,如果你只在一个三行的函数里用 unsafe,然后在它外面套一个正常函数,把契约写进文档注释,那么:

  • 调用者完全不需要碰 unsafe;
  • 需要人工审查的范围只有那三行;
  • 即使将来有人改了外层逻辑,也不会意外扩大危险面。

这叫做”安全抽象”(safe abstraction)。标准库本身就是这么做的典范:Vec::push 内部有 unsafe(要操作裸指针、要扩容重新分配),但你作为用户调 push 时,完全在 safe 世界里。Rust 的哲学是:unsafe 是地基里的钢筋,应该被混凝土封死在内部,而不是裸露在外墙让人摸。

写个示意,看看怎么把危险收口:

use std::slice;
 
// 对外暴露的安全函数:把两个切片拼接成一个新 Vec
// 内部确实会碰裸指针,但调用者无感知
fn concat(a: &[i32], b: &[i32]) -> Vec<i32> {
    let mut out = Vec::with_capacity(a.len() + b.len());
    unsafe {
        // 这里的每一句,我们都在注释里写明了为什么安全
        let dst = out.as_mut_ptr();
        std::ptr::copy_nonoverlapping(a.as_ptr(), dst, a.len());
        std::ptr::copy_nonoverlapping(b.as_ptr(), dst.add(a.len()), b.len());
        out.set_len(a.len() + b.len()); // 关键:手动告诉 Vec 真实长度,跳过零初始化
    }
    out
}

注意看 set_len 那行——它也是 unsafe 家族的近亲(这里通过安全包装暴露)。它跳过”把新内存清零”的步骤,性能更好,但前提是你确实已经把每个元素都写好了。这个前提,就由 concat 函数体内的那两行 copy_nonoverlapping 来履行。调用 concat 的人永远不需要知道这些肮脏细节,这就是封装的价值。

五、extern “C” 与 FFI:和 C 世界握手的心智

讲完 unsafe,自然要提 FFI(Foreign Function Interface,外部函数接口)。因为几乎所有调用外部 C 库的代码,都少不了 unsafe——这正是 unsafe 第二大类用武之地。

心智模型很简单:Rust 编译器对”Rust 函数”有一套调用约定(怎么传参、怎么清栈、怎么对齐返回值)。但 C 编译器有自己的约定,而且不同平台还不一样。要让两边的代码能互相调用,得显式声明”用 C 的规矩来”。这就是 extern "C"。

// 声明:我要调用一个外部 C 函数(链接时由 C 库提供)
extern "C" {
    // 假设系统有个 C 函数:int abs(int x);
    fn c_abs(x: i32) -> i32;
}
 
fn main() {
    unsafe {
        // 调 C 函数必须 unsafe:编译器没法验证 C 那边的契约
        println!("abs(-7) = {}", c_abs(-7));
    }
}

反过来,你也可以让 Rust 函数被 C 调用:在 Rust 端用 extern "C" fn 定义,再用 #[no_mangle] 保住函数名不被编译器改名,C 那边就能按名字找到它。

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b // 即使函数体是安全的,extern "C" 导出本身也意味着对外契约,故整体常包在可控边界
}

FFI 真正麻烦的不是语法,是两边的”世界观”对不上:

  • C 的字符串是 \0 结尾的 char*,Rust 的 String/&str 是带长度的。你不能直接把 &str 当 char* 传,得先用 .as_ptr() 拿到裸指针、并确保末尾有 \0。这一步一旦错了(比如忘了终止符),C 函数会一路读穿内存。
  • C 的指针可能是 NULL,Rust 的引用从不空。所以 FFI 边界上,裸指针满天飞,你必须用 Option<&T> 或显式检查来翻译。
  • 内存由谁释放?如果 C 函数 malloc 了一段内存交给你,Rust 的 drop 可不会用 free 去收它——释放得用 C 那边的函数。所有权在边界处断裂,需要你手写”桥”。

所以 FFI 的心智是:在 Rust 和 C 的边界上,画一条清晰的”责任分割线”。线这头,是 Rust 的安全世界,所有指针都被 Option、被智能指针、被契约包裹;线那头,是 C 的裸世界。unsafe 就站在线上的岗亭里,负责每一次交接时的”验货”。

这里墨叔要提醒一句:如果你写过 C,这里的”心智要重装”不在语法,而在责任感的归属。C 程序员习惯”指针爱传就传,崩了再说”;在 Rust 里做 FFI,你必须把 C 那边的每个前提,翻译成 Rust 这边的显式保证。你不是在写 C,你是在给 C 的行为做公证。

六、几个真正有用的实务建议

写到这儿,给你几条可落地的”军规”,都是机房里摔出来的教训:

第一,能不写 unsafe 就不写。先想清楚有没有 safe 的等价写法。现代 Rust 生态里,绝大多数需求都有现成的安全抽象(原子类型、RwLock、Cow、各种 crate),别为了酷去手写裸指针。

第二,unsafe 函数要写 # Safety 文档。在 rustdoc 里用 # Safety 段落,把”调用者必须满足哪些条件”逐条列清。这是给未来的你和你的同事看的担保清单。

第三,用 #[deny(unsafe_op_in_unsafe_fn)] 这类 lint 让 unsafe 函数体内的每个不安全操作也必须显式标出,进一步缩小盲区。

第四,FFI 边界优先用现成 crate。比如绑定 C 库,社区常有 *-sys 加安全封装的成熟方案(如 libc、openssl-sys),别重复造危险轮子。

说到底,unsafe 是 Rust 给你的最后一道逃生舱,也是它诚实的体现:它承认”静态分析有边界”,于是把边界外的责任,郑重地交还给你,并要求你签字。一个成熟的 Rust 工程师,不是”敢写多少 unsafe”,而是”能用多小的 unsafe,换来多大的安全”。

【思考题】

  1. 墨叔说”unsafe 不是关掉检查,是责任转移”。请你推一下:如果我把一段明明只动了 static mut 的代码,整个包进一个巨大的 unsafe { ... },和只把那一行 COUNTER += 1 单独放进 unsafe 块,这两种写法在”安全检查覆盖”上有什么本质区别?编译器在块外和块内,分别还管不管借用规则?

  2. 假设你实现一个自定义的裸指针包装 struct Wrapper(*mut i32),并手动 unsafe impl Send for Wrapper。请问”你向编译器担保的究竟是什么”?如果实际上这个指针指向的内存,在另一个线程 drop 后你还在用它,会发生什么——这个错误是编译期还是运行期暴露?为什么 unsafe trait 格外危险?

  3. FFI 边界上,Rust 的 &str 不能直接当 C 的 char* 传。请你推演:如果忘记在被传的字符串末尾补 \0 终止符,C 函数 strlen 或 printf 会怎么表现?这个 bug 属于”内存安全”里的哪一类经典漏洞?为什么这种错误 Rust 的编译期检查拦不住?

【参考答案】

  1. 关键区别在于”担保面积”。unsafe 块只是解锁那五类操作,但借用检查、类型检查、生命周期规则在块内外始终生效——即使你把整页代码都包进 unsafe,写出 let r = &x; let m = &mut x; 这种别名冲突,编译器照样报错。所以”把整段包进 unsafe”并不能让你绕过借用检查。但它的危害在于:它模糊了”到底哪几行需要人工担保”的范围。你本来只需为 COUNTER += 1 这一行的”全局可变竞争”负责;整段包起来后,审查者(包括未来的你)必须逐行判断”这里到底哪些前提是我担保的”,盲区被人为放大了。正确做法是把 unsafe 缩小到刚好覆盖那个不安全操作,让安全抽象把危险封死在最小面积内——审查范围小,出错概率就低。

  2. unsafe impl Send for Wrapper 担保的是:“把这个 Wrapper 移动到另一个线程是安全的”,即那个裸指针所指向的数据,在被多线程访问时不会出现数据竞争或悬垂。你本质上是在告诉类型系统”我确认这里没有别名/生命周期问题”,而编译器因为看不透裸指针,选择信任你的声明。如果事实上内存已被另一线程 drop、你还在用,那这就是 use-after-free——它在编译期完全不暴露,因为 unsafe impl 已经让编译器放行;错误只会在运行时表现为崩溃、数据损坏,甚至更难排查的偶发竞争。这就是为什么 unsafe trait 最危险:它直接伪造了类型系统的信任凭证,一旦担保错误,整套编译期安全网在你伪造的那一点上等于失效了,而且失效是静默的。

  3. C 的字符串函数依赖 \0 作为”结束哨兵”。若漏了终止符,strlen 会从你给的指针一路往后数,越过你实际数据的末尾,直到在内存更远处碰巧遇到一个值为 0 的字节才停——于是返回的长度远超真实长度;printf("%s") 同理会打印出大段相邻内存里的乱码。这属于越界读取(out-of-bounds read),是缓冲区溢出类漏洞的近亲(读方向的溢出)。Rust 编译期拦不住,是因为:你传给 C 的是一个 *const c_char 裸指针,Rust 对”那块内存后面有没有 \0”毫无信息——它既不知道 C 函数的契约,也看不见 C 那边的读取行为。这个不变量超出了静态分析边界,正是 unsafe/FFI 必须人工担保的典型盲区。正确做法是使用 CString::new(...).unwrap().as_ptr(),由 CString 自动在末尾补 \0 并拥有内存,把”终止符”这个前提固化进安全封装里。

小结

unsafe 不是把编译器关掉,而是把”无法静态验证的那几处不变量”的证明责任,由编译器肩上临时挪到你肩上,并精确限定为五类操作。高手的分寸,从不是敢写多少 unsafe,而是能用多小的 unsafe 换来多大的安全封装。