第11章:底层能力 —— Unsafe、宏、类型系统与编译陷阱
约 13 分钟 · 更新于 2026-09-01
第11章:底层能力 —— Unsafe、宏、类型系统与编译陷阱
Rust 的安全边界不是由“有没有写 unsafe”简单划分的,而是由一组可验证的不变量构成:地址有效、布局匹配、别名合法、析构可执行、线程访问受控。unsafe 允许实现者手动承担这些证明责任;宏负责在编译期生成重复代码;newtype、DST、型变和 PhantomData 则帮助类型系统更准确地表达语义。本章以“边界、证明、验证”三层方法组织这些高级主题。
本章目标
- 准确说明 unsafe 增加了哪些能力,以及哪些检查仍然存在。
- 能把底层操作封装在小型安全 API 后,并为其编写可审计的 SAFETY 说明。
- 理解 panic、Drop、型变和 Send / Sync 都可能影响 unsafe 代码的可靠性。
- 根据任务选择声明宏、过程宏、普通函数或泛型。
- 选择 From、TryFrom、专用转换 API,而不是用 as 或 transmute 掩盖失败。
- 使用 newtype、DST、?Sized 与 PhantomData 表达真实的数据模型。
- 借助 Miri、编译测试、属性测试与 fuzz 验证关键不变量。
一、先定义安全边界
一段底层代码通常可以分成三层:
text
安全入口
-> 验证长度、索引、对齐、状态和权限
-> 极小 unsafe 核心
-> 恢复可由 Safe Rust 继续维护的不变量
-> 安全返回值
审查重点不是“unsafe 行数是否为零”,而是:
- 调用者需要满足哪些前置条件;
- 实现者检查了哪些条件;
- 哪些事实由注释和类型保证;
- 正常返回、错误返回、panic 与 Drop 路径是否都保持有效状态。
如果安全 API 能在内部完成校验,就不应把责任无谓地推给调用者。
二、unsafe 开放的是有限能力
2.1 常见操作
在 unsafe 上下文中可以执行 Safe Rust 不允许直接执行的操作,例如:
- 解引用裸指针;
- 调用 unsafe fn 或方法;
- 访问可变静态状态;
- 实现 unsafe trait;
- 读取 union 字段;
- 进行某些 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 缩小访问路径数量
一个实用模式是:
- 在底层边界把引用转成裸指针;
- 在核心操作中只使用一组明确的裸指针;
- 避免旧引用、新引用和裸指针交叉使用;
- 返回前再构造满足契约的引用。
这不是形式化证明,但能让别名关系更容易推理。
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 期间的 T。PhantomData<&'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
});
没有 collect、sum、for_each 或 for,闭包不会运行。编译错误或“日志没打印”往往来自对惰性求值的误解。
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 与底层类型
宏
十五、实践练习
- 为 get_two_mut 增加完整测试,包括边界、重复索引和带 Drop 类型,并在 Miri 下运行。
- 实现一个固定容量缓冲区,写出初始化位图、析构和 panic safety 的不变量说明。
- 为 UserId、OrderId、Cents 和 Percentage 设计 newtype,限制无效构造并实现必要转换。
- 编写 ensure! 宏的多种分支,验证参数只求值一次并支持尾随逗号。
- 为一个 derive 宏编写 trybuild 测试,覆盖结构体、枚举、泛型和无效属性参数。
- 使用 TryFrom<u8> 解析协议枚举,并为未知值保留原始字节。
- 分别使用 PhantomData<T>、PhantomData<&'a T> 和 PhantomData<&'a mut T> 建立小型编译实验,观察线程属性和生命周期转换。
- 找一段使用 as 的业务代码,列出截断、符号变化和平台位宽风险,再替换为显式转换。
- 给一个 unsafe 容器写“正常、错误、panic、Drop”四条状态迁移图,确认每个节点均可安全析构。
十六、总结
- unsafe 开放有限底层能力,不会关闭 Rust 的全部检查。
- unsafe 的真正成本是证明责任:地址、布局、初始化、别名、寿命和析构都必须成立。
- 安全抽象应在内部验证可检查条件,并将危险核心压缩到最小范围。
- SAFETY 注释应记录证明链,而不是仅说明代码使用了裸指针。
- panic 和 Drop 路径同样属于内存安全审查范围。
- Miri 能发现已执行路径中的多类问题,但仍需编译测试、属性测试、fuzz 与人工审查配合。
- unsafe impl Send/Sync 是跨线程承诺,必须覆盖容器及其所有访问路径。
- 宏适合生成句法结构;普通业务逻辑应保留在易读、易测的函数和 trait 中。
- 转换应优先表达可靠性或失败可能,外部值不能直接被解释成任意枚举或布局。
- newtype 提供类型隔离和不变量入口,类型别名只改善可读性。
- DST 通过胖指针使用,?Sized 用于放宽泛型默认约束。
- 型变、PhantomData、Drop Check 与自动特征共同描述裸指针类型的真实语义。
- 许多编译问题本质上是权限、求值时机、复杂度或资源寿命问题,修复应从语义而非报错表面入手。
十七、章节导航
上一章解释了运行时如何安全驱动可能自引用的 Future;本章展开 Pin 背后的底层契约,并延伸到宏和高级类型系统。下一章把这些语言能力放进 Cargo Workspace、测试矩阵、CI 与发布流程中。
- 上一章:第10章:异步编程
- 下一章:第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布
- Rust 深度教程
- 第10章:异步编程
- 第12章:Cargo 工程化