Comprehensive Rust 实战:调用不安全函数(Calling Unsafe Functions)的正确姿势
2026/9/10 15:22:33 网站建设 项目流程

Comprehensive Rust 实战:调用不安全函数(Calling Unsafe Functions)的正确姿势

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

本篇文章基于 Google Android 团队的 Rust 课程 Comprehensive Rust 中「调用不安全函数」章节(calling.md),深入讲解调用unsafe函数时最容易踩的坑:未满足前置条件会破坏内存安全,导致未定义行为(UB)。通过剖析一个真实的slice::from_raw_parts误用案例,你将掌握安全注释的规范写法、元素数与字节数的陷阱、unsafe函数与 soundness(健全性)的关系,以及「优先安全替代方案」的工程原则。

前言:为什么调用 unsafe 函数如此危险

在 Rust 中,一切都是有代价的。根据课程「Unsafe Rust 入门」的定义,Rust 语言分为两部分:

  • Safe Rust:内存安全,不可能产生未定义行为;
  • Unsafe Rust:如果违反前置条件(preconditions),就可能触发未定义行为。

Unsafe Rust 共解锁了五类新能力,其中一类就是「调用unsafe函数,包括extern函数」。Unsafe 代码应当保持短小、隔离,其正确性需要被仔细记录,并且最好被包装在安全抽象层中。

调用一个unsafe函数,相当于向编译器承诺:"我已经检查过它要求的全部前置条件。"一旦这个承诺失效,内存安全随即被打破——这正是课程在calling.md开头用一句话点明的事实:

Failing to uphold the safety requirements breaks memory safety! (未能履行安全要求就会破坏内存安全!)

一个"看起来能跑"的危险例子:日志函数越界读取

课程给出了一个非常典型的反面教材——一个用于打印公钥的日志函数。它看起来能正常编译、甚至能正常输出,但实际上已经处于未定义行为的边缘:

#[derive(Debug)] #[repr(C)] struct KeyPair { pk: [u16; 4], // 8 bytes sk: [u16; 4], // 8 bytes } const PK_BYTE_LEN: usize = 8; fn log_public_key(pk_ptr: *const u16) { let pk: &[u16] = unsafe { std::slice::from_raw_parts(pk_ptr, PK_BYTE_LEN) }; println!("{pk:?}"); } fn main() { let key_pair = KeyPair { pk: [1, 2, 3, 4], sk: [0, 0, 42, 0] }; log_public_key(key_pair.pk.as_ptr()); }

这段代码的意图很直白:KeyPair#[repr(C)]保证内存布局与 C 结构体一致(pk公钥占 8 字节,sk私钥占 8 字节),然后通过裸指针把pk数组转成切片打印出来。但问题恰恰出在关键的那一行:

let pk: &[u16] = unsafe { std::slice::from_raw_parts(pk_ptr, PK_BYTE_LEN) };

这里有两个叠加的错误:

  1. 长度参数传的是字节数,而slice::from_raw_parts期望的是元素个数pk[u16; 4],按元素计只有 4 个,但PK_BYTE_LEN被定义为 8,于是切片实际包含 8 个u16元素,会越过pk数组的末尾继续读。
  2. 跨越数组边界读取属于未定义行为(undefined behavior)。指针pk_ptr是从pk数组派生的,而from_raw_parts要求"生成的切片必须完全落在指针所源自的内存对象之内"。读取越界意味着 Rust 内存安全模型被破坏,编译器将不再对这段代码的后续行为做任何保证——你可能读到相邻的sk数组(本例中确实读到了[0, 0, 42, 0]等值),也可能读到完全无关的内存、触发崩溃,或在优化下产生难以排查的逻辑错误。

课程还特意指出一个极具迷惑性的细节:这个例子没有写任何安全注释,而且log_public_key本身是一个安全函数。一个安全函数如果可能引发未定义行为,我们就称它是unsound(不健全)的。换句话说,log_public_key应该被标记为unsafe fn,并且附带安全文档说明调用者对pk_ptr必须承担哪些义务。

修复思路:它应该长这样

正确的做法至少包含两步:

  1. log_public_key改为unsafe fn,并在文档注释中声明前置条件(如"指针必须有效、对齐,且指向至少 8 个u16元素的可读内存");
  2. 调用from_raw_parts时按元素数传长度,即4(或更稳妥地,将元素数定义为pk.len()对应的常量)。

每个 unsafe 块都必须有安全注释

课程的第二个硬性要求是:

Always include a safety comment for eachunsafeblock. It must explain why the code is actually safe. (每个unsafe块都必须附带安全注释,解释为什么这段代码实际上是安全的。)

安全注释不是给编译器看的,而是给**未来的维护者(包括几个月后的你自己)**看的。它记录了调用者已经履行了哪些前置条件、编译器无法验证的部分由谁以何种方式保证了正确性。上面例子的问题之一,就是注释完全缺失——当不安全的代码没有任何解释时,它就不再是可审查、可推理的代码了。

对比之下,同一课程在「Unsafe Rust 函数」中给出了示范写法:自定义的unsafe fn swap同时具备# Safety文档注释和块内// SAFETY:注释,二者一一对应:

/// Swaps the values pointed to by the given pointers. /// /// # Safety /// /// The pointers must be valid, properly aligned, and not otherwise accessed for /// the duration of the function call. unsafe fn swap(a: *mut u8, b: *mut u8) { // SAFETY: Our caller promised that the pointers are valid, properly aligned // and have no other access. unsafe { let temp = *a; *a = *b; *b = temp; } }

这个模式是 Comprehensive Rust 反复强调的核心纪律:

  • 函数级# Safety文档:以"调用者必须满足什么条件"的视角,把前置条件写清楚;
  • 块级// SAFETY:注释:站在"当前这一处代码"的视角,说明为什么此刻条件已满足。

关键要点逐条拆解

课程在<details>中总结了本节的五个关键点,下面逐一展开:

1.slice::from_raw_parts的第二个参数是元素数,不是字节数

这是本节最重要的知识点。from_raw_parts的签名是:

pub unsafe fn from_raw_parts<'a, T>(data: *const T, len: usize) -> &'a [T]

其中len元素个数(number of elements)。对于u16(2 字节)这样的类型,8 个元素对应 16 字节,远超pk数组实际的 8 字节。元素数与字节数混淆,是围绕from_raw_parts系列 API(包括from_raw_parts_mut)最常见的事故原因。

2. 越过指针所源自的数组末尾读取,是未定义行为

课程明确指出:本例读取到了pk数组末尾之外,这是 undefined behavior。即使程序"碰巧"输出了看起来合理的结果(比如读到了相邻的sk字段),也不能改变它是 UB 的事实——UB 意味着编译器的所有保证全部失效,行为随优化级别、平台、调用环境而变化。

3. 一个会导致 UB 的安全函数是不健全的(unsound)

课程给出了一个重要概念辨析:

log_public_keyshould be unsafe, becausepk_ptrmust meet certain prerequisites to avoid undefined behaviour. A safe function which can cause undefined behaviour is said to beunsound.

  • sound(健全):安全代码绝不会触发未定义行为;
  • unsound(不健全):一个被标记为safe的函数,却可能在合法调用下导致 UB。

把可能有前置条件的函数暴露为安全函数,等于把一个陷阱交给所有调用者,编译器无法替你拦截。因此课程留了一个思考题:log_public_key的安全文档应该怎么写?合理的答案应包括:pk_ptr必须源自一个至少包含PK_BYTE_LEN(按元素计)个u16的数组,且该内存有效、对齐并在调用期间未被并发修改。

4. 标准库中存在大量底层 unsafe 函数,优先使用安全替代方案

std::slice::from_raw_parts只是标准库众多低层 unsafe 函数之一,类似的还有std::mem::transmuteVec::from_raw_parts、各种指针解引用等。只要存在安全替代,就优先使用安全替代

  • 需要"把数组的一部分当作切片"?直接用&arr[..]arr.get(..),由编译器做边界检查;
  • 需要"从指针构造切片"?尽可能早地把指针转成引用,把 unsafe 收缩到最小范围。

unsafe 函数的正确角色是胶水:只在安全 API 无法表达的地方(FFI、裸指针布局操作)使用,然后用一层安全封装把它"包起来",对外只暴露安全接口。

5. 若把 unsafe 当作优化手段,必须用基准测试证明收益

课程提醒:如果你为了性能而引入 unsafe 函数(例如绕过边界检查),请务必补充 benchmark 来证明优化确实带来了收益。没有数据的"优化"往往只是增加了 UB 风险而没有换来实际的性能提升。在 Comprehensive Rust 的工程语境下,这也是把 unsafe 与可量化验证绑定的惯常要求。

版本与规范:2024 edition 下的 unsafe 书写要求

需要注意的是,unsafe块内安全注释规范在近年发生了重要变化。课程在「Unsafe Rust 函数」中指出:

Rust 2021 及更早版本允许在unsafe fn内部直接书写不安全代码,无需额外的unsafe块。这一行为在 2024 edition 中发生了改变。

具体来说:

  • Rust 2024 editionunsafe fn内部的 unsafe 操作也必须显式包裹unsafe {}块,强制逐处声明与注释;
  • 更早的 edition:可以通过#[deny(unsafe_op_in_unsafe_fn)]主动启用同样的严格检查——课程建议读者亲自动手加上这个 lint,观察编译结果的变化。

这一点与当前仓库的配置是呼应的:本仓库中 unsafe-rust 练习的 Cargo.toml 声明的正是edition = "2024",也就是说课程内所有示例代码都运行在"unsafe 操作必须显式声明"的新规范之下。

关联阅读:两类 unsafe 函数的来源

理解"调用 unsafe 函数"时,还需要清楚 unsafe 函数从哪来。根据课程「Unsafe Functions」总览,共有两类:

  1. Rust 自身声明为unsafe的函数——即上文unsafe fn swap这类,前置条件由# Safety文档写明;
  2. extern "C"块中的外部(FFI)函数——详见课程「Unsafe External Functions」。

对于外部函数,Rust 1.82 起引入了unsafe extern块机制,块内的每个函数可以分别标记为safeunsafe:没有指针操作、无条件安全的外来函数(如 C 的abs)可标记safe;而像strlen这样要求"NUL 结尾且调用期间不被修改的 C 字符串指针"的外来函数,则必须标记unsafe并写# Safety文档。同时课程提醒:Rust 无法校验你声明的函数签名是否与外部定义一致,这完全由你负责

在仓库中,src/unsafe-rust/exercise.rs 提供了一个完整的 FFI 实战样本:它用unsafe extern "C"声明了opendirreaddirclosedir等 POSIX 目录遍历函数,并配以#[repr(C)]DIR不透明类型和dirent结构体布局,演示了 unsafe 外部函数在实际系统编程中的典型用法——这也正是"调用 unsafe 函数"能力在真实场景下的落点。

小结

「调用不安全函数」这一节可以浓缩为三条工程纪律:

  1. 先满足前置条件,再进unsafe:每个unsafe块都必须有// SAFETY:注释说明为何安全,函数则用# Safety文档声明调用义务;
  2. 认清每个 unsafe API 的参数语义from_raw_partslen是元素数而非字节数,读越界即是 UB;
  3. 让不安全保持最小且可辩护:能写安全函数就写安全函数,必须用 unsafe 时缩小范围、写注释、加基准测试,并确保对外暴露的接口是健全(sound)的。

把 unsafe 视为一种需要"证据"的特权,而不是随手可用的快捷键,这是 Comprehensive Rust 贯穿始终的哲学,也是写出可长期维护系统代码的关键。

延伸阅读(仓库内)

  • 章节总览:src/unsafe-rust/unsafe-functions.md
  • Unsafe Rust 能力总览:src/unsafe-rust/unsafe.md
  • 自定义 unsafe 函数与unsafe_op_in_unsafe_fn:src/unsafe-rust/unsafe-functions/rust.md
  • 外部函数与unsafe extern块:src/unsafe-rust/unsafe-functions/extern-c.md
  • FFI 目录遍历完整示例:src/unsafe-rust/exercise.rs

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询