课程概览 · 第 19 章

上一篇:并发与异步:先划清所有权和取消边界

下一篇:Cargo 与依赖:建立可重复的 Rust 开发闭环

unsafe 不会关闭类型、借用或生命周期检查。它允许执行需要额外安全证明的操作,保证的责任因此落到实现者或调用者身上。本章把证明写进最小代码边界,同时区分真实文件写出、FFI 协议演示与不能普通执行的 UB 示意。

safe 不变量、最小 unsafe 包装器、Miri 样本证据与 unsafe trait 合同
图:安全封装要证明并维持不变量;测试与 Miri 只提供已执行路径的证据,不能替代合同推理。

前置知识与学习目标

需要第 06 章的借用、第 10 章的 trait 与第 05 章的可失败 I/O。学完本章应能:

  • 区分 unsafe 操作、unsafe fn 调用合同与 unsafe trait 实现合同。
  • 为小型安全包装器写出具体 SAFETY 依据。
  • 分析裸指针、FFI 的有效性、别名、ABI 与释放责任。
  • 用显式 Result 返回写出错误,并解释测试和 Miri 的证据边界。

unsafe 能做什么,不能放宽什么

unsafe 允许解引用裸指针、调用 unsafe 函数、访问可变静态变量及 union 字段等操作;实现 unsafe trait 也需要显式 unsafe impl。它不会让悬垂引用、数据竞争、未初始化读取或无效位模式成为合法行为。&T/&mut T 仍须满足有效性、对齐、寿命和别名要求;&T 的内部可变性例外通过 UnsafeCell 及其安全封装建立,不能直接把共享引用强转成可写指针就修改。

未定义行为(UB)不是一种可恢复的错误返回,也不保证以 panic 或崩溃表现。某些构建可能插入额外诊断,但不能依赖它捕获违规;“编译通过、打印了正确值”都不是安全证明。默认使用安全 API,不能为压掉借用错误或未经测量的性能直觉引入 unsafe。

first_fast 与安全 API 对照

空检查证明索引有效,再进入一个表达式大小的 unsafe 块。返回引用的寿命仍由输入切片约束,调用者无需额外条件。

独立示例(仅标准库;src/main.rs,cargo +stable run):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn first_fast<T>(items: &[T]) -> Option<&T> {
if items.is_empty() {
return None;
}
// SAFETY: 上一分支排除了空切片,0 < items.len();
// 引用直接来自输入切片,不超出其借用寿命。
Some(unsafe { items.get_unchecked(0) })
}

fn main() {
let empty: [u64; 0] = [];
let one = [42u64];
let many = [1u64, 2, 3];
assert_eq!(first_fast(&empty), empty.first());
assert_eq!(first_fast(&one), one.first());
assert_eq!(first_fast(&many), many.first());
assert_eq!(first_fast(&one), Some(&42));
}

这只是封装结构的教学示例,没有证据证明比 items.first() 更快。实际业务默认直接使用 first;若 unsafe 的复杂性不能由必要的底层合同或测量结果支持,就删掉它。

unsafe fn 与 # Safety

unsafe fn 将前置条件交给调用者;unsafe 块标出实现中需要证明的具体操作。合同必须覆盖调用时的条件,不只是指针创建时曾经有效。

独立示例(仅标准库;src/main.rs,cargo +stable run):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// 读取一个 i32。
///
/// # Safety
///
/// ptr 必须非空、正确对齐,指向仍存活且已初始化的 i32;
/// 读取必须符合指针来源与别名规则,且不能与写入数据竞争。
unsafe fn read_i32(ptr: *const i32) -> i32 {
// SAFETY: 调用者须满足文档中的有效性及读取条件。
unsafe { *ptr }
}

fn main() {
let value = 4242i32;
let ptr = &value as *const i32;
// SAFETY: ptr 来自 value 的共享引用,value 仍存活且未被修改。
assert_eq!(unsafe { read_i32(ptr) }, 4242);

let boxed = Box::new(7i32);
let ptr = &*boxed as *const i32;
// SAFETY: boxed 仍存活;此处没有并发写入或独占访问。
assert_eq!(unsafe { read_i32(ptr) }, 7);
}

Edition 2024 将 unsafe_op_in_unsafe_fn 默认设为 warning,不是默认硬错误。本课程规范始终写显式 unsafe 块,把函数级调用合同与语句级操作分开;项目可用 deny 将 lint 升为错误。

独立示例(仅标准库;独立 src/main.rs,Edition 2024 编译产生 unsafe_op_in_unsafe_fn 警告;实际调用条件合法):

1
2
3
4
5
6
7
8
9
unsafe fn read_without_block(ptr: *const i32) -> i32 {
*ptr
}

fn main() {
let value = 42;
// SAFETY: 指针来自存活、未被修改的 value。
assert_eq!(unsafe { read_without_block(&value) }, 42);
}

故意失败:编译错误(仅标准库;独立 src/main.rs。deny 将缺少显式 unsafe 块的 lint 升为错误):

1
2
3
4
5
6
7
#![deny(unsafe_op_in_unsafe_fn)]

unsafe fn read_without_block(ptr: *const i32) -> i32 {
*ptr
}

fn main() {}

裸指针、别名与 Miri

裸指针不由借用检查器完整追踪,不等于任意访问都合法。下面先从共享引用获得指针,再通过新独占借用访问同一位置,最后使用旧指针;这是工具别名模型下的违规示意,不应放进正常运行路径。

不应执行:UB 示意(仅标准库;独立 src/main.rs。只可单独交给 Miri 观察别名诊断,不普通运行):

1
2
3
4
5
6
7
8
fn main() {
let mut value = 100i32;
let ptr = &value as *const i32;
let alias = &mut value;
*alias += 1;
// 旧指针的访问权限不能由“地址相同”推出。
println!("{}", unsafe { *ptr });
}

Miri 的 Stacked Borrows/Tree Borrows 是用于检查的模型,不是已经完全定型的语言别名规范。具体诊断、标签和规则受工具版本与模型配置影响,不应把某条诊断逐字视为稳定接口。参见 Rust Reference 的未定义行为说明。Miri 能发现执行路径中的许多有效性错误,但不能证明任意输入或全部外部代码安全,也不应把所有内存泄漏一概称为 UB。

独立示例(仅标准库;src/main.rs,cargo +stable run。独占访问结束后重新取得指针):

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut value = 100i32;
{
let alias = &mut value;
*alias += 1;
}
let ptr = &value as *const i32;
// SAFETY: 指针来自当前合法共享引用,读取期间 value 存活未改。
assert_eq!(unsafe { *ptr }, 101);
}

代码片段(工具命令;分别在单独的合法示例或 UB 示意临时项目根执行。需已安装带 Miri 的 nightly,不替代稳定版测试):

1
cargo +nightly miri run

FFI:ABI、有效性与释放责任

#[repr(C)] 控制结构体字段布局,但不会把其中的 String、Vec 或任意 Rust 字段变成 FFI-safe。默认 Rust 布局不应作为跨语言协议依赖;整数宽度、对齐、指针长度、字符串终止和分配器约定都要与对端一致。C 有 bool/_Bool,仍须按实际 ABI 约定选择对应表示;下例明确约定 done 使用 c_int 的 0/1。

独立示例(仅标准库;src/main.rs,cargo +stable run。这是 Rust 侧同签名演示,不验证真实 C 库或跨语言链接):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use std::os::raw::c_int;

#[repr(C)]
struct TaskSummary {
id: c_int,
done: c_int,
}

/// # Safety
/// items 须指向 n 个有效 TaskSummary,读取期间存活且无冲突写入。
unsafe extern "C" fn summarize(
items: *const TaskSummary,
n: usize,
) -> c_int {
let mut done = 0;
for index in 0..n {
// SAFETY: 由调用合同保证该元素有效且允许共享读取。
let summary = unsafe { &*items.add(index) };
done += c_int::from(summary.done == 1);
}
done
}

fn main() {
let items = [
TaskSummary { id: 1, done: 0 },
TaskSummary { id: 2, done: 1 },
];
// SAFETY: 数组存活,长度匹配;函数只读且不保留指针。
let done = unsafe { summarize(items.as_ptr(), items.len()) };
assert_eq!(done, 1);
}

代码片段(真实 C 库的声明语法;依赖上块的 TaskSummary、c_int 定义,替换上块 summarize 实现而非并存。调用前必须链接提供该符号的库):

1
2
3
unsafe extern "C" {
fn summarize(items: *const TaskSummary, n: usize) -> c_int;
}

Edition 2024 要求 extern 块标 unsafe,块内也可显式声明 safe 项,前提是作者能保证所有合法 Rust 实参都安全;不能把整个块里的项一概解释成必然 unsafe 调用。分配与释放须遵循对端合同:Rust 分配的 Box 不能默认交给 C free,C malloc 得到的内存也不能默认包装成 Box::from_raw;即使分配器相同,还须满足 Rust 类型、布局与所有权条件。

ABI 还决定 unwind 合同。普通 extern "C" 不允许 Rust panic unwind 穿过,C-unwind 有不同约定;catch_unwind 只处理可展开的 Rust panic,不捕获 panic=abort,也不保证处理任意外部异常。边界上返回错误码或 Result 转换后的协议值,不能靠“捕获一切异常”代替设计。

显式可失败清理:真实写出

Drop 的签名不能返回 Result,隐式销毁中发生的写出错误无法直接传给调用者。复用第 05 章的标准库模式即可:创建文件,缓冲写入,然后显式 flush 并返回错误,不需要虚构 Journal 包装器。

独立示例(仅标准库;src/main.rs,cargo +stable run。只在本次创建的临时目录中写入与清理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use std::fs::{self, File};
use std::io::{self, BufWriter, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

fn save_report(path: &Path, contents: &[u8]) -> io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
writer.write_all(contents)?;
writer.flush()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let name = format!("rust-course-ch19-{}-{stamp}", std::process::id());
let dir = std::env::temp_dir().join(name);
fs::create_dir(&dir)?;
let result = (|| -> io::Result<()> {
let path = dir.join("report.txt");
let contents = b"completed=3\ntimeout=1\n";
save_report(&path, contents)?;
assert_eq!(fs::read(&path)?, contents);
let missing = dir.join("missing-parent").join("report.txt");
let error = save_report(&missing, contents).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
Ok(())
})();
fs::remove_dir_all(&dir)?;
result?;
Ok(())
}

不存在的父目录触发的是 File::create 失败,并非 flush 失败。成功例证明写出并重读的字节一致,不证明已经 fsync 或掉电持久化。File/BufWriter 自动销毁负责释放资源,显式 flush 负责报告缓冲写出的错误;Drop 也可能尽力执行可失败清理,但不应以它作为唯一错误报告路径。析构期间再次 panic 可能导致 abort,因此自定义 Drop 应避免 panic。

unsafe trait:实现者承担合同

普通 trait 的 Eq/Hash 一致性、Ord 全序等语义合同很重要,但 unsafe 代码不能因为某个 safe trait 的实现违反业务语义就产生 UB。需要让底层实现承诺内存安全前提时,使用 unsafe trait 并写清 # Safety;unsafe impl 的审查重点是为何每个实现都满足这些前提。

独立示例(仅标准库;src/main.rs,cargo +stable run。零初始化仅用于显式证明有效的类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// # Safety
/// 实现类型的全零位模式必须是完整有效的值,满足其全部不变量。
unsafe trait ZeroValid: Sized {}

// SAFETY: u32 的所有位模式有效,全零表示 0。
unsafe impl ZeroValid for u32 {}

fn zero_value<T: ZeroValid>() -> T {
// SAFETY: T 的 unsafe trait 合同保证全零值有效。
unsafe { std::mem::zeroed() }
}

fn main() {
assert_eq!(zero_value::<u32>(), 0);
}

不能给引用或 NonZeroU32 这样零位模式无效的类型写同样实现。unsafe impl Send/Sync 同理:必须证明字段、生命周期、同步和析构路径,不是只写一句“可跨线程”。Unpin 是 safe trait,Deref 也是 safe trait;它们有语义边界,但不能混称为 unsafe trait。

深入阅读:安全边界的维护

安全包装器要在构造、修改、销毁和 FFI 回调各路径维持不变量,而不只是检查一次。SAFETY 注释应说明具体来源、范围与寿命;未来修改前置分支或表示方式时,必须同时复核引用该证明的 unsafe 操作。

Edition 2024 的 static_mut_refs 默认 deny 禁止创建对可变静态的引用(包括隐式引用),并不是只禁止 &'static mut 这一种写法。共享全局状态通常用原子类型、锁、OnceLock/LazyLock;裸指针本身不免除同步和别名责任。常用 trait 的选型可补充阅读标准库模块地图与常用 trait,本章的安全合同不依赖该文章。

自测

  1. first_fast 的 SAFETY 注释必须引用哪条可检查证据?为什么默认仍应使用 first?
  2. unsafe fn 和 unsafe 块分别承担什么职责?2024 默认警告与 deny 有何区别?
  3. 编译通过和 Miri 通过为什么都不能证明任意输入无 UB?
  4. repr©、extern 块和 catch_unwind 各自不能保证什么?
  5. save_report 的失败例发生在哪一步?flush 成功能否保证掉电持久化?

参考答案

  1. 空分支已被排除,索引 0 在范围内,返回借用不超出输入寿命;安全 first 已满足需求,没有测量收益就不引入 unsafe。
  2. 函数文档约束调用者,块标出实现中的具体操作;默认 lint 是 warning,deny 才使缺块被拒绝编译。
  3. rustc 不完整检查裸指针合同;Miri 依照工具模型检查已执行样本,既非完整语言规范也非全输入证明。
  4. repr© 不使内部 Rust 字段自动 FFI-safe;extern unsafe 不保证声明正确;catch_unwind 不捕获 abort 或任意外部异常,ABI 必须匹配。
  5. 缺父目录在 File::create 阶段返回错误;flush 报告缓冲写出,不等于 fsync 或持久化保证。