Rust 之所以能说"Safe Rust 是安全的语言",关键不是因为程序里永远没有风险操作,而是因为:
-
所有可能导致未定义行为(Undefined Behavior, UB)的能力,都被关在
unsafe这道门后面 -
只要你不跨过这道门(不用
unsafe),Safe Rust 自身保证:无论如何都不会造成 UB这就是强调的 soundness property:Safe Rust 不能导致 UB。
你甚至可以在项目里加:
rust
#![forbid(unsafe_code)]
来静态保证整个代码库里完全没有 unsafe。
2) 什么是 UB?为什么"千万不要触发 UB"?
Undefined Behavior , 未定义行为 。 触发 UB 会让编译器拥有"随意把程序代码改写/删掉/重排"的权利 。
直观理解:一旦 UB 出现,程序的行为就不再受语言规则约束------你看到的现象可能是随机崩溃、数据错乱、看似正常但暗中腐坏,甚至"只在 release 模式出问题"。 比如:
-
某些检查/分支被直接删掉
编译器觉得"这个条件永远不可能为真",就把
if砍了。 -
读写顺序被重排,导致你以为的先后顺序不成立
你以为"先写再读",但编译器/CPU 可能重排(尤其在并发/别名被破坏时)。
-
值凭空变化(好像"时间倒流")
编译器可能把某次读当成"肯定等于之前那个值",于是直接复用旧值。
-
debug 正常、release 崩坏/乱跑
因为 release 优化更激进,更依赖这些"不可能发生"的前提。
-
有时崩溃,有时不崩溃;换个打印语句就变了
UB 的行为不受约束,任何微小变化都会影响布局与优化结果。
所以 Rust 把可能导致 UB 的能力集中到 unsafe 里:你用了,就必须承担契约责任。
和 C 不同,Rust 把 UB 的"内置原因"列得很明确;核心关心的主要是下面这些类别(逐条列出):
指针/引用相关
- 解引用悬垂(dangling)或未对齐(unaligned)的指针
- 破坏指针别名规则(pointer aliasing rules)
(直觉:Rust 对&/&mut的"独占/共享"有严格优化假设;你用 unsafe 搞出同时存在的冲突别名,就可能 UB。)
ABI / unwind 不匹配
- 用错调用 ABI (比如声明成
extern "C"但实际不是) - 用错 unwind ABI,或者从不允许 unwind 的 ABI 里 unwinding 出来
并发:数据竞争
- 造成 data race(数据竞争)
注意:在 Rust 里 data race 是 UB(这和很多人"只是逻辑 bug" 的直觉不同)。
CPU 特性不匹配
- 在当前线程不支持的 target features 下执行了相关指令/代码
产生"无效值"(invalid values)
这是列表里最长的一块:Rust 对某些类型的"位模式"有约束,产生不合法的值就是 UB。页面举了很多例子,比如:
-
bool只能是 0 或 1 -
enum不能有非法 discriminant -
fn指针不能是 null -
char必须在合法 Unicode 范围内 -
!类型没有任何合法值 -
从未初始化内存 读出来的整数/浮点/原始指针是无效的(还有
str里的未初始化) -
&T/Box<T>如果是悬垂、未对齐、或指向无效值 → 无效 -
胖指针(wide pointer)元数据无效:
dyn Trait的 vtable 指针不匹配真实类型- slice 的长度元数据不是有效
usize(例如来自未初始化内存)
还有"自定义无效值"的类型(文中提到 NonNull 这种在稳定库里就利用了"不能为空"这一无效值约束)。
这里的关键直觉:Unsafe 操作往往会"绕开初始化/布局/别名"这些保证,一旦构造出语言认为不可能出现的值,编译器就会按"不可能"去优化,立刻进入 UB 地带。
3) unsafe 关键字的两层含义:声明"这里可能会出现 UB 要注意",以及声明"但是我检查过了"
这里把 unsafe 的作用归纳为两类(非常重要):
用 unsafe 声明存在编译器检查不了的契约(contract)
用之前提示你,用的时候你要注意,得按照文档提示的用法才行,否则可能 UB
用 unsafe 声明:我已经手动检查过这些契约被满足
用了之后,则说明,我已经都检查过了,我可以确保我的用法是安全的。
一句话理解:
unsafe不是"这里会出错",而是"这里有编译器无法证明的条件,我人为担保它成立"。
4) 哪些东西典型地是 unsafe:为什么它们危险
举了标准库里一些典型的 unsafe 能力:
slice::get_unchecked:不做边界检查,越界就可能读/写非法内存 → UBmem::transmute:任意重解释类型,绕过类型系统 → 很容易 UB- 原始指针
offset:如果偏移不满足"in bounds"规则 → UB - FFI 调用(外部语言函数):Rust 编译器无法验证对方行为 → 默认
unsafe
这些共同点:编译器没法(或没成本)为你验证关键约束。
5) 在编写 Unsafe 代码的时候,要确保 Unsafe 的正确性,,让 Safe 能够并且必须信任 Unsafe;同时 写 Unsafe 代码不能随便信任 Safe 代码传过来的数据,有可能是错的,这种情况下要有防护。
- 确保提供给别人的 Safe Rust 用的 Unsafe Rust 编写是正确的
因为 Safe 代码做不到检查那些底层细节(比如指针、布局、别名规则等)。 - 编写 Unsafe Rust 不能不加防护地信任"外部传进来的 Safe Rust 行为是正确的"
因为 Safe 代码可以写得"逻辑不正确",虽然它不会 UB,但它可以违反你以为它会遵守的"逻辑规律"。
经典例子:BTreeMap 为什么不能信任你的 Ord
BTreeMap 要求 key: Ord,看起来你实现 Ord 就应该给出"全序"(total ordering)。
但问题是:
Ord是 safe trait:任何人都能随便实现- 你的
Ord可能写错(比如违反传递性),甚至"假装能用"
而 BTreeMap 内部为了性能使用了 unsafe 实现。如果它"天真地相信 Ord 一定正确",就可能被错误的比较逻辑逼进内部不变量被破坏,从而触发 UB。
因此结论是:
- BTreeMap 的 unsafe 实现必须对"不靠谱的 Ord"也保持内存安全
- 即使这样会让 BTreeMap 行为"很怪/乱",但必须做到:乱可以,UB 不行
6) "无界泛型信任"问题:为什么有 unsafe trait
一个更抽象但很实用的观点:
- 信任
i32、切片这类东西:范围可控(标准库一份实现,维护者也清楚) - 信任泛型参数
T: Ord:这等于信任全世界过去、现在、未来所有人 写的Ord实现
→ 风险不可控("无界信任")
unsafe trait 就是为了解决这个问题:
如果某个 trait 的正确实现对内存安全至关重要,而 unsafe 代码也无法防御错误实现,那么就应该把责任明确压到实现者身上:
- trait 设计成
unsafe trait - 实现时必须
unsafe impl ...,表示"我担保契约成立" - 如果担保是假的,责任归到实现者(符合 Rust 的安全边界设计)
用一个假想的 UnsafeOrd 来说明:如果 BTreeMap 内部真的需要完全信任比较契约,它理论上可以要求 T: UnsafeOrd,而不是 T: Ord。
7) 为什么 Send/Sync/GlobalAlloc 是 unsafe trait
理由很务实:这些性质一旦实现错了,unsafe 代码也没法在运行时"兜底防御" 。
Send:跨线程移动是否安全Sync:多线程共享引用是否安全GlobalAlloc:全局分配器,所有内存分配都建立在它上面;如果它乱来(比如重复发同一块仍在使用的内存),几乎无法检测和补救
所以这些 trait 设计成 unsafe trait 是合理的:把"不可防御的正确性"责任交给实现者。
另外:Send/Sync 还经常能被编译器自动推导实现 ,减少你手写 unsafe impl 的频率。
8)哪些场景下必须要用 Unsafe ?
因为用错会触发 Undefined Behavior(UB) 。所以五件事必须进 unsafe
-
解引用原始指针 (
*const T/*mut T的*p) -
调用
unsafe fn(包括 C FFI、编译器 intrinsic、原始分配器等) -
实现
unsafe trait -
访问或修改
static mut(可变全局静态变量) -
访问
union的字段
9) Rust 认为哪些"很糟糕的事"仍然是 safe 的?
这是这页结尾很重要的"反直觉点":Rust 把很多灾难级后果归为"安全但可能错误",不算 UB,包括:
- 死锁(deadlock)
- 竞态条件(race condition,注意不等于 data race)
- 内存泄漏
- 整数溢出(用
+这类内置运算;debug/release 行为不同但不算 UB) abort程序- "删库跑路"(删除生产数据库)
这些都可能是严重 bug,但 Rust 认为不现实做到语言层面彻底杜绝,因此不把它们算作"内存安全/UB"范畴。
10) enum:带"标签(discriminant)"的联合体(tagged union)
enum 在内存里通常是:
- 一块存放数据的区域(payload)
- 外加一个"我现在是哪一种"的标签(discriminant)
所以编译器知道 当前有效的是哪个变体,你用 match 时也能被强制写全分支:
rust
enum E {
I(u32),
F(f32),
}
fn demo(e: E) {
match e {
E::I(x) => println!("int {x}"),
E::F(x) => println!("float {x}"),
}
}
这里完全 safe:因为 e 自带"当前是 I 还是 F"的信息。
所以:
enum的"当前是什么"由类型系统和编译器维护。
union:没有标签的联合体(untagged union) union 只有一块共享内存,没有"现在是哪一种"的信息:
rust
union U {
i: u32,
f: f32,
}
union 的常见用途是:
- FFI / C 互操作:C 里大量用 union 表示"同一块内存不同解释"
- 底层按位重解释 (bit-cast)/性能敏感的表示法(但 Rust 里很多场景更推荐用标准库提供的安全 API,比如
f32::from_bits、to_bits) - 需要明确控制布局 时(常配合
#[repr(C)])
11) unsafe 的危险不是"块里那几行",而是"它依赖的状态"
Rust 语法上把不安全操作圈在 unsafe { ... } 里,看起来像是"危险被局部隔离了"。但现实是:
- 你在
unsafe里做的事,往往依赖 之前 的某些检查/状态是否成立; - 而这些检查/状态可能在 safe 代码里 建立、也可能在 safe 代码里被破坏。
比如:rust
rust
fn index(idx: usize, arr: &[u8]) -> Option<u8> {
if idx < arr.len() {
unsafe { Some(*arr.get_unchecked(idx)) }
} else {
None
}
}
为什么这是 sound?
get_unchecked(idx)的前置条件是:idx必须在范围内。- 外面的
if idx < arr.len()正好保证了这一点。 - 所以虽然内部用了
unsafe,但这个 safe 函数 对外仍然是健全的。
然后说:把 < 改成 <=:
rust
if idx <= arr.len() { ... }
这就变成 unsound 了,因为当 idx == arr.len() 时:
- safe 分支会执行
get_unchecked(arr.len()) - 这是越界访问 → UB
- 更"恐怖"的点:你只改了一行safe 代码(
<=),却让整个 safe API 变成能触发 UB。
要点 :unsafe 的正确性依赖于某些"看似普通"的 safe 逻辑(比如一个边界条件),因此安全性不是"只审计 unsafe 块那几行"就够的。正确使用的前置条件确实也会造成 UB
12) 第二个例子:有了"持久状态"(struct 字段)后,问题会升级为"污染整个模块"
用一个简化版 Vec<T> 说明:unsafe 往往依赖结构体内部字段的不变量(invariants)。
简化 Vec:
rust
pub struct Vec<T> {
ptr: *mut T,
len: usize,
cap: usize,
}
impl<T> Vec<T> {
pub fn push(&mut self, elem: T) {
if self.len == self.cap { self.reallocate(); }
unsafe {
ptr::write(self.ptr.add(self.len), elem);
self.len += 1;
}
}
}
这里 push 里的 unsafe 依赖什么不变量?
ptr指向一段已分配内存,至少能容纳cap个Tlen <= capptr.add(len)仍在分配范围内- 等等
然后加了一个"看起来 100% safe"的方法:
rust
fn make_room(&mut self) {
self.cap += 1;
}
这段代码没有 unsafe,但它是完全 unsound 的。
因为它破坏了 Vec 的核心不变量:cap 应该反映"实际分配了多大",你不能光改数字不去重新分配内存。
一旦 cap 被你虚增,push 可能认为"容量够了",于是直接 ptr::write 写到未分配区域 → UB。
关键结论
当 unsafe 依赖 struct 的私有字段不变量时,危险不再只"污染"一个函数 ,而是可能"污染"整个模块:
因为任何能随意改这些字段的代码,都能让别处的 unsafe 失效。
解决方案:用"模块边界 + 隐私(privacy)"把不变量锁住
限制 unsafe 影响范围最"子弹proof"的方式,是靠 模块边界的隐私。
为什么?
- 如果
Vec的字段是私有的(ptr/len/cap不对外暴露) - 像
make_room这种会破坏不变量的函数,即使存在,只要不是 public,外部 safe 代码就调用不到 - 外部也无法直接改字段
- 那么你就只需要信任"这个模块内部的实现",不用信任"宇宙中所有 safe 代码都不会乱搞你的字段"
这就让我们能实现一个重要目标:
写出一个对外完全 safe 的抽象,但内部靠
unsafe+ 私有不变量实现高性能/底层能力。
只要用 private 把不变量关隐藏,safe/unsafe 的分层是可以成立的。from Pomelo_刘金,转载请注明原文链接。感谢!