unsafe 块与 Soundness 契约

10 分钟
本页目录

unsafe 不关闭 Rust 的类型与借用检查。它允许表达某些编译器无法完全验证的操作,同时要求程序员证明它们符合安全契约。一个包含 unsafe 的库是否 sound,要看其公开安全接口能否被普通安全代码调用出未定义行为。

这里的“普通安全代码”也包含奇怪输入、提前返回、panic、mem::forget 或意外的 trait 实现。安全接口不能把没有写在类型或检查中的前提偷偷转嫁给调用者。

不同 unsafe 位置承担不同责任

形式含义
unsafe fn调用者需满足额外安全前提,应写明 Safety 文档
unsafe 块实现者在此证明相关操作前提已满足
unsafe trait实现者必须维护额外契约
unsafe impl对满足该 trait 契约作出实现承诺
unsafe extern / unsafe 属性外部声明、符号等另外一类全局义务

不能说 unsafe 块“开启实现 unsafe trait 的能力”,unsafe impl 是自己的语法位置。Rust 2024 默认对 unsafe fn 中未显式包进 unsafe 块的危险操作给出警告,目的是把调用者义务与实现者证明分开。unsafe_op_in_unsafe_fn

普通引用代码在 unsafe 中仍会被借用检查器限制;但从裸指针人为制造冲突引用时,编译器不一定能拒绝。所以“违反别名条件仍是错误”与“编译器一定替你发现”不同。

用 split_at_mut 的思路建立一个证明

生产代码应直接使用标准库 split_at_mut。下面单独重写一个教学函数,展示安全接口怎样把动态索引约束转成 unsafe 操作前提:

pub fn split_checked<T>(slice: &mut [T], mid: usize)
    -> (&mut [T], &mut [T])
{
    let len = slice.len();
    assert!(mid <= len);
    let pointer = slice.as_mut_ptr();
    // SAFETY: 输入 slice 已保证整段存储有效、对齐、初始化和独占借用。
    // mid <= len 保证偏移与长度合法;两段元素范围不重叠。
    // 返回引用的生命周期由输入 slice 限定,之后不再通过原指针访问。
    unsafe {
        (
            std::slice::from_raw_parts_mut(pointer, mid),
            std::slice::from_raw_parts_mut(pointer.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut values = [1, 2, 3];
    let (left, right) = split_checked(&mut values, 1);
    left[0] = 10;
    right[0] = 20;
    assert_eq!(values, [10, 20, 3]);
    for mid in 0..=values.len() {
        let (left, right) = split_checked(&mut values, mid);
        assert_eq!(left.len(), mid);
        assert_eq!(left.len() + right.len(), 3);
    }
    let mut empty: [u8; 0] = [];
    let (left, right) = split_checked(&mut empty, 0);
    assert!(left.is_empty() && right.is_empty());
    let mut zst = [(); 3];
    let (left, right) = split_checked(&mut zst, 2);
    assert_eq!((left.len(), right.len()), (2, 1));
    let rejected = std::panic::catch_unwind(|| {
        let mut bytes = [1u8, 2];
        let _ = split_checked(&mut bytes, 3);
    });
    assert!(rejected.is_err());
}
012345left:[0, mid)right:[mid, len)mid = 2安全拆分的依据:同一借用中的两个不重叠元素范围源自一个有效 &mut [T];检查 mid 后,返回借用不能超过原借用的生命周期。

assert 并非独自保证一切。证明还利用了输入有效 &mut [T] 的性质、指针来源、元素初始化和生命周期。对任意外部裸指针,就算 mid <= len,也不能照搬这段论证。from_raw_parts_mut

mid 为 0 或 len 时会产生空切片,仍满足接口要求。零大小 T 的元素地址可能相同,论证应基于元素范围与访问语义,不能只画两段不同数值地址就宣称证明完成。

将证明前提对应到具体代码

前提本例依据修改时容易失效的位置
一个有效存储范围参数来自 &mut [T]改成任意裸指针而未增加调用者契约
索引与长度不越界mid <= len,之后用 len - mid把检查移到指针运算之后
返回的两段独占范围不冲突[0, mid) 与 [mid, len)第二段起点写成 mid - 1
引用不超过存储寿命返回生命周期从输入借用推导暴露不受拥有者约束的任意生命周期
返回后不再通过原入口访问原借用被返回的子借用占用缓存原指针,另开可冲突访问入口

测试中 mid=len 产生 one-past 位置的空右切片,这不允许解引用越过末端的元素;长度为零的切片仍需满足相应指针契约。ZST 示例则提醒我们不能拿“指针数值不同”替代元素范围的论证。

安全函数也可能写坏 unsafe 依赖的条件

下面的函数只是展示检查与危险操作的关联,普通代码直接用 slice.get 即可:

fn lookup(slice: &[u8], index: usize) -> Option<u8> {
    if index >= slice.len() { return None; }
    // SAFETY: 上面的检查保证 index 严格小于当前这个 slice 的长度。
    Some(unsafe { *slice.get_unchecked(index) })
}
fn main() {
    assert_eq!(lookup(&[7, 8], 1), Some(8));
    assert_eq!(lookup(&[7, 8], 2), None);
    assert_eq!(lookup(&[], 0), None);
    assert_eq!(lookup(&[7], usize::MAX), None);
}

若把 index >= len 改成 index > len,改动发生在安全代码,却会让 index=len 穿过检查。危险操作所依赖的证明已经失效,不能因为 unsafe 块一行没动就跳过审查。对持久容器,所有能修改长度、容量和指针的内部方法都在同一证明范围内。Working with Unsafe

只错一个起点,证明哪里断了?

将右起点从 mid 改为 mid−1,两个独占切片就会共享一个元素;改为 mid+1 而仍保留 len−mid 的长度,又可能越过末端。这里输入引用已经提供存储有效性,滑块只检查范围这一部分,不能推广成任意裸指针的安全验证器。

正在呈现知识画面
只错一个起点,证明哪里断了?

正确拆分为 [0,mid) 与 [mid,len);边界检查与非重叠证明必须一起成立。

不变量跨越 unsafe 块边界

如果某个容器内部用 len 表示已初始化元素数量,所有修改 len 的安全方法都属于证明范围。扩容、插入、克隆、析构和错误分支必须保证:未初始化的槽位不作为 T 读取或析构,同一资源不会释放两次。

panic 发生在部分初始化之后时,也要知道哪些元素已经存在、哪些资源需要回收。不能仅因为函数正常路径测试通过,就假定 Drop 和中断路径安全。反过来,安全代码允许泄漏资源,抽象不能把“调用者一定运行析构器”当作避免 UB 的唯一条件。

构造与校验建立有效状态所有修改入口保持长度、拥有关系与别名unsafe 操作依赖前面建立的条件提前失败、panic、回调重入和跳过 Drop 也要有合法状态证明范围沿不变量传播,通常大于 unsafe 块私有字段限制谁能修改表示;安全 trait 的实现仍可能不遵守业务预期代码审阅的单位是完整安全抽象及其状态转换,不能只数 unsafe 关键字。

不能把普通 trait 的“诚实”当作内存安全前提

trait Position { fn index(&self) -> usize; }
struct Outside;
impl Position for Outside {
    fn index(&self) -> usize { usize::MAX }
}
fn selected<P: Position>(values: &[u8], position: &P) -> Option<u8> {
    let index = position.index();
    values.get(index).copied()
}
fn main() {
    assert_eq!(selected(&[1, 2], &Outside), None);
}

Position 是安全 trait,外部实现可以返回超大索引。即使文档建议返回合法索引,安全包装也不能直接将其交给 get_unchecked;应检查本次实际返回值。若调用两次 index,第一次校验、第二次访问,还可能遇到内部可变性导致两次结果不同,所以要保存被校验的那个值。

类似问题出现在排序器、迭代器、比较器和回调中。逻辑契约被违反时,库可以报告错误、panic 或产生定义允许的逻辑后果,但不能让安全 trait 的错误实现直接触发 UB。如果某项保证确实必须由实现者承担内存安全责任,应设计明确的 unsafe trait,并评估这是否值得增加调用生态的证明负担。

guard 被忘记之后,底层对象仍需保持可安全访问

设想一个 drain guard 已把容器中的部分 String 移出,计划在 Drop 中修正 len。如果安全调用者 mem::forget 这个 guard,原容器之后仍按旧 len 析构,可能重复释放已移出的字符串。安全设计必须在允许 guard 离开前,让原容器进入即使 guard 永远不析构也不会 UB 的状态;正常 Drop 再尽量恢复完整逻辑结果。析构泄漏与代理对象

这允许最坏情况下泄漏部分资源,却不允许释放后使用。它也说明“用 RAII 就不用想失败路径”并不适用于自制 unsafe 容器:RAII 的析构路径本身就是需要证明的实现之一。

未定义行为与普通错误分开

典型 UB 包括非法内存访问、数据竞争、违反引用别名约定、制造无效 bool/char/引用值,以及以错误 ABI 调用函数等。仅创建空裸指针不是 UB;形成要求非空的引用或执行不满足条件的访问才跨过了边界。

panic、死锁、内存泄漏和返回错误结果通常不是语言层面的 UB,但可能是严重业务问题。整数溢出又需结合操作与编译设置判断,不要套用 C 的有符号溢出规则解释所有 Rust 运算。Rust 未定义行为

FFI 展开必须按 ABI 区分:普通 C ABI 边界上的 Rust panic 终止进程,与允许展开的 C-unwind、外部异常是不同情况,不应统统列成同一个 UB 项。

写 Safety 注释时回答具体问题

一条有用注释应指出当前指针由谁持有、为什么此范围有效、是否初始化、访问时是否独占或同步,以及函数返回后谁负责释放。只写“这里很安全”或“编译器不知道”无法供后续维护者检查。

unsafe fn 的文档面向调用者,描述怎样合法调用;unsafe 块注释面向实现审阅者,说明此处为什么已经满足前提。尽量缩小危险操作范围,但不要只按行数最小化而隐藏真正依赖的不变量。

Miri:检查执行过的路径,不是 soundness 证明

Miri 解释执行 MIR,可发现许多越界、释放后使用、未对齐、无效值和别名/并发问题。常见项目工作流是配置包含 miri 组件的 nightly 工具链后运行:

cargo +nightly miri test

这条命令依赖相应工具链管理与组件,不是每个安装了 rustc 的环境都已具备。Miri 对原生 FFI、内联汇编和部分系统接口有能力边界,不能把未执行的外部操作当作已验证。

测试应覆盖空输入、边界索引、零大小类型、带 Drop 的对象及失败路径。即便所有测试通过,也只检查了这些执行和当前模型;还需人工检查公开安全 API 的不变量及所有调用路径。Miri

将测试结果写成可复核的证据

检查方式能提供的证据没有覆盖的部分
正常输入与边界单测选定输入上的结果和拒绝行为全部输入及全部调度
带 Drop 计数对象这些路径上的释放次数指针来源和所有别名情况
Miri当前执行与支持模型中的内存错误任意原生 FFI、所有未执行路径
并发模型检查被建模状态与调度的组合未纳入模型的外部资源
人工契约审查前提、状态转换和责任是否闭合仍可能漏掉实现细节

不要运行已知 UB 示例并用“没有崩溃”判断安全。对可拒绝输入,断言它在危险操作之前被拒绝;对违反生命周期的安全用法,用编译失败案例验证;对必须满足的 unsafe 调用前提,用文档与有效输入测试明确边界。

当修改来自性能优化时,还应记录它为何需要 unsafe,以及标准安全接口在目标负载中缺少什么。若优化无法测出收益,保留额外证明成本通常没有价值;若有收益,也不能用性能数据替代 soundness 论证。