Rust : Safe and Unsafe

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:不做边界检查,越界就可能读/写非法内存 → UB
  • mem::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)。

但问题是:

  • Ordsafe 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/GlobalAllocunsafe 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_bitsto_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 指向一段已分配内存,至少能容纳 capT
  • len <= cap
  • ptr.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_刘金,转载请注明原文链接。感谢!

相关推荐
DongLi012 天前
rustlings 学习笔记 -- exercises/05_vecs
rust
番茄灭世神2 天前
Rust学习笔记第2篇
rust·编程语言
shimly1234563 天前
(done) 速通 rustlings(20) 错误处理1 --- 不涉及Traits
rust
shimly1234563 天前
(done) 速通 rustlings(19) Option
rust
@atweiwei3 天前
rust所有权机制详解
开发语言·数据结构·后端·rust·内存·所有权
shimly1234563 天前
(done) 速通 rustlings(24) 错误处理2 --- 涉及Traits
rust
shimly1234563 天前
(done) 速通 rustlings(23) 特性 Traits
rust
shimly1234563 天前
(done) 速通 rustlings(17) 哈希表
rust
shimly1234563 天前
(done) 速通 rustlings(15) 字符串
rust
shimly1234563 天前
(done) 速通 rustlings(22) 泛型
rust