Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第11章

第11章:底层能力 —— Unsafe、宏、类型系统与编译陷阱

13 分钟 · 更新于 2026-09-01

第11章:底层能力 —— Unsafe、宏、类型系统与编译陷阱

Rust 的安全边界不是由“有没有写 unsafe”简单划分的,而是由一组可验证的不变量构成:地址有效、布局匹配、别名合法、析构可执行、线程访问受控。unsafe 允许实现者手动承担这些证明责任;宏负责在编译期生成重复代码;newtype、DST、型变和 PhantomData 则帮助类型系统更准确地表达语义。本章以“边界、证明、验证”三层方法组织这些高级主题。

本章目标

  • 准确说明 unsafe 增加了哪些能力,以及哪些检查仍然存在。
  • 能把底层操作封装在小型安全 API 后,并为其编写可审计的 SAFETY 说明。
  • 理解 panic、Drop、型变和 Send / Sync 都可能影响 unsafe 代码的可靠性。
  • 根据任务选择声明宏、过程宏、普通函数或泛型。
  • 选择 FromTryFrom、专用转换 API,而不是用 astransmute 掩盖失败。
  • 使用 newtype、DST、?SizedPhantomData 表达真实的数据模型。
  • 借助 Miri、编译测试、属性测试与 fuzz 验证关键不变量。

一、先定义安全边界

一段底层代码通常可以分成三层:

text
安全入口
  -> 验证长度、索引、对齐、状态和权限
  -> 极小 unsafe 核心
  -> 恢复可由 Safe Rust 继续维护的不变量
  -> 安全返回值

审查重点不是“unsafe 行数是否为零”,而是:

  • 调用者需要满足哪些前置条件;
  • 实现者检查了哪些条件;
  • 哪些事实由注释和类型保证;
  • 正常返回、错误返回、panic 与 Drop 路径是否都保持有效状态。

如果安全 API 能在内部完成校验,就不应把责任无谓地推给调用者。


二、unsafe 开放的是有限能力

2.1 常见操作

unsafe 上下文中可以执行 Safe Rust 不允许直接执行的操作,例如:

  1. 解引用裸指针;
  2. 调用 unsafe fn 或方法;
  3. 访问可变静态状态;
  4. 实现 unsafe trait
  5. 读取 union 字段;
  6. 进行某些 FFI、内联汇编和布局相关操作。
rust
let mut level = 10;
let pointer = &mut level as *mut i32;

unsafe {
    *pointer += 5;
}

assert_eq!(level, 15);

2.2 unsafe 不会关闭类型系统

rust
unsafe {
    let message = String::from("ready");
    let moved = message;
    // println!("{message}"); // 仍然违反所有权规则
    println!("{moved}");
}

所有权、类型匹配、模式穷尽、很多生命周期检查仍然存在。unsafe 只是允许实现者执行几类编译器无法独立证明的操作。

2.3 未定义行为与普通错误不同

普通错误可能返回错误值或触发 panic;未定义行为会破坏语言对程序的基本假设。常见来源包括:

  • 越界访问;
  • 使用已释放内存;
  • 解引用空、悬垂或未对齐指针;
  • 读取未初始化数据;
  • 同时构造冲突的引用;
  • 数据竞争;
  • 构造类型不允许的位模式;
  • 错误 ABI 或布局转换。

“当前运行结果正确”无法证明不存在未定义行为。优化器可以假设合法 Rust 程序不会触发这些情况,并据此重排或删除代码。


三、构建安全抽象:以两个可变元素为例

目标 API:从切片中取得两个索引不同的可变引用。

rust
#[derive(Debug, PartialEq)]
enum TwoMutError {
    SameIndex,
    OutOfBounds,
}

fn get_two_mut<T>(
    slice: &mut [T],
    left: usize,
    right: usize,
) -> Result<(&mut T, &mut T), TwoMutError> {
    if left == right {
        return Err(TwoMutError::SameIndex);
    }

    if left >= slice.len() || right >= slice.len() {
        return Err(TwoMutError::OutOfBounds);
    }

    let pointer = slice.as_mut_ptr();

    // SAFETY:
    // - 两个索引已经验证均位于切片范围内;
    // - left != right,因此两个元素不重叠;
    // - pointer 来源于当前独占借用,且返回引用不会超过 slice 的生命周期;
    // - add 仍位于同一已分配对象内,元素对齐由 Vec/切片布局保证。
    unsafe {
        Ok((&mut *pointer.add(left), &mut *pointer.add(right)))
    }
}

3.1 这个 API 为什么可以是安全函数

调用者只传索引,所有危险前提都能由实现内部检查。因此将函数标记为 unsafe fn 只会增加调用成本,并不会提供更多正确性。

安全抽象的责任分配原则是:

  • 可验证条件由实现者检查;
  • 无法在运行时验证的契约才由 unsafe fn 文档交给调用者;
  • 返回安全引用前,必须恢复别名、寿命和布局约束。

3.2 SAFETY 注释应写证明链

无效注释:

rust
// SAFETY: use raw pointer here.

有效注释应逐项连接 API 前置条件与底层操作要求:来源、长度、对齐、不重叠、初始化状态和生命周期。审查者应能从注释反推出每条条件由哪段代码保证。


四、裸指针与别名:地址相同不代表访问合法

4.1 引用包含语义承诺

&T 表示一段有效的共享访问;&mut T 表示独占访问。它们不仅是整数地址。

rust
let mut value = 5;
let raw = &mut value as *mut i32;

unsafe {
    *raw += 1;
}

这段代码中,裸指针由合法可变引用产生,并在原值仍然存活时使用。真正困难的情况是同时保留多个访问路径、从裸指针反复制造冲突引用,或让引用活过原分配。

4.2 缩小访问路径数量

一个实用模式是:

  1. 在底层边界把引用转成裸指针;
  2. 在核心操作中只使用一组明确的裸指针;
  3. 避免旧引用、新引用和裸指针交叉使用;
  4. 返回前再构造满足契约的引用。

这不是形式化证明,但能让别名关系更容易推理。

4.3 指针算术需要同时证明多件事

调用 pointer.add(index) 时,不能只检查最终地址“看起来可访问”。还要考虑:

  • 指针是否来自正确分配;
  • 运算是否停留在同一分配对象内;
  • 目标是否正确对齐;
  • 目标元素是否已初始化;
  • 得到的访问是否与其他引用冲突;
  • 对象是否在整个使用期间存活。

专用标准库 API 往往已经封装这些条件,应优先使用。


五、panic 与 Drop:失败路径也属于证明范围

5.1 临时破坏不变量的危险窗口

实现链表、向量或队列时,代码可能经历:

text
取出旧节点 -> 改写前驱 -> 改写后继 -> 更新长度 -> 返回

如果中间调用了可能 panic 的用户代码,或者断言失败,析构逻辑可能面对半完成结构。

安全设计应尽量:

  • 在修改指针前完成可能失败的计算;
  • 使用 Option::take 明确转移所有权;
  • 每一步后都保持对象至少可安全析构;
  • 不在临时不一致状态中调用用户回调;
  • 将长度、头尾指针等关键元数据作为事务一起更新;
  • 为 panic 路径编写测试。

5.2 析构顺序是实现的一部分

含裸指针的拥有型容器需要说明:

  • 谁释放分配;
  • 每个元素析构几次;
  • 部分初始化时如何清理;
  • 元素 Drop 发生 panic 时剩余节点如何处理;
  • 容器移动或线程转移后指针是否仍有效。

unsafe 审查不能只看正常读取和写入,还必须跟踪所有权结束时的路径。


六、Miri 与分层验证

Miri 可以解释执行 MIR,并检测许多实际走到的未定义行为:

bash
cargo +nightly miri test

它特别适合发现:

  • 越界和悬垂访问;
  • 未初始化读取;
  • 对齐错误;
  • 部分别名违规;
  • 部分数据竞争。

但 Miri 只能检查测试覆盖到的路径。一个更完整的验证组合可以是:

text
单元测试与文档测试
  + compile-pass / compile-fail
  + Miri
  + 属性测试
  + fuzz
  + 目标平台测试
  + 人工不变量审查

6.1 让测试对准契约

不要只测试“结果为 42”。底层模块还应覆盖:

  • 空集合、单元素和容量边界;
  • 重复插入删除;
  • 不同析构顺序;
  • panic 中断;
  • Copy、带 Drop 和高对齐类型;
  • 迭代期间结构变化限制;
  • 正向和负向线程属性。

七、Send、Sync 与手动承诺

7.1 自动特征会沿字段传播

Send 表示值可在线程间移动;Sync 表示共享引用可在线程间使用。普通组合类型通常由字段自动推导。

含有裸指针的自定义容器不会自动获得作者期望的语义,因为编译器不知道指针指向什么、由谁拥有、是否有同步机制。

7.2 unsafe impl 的审查问题

rust
struct Buffer<T> {
    pointer: *mut T,
    len: usize,
    capacity: usize,
}

// 只有在完整证明后才可能实现:
// unsafe impl<T: Send> Send for Buffer<T> {}

实现前应回答:

  • 线程转移时,分配与指针关系是否保持;
  • 是否存在与外部共享的未同步别名;
  • 析构是否只能发生一次;
  • T 的 trait 边界是否足够;
  • 容器暴露的迭代器和引用是否遵守同一规则。

Sync 通常比 Send 要求更强,因为它允许多个线程持有 &Buffer<T>。若共享方法能修改内部状态,就必须有可靠同步。

7.3 编译期断言

rust
#[allow(dead_code)]
fn assert_thread_properties() {
    fn require_send<T: Send>() {}
    fn require_sync<T: Sync>() {}

    require_send::<Vec<i32>>();
    require_sync::<Vec<i32>>();
}

负向属性可以使用 compile_fail 文档测试或 trybuild,以验证某些类型必须无法跨线程。


八、宏:生成句法,不隐藏语义

8.1 何时使用宏

普通函数处理运行时的值;宏在编译期接收 token 并生成代码。宏适合:

  • 可变数量的句法参数;
  • 大量重复实现;
  • derive、属性注入和小型 DSL;
  • 需要在调用位置生成类型或模式的代码。

如果普通函数、泛型或 trait 已经足够,宏通常只会增加错误信息和维护复杂度。

8.2 声明宏:生成验证代码

rust
macro_rules! ensure {
    ($condition:expr, $error:expr $(,)?) => {{
        if !$condition {
            return Err($error);
        }
    }};
}

fn validate_port(port: u16) -> Result<(), &'static str> {
    ensure!(port != 0, "port must not be zero");
    Ok(())
}

双层花括号创建局部作用域,尾随逗号提升调用体验。

8.3 避免输入表达式重复求值

错误展开:

rust
macro_rules! duplicate {
    ($value:expr) => {
        ($value, $value)
    };
}

传入 next_id() 会执行两次。若宏语义要求只求值一次,应先绑定:

rust
macro_rules! duplicate_once {
    ($value:expr) => {{
        let value = $value;
        (value.clone(), value)
    }};
}

宏作者必须检查:输入表达式求值次数、临时变量作用域、控制流跳转、名称冲突和错误定位。

8.4 过程宏的职责边界

过程宏常见形态:

rust
#[derive(Validate)]
struct Request;

#[trace]
async fn handle() {}

query!(select users);

典型工程分为:

  • 普通库:定义 trait 和运行时逻辑;
  • proc-macro crate:解析 token,生成胶水代码;
  • 编译测试:验证有效输入和错误输入。

过程宏不应成为难以测试的业务逻辑仓库。生成代码应使用稳定路径,错误应指向调用位置,并保留泛型参数与 where 子句。


九、转换:让失败进入类型

9.1 as 可能静默改变值

rust
let source: u16 = 300;
let narrowed = source as u8;
assert_eq!(narrowed, 44);

窄化会截断高位。协议解析、金额、索引和外部输入不应依赖这种静默行为。

9.2 可靠与可失败转换

rust
let text = String::from("ready");
let bytes = text.into_bytes();

From / Into 表达可靠转换;TryFrom / TryInto 表达可能失败:

rust
use std::convert::TryFrom;

let value = u8::try_from(300_u16);
assert!(value.is_err());

API 接收多种可靠输入时可以使用 impl Into<String>;外部数值转换则应优先返回具体错误。

9.3 枚举解析要显式校验

rust
#[repr(u8)]
#[derive(Debug, PartialEq)]
enum PacketKind {
    Data = 1,
    Ack = 2,
}

#[derive(Debug)]
struct UnknownPacketKind(u8);

impl TryFrom<u8> for PacketKind {
    type Error = UnknownPacketKind;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            1 => Ok(Self::Data),
            2 => Ok(Self::Ack),
            other => Err(UnknownPacketKind(other)),
        }
    }
}

即使指定 repr,任意整数也不一定是枚举的合法值。直接把外部字节变成枚举可能构造无效位模式。

9.4 transmute 应位于选择链末端

优先顺序可以是:

text
转换 trait
-> 专用 API(from_*_bytes、pointer::cast 等)
-> 明确 repr 后逐字段转换
-> 有完整布局和有效值证明的 transmute

“源和目标大小相同”远远不够,还要证明对齐、有效位模式、生命周期、所有权和布局稳定性。


十、newtype 与类型别名

10.1 newtype 创建业务隔离

rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct UserId(u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct OrderId(u64);

fn load_user(id: UserId) {
    println!("load {id:?}");
}

let order_id = OrderId(42);
// load_user(order_id); // 类型不同,编译器拒绝

newtype 可用于:

  • 防止单位和业务概念混用;
  • 限制构造,维护范围不变量;
  • 为外部类型实现本地 trait;
  • 隐藏内部表示;
  • 控制序列化和展示格式。

10.2 类型别名只改善可读性

rust
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type AppResult<T> = Result<T, BoxError>;

类型别名不创建新类型。若写 type UserId = u64,普通 u64 仍可直接传入需要 UserId 的位置。


十一、DST、Sized 与胖指针

11.1 动态大小类型需要间接持有

str[T]dyn Trait 的大小在编译期不固定,不能作为普通局部值直接按值存放。常见形式是:

rust
&str
&[T]
Box<dyn Trait>
Arc<dyn Trait + Send + Sync>

这些指针除数据地址外还带有长度或虚表信息,因此常被称为胖指针。

11.2 泛型默认具有 Sized 约束

rust
fn inspect<T>(value: &T) {
    let _ = value;
}

默认要求 T: Sized。若函数只接收引用,并希望允许 DST:

rust
fn inspect<T: ?Sized>(value: &T) {
    let _ = value;
}

?Sized 表示解除默认约束,不表示 T 必然是动态大小类型。

11.3 类型应表达可用操作

String 可增长,Box<str> 拥有固定内容:

rust
let text = String::from("immutable payload");
let boxed: Box<str> = text.into_boxed_str();

若业务不需要容量和追加能力,固定表示可以让 API 意图更清楚。


十二、型变与 PhantomData

12.1 型变决定类型参数能否被安全替换

对于生命周期,较长生命周期的共享引用通常可以缩短使用;但可变引用若允许任意替换,就可能写入寿命更短的值。

常见直觉:

构造T 的关系
&T协变
&mut T不变
Cell<T>不变
*const T协变
*mut T不变

型变不是日常语法细节,而是含裸指针容器、迭代器和生命周期包装器能否安全转换的基础。

12.2 PhantomData 补充未出现在字段中的语义

rust
use std::marker::PhantomData;

struct Iter<'a, T> {
    current: *const T,
    end: *const T,
    marker: PhantomData<&'a T>,
}

虽然真实字段只有裸指针,但迭代器语义上借用了 'a 期间的 TPhantomData<&'a T> 将这个事实告诉编译器,并影响:

  • 生命周期使用;
  • 型变;
  • Drop Check;
  • 自动特征推导。

可变迭代器应表达独占语义:

rust
struct IterMut<'a, T> {
    current: *mut T,
    end: *mut T,
    marker: PhantomData<&'a mut T>,
}

不能只为了消除“未使用类型参数”警告而随意选择 marker。不同 PhantomData 形式会改变类型的真实合同。


十三、从编译错误回到语义

13.1 字段是 &mut T,不代表 &self 可修改它

rust
struct Holder<'a> {
    value: &'a mut i32,
}

impl Holder<'_> {
    fn update(&mut self) {
        *self.value += 1;
    }
}

要通过结构体取得独占访问,方法本身也需要足够的独占权限。&self 可能同时存在多份,不能从每一份共享引用中都取出同一个可变访问。

13.2 辅助函数参数过宽会扩大借用

若函数只修改 state.left,签名应接收 &mut Left,而不是整个 &mut State。参数越精确,借用检查器越容易证明不同字段可以独立使用。

13.3 惰性迭代器需要消费者

rust
let transformed = values.iter().map(|value| {
    println!("visit {value}");
    value * 2
});

没有 collectsumfor_eachfor,闭包不会运行。编译错误或“日志没打印”往往来自对惰性求值的误解。

13.4 UTF-8 索引要考虑复杂度和边界

反复调用 chars().nth(index) 每次都从头解码,可能形成平方复杂度。需要字符位置时应直接迭代 chars()char_indices();需要字节切片时必须保证边界位于合法 UTF-8 字符分界处。

13.5 算术策略应显式

rust
value.checked_add(delta)
value.saturating_add(delta)
value.wrapping_add(delta)
value.overflowing_add(delta)

选择哪一个取决于业务含义,而不是依赖 debug/release 构建差异。


十四、审查清单

unsafe 与底层类型

  • 是否已有安全标准库或成熟 crate 可用?
  • unsafe 核心是否足够小,入口是否先验证条件?
  • SAFETY 注释是否覆盖来源、范围、对齐、初始化、别名和生命周期?
  • 正常、错误、panic 和 Drop 路径是否都维持不变量?
  • Send / Sync、型变与 Drop Check 是否与字段语义一致?
  • 是否使用 Miri、属性测试或 fuzz 覆盖关键状态?
  • FFI 的 ABI、所有权和释放方是否明确?

  • 普通函数、泛型或 trait 是否更简单?
  • 输入表达式的求值次数是否符合预期?
  • 展开代码是否依赖调用者的隐式导入?
  • 错误能否准确指向调用位置?
  • 泛型、生命周期和 where 子句是否完整保留?
  • 是否有成功与失败的编译测试?
  • 复杂逻辑是否留在普通库中?

十五、实践练习

  1. get_two_mut 增加完整测试,包括边界、重复索引和带 Drop 类型,并在 Miri 下运行。
  2. 实现一个固定容量缓冲区,写出初始化位图、析构和 panic safety 的不变量说明。
  3. UserIdOrderIdCentsPercentage 设计 newtype,限制无效构造并实现必要转换。
  4. 编写 ensure! 宏的多种分支,验证参数只求值一次并支持尾随逗号。
  5. 为一个 derive 宏编写 trybuild 测试,覆盖结构体、枚举、泛型和无效属性参数。
  6. 使用 TryFrom<u8> 解析协议枚举,并为未知值保留原始字节。
  7. 分别使用 PhantomData<T>PhantomData<&'a T>PhantomData<&'a mut T> 建立小型编译实验,观察线程属性和生命周期转换。
  8. 找一段使用 as 的业务代码,列出截断、符号变化和平台位宽风险,再替换为显式转换。
  9. 给一个 unsafe 容器写“正常、错误、panic、Drop”四条状态迁移图,确认每个节点均可安全析构。

十六、总结

  1. unsafe 开放有限底层能力,不会关闭 Rust 的全部检查。
  2. unsafe 的真正成本是证明责任:地址、布局、初始化、别名、寿命和析构都必须成立。
  3. 安全抽象应在内部验证可检查条件,并将危险核心压缩到最小范围。
  4. SAFETY 注释应记录证明链,而不是仅说明代码使用了裸指针。
  5. panic 和 Drop 路径同样属于内存安全审查范围。
  6. Miri 能发现已执行路径中的多类问题,但仍需编译测试、属性测试、fuzz 与人工审查配合。
  7. unsafe impl Send/Sync 是跨线程承诺,必须覆盖容器及其所有访问路径。
  8. 宏适合生成句法结构;普通业务逻辑应保留在易读、易测的函数和 trait 中。
  9. 转换应优先表达可靠性或失败可能,外部值不能直接被解释成任意枚举或布局。
  10. newtype 提供类型隔离和不变量入口,类型别名只改善可读性。
  11. DST 通过胖指针使用,?Sized 用于放宽泛型默认约束。
  12. 型变、PhantomData、Drop Check 与自动特征共同描述裸指针类型的真实语义。
  13. 许多编译问题本质上是权限、求值时机、复杂度或资源寿命问题,修复应从语义而非报错表面入手。

十七、章节导航

上一章解释了运行时如何安全驱动可能自引用的 Future;本章展开 Pin 背后的底层契约,并延伸到宏和高级类型系统。下一章把这些语言能力放进 Cargo Workspace、测试矩阵、CI 与发布流程中。

  • 上一章:第10章:异步编程
  • 下一章:第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布

  • Rust 深度教程
  • 第10章:异步编程
  • 第12章:Cargo 工程化

本章目录
一、先定义安全边界二、unsafe 开放的是有限能力三、构建安全抽象:以两个可变元素为例四、裸指针与别名:地址相同不代表访问合法五、panic 与 Drop:失败路径也属于证明范围六、Miri 与分层验证七、Send、Sync 与手动承诺八、宏:生成句法,不隐藏语义九、转换:让失败进入类型十、newtype 与类型别名十一、DST、Sized 与胖指针十二、型变与 PhantomData十三、从编译错误回到语义十四、审查清单十五、实践练习十六、总结十七、章节导航Related Documents
苏ICP备2025204887号-2