22. Unsafe Code — 不安全代码
Rust 的安全保证在绝大多数场景下足够强大,但系统编程有时需要突破这些限制。unsafe 关键字让你告诉编译器:"我知道自己在做什么,请相信我。"本章介绍 unsafe 代码的五大能力、原始指针操作、未定义行为的边界、以及如何用 safe 抽象包装 unsafe 实现。
为什么需要 unsafe
所有编程语言最终都要操作裸机和字节。Rust 的类型系统、借用检查器和生命周期分析是抽象层,而这些抽象层本身是用 unsafe 代码实现的。Vec<T> 的高效缓冲区管理、std::io 的系统调用交互、std::sync 的并发原语——底层都依赖 unsafe。
unsafe 并不是"关闭所有安全检查"的开关。进入 unsafe 上下文后,Rust 仍然进行类型检查、生命周期检查和借用检查。unsafe 只解锁了五个额外的能力。
unsafe 块解锁的五种能力
unsafe {
// 1. 调用 unsafe 函数
// 2. 解引用原始指针
// 3. 访问 union 字段
// 4. 访问可变 static 变量
// 5. 访问 extern 函数(FFI)
}unsafe 块的主要作用是吸引人工审查——它标记出编译器无法验证的区域,提醒 reviewer 仔细检查。
unsafe 函数
将函数标记为 unsafe 表示调用者必须遵守额外的合约:
/// # Safety
///
/// 调用者必须确保 bytes 只包含 ASCII 字符(0x00..=0x7f)。
/// 违反此合约将导致未定义行为。
pub unsafe fn from_bytes_unchecked(bytes: Vec<u8>) -> Ascii {
Ascii(bytes)
}调用 unsafe 函数必须在 unsafe 块内进行。合约应写在文档注释中。
未定义行为(Undefined Behavior)
未定义行为是 Rust 假设绝对不可能发生的事情。当代码违反 unsafe 特性的合约时,程序的行为变得不可预测——可能崩溃、可能产生错误结果、可能为攻击者打开后门。关键规则:
- 禁止读取未初始化的内存
- 禁止创建无效的基础值:空引用、非 0/1 的
bool、非 Unicode 码点的char、非有效 UTF-8 的str - 必须遵守引用规则:共享引用只读、可变引用独占、引用不能比被引用对象活得更久
- 禁止解引用空指针、未对齐指针、悬垂指针
- 禁止用指针访问关联内存范围之外的地址
- 禁止数据竞争:两个线程非同步地访问同一内存,且至少一个为写
- 禁止在跨越 FFI 边界时展开栈(unwinding 进入 C 代码)
不使用 unsafe 特性的 safe 代码被保证不会触发以上任何一条。一旦使用 unsafe,你就要自己负责。
原始指针(Raw Pointers)
原始指针是 Rust 中最灵活也最危险的数据类型:
let mut x = 10;
let ptr_x: *mut i32 = &mut x; // 从可变引用创建
let ptr_y: *const i32 = &x; // 从共享引用创建
unsafe {
*ptr_x += 5; // 解引用必须在 unsafe 内
println!("{}", *ptr_y); // 15
}原始指针 vs 引用的区别
| 特性 | 引用 (&T, &mut T) | 原始指针 (*const T, *mut T) |
|---|---|---|
| 空值 | 不允许 | 允许 (std::ptr::null()) |
| 自动解引用 | 支持 | 必须显式 *ptr |
| 借用检查 | 编译时强制 | 无检查 |
| 生命周期 | 有界 | 无界 |
| 别名规则 | 编译时保证 | 无保证 |
| 算术运算 | 不支持 | offset, add, sub |
| 线程安全 | 通过 Send/Sync 推导 | 默认都不是 |
指针算术
let arr = [10, 20, 30, 40];
let ptr: *const i32 = arr.as_ptr();
unsafe {
assert_eq!(*ptr, 10);
assert_eq!(*ptr.add(1), 20); // 等同于 ptr.offset(1)
assert_eq!(*ptr.add(2), 30);
// offset_from 计算两个指针间的元素数量
let end = ptr.add(4);
assert_eq!(end.offset_from(ptr), 4);
}ptr.offset(n) 产生指向第 n 个(从 0 开始)元素的指针。offset 要求结果指针不超出原始对象的内存范围(指向"末尾之后第一个位置"是允许的,但不能解引用)。wrapping_offset 是更宽松的版本,不保证结果在范围内。
指针运算前必须确保类型正确对齐。std::mem::align_of::<T>() 返回对齐要求。
创建原始指针是安全的,解引用才需要 unsafe
// safe:创建、传递、比较
let ptr = &value as *const i32;
let is_null = ptr.is_null();
let addr = ptr as usize;
// unsafe:通过指针读取或写入
unsafe {
let val = *ptr;
*mut_ptr = 42;
}安全地使用原始指针
解引用原始指针时必须遵守的规则:
- 指针不能为空或悬垂(指向已释放或已移动的值)
- 指针必须按照类型对齐
- 指针指向的值必须是该类型的有效值(不能将无效位模式解释为
bool或char) - 通过指针获取引用时,必须遵守引用规则
- 指针运算的结果必须仍然在同一个内存对象内
从原始指针读写值
std::ptr 模块提供低级别内存操作:
// ptr::read — 从指针处移出值(所有权转移)
let value = unsafe { std::ptr::read(src_ptr) };
// ptr::write — 将值写入指针位置(覆盖,不 drop 原有值)
unsafe { std::ptr::write(dest_ptr, new_value); }
// ptr::copy — 复制 count 个元素(非重叠或可处理重叠)
unsafe { std::ptr::copy(src, dest, count); }
// ptr::copy_nonoverlapping — 更快的复制(要求不重叠)
unsafe { std::ptr::copy_nonoverlapping(src, dest, count); }
// ptr::drop_in_place — 原地析构值,不释放内存
unsafe { std::ptr::drop_in_place(ptr); }这些函数是 Vec::push、Vec::pop、Vec::insert 等标准库方法的底层基础。
安全抽象:用 safe 接口包装 unsafe
Rust 的哲学是用 unsafe 实现 safe 的 API。标准库中几乎所有类型都是这样构建的。关键原则是:模块内部可以使用 unsafe,但对外的公共接口必须是 safe 的,并且模块自身保证所有 unsafe 操作的合约都被满足。
示例——Ascii 类型,确保只包含 ASCII 字节:
mod my_ascii {
pub struct Ascii(Vec<u8>); // 内部字段不公开
impl Ascii {
// safe 构造器——检查输入
pub fn from_bytes(bytes: Vec<u8>) -> Result<Ascii, NotAsciiError> {
if bytes.iter().any(|&b| !b.is_ascii()) {
return Err(NotAsciiError(bytes));
}
Ok(Ascii(bytes))
}
}
impl From<Ascii> for String {
fn from(ascii: Ascii) -> String {
// 安全:ASCII 文本一定是有效 UTF-8
unsafe { String::from_utf8_unchecked(ascii.0) }
}
}
}Ascii 的公共 API 保证内部数据总是有效的 ASCII,因此调用 String::from_utf8_unchecked(一个 unsafe 函数)的合约在模块内部得到满足。用户无需写任何 unsafe。
unsafe trait
某些 trait 带有编译器无法验证的合约。实现它们需要 unsafe impl:
// Send 和 Sync 是 unsafe trait 的典型例子
unsafe impl Send for MyType {}
unsafe impl Sync for MyType {}Send 的合约:类型可以安全地转移到其他线程。Sync 的合约:类型可以安全地跨线程共享。为不恰当的类型实现它们会导致数据竞争。
调用者在使用它们时通常也依赖这些合约:
fn send_to_thread<T: Send>(value: T) {
thread::spawn(move || { /* 使用 value */ });
}Union 类型
Union 让多个字段共享同一块内存,类似于 C 的 union:
#[repr(C)]
union FloatOrInt {
f: f32,
i: i32,
}
let u = FloatOrInt { i: 1 };
unsafe {
assert_eq!(u.i, 1);
}
let u = FloatOrInt { f: 1.0 };
unsafe {
// 查看同一段内存的整数表示
println!("0x{:08x}", u.i); // 0x3f800000
}写入 union 字段是 safe 的,读取是 unsafe 的——编译器无法知道当前存储的是哪个变体,也不会检查位模式是否有效。
#[repr(C)] 属性保证所有字段从偏移 0 开始。不使用时,Rust 可以自由优化布局。
match union 时,每个分支必须指定精确的字段名,但不会做判别——由程序员保证与最后写入的字段一致。
可变 static 变量
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1; // 读和写都是 unsafe
}可变 static 变量天然不是线程安全的——任何线程随时可以读写。使用它们需要 unsafe 块。更安全的替代:AtomicUsize、Mutex<T> 包装的静态变量、或 lazy_static!。
内联汇编
asm! 宏允许直接嵌入汇编指令(nightly 特性):
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 42", out(reg) x);
}
assert_eq!(x, 42);asm! 与 LLVM 的内联汇编集成,支持指定输入/输出寄存器、clobber 列表和选项标记。
panic 安全性
在 unsafe 代码中,如果 panic 发生在"临时打破不变量"和"恢复不变量"之间,类型会处于不一致状态,导致未定义行为:
// 危险:如果 panic 发生在 read 和 gap.end 更新之间,
// GapBuffer 的不变量被破坏
let element = unsafe { std::ptr::read(self.space(self.gap.end)) };
// ... 如果这里有 panic ...
self.gap.end += 1; // 这行不会执行原则:unsafe 代码中打破不变量的区域应当极短,且不能包含任何可能 panic 的操作。
小结
- unsafe 不是全局关闭安全检查——它只在五个特定领域解除限制,其余所有安全检查照常生效。
- unsafe 的职责在人而非编译器:你必须自己保证代码遵守了相应的合约,否则将导致未定义行为。
- 原始指针是灵活但危险的底层工具:创建和传递是 safe 的,解引用需要 unsafe。
- 安全抽象是 Rust 的核心理念:用 unsafe 实现底层细节,用 safe API 封装它们,让使用者无需接触 unsafe。
- 使用 unsafe 前应先问自己:是否真的需要?是否有 safe 的替代方案?是否有性能数据证明 unsafe 是必要的?
- 未定义行为不仅仅是"可能得到错误结果"——它意味着编译器可以做出任意假设,程序的行为完全不可预测。