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_刘金,转载请注明原文链接。感谢!

相关推荐
围炉聊科技1 天前
Vibe Kanban:Rust构建的AI编程代理编排平台
开发语言·rust·ai编程
半夏知半秋1 天前
rust学习-循环
开发语言·笔记·后端·学习·rust
梦想画家1 天前
Rust模块化开发从入门到精通:用mod搭建高可维护项目架构
架构·rust·模块化开发
木木木一1 天前
Rust学习记录--C4 Rust所有权
开发语言·学习·rust
浪客川1 天前
【百例RUST - 004】函数使用
服务器·开发语言·rust
superman超哥1 天前
Rust Cell与RefCell的使用场景与区别:内部可变性的精确选择
开发语言·后端·rust·refcell·rust cell·内部可变性·精确选择
superman超哥2 天前
Rust 可变借用的独占性要求:排他访问的编译期保证
开发语言·后端·rust·rust可变借用·独占性要求·排他访问·编译期保证
superman超哥2 天前
Rust 引用的作用域与Non-Lexical Lifetimes(NLL):生命周期的精确革命
开发语言·后端·rust·生命周期·编程语言·rust引用的作用域·rust nll
古城小栈2 天前
Rust 生命周期,三巨头之一
开发语言·后端·rust