楼层: 首页/ 软件技术/ Rust 语言基础/ unsafe、FFI 与并发内存模型:把安全边界讲清楚
23

unsafe、FFI 与并发内存模型:把安全边界讲清楚

Unsafe Rust · FFI · Memory Model

第 17 章你已经见过 unsafe 长什么样。但"见过"和"敢在项目里用"之间隔着一整套认知:unsafe 到底解除了什么约束、Send/Sync 凭什么能自动推导、&T 明明不可变为什么 Cell 还能改、C 那边传过来的指针该怎么安全地接住。这一章按"先建立心智模型,再动手写 FFI"的顺序展开,最后给你一份 UB 清单——写 unsafe 的人手里必须有一份。

unsafe 到底解锁了什么:四种超能力 + 一个容易忘的

关键认知先摆在这里:unsafe 不是一个"关闭安全检查"的开关。进了 unsafe 块,借用检查、类型检查、生命周期检查全都照常工作。它只做一件事:允许你做编译器无法验证的那几件事,代价是"正确性由你负责"。

超能力具体操作你要自己保证什么
① 解引用裸指针 *const T / *mut T 的读写 指针非空、已对齐、指向有效内存、没有被别人可变借用(别名规则)。这四条少一条都是 UB。
② 调用 unsafe 函数 调用 unsafe fn、extern 函数、unsafe 方法 满足被调函数在文档里写明的"安全前置条件"(safety contract)。这些条件不会在类型里体现,只能读文档。
③ 访问可变静态变量 读写 static mut static mut 没有同步保证,跨线程访问就是数据竞争。正确做法是改用 AtomicXxx、OnceLock、Mutex。
④ 实现 unsafe trait unsafe impl Send for X {} 编译器无法验证的"语义承诺",比如"我的裸指针内部其实有锁保护"。
⑤ 访问 union 字段 u.i32_field union 不做类型检查,读到的可能是"用另一种类型写进去的位模式",写错就是 UB。

四种超能力的最小示例(每一行都要能说清"我为什么是安全的")

use std::mem; use std::os::raw::{c_int, c_void}; // ① 解引用裸指针:这里的安全依据是"i 是栈上的局部变量,活得好好的" fn raw_pointer() { let mut i: i32 = 7; let p: *mut i32 = &mut i; unsafe { *p += 1; assert_eq!(*p, 8); } } // ② 调用 unsafe 函数:rustc 会警告你 `unsafe_op_in_unsafe_fn`(edition 2024 默认警示) unsafe fn read_raw(p: *const i32) -> i32 { // 最佳实践:函数体里再套一层 unsafe,并写 SAFETY 注释说明依据 // SAFETY: 调用方承诺 p 非空、对齐、指向有效 i32 unsafe { *p } } // ③ 可变的全局状态:不要用 static mut,用原子类型 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); fn bump() { // 不需要 unsafe,不需要锁,跨线程安全 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } // ④ unsafe impl:这里宣称"我的内部状态本身已加锁" struct MyHandle { inner: *mut c_void, _lock: std::sync::Mutex<()> } // SAFETY: inner 指向的内存由 C 库管理且内部自旋锁保护,所有访问都先拿到 _lock unsafe impl Send for MyHandle {} unsafe impl Sync for MyHandle {}

论为什么 unsafe fn 的"前置条件"不能靠类型表达

① 类型系统能表达的是"结构性事实":这个值是不是空、生命周期够不够长、有没有被同时可变借用。它表达不了"这个 *const c_char 是不是 NUL 结尾""这个 fd 是不是已经关了""这个数组的第 N 个元素是不是已初始化"。

② 所以 Rust 的约定是:unsafe fn 的文档必须有一个 # Safety 段落,写清调用方要满足什么。这是纯社会契约,编译器不检查,但 Code Review 和 clippy::missing_safety_doc 会检查。你在自己的库里写 unsafe fn 时,不写这段文档等于给别人埋雷。

③ 更重要的工程习惯:能封装就封装。一个 crate 里所有 unsafe 应该集中在少数几个模块里,对外只暴露 safe fn。判断标准是"如果我把 unsafe 块里的代码原样复制出去,它还敢保证安全吗"——不敢,就说明抽象边界立对了;敢,说明这个 unsafe 块其实是多余的。

坑:unsafe 块不是"免责声明",写错照样 UB

最常见的三类 UB,都发生在"看起来很正常"的代码里:

① 别名违规。同时存在 &mut T 和 &T(或两个 &mut)的时期重叠,哪怕它们指向不同元素。这是 Vec::as_mut_ptr 类代码最爱踩的坑,而且通常不会崩,只会在某个优化等级下产生诡异的错误结果。

② 未初始化内存被读到。let x: u32 = unsafe { mem::uninitialized() }; 是纯 UB。0 值对 bool、char、引用、枚举都是"无效值"——构造出来就是 UB,不是"读到脏数据"那么简单。

③ 悬垂指针。Box::into_raw 之后忘 Box::from_raw 是泄漏(安全但浪费),反过来 from_raw 一个不是 Box::into_raw 来的指针就是 UB。中间态是 into_raw → from_raw → 再用裸指针,double free。

验证手段:cargo +nightly miri test(Miri 会在解释执行时抓 UB)、cargo +nightly carefy/cargo careful、以及 cargo test --release(有些 UB 只在优化后暴露)。

Send / Sync:编译器是怎么"自动推导"出并发安全的

Send 和 Sync 是自动 trait(auto trait):你从来不需要为普通类型手写它们,编译器会按规则推导。规则非常简单,但推论非常强。

trait语义推导规则与常见反例
Send 值可以转移到另一个线程 若 struct S { a: A, b: B } 且 A: Send、B: Send,则 S: Send。
反例:Rc<T>、*mut T、MutexGuard(在某些版本)。原因是引用计数非原子,跨线程会竞争。
Sync &T 可以在多个线程共享 若所有字段 Sync,则 S: Sync。
反例:Cell<T>、RefCell<T>、Rc<T>——它们能在只有 &T 的情况下改内部状态,多线程共享就会数据竞争。
两者关系 T: Sync 等价于 &T: Send 记住这条等价关系,很多困惑立刻消解:&T 能跨线程传递,当且仅当 T 是 Sync。
组合结论 Arc<T>: Send + Sync 要求 T: Send + Sync 所以 Arc<RefCell<T>> 不是 Sync,跨线程会编译报错;Arc<Mutex<T>> 才是标准答案(Mutex 把内部可变性做成了 Sync 的形态)。

用 PhantomData 精确控制自动推导的结果

use std::marker::PhantomData; use std::rc::Rc; // 场景:结构体里没有任何 Rc 字段,但语义上"绑定了线程局部的资源" struct ThreadLocalCache { data: Vec<u8>, // PhantomData 不占空间,但会参与 Send/Sync 的自动推导 // 因为 Rc<()> 不是 Send/Sync,这个结构体也就不是了 _not_send: PhantomData<Rc<()>>, } // 反过来:想强制"一定 Send",可以用 PhantomData<*const ()> 配合 unsafe impl // 但更清晰的写法是直接 unsafe impl 并写 SAFETY 注释 // 检验某个类型到底是不是 Send/Sync(编译期断言,写库时非常有用) fn assert_send<T: Send>() {} fn assert_sync<T: Sync>() {} fn check() { assert_send::<Arc<Mutex<Vec<u8>>>>(); assert_sync::<Arc<Mutex<Vec<u8>>>>(); // assert_send::<Rc<u8>>(); // 取消注释会编译失败,这就是"编译期单测" }

什么时候必须手写 unsafe impl Send

// 典型场景:把 FFI 句柄包起来,句柄本身是裸指针,编译器无法判断能否跨线程 struct ZlibHandle { ptr: *mut zlib_sys::Stream } // 编译器默认认为 *mut T 既不是 Send 也不是 Sync,所以下面两行是必需的 // SAFETY: 前提是【每个线程使用自己的 ZlibHandle 实例】,且 C 库不共享全局状态。 // 这个前提是我们的封装保证的(构造时按线程创建、生命周期内不跨线程共享同一实例)。 unsafe impl Send for ZlibHandle {} // 注意:我们【不】实现 Sync —— 同一个句柄不能被多线程共享,那才是危险的 impl Drop for ZlibHandle { fn drop(&mut self) { // SAFETY: ptr 一定由 zlib_sys::create 返回,且只在此处释放一次 unsafe { zlib_sys::destroy(self.ptr) }; } }
坑:随手 unsafe impl Send 是把编译器的安全网剪了

unsafe impl Send for X {} 的语义是"我向编译器保证 X 可以安全地移动到别的线程,出问题我自己负责"。它不会带来任何运行时检查,编译器此后完全不再审查这个类型。

判断要不要写,问自己两个问题:① 这个类型的字段里,有没有"非线程安全"的东西(Rc、RefCell、裸指针、第三方 C 句柄)?② 即使有,我的封装是否真的保证了跨线程访问的安全(有锁、按线程隔离、无共享状态)?第二个问题答不上来就不要写,改为让类型只在单线程内使用,出错时编译器会拦住你——那正是我们想要的。

还有一条经验:同时实现 Send + Sync 的风险远高于只实现 Send。Sync 意味着 &X 能在多线程共享,如果你没有真正的同步手段,这就是在制造数据竞争。

UnsafeCell / Cell / RefCell:内部可变性的三层设计

有一个反直觉的事实:&T 是不可变引用,但 Cell<T> 能在只有 &Cell<T> 的情况下修改里面的值。这看起来和"不可变引用"矛盾,实际上是编译器留的一个"后门",这个名字就叫 UnsafeCell。

三者是层层包装的关系,从最底层往上看

// 第 0 层:UnsafeCell —— 编译器唯一"放过"的类型 // 它是一个 #[repr(transparent)] 包装,唯一作用是告诉编译器: // "请【不要】对这块内存做 '内容不会被改' 的优化假设" pub struct UnsafeCell<T> { value: T } // 关键方法:拿到裸指针,剩下的安全责任全在你身上 impl<T> UnsafeCell<T> { pub const fn new(value: T) -> Self { UnsafeCell { value } } pub const fn get(&self) -> *mut T { ... } } // 第 1 层:Cell<T> —— 用"拷贝进出"取代"引用" // 因为不能给出 &mut T 到内部,所以只能整块取出来 / 整块放进去 let c = std::cell::Cell::new(10); c.set(c.get() + 1); // 要求 T: Copy c.replace(99); // 换掉并返回旧值 let old = c.take(); // 拿走,留下 Default::default() // Cell 不是 Sync:多线程共享 &Cell 改值就是数据竞争 // 第 2 层:RefCell<T> —— 把借用检查从编译期挪到运行期 let r = std::cell::RefCell::new(vec![1, 2, 3]); r.borrow_mut().push(4); // 运行时检查:"现在有没有别人在借用?" let len = r.borrow().len(); // 多个不可变借用可以共存 // 违规会 panic 而不是编译错误: // let a = r.borrow_mut(); let b = r.borrow_mut(); // panic: already borrowed
类型可变性检查时机Sync?适用场景
UnsafeCell<T>裸指针读写无(你自己保证)否实现自己的并发原语:锁、原子结构、无锁队列。日常业务不要直接用。
Cell<T>整取整存编译期(类型层面)否T: Copy 的计数器、标志位、状态机字段。零运行时开销,比 RefCell 快。
RefCell<T>借用进出运行期(panic)否单线程内需要"持有引用再修改"的场景:树结构、观察者、缓存。借用违规会 panic。
Mutex<T>加锁访问运行期(阻塞)是跨线程共享可变状态的标准答案。Arc<Mutex<T>> 是后端代码里出现频率最高的组合。
RwLock<T>读写锁运行期(阻塞)是读多写少。注意写饥饿与平台实现差异,别默认它一定比 Mutex 快。

论为什么编译器要靠 UnsafeCell 这个"标记"

① 编译器会基于 &T 做优化。看到 &T,rustc 可以假设"通过这个引用,这块内存不会被别人改",于是把多次读取合并、把值缓存在寄存器里(LLVM 的 noalias 属性就是干这个的)。如果允许 &Cell<T> 偷偷改内容而不告诉编译器,那些优化就会产生错误结果——这是 C 语言 const 语义被 cast 掉之后经典的 bug 来源。

② UnsafeCell 就是这个"告诉编译器"的手段。它的类型声明里带了 #[lang = "unsafe_cell"],编译器看到这个类型出现在字段中就关闭对该字段的 noalias 假设。所以一切的"内部可变性"都必须最终包含一个 UnsafeCell,否则就是 UB——哪怕你逻辑上真的没并发。Cell、RefCell、Mutex、AtomicU32、RwLock 内部都藏着它。

③ 反过来给你一个判据:想在 &self 方法里改状态,合法路径只有三条——用 Cell/RefCell(单线程)、用 Mutex/Atomic(多线程)、自己用 UnsafeCell 并说明为什么安全。如果你觉得"我直接 transmute 一下 &T 成 &mut T 就行",那不是捷径,那是 UB。

extern "C" 与 repr(C):跨语言的第一道门槛

FFI 有两类需求:导出(Rust 写库给 C/Python/Go 调)和导入(Rust 调 C 库)。两者的语法都是 extern "C",但陷阱完全不同。

导出:Rust 实现、C 调用

// lib.rs —— 编译成 cdylib 供 C 使用 // Cargo.toml: [lib] crate-type = ["cdylib", "staticlib"] use std::ffi::{c_char, c_int, CStr, CString}; // #[no_mangle] 才是"这个名字要原样出现在符号表里" // edition 2024 起要写成 #[unsafe(no_mangle)],语义上它属于 unsafe 范畴 #[unsafe(no_mangle)] pub extern "C" fn rust_add(a: c_int, b: c_int) -> c_int { // extern "C" 函数体默认是 safe 的,但 panic 跨边界是 UB a + b } // 接收 C 字符串:返回给 C 的内存必须由 C 负责释放,否则就是内存泄漏 // 这里用 Box::into_raw 把所有权"送出去" #[unsafe(no_mangle)] pub extern "C" fn rust_greet(name: *const c_char) -> *mut c_char { // SAFETY: 调用方承诺 name 是 NUL 结尾的合法 C 字符串 let name = unsafe { CStr::from_ptr(name) }.to_string_lossy(); let s = CString::new(format!("hello, {name}")).unwrap(); // 把所有权交给 C,由 C 调 rust_free 还回来 s.into_raw() } // 配对释放函数:必须有,否则宿主语言拿不到 Rust 的分配器 #[unsafe(no_mangle)] pub extern "C" fn rust_free(p: *mut c_char) { if p.is_null() { return; } // SAFETY: p 由 rust_greet 里的 CString::into_raw 产生,且只释放一次 unsafe { drop(CString::from_raw(p)) }; }

导入:Rust 调 C 库

// 声明外部符号。edition 2024 起 extern block 要写 unsafe extern unsafe extern "C" { fn abs(input: c_int) -> c_int; fn strlen(s: *const c_char) -> usize; // 变参函数只能声明,不能定义 fn printf(fmt: *const c_char, ...) -> c_int; } // 告诉 Cargo 去哪找库。更常见的做法是写进 build.rs // build.rs: println!("cargo:rustc-link-lib=z"); // println!("cargo:rustc-link-search=/opt/homebrew/lib"); fn call_c() -> c_int { // SAFETY: abs 是纯函数,对任意 i32 输入都安全 unsafe { abs(-42) } }

#[repr(C)]:让结构体的内存布局跟 C 一致

// 默认的 #[repr(Rust)] 允许编译器重排字段以减少 padding(只保证行为,不保证布局) // 跨语言传递结构体【必须】用 #[repr(C)],否则 C 端读到的字段顺序可能是错的 #[repr(C)] pub struct Point { pub x: f64, // 偏移 0 pub y: f64, // 偏移 8 pub tag: u8, // 偏移 16,后面有 7 字节 padding,总大小 24 } // 常用 repr 变体 // #[repr(transparent)] —— 只有一个非零大小字段,保证与它同布局(newtype 包装用) // #[repr(u8)] —— 枚举用 u8 作为判别式,可与 C 的 enum 对齐 // #[repr(C, u8)] —— 让 Rust 的带数据枚举有 C 兼容的标签+联合布局 // #[repr(packed)] —— 去除 padding(危险:可能产生未对齐引用,取字段地址就是 UB) // #[repr(align(64))] —— 强制对齐到缓存行,避免伪共享 fn check_layout() { assert_eq!(std::mem::size_of::<Point>(), 24); assert_eq!(std::mem::align_of::<Point>(), 8); // 用这些断言给"我假设的布局"上锁,换编译器/换平台时会立刻发现 }
坑:panic 穿过 extern "C" 边界是 UB

Rust 的 panic 默认是 unwind(栈展开),而 C 没有 unwind 的语义也没有清理器。你的 extern "C" 函数里如果 unwrap() 失败或者数组越界 panic 了,栈展开穿到 C 代码里就是未定义行为(在 Rust 1.81 之后的行为是直接 abort 进程,比之前"静默 UB"好一些,但仍然不是你能接受的)。

三种正确做法:① 在函数体内把 panic 全部转成错误码(std::panic::catch_unwind 包一层,返回 -1);② 用 extern "C-unwind" 明确声明"我允许展开"(需要宿主语言也支持);③ 在 crate 层面配 panic = "abort",让 panic 直接终止进程,至少行为是确定的。生产上的 FFI 库一般用方案 ①,并在文档里写清"这个函数永不 panic"。

顺带一个同类坑:extern "C" 函数里 unwrap() 配合 Mutex 中毒(poison)也很容易踩——一个线程 panic 后 Mutex 变"中毒",后续 lock().unwrap() 会连环 panic。

bindgen:把 C 头文件变成 Rust 绑定

手写 FFI 声明又慢又容易写错(尤其是带位域的结构体和宏常量)。bindgen 读 C/C++ 头文件,在构建期自动生成 bindings.rs。

标准 -sys crate 结构(社区惯例:foo 提供安全封装,foo-sys 只放自动生成的裸绑定)

zlib-wrapper/ ├── Cargo.toml ├── build.rs ├── wrapper.h # 只 #include 需要的头文件 └── src/ ├── lib.rs # 安全封装层,对外不暴露 unsafe └── bindings.rs # include!(concat!(env!("OUT_DIR"), "/bindings.rs")) # Cargo.toml [build-dependencies] bindgen = "0.70" cc = "1" # 如果要把 C 源码一起编进来

build.rs:生成绑定的关键配置

// build.rs use std::env; use std::path::PathBuf; fn main() { println!("cargo:rerun-if-changed=wrapper.h"); // 用 cc 把 C 源码编成静态库(若系统已装库,可省略) cc::Build::new().file("src/zlib_helper.c").compile("zlibhelper"); let bindings = bindgen::Builder::default() .header("wrapper.h") // 只在白名单里的符号才生成,能大幅减少编译时间与噪音 .allowlist_function("zlib_.*") .allowlist_type("zlib_stream") .allowlist_var("ZLIB_.*") // 生成的代码风格:用 libc 里的类型而不是自己重新定义 .ctypes_prefix("libc") .use_core() // 让 bindgen 输出 "哪个头文件变了要重跑" 的指令 .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) // 默认生成的枚举是 const,这里改成真正的 Rust enum .rustified_enum("zlib_level") .generate() .expect("无法生成绑定,检查 wrapper.h 与头文件搜索路径"); let out = PathBuf::from(env::var("OUT_DIR").unwrap()); bindings.write_to_file(out.join("bindings.rs")).unwrap(); }

再包一层安全 API(这才是别人 import 的东西)

// src/lib.rs #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)] mod raw { // bindgen 生成的代码一定带一堆命名/空指针警告,用 allow 收口在一个模块里 #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } pub struct Zlib { stream: *mut raw::zlib_stream, } // 这个类型对外【只】暴露安全接口,unsafe 全被关在这个模块里面 impl Zlib { pub fn new(level: u8) -> Result<Self, ZlibError> { // SAFETY: create 对任意 u8 输入都返回合法指针或 null,我们检查了 null let stream = unsafe { raw::zlib_create(level) }; if stream.is_null() { return Err(ZlibError::InitFailed); } Ok(Zlib { stream }) } pub fn compress(&mut self, data: &[u8]) -> Result<Vec<u8>, ZlibError> { // SAFETY: self.stream 由 new 保证非 null 且本方法持有 &mut self unsafe { raw::zlib_compress(self.stream, data.as_ptr(), data.len()) } // 借安全层把裸指针转成自己的类型 .map_or(Err(ZlibError::CompressFailed), Ok) } } impl Drop for Zlib { fn drop(&mut self) { // SAFETY: 只在这里释放,且 release 后不再使用 stream unsafe { raw::zlib_release(self.stream) }; } }

论为什么社区约定用 foo + foo-sys 两层

① foo-sys 是"会变的、机器生成的、unsafe 的"。头文件升级、bindgen 版本升级、目标平台不同,都会让这层代码大改。把它单独放一个 crate,diff 就干净、评审就快,也方便 pin 住版本。

② foo 是"稳定的、手写的、安全的"。它负责把裸指针变成 &[u8]、把返回码变成 Result、用 Drop 管生命周期、把 unsafe impl Send/Sync 的 SAFETY 注释写清楚。使用者看到的是纯 safe Rust API。

③ 这套分层带来一个非常实际的收益:unsafe 的数量被固定在少数几个文件里。审计时你只需要审 foo 这一个 crate 的安全封装,不需要看整个依赖树。Rust 的 #![forbid(unsafe_code)] 属性可以用在业务 crate 上,让"业务代码里禁止出现 unsafe"变成编译器强制。

字符串与所有权跨边界:CStr / CString / into_raw

跨语言传字符串是 FFI 里最容易出内存问题的地方,因为两边对"谁负责释放"的约定完全不同。

类型方向关键点
CStringRust 持有所有权,借给 C内部保证 NUL 结尾、不允许内部有 NUL 字节(::new 会返回 Err)。as_ptr() 得到 *const c_char,有效期只到 CString 被 drop 为止。
&CStr借用别人的 C 字符串用 CStr::from_ptr(p) 从裸指针转,要求 p 非空且 NUL 结尾。返回的 &CStr 生命周期是"推断出来的",编译器并不知道 C 那边什么时候释放,必须靠人保证。
CString::into_rawRust 把所有权交给 CC 用完后必须调 CString::from_raw 还回来(由 Rust 侧提供释放函数)。不还 = 泄漏;还两次 = double free。
c_void不透明指针对应 C 的 void*。现在推荐 std::ffi::c_void,老代码里的 std::os::raw::c_void 是同一类型。
OsString/PathBuf平台原生字符串路径、环境变量不要走 CString,用 OsStr::as_bytes() / OsStrExt::from_bytes(Unix)才能正确处理非 UTF-8。

完整的所有权交接模式(这是 FFI 里最标准的写法)

use std::ffi::{c_char, CStr, CString}; // 传入:C 给 Rust 一个字符串,Rust 只借用,不负责释放 #[unsafe(no_mangle)] pub extern "C" fn parse_config(path: *const c_char) -> c_int { if path.is_null() { return -1; // 必须判空:C 侧很容易传 null } // SAFETY: 已判空,且调用方承诺是 NUL 结尾的 C 字符串 let cstr = unsafe { CStr::from_ptr(path) }; // 不一定是合法 UTF-8,用 lossy 而不是 unwrap let s = cstr.to_string_lossy(); match do_parse(&s) { Ok(_) => 0, Err(_) => -1, } } // 传出:Rust 分配、C 使用、Rust 释放(由 C 主动调用返回的释放函数) #[unsafe(no_mangle)] pub extern "C" fn config_dump() -> *mut c_char { let owned = CString::new("{\"ok\":true}").unwrap(); // into_raw 会"忘记"这块内存,把所有权转成裸指针交给 C owned.into_raw() } #[unsafe(no_mangle)] pub extern "C" fn config_dump_free(p: *mut c_char) { if !p.is_null() { // SAFETY: p 只能来自 config_dump,且每个指针只释放一次 unsafe { drop(CString::from_raw(p)) }; } }

Box 的所有权交接:同一个套路用在任意结构体上

#[repr(C)] pub struct VecHandle { ptr: *mut f32, len: usize, cap: usize } #[unsafe(no_mangle)] pub extern "C" fn vec_new(len: usize) -> *mut VecHandle { // 用一个真正的 Vec 分配,然后把它的"三要素"复制到 C 可见的结构体里 let mut v: Vec<f32> = vec![0.0; len]; let h = VecHandle { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() }; // 关键:manually_forget 防止 v 在这里被释放(所有权转移到裸指针) std::mem::forget(v); Box::into_raw(Box::new(h)) } #[unsafe(no_mangle)] pub extern "C" fn vec_free(h: *mut VecHandle) { if h.is_null() { return; } // SAFETY: h 来自 vec_new 的 Box::into_raw,且只释放一次 let h = unsafe { Box::from_raw(h) }; // 用三要素把 Vec "拼"回来,再由 Rust 正常 drop unsafe { let _ = Vec::from_raw_parts(h.ptr, h.len, h.cap); } // 注意:结构体本身由 Box::from_raw 处理,Vec 的缓冲由 from_raw_parts 处理 }
坑:把 CString::as_ptr() 的结果存起来,然后 CString 被 drop 了

这是 FFI 中最隐蔽的一类 bug:let p = CString::new("x").unwrap().as_ptr(); —— 临时 CString 在这一行末尾就被释放了,p 立刻变成悬垂指针。C 那边读到的可能是任何东西,也可能在几小时后才崩。

正确写法是把 CString 绑定到变量、活到调用结束:let c = CString::new("x").unwrap(); call(c.as_ptr());。

另外三条高频坑:① 忘写配对的 *_free 函数,宿主语言没法释放导致内存泄漏(这是 Box::into_raw 之后最常见的事故);② 同一个指针释放两次(例如 C 侧和 Rust 侧都以为自己是所有者),表现为 double free 崩溃;③ 用 mem::forget 之后忘了它其实是"故意泄漏"——如果对应的 from_raw 永远不会被调用,这块内存就永久丢失了,cargo miri 能帮你发现这类问题。

记
本章小结

① unsafe 只解锁四件事:解裸指针、调 unsafe 函数、访问 static mut、实现 unsafe trait(外加访问 union 字段)。借用检查、类型检查全都还在。

② unsafe fn 必须有 # Safety 文档,因为它的前置条件无法用类型表达,只能靠文档 + Review。

③ Send 管"能不能搬",Sync 管"能不能共享";T: Sync 等价于 &T: Send;Arc<Mutex<T>> 是跨线程共享可变状态的标准答案。

④ UnsafeCell 是所有内部可变性的地基:Cell(整取整存、无开销)、RefCell(运行期借用检查、会 panic)、Mutex(跨线程 + 阻塞)。

⑤ 跨语言结构体必须 #[repr(C)],枚举用 #[repr(u8)],newtype 用 #[repr(transparent)];panic 绝不能穿过 extern "C"。

⑥ bindgen 只负责生成裸绑定,务必再包一层 safe API;社区惯例是 foo-sys(自动生成) + foo(手写安全层)。

⑦ 所有权交接必须成对:into_raw 配 from_raw、Box::into_raw 配 Box::from_raw、mem::forget 配 Vec::from_raw_parts。少一半就是泄漏或 double free。

⑧ 写完 unsafe 一定跑一次 cargo +nightly miri test,Miri 能在解释执行时抓住绝大多数 UB。

小练习 · 五道 unsafe 与 FFI 自测题(点开看答案)

1.(概念题)进了 unsafe 块之后,Rust 还会检查借用规则和生命周期吗?

查看答案

会。unsafe 只允许"解裸指针、调 unsafe 函数、访问 static mut、实现 unsafe trait、访问 union 字段"这几件事,借用检查、类型检查、移动语义、Drop 全部照常生效。所以你仍不能在一个 unsafe 块里绕开借用检查器。

2.(排错题)Arc<RefCell<Vec<u8>>> 直接 thread::spawn 会怎样?为什么?

查看答案

编译不过:RefCell 不是 Sync,所以 Arc<RefCell<T>> 不是 Send。RefCell 可以在只有 &self 的情况下改内部状态,多线程共享必然数据竞争。改成 Arc<Mutex<Vec<u8>>>,或者每个线程各自 clone 一份数据。

3.(原理题)&T 是不可变引用,为什么 Cell::set 能改内容而不算违反规则?

查看答案

因为 Cell 内部包含 UnsafeCell,编译器看到它会关闭"这块内存不会被改"的优化假设。换句话说,"&T 不可变"是编译器用来做优化的契约,而 UnsafeCell 是官方指定的"豁免标记"。任何内部可变性类型(Cell/RefCell/Mutex/Atomic)最终都包着一个 UnsafeCell,没有它就是不合法(UB)。

4.(FFI 题)一个 Rust 函数返回 *mut c_char 给 C,为什么还必须在 Rust 侧提供一个 xxx_free 函数?

查看答案

因为内存是 Rust 的分配器分配的,C 侧free() 会用到不同的分配器,是 UB。必须由 Rust 提供配对的释放函数,内部调 CString::from_raw(或 Box::from_raw)把所有权收回来再 drop。同一份原则也适用于 Vec、Box、任何 into_raw 出去的指针。

5.(填空/排错题)下面代码为什么崩溃?
let p = CString::new("hi").unwrap().as_ptr(); unsafe { call(p) };

查看答案

临时的 CString 在分号处就被 drop 了,p 是悬垂指针。改法:let c = CString::new("hi").unwrap(); unsafe { call(c.as_ptr()) }——把 CString 绑定到变量上,让它活到调用结束。这类"借用了一个立刻消失的拥有者"的 bug 在 FFI 代码里非常常见。