☰
Rust unsafe 全解析:五类能力、安全契约与实战封装
2026/10/9 17:09:03 网站建设 项目流程

1. 为什么 unsafe 值得单独拎出来讲

很多人学 Rust 的时候,都会被“内存安全”这四个字吸引进来,然后写着写着发现,标准库底层、第三方高性能库、FFI 绑定层里,到处都是unsafe。于是心态就分成两派:一派觉得unsafe是“逃生舱”,能不用就不用;另一派觉得反正编译器管不着,随便写。这两种态度都容易出问题。

unsafe不是“关掉安全检查的开关”,它更像是一份你亲手签名的承诺书:你向编译器承诺“这块代码我人工验证过了,它满足 Rust 的安全契约”,编译器则把对应的检查责任交还给你。它一共解锁五类能力,每一类背后都对应着一条必须由人来兜底的不变量。这篇文章我就按“这五类能力分别是什么、为什么危险、实际怎么写、怎么排查”这条线,把unsafe的全功能拆开讲一遍,顺带把我自己踩过的坑和常用的排查套路一起放进来。

适合谁看:已经写过一段时间 Rust、能看懂所有权和生命周期、但一碰到裸指针和unsafe就心里没底的同学;也适合需要写 FFI、写底层数据结构、做性能优化的从业者。全文尽量不堆术语,能类比的地方我都用生活化的例子讲清楚。

2. unsafe 的五类超能力与背后的契约

2.1 先搞清楚:unsafe 到底解锁了什么

Rust 官方把unsafe的能力归纳为五类,我按“危险程度”和“使用频率”重新排一下,方便你建立直觉:

能力典型场景主要风险使用频率
解引用裸指针底层容器、FFI空指针、悬垂、越界高
调用 unsafe 函数FFI、SIMD、系统调用违反被调函数的前置条件高
访问/修改可变静态变量全局状态、缓存数据竞争中
实现 unsafe traitSend/Sync 自定义跨线程误用中
访问 union 字段类型双关、底层布局读到无效位模式低

这里有个特别容易被忽略的点:unsafe块只是“允许”你做这五件事,它不会自动帮你做任何检查。也就是说,unsafe { ... }里的代码,编译器依然会做借用检查、类型检查,只是不再阻止你解引用裸指针、调用 unsafe 函数这些动作。很多人误以为进了unsafe就“为所欲为”,其实类型系统还在,只是安全契约的举证责任转移到了你身上。

我习惯把unsafe块想象成“手术室”:普通代码是门诊,编译器是分诊台,帮你挡掉大部分明显问题;进了手术室,分诊台不再拦你,但无菌操作、器械清点这些规矩得你自己守,出了事也是你自己担。

2.2 裸指针:最常用也最容易翻车的一类

裸指针分两种:*const T和*mut T。它们和引用最大的区别是:可以为空、可以悬垂、可以别名、不携带生命周期。正因为约束少,所以灵活,也所以危险。

创建裸指针本身是安全的,危险的是解引用。看个例子:

let mut x = 10; let p = &mut x as *mut i32; // 创建,安全 unsafe { *p += 1; // 解引用,unsafe }

这段代码没问题,因为p指向的x还活着。但如果我把x挪走或者作用域结束,p就成了悬垂指针,再解引用就是未定义行为(UB)。UB 最坑的地方在于:它不一定立刻崩溃,可能这次跑得好好的,换个编译器版本、换个优化等级就出诡异结果。

我总结裸指针的三条铁律,写底层代码时基本靠它们自查:

  • 解引用前必须保证指针非空且指向有效内存;
  • 必须保证指针指向的对象在解引用期间没有被释放或移动;
  • 如果通过裸指针写入,必须保证没有其他引用同时指向同一块内存(别名规则)。

注意:unsafe里的 UB 不会给你“友好报错”,它可能表现为结果错误、段错误、甚至看似正常运行。所以裸指针相关的代码,测试要覆盖边界,最好配合 Miri 跑一遍。

2.3 unsafe 函数:调用方要背的锅

一个函数被标记为unsafe fn,意思是“调用我的人必须满足某些前置条件,否则后果自负”。注意,是调用方负责,不是函数内部负责。这是 Rust 里一个很反直觉但非常重要的约定。

/// # Safety /// `ptr` 必须指向一个有效的、已初始化的 i32 unsafe fn read_value(ptr: *const i32) -> i32 { *ptr }

调用它的时候:

let v = 42; let p = &v as *const i32; let got = unsafe { read_value(p) }; // 调用方保证 p 有效

这里的关键是文档里的# Safety段落。写 unsafe 函数时,必须用这个段落写清楚前置条件,这是社区约定,也是clippy会检查的项。我见过太多库只写了个unsafe fn却不说清楚要求,调用方只能靠猜,这种库我一般直接不用。

从调用方角度,我的经验是:每次写unsafe { foo() }之前,先问自己“这个函数的 Safety 文档说了什么,我满足了吗”。如果文档没写,那就去看源码,看不懂就别用。

2.4 可变静态变量:全局状态的诱惑与陷阱

Rust 默认不允许可变静态变量,因为多线程下访问它就是数据竞争。但有些场景确实需要全局可变状态,比如底层运行时、缓存、计数器。这时候就得用static mut加unsafe。

static mut COUNTER: u64 = 0; fn bump() { unsafe { COUNTER += 1; } }

这段代码单线程没问题,多线程就是灾难。更现代的做法是用AtomicU64或者Mutex,能不用static mut就不用。如果非用不可,一定要配合同步原语,并且把访问全部收敛到少数几个函数里,别到处散落unsafe。

我个人的原则是:static mut只出现在“我明确知道只有一个线程会碰它”或者“外面已经用锁包住了”的场景,并且一定加注释说明为什么安全。

2.5 unsafe trait 与 union:低频但关键

unsafe trait的典型代表是Send和Sync。它们本身没有方法,是“标记 trait”,编译器靠它们判断类型能不能跨线程移动或共享。手动实现它们,等于你向编译器保证“我的类型确实满足线程安全语义”。

struct MyBox(*mut u8); unsafe impl Send for MyBox {}

如果你实现错了,比如内部其实有非线程安全的共享状态却标了Send,那跨线程用的时候就是数据竞争。这类 bug 极难排查,因为编译器完全信任你。

union则是 C 风格的类型双关,访问字段必须unsafe,因为编译器无法保证你读的那个字段当前是有效的位模式。除非在做底层布局优化或 FFI,否则我建议用enum代替。

3. 实操:从零写一个安全的 unsafe 封装

3.1 目标:手写一个极简的裸指针切片容器

光讲概念不够,我们动手写一个能跑的小东西:一个固定容量的栈上缓冲区,内部用裸指针管理,对外暴露安全接口。这个例子能覆盖裸指针、unsafe 函数、Safety 文档、Drop 实现这几个核心点。

先定义结构:

pub struct FixedBuf<T> { ptr: *mut T, len: usize, cap: usize, }

这里用*mut T而不是Vec<T>,是为了演示手动管理内存。len是已初始化元素个数,cap是容量。

3.2 构造与写入:每一步都要交代清楚为什么

构造时分配内存:

impl<T> FixedBuf<T> { pub fn with_capacity(cap: usize) -> Self { let layout = std::alloc::Layout::array::<T>(cap).unwrap(); let ptr = unsafe { std::alloc::alloc(layout) as *mut T }; if ptr.is_null() { std::alloc::handle_alloc_error(layout); } Self { ptr, len: 0, cap } } }

这里alloc返回*mut u8,我们转成*mut T。注意Layout::array会处理对齐和溢出,比手算cap * size_of::<T>()安全得多。分配失败要调handle_alloc_error,不能直接 panic 或者忽略。

写入元素:

pub fn push(&mut self, value: T) -> Result<(), T> { if self.len == self.cap { return Err(value); } unsafe { self.ptr.add(self.len).write(value); } self.len += 1; Ok(()) }

ptr.add(len)做指针运算,write把值写进去但不读取旧值(这点和*ptr = value不同,后者会先 drop 旧值)。因为这块内存还没初始化,所以必须用write。这就是“为什么”的典型:未初始化内存上不能用赋值,只能用 write。

3.3 读取与 Drop:最容易漏掉的一环

读取:

pub fn get(&self, index: usize) -> Option<&T> { if index >= self.len { return None; } unsafe { Some(&*self.ptr.add(index)) } }

这里先做边界检查,再解引用,保证不会越界。返回引用时生命周期绑定到&self,借用检查器会帮我们防止悬垂。

Drop 实现是重头戏:

impl<T> Drop for FixedBuf<T> { fn drop(&mut self) { unsafe { // 先 drop 已初始化的元素 std::ptr::drop_in_place(std::slice::from_raw_parts_mut(self.ptr, self.len)); // 再释放内存 let layout = std::alloc::Layout::array::<T>(self.cap).unwrap(); std::alloc::dealloc(self.ptr as *mut u8, layout); } } }

顺序不能反:先 drop 元素,再释放内存。如果先释放内存,元素的析构函数就会访问已释放内存,直接 UB。drop_in_place接收一个切片,只 droplen个元素,不会碰未初始化的部分。

3.4 用 Miri 验证:把 UB 揪出来

写完上面这些,光靠肉眼很难确认没有 UB。这时候用 Miri:

rustup component add miri cargo +nightly miri test

Miri 是一个 MIR 解释器,能检测出越界、悬垂、未初始化读取、别名违规等问题。我实测下来,很多“看起来没问题”的 unsafe 代码,Miri 一跑就报。它不能覆盖所有情况(比如某些 FFI),但作为第一道防线非常值。

提示:Miri 跑得慢,适合在 CI 里对核心模块跑,不必全量。另外它需要 nightly 工具链。

4. 常见问题与排查技巧实录

4.1 那些年我踩过的 unsafe 坑

坑一:以为 unsafe 块能绕过借用检查。实际上借用检查照常工作,unsafe只是解锁那五类操作。我早期写过unsafe { &mut x }想绕过借用冲突,结果编译器照样报错,白折腾半天。

坑二:裸指针运算忘了按元素大小偏移。ptr.add(1)是按T的大小偏移,不是按字节。如果误用ptr as *mut u8再add(1),就只挪了一个字节,读到错位数据。这个 bug 在跨类型转换时特别常见。

坑三:static mut在多线程下裸奔。曾经写过一个全局计数器,单线程测试全过,一上多线程就偶发错误。后来换成AtomicUsize才稳。教训是:能用原子类型或锁,就别用static mut。

坑四:Drop 里 panic。如果drop_in_place过程中某个元素的析构函数 panic,而这时又恰好在另一个 panic 展开过程中,就会触发双重 panic,直接 abort。所以析构函数里尽量别 panic。

4.2 排查速查表

现象可能原因排查手段
偶发结果错误数据竞争或 UBMiri、ThreadSanitizer
段错误空指针/悬垂解引用加断言、Miri
内存泄漏Drop 没实现或提前 returnValgrind、自定义计数
双重释放Drop 和手动释放重复审查所有权路径
跨线程诡异行为错误实现 Send/Sync审查 unsafe impl

4.3 几条我坚持的实操原则

第一,unsafe 块尽量小。能一行解决就别写十行,块越小,需要人工验证的范围越小。我见过把整个函数体包进unsafe的写法,那基本等于放弃治疗。

第二,每个 unsafe 块上面写注释,说明“为什么这里安全”。这不是形式主义,是给未来的自己和 code review 的人看的。半年后你根本记不住当时的推理。

第三,安全抽象要封死。对外暴露的 API 必须是安全的,unsafe全部藏在内部。这是 Rust 生态里所有底层库的通用做法,比如标准库的Vec、Rc,内部一堆unsafe,对外零unsafe。

第四,测试 + Miri + fuzz 三件套。单元测试覆盖正常路径,Miri 抓 UB,fuzz 找边界。三者结合,能挡掉绝大多数问题。

5. 安全抽象的边界与性能权衡

5.1 什么时候真的需要 unsafe

不是所有性能优化都需要unsafe。我见过有人为了“快”把简单循环改成裸指针,结果性能没提升多少,bug 多了一堆。判断标准很简单:先用安全代码写,profile 之后确认瓶颈在安全检查上,再考虑 unsafe。

真正需要unsafe的场景其实有限:FFI 调用、底层数据结构(自定义容器、侵入式链表)、SIMD 内联、无锁并发、以及某些需要精确控制内存布局的场合。其余情况,安全 Rust 的性能已经足够好。

5.2 安全抽象的“契约”怎么设计

一个合格的安全抽象,核心是:把 unsafe 的不变量收敛到少数几个地方,并保证外部无法破坏它们。以我们的FixedBuf为例,不变量是“len之前的元素都已初始化,len到cap之间未初始化”。只要所有方法都维护这个不变量,外部拿到的就是安全接口。

设计时我会问三个问题:这个类型的不变量是什么?哪些操作可能破坏它?我如何保证外部无法绕过?把这三个问题答清楚,抽象基本就稳了。

5.3 性能实测:unsafe 到底快多少

我做过一个简单对比:对一个Vec<u64>求和,安全迭代器版本和裸指针版本,在 release 下差距通常在个位数百分比以内,很多时候编译器优化后几乎一样。真正拉开差距的是涉及边界检查消除、内存布局控制、SIMD 的场景。

所以我的建议是:别为了“可能更快”而用 unsafe,要为“确实需要”而用。每次引入unsafe,都要能说清楚它换来了什么,以及你为此承担了什么风险。

6. 我个人在实际操作中的体会

写了几年 Rust,我对unsafe的态度从“敬而远之”变成了“尊重但敢用”。它不是什么洪水猛兽,也不是炫技工具,而是一把需要正确握持的手术刀。用得好,你能写出既安全又高效的底层代码;用不好,UB 会在你最意想不到的时候找上门。

最后分享一个小习惯:每次写完unsafe代码,我会刻意隔一天再回看一遍,假装自己是审查者,逐条核对 Safety 文档和不变量。这个“隔夜复查”帮我抓到过好几次当时没意识到的悬垂和别名问题。如果你也在写底层代码,不妨试试这个笨办法,比事后 debug 划算得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询