Rust 2026 经验谈 - Unsafe 代码审查清单
3276 字
16 分钟
Rust 2026 经验谈 - Unsafe 代码审查清单
unsafe 是 Rust 的”信任边界”——编译器放弃检查,由开发者担保安全性。但担保不是口号,需要系统化的审查和验证。本文从文档规范、工具检测、审查清单三个维度,建立 unsafe 代码的质量保障体系。
Safety doc comment 规范
标准 Safety 段落
Rust API Guidelines 要求每个 unsafe 函数和 unsafe trait 实现都包含 # Safety 段落,说明调用者必须满足的前置条件:
/// 将字节切片重新解释为 T 的引用。////// # Safety////// 调用者必须保证:/// - `bytes` 的长度恰好等于 `size_of::<T>()`/// - `bytes` 的对齐满足 `align_of::<T>()`/// - `bytes` 指向的内存包含 `T` 的有效位模式/// - 在此引用存活期间,没有其他可变引用指向同一内存pub unsafe fn transmute_ref<T>(bytes: &[u8]) -> &T { // Edition 2024: unsafe fn 函数体需显式 unsafe {} unsafe { &*(bytes.as_ptr() as *const T) }}unsafe impl 的 Safety
/// 自定义的线程安全引用计数指针。////// # Safety////// 实现 `Send` 是安全的,因为:/// - 内部计数使用 `AtomicUsize`,线程安全/// - 数据通过 `UnsafeCell` 访问,但所有访问都通过原子计数守卫/// - 不存在 `&mut` 引用的线程间传递unsafe impl<T: Sync> Send for MyRc<T> {}
/// # Safety////// 实现 `Sync` 是安全的,因为:/// - `MyRc` 本身是只读的(引用计数是原子的)/// - 内部数据 `T: Sync` 保证并发共享引用安全unsafe impl<T: Sync> Sync for MyRc<T> {}unsafe 块的 SAFETY 注释
Rust 2024 Edition 推荐在 unsafe 块内加 // SAFETY: 注释,解释为何这个 unsafe 操作是安全的:
fn read_config(path: &Path) -> Result<Config> { let file = std::fs::File::open(path)?; let mut reader = std::io::BufReader::new(file);
let version = unsafe { // SAFETY: // - buf 是 4 字节的栈上数组,对齐为 1 // - reader.read_exact 确保写入 4 字节 // - u32 的小端字节解释总是有效的 let mut buf = [0u8; 4]; reader.read_exact(&mut buf)?; u32::from_le_bytes(buf) };
Ok(Config { version })}unsafe fn 调用处的注释
fn process(data: &mut [u8]) { let ptr = data.as_mut_ptr(); let len = data.len();
unsafe { // SAFETY: // - ptr 来自 &mut [u8],保证非空且对齐 // - len 是 data 的原始长度,不越界 // - 我们拥有 &mut [u8],没有其他引用 core::ptr::write_bytes(ptr, 0, len); }}踩坑:Safety 注释不是安全证明
Safety 注释是给审查者看的,不是编译器验证的。错误的注释比没有注释更危险:
unsafe fn bad_assumption(ptr: *const u8, len: usize) -> &[u8] { // SAFETY: ptr 是有效的,len 也是对的 // ↑ 这个注释没有任何证明力! std::slice::from_raw_parts(ptr, len)}
// 好的 Safety 注释应该引用具体的保证来源:unsafe fn good_assumption(ptr: *const u8, len: usize, capacity: usize) -> &[u8] { // SAFETY: // 调用者(process_buffer)保证: // - ptr 来自 Vec::as_ptr,对齐为 1,非空 // - len <= capacity(在 process_buffer 中已检查) // - 数据在 'a 期间有效(Vec 的生命周期约束) std::slice::from_raw_parts(ptr, len)}Miri 检测 UB
安装与运行
Miri 是 Rust 的 UB 检测器,基于形式化方法解释 Rust 程序:
# 安装rustup +nightly component add miri
# 运行测试cargo +nightly miri test
# 运行特定测试cargo +nightly miri test test_ffi_boundary
# 带参数运行cargo +nightly miri runMiri 检测的 UB 类型
1. 越界内存访问
fn test_oob() { let v = vec![1, 2, 3]; let val = unsafe { *v.as_ptr().add(3) }; // Miri: pointer being out-of-bounds}2. 使用未初始化内存
fn test_uninit() { let x: i32 = unsafe { // Miri: using uninitialized data let mut v: i32 = std::mem::MaybeUninit::uninit().assume_init(); v };}3. 违反别名规则(Stacked Borrows)
fn test_aliasing() { let mut x = 42; let raw = &mut x as *mut i32; let ref1 = &x; unsafe { // Miri: trying to reborrow for Unique, but parent tag <...> is Disabled // 因为 ref1 仍然存活,通过 raw 写入违反别名规则 *raw = 13; } println!("{}", ref1); // ref1 仍存活}4. 无效指针解引用
fn test_invalid_ptr() { let ptr: *const i32 = 0xdeadbeef as *const i32; unsafe { // Miri: pointer to 0xdeadbeef is not a valid pointer let _val = *ptr; }}5. 整数溢出(debug 模式)
fn test_overflow() { let x: u8 = 255; let y = x + 1; // Miri: attempt to compute `u8::MAX + 1_u8`, which would overflow}6. 数据竞争(-Zmiri-track-raw-pointers)
use std::thread;
fn test_data_race() { let mut x = 0; let raw = &mut x as *mut i32;
let t = thread::spawn(move || { unsafe { *raw = 1; } });
// Miri: data race detected between a Read and Write println!("{}", x); t.join().unwrap();}Miri 的 Tree Borrows 模式
# 使用 Tree Borrows(更宽松的别名模型)cargo +nightly miri test -Zmiri-tree-borrows
# 对比 Stacked Borrows(默认,更严格)cargo +nightly miri testTree Borrows 是 Stacked Borrows 的替代模型,允许更多模式(如某些 io_uring 用法)。
Miri 的局限
// Miri 不支持:// 1. FFI 调用(C 函数)——除非用 miri::foreign_function_hook// 2. 系统调用(大部分被模拟,但不完全)// 3. 内联汇编// 4. 某些平台相关的操作
// 变通:用 -Zmiri-seed 控制随机性// cargo +nightly miri test -Zmiri-seed=42
// 变通:跳过不支持的测试#[cfg(not(miri))]fn test_with_ffi() { // Miri 不支持 FFI,跳过}实战:CI 中集成 Miri
name: Miri
on: [push, pull_request]
jobs: miri: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly with: components: miri - name: Miri test run: cargo miri testloom 并发测试
为什么需要 loom
Miri 能检测数据竞争,但对于并发算法的正确性(如:锁是否真的互斥?CAS 循环是否终止?)需要更系统的验证。loom 是 Rust 的并发模型检查器,枚举所有可能的线程交错:
[dev-dependencies]loom = "0.7"基本用法:测试原子计数器
#[cfg(test)]mod tests { use loom::sync::atomic::{AtomicUsize, Ordering}; use loom::thread;
#[test] fn test_atomic_counter() { loom::model(|| { let counter = AtomicUsize::new(0);
let t1 = thread::spawn(|| { counter.fetch_add(1, Ordering::SeqCst); });
let t2 = thread::spawn(|| { counter.fetch_add(1, Ordering::SeqCst); });
t1.join().unwrap(); t2.join().unwrap();
let val = counter.load(Ordering::SeqCst); assert!(val == 1 || val == 2); // fetch_add 不会丢失,最终值一定是 2 // 但上面的断言允许中间状态 }); }}测试 Mutex 实现
#[cfg(test)]mod tests { use loom::sync::Arc; use loom::thread;
struct SimpleLock { locked: loom::sync::atomic::AtomicBool, data: loom::sync::atomic::AtomicUsize, }
impl SimpleLock { fn new() -> Self { Self { locked: loom::sync::atomic::AtomicBool::new(false), data: loom::sync::atomic::AtomicUsize::new(0), } }
fn lock(&self) { while self.locked.compare_exchange( false, true, loom::sync::atomic::Ordering::Acquire, loom::sync::atomic::Ordering::Relaxed, ).is_err() { loom::thread::yield_now(); } }
fn unlock(&self) { self.locked.store(false, loom::sync::atomic::Ordering::Release); } }
#[test] fn test_simple_lock_mutual_exclusion() { loom::model(|| { let lock = Arc::new(SimpleLock::new()); let lock1 = lock.clone(); let lock2 = lock.clone();
let t1 = thread::spawn(move || { lock1.lock(); let v = lock1.data.load(loom::sync::atomic::Ordering::Relaxed); lock1.data.store(v + 1, loom::sync::atomic::Ordering::Relaxed); lock1.unlock(); });
let t2 = thread::spawn(move || { lock2.lock(); let v = lock2.data.load(loom::sync::atomic::Ordering::Relaxed); lock2.data.store(v + 1, loom::sync::atomic::Ordering::Relaxed); lock2.unlock(); });
t1.join().unwrap(); t2.join().unwrap();
let val = lock.data.load(loom::sync::atomic::Ordering::Relaxed); assert_eq!(val, 2); }); }}loom 与真实代码的双模测试
// 使用条件编译切换 loom 和真实实现#[cfg(test)]mod concurrency_tests { #[cfg(not(loom))] use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[cfg(loom)] use loom::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[cfg(not(loom))] use std::sync::Arc; #[cfg(loom)] use loom::sync::Arc;
#[cfg(not(loom))] use std::thread; #[cfg(loom)] use loom::thread;
#[test] fn test_concurrent_access() { #[cfg(loom)] loom::model(|| { self::test_body(); });
#[cfg(not(loom))] self::test_body(); }
fn test_body() { // 测试逻辑,使用上面的条件编译类型 }}运行:
# 普通 cargo test(用 std)cargo test
# loom 模型检查RUSTFLAGS="--cfg loom" cargo testloom 的局限
- 只支持
loom::sync中的类型(Atomic、Arc、Mutex 等) - 不支持
parking_lot、crossbeam等第三方并发原语 - 状态爆炸:线程越多、操作越多,枚举的交错数指数增长
- 需要手动将代码中的
std::sync替换为loom::sync
unsafe 代码审查 checklist
Checklist 总览
| 检查项 | 类别 | 严重性 |
|---|---|---|
| 指针有效性 | 内存 | 关键 |
| 别名规则 | 内存 | 关键 |
| 生命周期 | 内存 | 关键 |
| 初始化 | 内存 | 关键 |
| 整数溢出 | 算术 | 高 |
| 并发安全 | 并发 | 关键 |
| FFI 边界 | 跨语言 | 高 |
| panic 安全 | 控制 | 高 |
| 未定义行为 | 综合 | 关键 |
1. 指针有效性
unsafe fn process_ptr(ptr: *const u8, len: usize) { // Edition 2024: unsafe fn 函数体需显式 unsafe {} // ✓ 检查空指针 if ptr.is_null() { return; }
// ✓ 检查对齐(如果需要) assert_eq!((ptr as usize) % std::mem::align_of::<u8>(), 0);
// ✓ 检查是否在有效分配范围内 // (无法直接检查——需要从调用者保证)
// ✓ 检查 len 是否合理 if len > isize::MAX as usize { panic!("len too large"); }
unsafe { let slice = std::slice::from_raw_parts(ptr, len); }}审查问题:
- 指针是否可能为 null?是否检查了?
- 指针是否指向有效内存?保证来源是什么?
- 对齐要求是否满足?
- 偏移后是否仍在分配范围内?
- 是否有
offset_from的前提条件?
2. 别名规则(Stacked Borrows / Tree Borrows)
// ❌ 违反别名规则fn bad_aliasing(v: &mut Vec<i32>) -> &i32 { let ptr = v.as_ptr(); // 共享引用 v.push(42); // 可变引用——使 ptr 失效! unsafe { &*ptr } // UB:使用已失效的指针}
// ✓ 正确:在可变操作之前获取指针fn good_aliasing(v: &mut Vec<i32>) -> i32 { v.push(42); let val = *v.last().unwrap(); val}审查问题:
- 是否同时持有
&T和&mut T? - 原始指针是否在可变引用活跃期间使用?
UnsafeCell是否正确使用?- 引用和原始指针之间的 reborrow 链是否合法?
3. 生命周期
// ❌ 返回悬垂引用fn dangling_ref<'a>(v: &mut Vec<i32>) -> &'a i32 { let ptr = v.as_ptr(); // v 可能在 'a 结束前被 Drop unsafe { &*ptr }}
// ✓ 使用索引代替引用fn safe_access(v: &Vec<i32>, idx: usize) -> Option<i32> { v.get(idx).copied()}审查问题:
- 返回的引用/指针的生命周期是否被正确约束?
- 是否有栈上变量的引用逃逸到更长的生命周期?
transmute是否延长了生命周期?Box::into_raw/Box::from_raw的生命周期是否配对?
4. 初始化
// ❌ 未初始化读取fn bad_uninit() -> i32 { let mut x: i32; // x 未初始化就被使用 unsafe { std::ptr::read(&x) } // UB}
// ✓ 使用 MaybeUninitfn good_uninit() -> i32 { let mut x = std::mem::MaybeUninit::<i32>::uninit(); unsafe { x.as_mut_ptr().write(42); // 先写入 x.assume_init() // 再读取 }}
// ✓ 逐字段初始化结构体fn init_struct() -> MyStruct { let mut s = std::mem::MaybeUninit::<MyStruct>::uninit(); unsafe { let ptr = s.as_mut_ptr(); std::ptr::addr_of_mut!((*ptr).field1).write(1); std::ptr::addr_of_mut!((*ptr).field2).write(2); std::ptr::addr_of_mut!((*ptr).field3).write(3); s.assume_init() }}审查问题:
- 是否有
MaybeUninit::assume_init()在未初始化时调用? - 结构体是否所有字段都已初始化?
read/write的顺序是否正确?- 是否有
mem::zeroed()对不合法类型的误用(如bool的 2、char的非标量值)?
5. 并发安全
// ❌ 数据竞争use std::sync::atomic::AtomicBool;use std::cell::UnsafeCell;
struct BadOnce<T> { initialized: AtomicBool, value: UnsafeCell<T>,}
impl<T> BadOnce<T> { fn get(&self) -> &T { if self.initialized.load(std::sync::atomic::Ordering::Relaxed) { unsafe { &*self.value.get() } } else { // 多个线程可能同时进入这里 // 写入 value 的数据竞争! unsafe { std::ptr::write(self.value.get(), /* init */); } self.initialized.store(true, std::sync::atomic::Ordering::Relaxed); unsafe { &*self.value.get() } } }}
// ✓ 使用正确的同步struct GoodOnce<T> { initialized: AtomicBool, value: UnsafeCell<MaybeUninit<T>>,}
impl<T> GoodOnce<T> { fn get(&self, init: impl FnOnce() -> T) -> &T { if self.initialized.load(std::sync::atomic::Ordering::Acquire) { unsafe { (*self.value.get()).assume_init_ref() } } else { let val = init(); unsafe { std::ptr::write(self.value.get(), MaybeUninit::new(val)); } self.initialized.store(true, std::sync::atomic::Ordering::Release); unsafe { (*self.value.get()).assume_init_ref() } } }}// 注意:上面的 GoodOnce 仍有一个问题——多线程同时初始化// 生产代码应使用 std::sync::OnceLock审查问题:
UnsafeCell的访问是否通过正确的同步原语守卫?- Atomic 操作的 Ordering 是否足够强?
- 是否存在 TOCTOU(Time-of-check to time-of-use)竞争?
Send/Sync实现是否正确?
6. 整数溢出与未定义行为
// ❌ 潜在溢出fn compute_offset(base: usize, offset: usize) -> *const u8 { // 如果 base + offset 溢出 usize,结果错误 (base + offset) as *const u8}
// ✓ 检查溢出fn safe_offset(base: usize, offset: usize) -> Option<*const u8> { base.checked_add(offset).map(|addr| addr as *const u8)}7. FFI 边界
// ✓ FFI 边界的标准处理#[unsafe(no_mangle)]pub extern "C" fn my_lib_process(data: *const u8, len: usize) -> i32 { // 1. 验证输入 if data.is_null() && len > 0 { return -1; }
// 2. catch_unwind 防止 panic 跨边界 let result = std::panic::catch_unwind(|| { let slice = if data.is_null() || len == 0 { &[] } else { unsafe { std::slice::from_raw_parts(data, len) } }; internal_process(slice) });
match result { Ok(Ok(())) => 0, Ok(Err(e)) => e as i32, Err(_) => -999, }}geiger crate 统计 unsafe 使用量
安装与运行
cargo install cargo-geiger
# 统计当前 crate 和所有依赖的 unsafe 使用cargo geiger
# 只统计依赖cargo geiger --dependencies-only
# 指定特征cargo geiger --features "full"输出解读
Unsound (unsafe) packages: ┌───────────────────────────────┬────────────┬────────────┬────────────┬────────────┐ │ Package │ Unsafe │ Unsafe │ Unsafe │ Unsafe │ │ │ extern │ impl │ trait │ fn │ ├───────────────────────────────┼────────────┼────────────┼────────────┼────────────┤ │ my-crate 0.1.0 │ 2 │ 3 │ 1 │ 5 │ │ parking_lot_core 0.9.0 │ 0 │ 12 │ 0 │ 18 │ │ smallvec 1.11.0 │ 0 │ 2 │ 0 │ 4 │ └───────────────────────────────┴────────────┴────────────┴────────────┴────────────┘各列含义:
- Unsafe extern:
extern "C"声明数量 - Unsafe impl:
unsafe impl数量 - Unsafe trait:
unsafe trait数量 - Unsafe fn:
unsafe fn数量
在 CI 中使用
name: Unsafe Audit
on: [push, pull_request]
jobs: geiger: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install geiger run: cargo install cargo-geiger - name: Count unsafe run: | cargo geiger --dependencies-only 2>&1 | tee geiger-report.txt # 检查新增依赖的 unsafe 量踸见发现
# 几乎所有 crate 都有 unsafe——问题不是"有没有",而是"是否合理"# 标准库本身就有大量 unsafe(指针操作、OS 调用等)
# 关注点:# 1. 自己 crate 中 unsafe 的数量是否可接受?# 2. 依赖中 unsafe 的数量是否意外地多?# 3. 新增依赖是否引入了大量 unsafe?unsafe 审查流程
代码审查时的标准流程
1. 定位所有 unsafe 块和 unsafe fn └─ cargo geiger(自己的 crate) └─ grep -r "unsafe" src/
2. 对每个 unsafe 实例,检查: ├─ 是否有 # Safety 文档? ├─ Safety 的前置条件是否可验证? ├─ 调用处是否有 SAFETY 注释? ├─ 是否可通过 safe API 消除? └─ 是否有 Miri 测试覆盖?
3. 运行 Miri └─ cargo +nightly miri test
4. 运行 loom(如果有并发 unsafe) └─ RUSTFLAGS="--cfg loom" cargo test
5. 审查依赖的 unsafe └─ cargo geiger --dependencies-only └─ 审查关键依赖的 unsafe 代码unsafe 消除优先级
// 优先级 1:可以用 safe API 替代// unsafe { std::ptr::copy(src, dst, len); }// → src[..len].copy_to_slice(&mut dst[..len]);
// 优先级 2:缩小 unsafe 块范围// unsafe {// let a = *ptr1;// let b = *ptr2;// let c = a + b; // 加法不需要 unsafe!// }// →// let (a, b) = unsafe { (*ptr1, *ptr2) };// let c = a + b;
// 优先级 3:用封装层隔离pub fn safe_wrapper(data: &[u8]) -> Result<u32> { if data.len() < 4 { return Err(Error::TooShort); } Ok(unsafe { read_u32_le(data.as_ptr()) })}
unsafe fn read_u32_le(ptr: *const u8) -> u32 { // Safety: 调用者保证 ptr 有效且至少 4 字节 // Edition 2024: unsafe fn 函数体需显式 unsafe {} unsafe { u32::from_le_bytes([ *ptr, *ptr.add(1), *ptr.add(2), *ptr.add(3), ]) }}支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
Rust 2026 经验谈 - Unsafe 代码审查清单
https://tinyzzh.github.io/posts/rust-2026/2026-07-02-rust_2026_032_unsafe_review_checklist/ 相关文章 智能推荐
1
Rust 2026 经验谈 - Unsafe 的哲学与边界
Rust unsafe 语义四件详解、unsafe 边界最小化原则、模块级 unsafe、unsafe 与安全抽象的关系、Soundness 概念。
2
Rust 2026 经验谈 - Tokio 2026 实战
Rust Tokio 运行时配置选型、spawn 策略、task budgeting 与 cooperative yielding、tokio::select! 与 Cancel safety、join! vs try_join!、运行时生命周期管理。
3
Rust 2026 经验谈 - 错误处理体系 2026
Rust 深入 thiserror 2.0 + anyhow 1.0 最佳实践、Error source chain 遍历、? 操作符类型推导细节、Provider API 替代 Error::source 获取上下文,以及 eyre vs anyhow 选型。
4
Rust 2026 经验谈 - 所有权模型经验谈
Rust 深入所有权心智模型、借用检查器常见对抗与和解方案、2024 Edition 生命周期省略规则调整、reborrowing 深层理解与函数签名设计经验。
5
Rust 2026 经验谈 - Rust 2024 Edition 变更全景
Rust 深度解析 Rust 2024 Edition 的全部断代变更,涵盖 RPIT 生命周期捕获、临时变量作用域、unsafe 边界收紧、match ergonomics、Prelude 扩充、新 Range 类型等 25 项变更,配合 cargo fix --edition 迁移实操与 2021 差异对比表。
随机文章 随机推荐
TinyZ's Blog