Comprehensive Rust 教程:深入理解 Unsafe Functions(安全前提、调用规范与 FFI 声明)
【免费下载链接】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)中Unsafe Functions(不安全函数)一讲展开,系统讲解:为什么函数需要被标记为unsafe、Rust 不安全函数与extern "C"外部函数这两类不安全函数的区别、调用时必须满足的前提条件(preconditions)、安全注释(Safety Comment)的写法,以及 Rust 2024 edition 与 Rust 1.82 之后相关语法的变化。读完本文,你将掌握安全地声明、调用和封装 unsafe 函数的方法,并能在真实项目中用安全抽象层包裹底层 FFI 调用。
Unsafe Functions 是什么:两类不安全函数的来源
在正式进入 unsafe 函数的细节之前,先回到课程对Unsafe Rust的整体定位。unsafe.md 明确指出,Rust 语言由两部分构成:
- Safe Rust(安全 Rust):内存安全,不可能产生未定义行为(undefined behaviour, UB);
- Unsafe Rust(不安全 Rust):一旦违反前提条件,就可能触发未定义行为。
Unsafe Rust 并非"代码写错了",而是开发者主动关闭了部分编译器安全检查,需要自己保证正确性。它一共解锁了5 种新能力:
- 解引用裸指针(dereference raw pointers);
- 访问或修改可变静态变量(mutable static variables);
- 访问
union字段; - 调用
unsafe函数,包括extern外部函数; - 实现
unsafetrait。
本文聚焦其中的第 4 项——调用不安全函数。按照 unsafe-functions.md 的定义:
如果一个函数或方法带有额外的、必须由调用方维护的前提条件(preconditions)以避免未定义行为,就可以把它标记为
unsafe。
不安全函数可能来自两个地方:
- Rust 自身声明的
unsafe函数:代码由你(或某个 crate)编写,编译器无法替你验证前提条件; extern "C"块中声明的外部函数:来自 C/C++ 等语言的符号,编译器完全没有办法推断其行为。
课程接下来分别讨论了这两个来源,下文依次展开。
调用不安全函数:前提条件一旦失守就是 UB
调用不安全函数(calling.md) 一讲以一句话定调:没有满足安全要求就会破坏内存安全。课程给出的示例是"只打印公钥、不打印私钥"的日志函数:
#[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()); }这段代码看似能跑,实则已经 unsound。课程在这里埋了三个关键的坑:
第二个参数是"元素个数"而非"字节数"。
std::slice::from_raw_parts(ptr, len)的len是u16元素的个数,而不是字节长度。这里PK_BYTE_LEN = 8被当作元素个数传入,切片会越过pk数组的末尾,一路读进相邻的sk数组——在示例中你会看到私钥数据被意外打印出来。这属于未定义行为。因为我们在读取"指针所源自对象(
pk数组)边界之外"的内存,编译器不会为这种越界读取提供任何保证。log_public_key本身应该被声明为unsafe。它的参数pk_ptr必须满足一系列前提条件(非空、指向合法对象、对象存活且未被并发访问等)才能避免 UB。一个可以被安全调用却导致未定义行为的函数,被称为unsound(不健全)的。课程建议思考:这个函数的# Safety文档应该怎么写?
安全注释:每个 unsafe 块都必须有
无论代码是否正确,课程(以及 Android Rust 风格指南)都要求:每个unsafe块都要附带一条安全注释,解释为什么这段代码实际上是安全的。上面这个示例恰恰缺少安全注释,因此课程明确把它判定为 unsound。
在 dereferencing.md 的裸指针解引用示例中可以看到标准的安全注释写法:
fn main() { let mut x = 10; let p1: *mut i32 = &raw mut x; let p2 = p1 as *const i32; // SAFETY: p1 and p2 were created by taking raw pointers to a local, so they // are guaranteed to be non-null, aligned, and point into a single (stack-) // allocated object. // // The object underlying the raw pointers lives for the entire function, so // it is not deallocated while the raw pointers still exist. It is not // accessed through references while the raw pointers exist, nor is it // accessed from other threads concurrently. unsafe { dbg!(*p1); *p1 = 6; // Mutation may soundly be observed through a raw pointer, like in C. dbg!(*p2); } }对于"裸指针解引用"这一类操作,注释需要覆盖的前提条件(与标准库ptr模块的 [Safety] 要求一致)包括:
- 指针必须非空(non-null);
- 指针必须可解引用(dereferenceable,位于单个已分配对象的边界内);
- 底层对象不得已被释放(deallocated);
- 同一位置不得存在并发访问;
- 若指针由引用转换而来,底层对象必须存活,且不能有引用被用于访问该内存;
- 大多数情况下指针还必须正确对齐(properly aligned)。
课程还专门演示了一种常见的 UB 写法(unsound 的反面教材):把&*p1直接当作引用使用——借由裸指针创建引用会绕过编译器对"引用到底指向哪个对象"的认知,借用检查器因此不会冻结x,即使存在指向它的引用,x仍可能被修改,从而触发 UB。从指针创建引用必须格外小心。
为什么课程推荐优先使用安全替代品
标准库中有一批底层 unsafe 函数(如slice::from_raw_parts、ptr::read、mem::transmute等)。课程给出的建议是:
- 尽可能优先使用安全替代品(如用
&key_pair.pk直接切片,而不是from_raw_parts); - 如果为了性能优化而使用 unsafe 函数,务必配套编写基准测试(benchmark)来证明优化收益,而不是"感觉更快"。
声明自己的 unsafe 函数:以swap为例
Unsafe Rust 函数(rust.md) 说明:你可以把自己的函数标记为unsafe,只要它要求调用方满足特定前提条件以避免 UB。课程用经典的指针交换函数演示:
/// 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; } } fn main() { let mut a = 42; let mut b = 66; // SAFETY: The pointers must be valid, aligned and unique because they came // from references. unsafe { swap(&mut a, &mut b); } println!("a = {}, b = {}", a, b); }这个例子有两点值得深挖:
文档与代码的双层安全契约
- 在函数文档中:
# Safety小节向调用方声明前提条件——"两个指针必须有效、正确对齐,并且在函数调用期间不被其他方式访问"。 - 在函数体内:每个
unsafe块都要有SAFETY:注释,说明此处假设调用方已经履行了承诺。
两层注释互相呼应,构成了 unsafe 函数完整的"契约文档"。这也呼应了 unsafe-traits.md 中 unsafe trait 的写法——zerocopy的IntoBytes之类的 trait 同样要求在 Rustdoc 中提供# Safety小节。
Edition 差异:unsafe_op_in_unsafe_fn
课程特别指出一个重要语法演进:
- Rust 2021 及更早版本:在
unsafe fn函数体内使用 unsafe 操作不需要再包一层unsafe块; - Rust 2024 edition:在
unsafe fn内部执行 unsafe 操作也必须显式写出unsafe块。
对于老版本项目,可以用 lint 强制要求显式 unsafe 块:
#[deny(unsafe_op_in_unsafe_fn)]课程建议读者亲自加上这个属性试一下,观察编译器报错——这正是本仓库 src/unsafe-rust/Cargo.toml 使用edition = "2024"的背景下,现代 unsafe 代码的标配写法。
一个教学层面的提醒
课程同时提醒:真实的swap根本不需要指针,用引用就可以安全完成。这个例子纯粹是为了演示unsafe fn的声明、文档与调用机制——能用安全代码解决的,不要为了炫技引入 unsafe。
Unsafe 外部函数:extern "C"块与safe fn
Unsafe 外部函数(extern-c.md) 讲解第二类不安全函数:通过unsafe extern声明外部(foreign)函数。之所以需要 unsafe,是因为编译器无法推断外部函数的行为。课程示例同时展示了safe fn与unsafe fn两种声明:
use std::ffi::c_char; unsafe extern "C" { // `abs` doesn't deal with pointers and doesn't have any safety requirements. safe fn abs(input: i32) -> i32; /// # Safety /// /// `s` must be a pointer to a NUL-terminated C string which is valid and /// not modified for the duration of this function call. unsafe fn strlen(s: *const c_char) -> usize; } fn main() { println!("Absolute value of -3 according to C: {}", abs(-3)); unsafe { // SAFETY: We pass a pointer to a C string literal which is valid for // the duration of the program. println!("String length: {}", strlen(c"String".as_ptr())); } }课程在这段代码的讲解(details)中给出四点关键知识:
历史演变:Rust 曾经把所有 extern 函数一律视为 unsafe;Rust 1.82 引入
unsafe extern块之后,extern 块中的每个函数必须显式标记为safe或unsafe,取决于它是否带有安全使用的前提条件。abs为什么必须写safe:因为它是外部(FFI)函数,默认继承块级的不安全属性;而像abs这样不碰指针、没有任何安全要求的函数,可以(也应该)显式标记为safe,从而允许在安全代码中直接调用。需要注意的是:任何 C 函数都可能在任意情况下出现未定义行为,所以"该函数是否安全"需要逐个函数判断,不能想当然。"C"是 ABI 名称:本示例使用的是 C ABI;Rust 参考手册(Reference)的 external blocks 章节列出了其他可用的 ABI(如"system"、"stdcall"等)。签名匹配全靠自觉:编译器不会校验 Rust 侧声明的函数签名与外部真实定义是否一致——这是调用方必须自己负责的约束,一旦签名对不上,就是未定义行为。
实战案例:逐步封装abs(3)
abs.md 提供了封装 C 标准库abs(3)的完整演练,正好把上面语法点串成一条可操作的路径,其核心步骤是:
- 查外部定义:找到目标函数的真实 C 签名——
int abs(int j);(可参考man 3 abs); - 写出匹配的 extern 声明;
- 确认安全不变量:
abs只接收和返回i32,不涉及指针,无安全前提; - 决定能否标记为 safe。
过程中的关键细节:
- 许多 POSIX 函数之所以可直接调用,是因为Cargo 默认链接 C 标准库(libc),其符号天然在程序作用域内;
- 签名应使用 C 类型别名
std::ffi::c_int而不是硬编码i32:C 标准规定int可能是i16,c_int由目标平台决定宽度,使用别名能提高可移植性(在主流平台上它通常就是i32的类型别名); - 早期写法
extern "C"会被编译器报错"extern blocks must be unsafe",需要把块升级为unsafe extern "C"; - 块写为 unsafe 后,函数默认是 unsafe 的;只有当确认无前提条件时,才在函数上追加
safe fn,让它能在安全代码中直接调用。
最终完整程序如下:
use std::ffi::c_int; unsafe extern "C" { safe fn abs(x: c_int) -> c_int; } fn main() { let x = -42; let abs_x = abs(x); println!("{x}, {abs_x}"); }课堂实战:用 Safe FFI Wrapper 把不安全函数封装成安全迭代器
课程在 exercise.md 中提供了一个 30 分钟的实战练习,把"声明 unsafe extern 函数 → 提供安全抽象"的全流程走一遍:为libc的目录读取函数opendir(3)、readdir(3)、closedir(3)编写一个安全封装,实现一个可以迭代目录条目名的DirectoryIterator。
练习涉及 FFI 中最核心的一个环节——字符串类型转换。课程给出了对照表:
| Types | Encoding | Use |
|---|---|---|
str和String | UTF-8 | Rust 内的文本处理 |
CStr和CString | NUL 结尾 | 与 C 函数通信 |
OsStr和OsString | 操作系统相关 | 与操作系统通信 |
需要在上述类型之间完成一系列转换,每一步都有明确目的:
&str→CString:需要为结尾的\0分配空间;CString→*const c_char:得到可传给 C 函数的指针;*const c_char→&CStr:借以找到结尾的\0;&CStr→&[u8]:字节切片是"未知数据"的通用接口;&[u8]→&OsStr:借助OsStrExt创建,向OsString过渡;&OsStr→OsString:克隆数据,因为下一次readdir调用会复用缓冲。
仓库中的参考实现 exercise.rs 展示了这个安全封装在源码层面的完整形态,几个值得对照学习的要点:
extern 块声明(ANCHOR: ffi)
mod ffi { use std::os::raw::{c_char, c_int}; // ... // Opaque type. See https://doc.rust-lang.org/nomicon/ffi.html. #[repr(C)] pub struct DIR { _data: [u8; 0], _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>, } // Layout according to the Linux man page for readdir(3) ... #[repr(C)] pub struct dirent { pub d_ino: c_ulong, pub d_off: c_long, pub d_reclen: c_ushort, pub d_type: c_uchar, pub d_name: [c_char; 256], } unsafe extern "C" { pub unsafe fn opendir(s: *const c_char) -> *mut DIR; pub unsafe fn readdir(s: *mut DIR) -> *const dirent; pub unsafe fn closedir(s: *mut DIR) -> c_int; } }注意其中的工程细节:
DIR是不透明类型(opaque type),Rust 侧只需要知道它是一个指针大小的句柄,内部布局不对外暴露;dirent结构体必须用#[repr(C)]并按readdir(3)手册的内存布局逐字段复刻,字段类型随平台而定(源码中针对 Linux 与 macOS 分别定义了布局,macOS x86_64 还通过#[link_name = "readdir$INODE64"]处理了_DARWIN_FEATURE_64_BIT_INODE的符号名差异);- 平台相关的 FFI 声明本身就是 unsafe 函数"前提条件随目标平台变化"的典型例证。
用安全注释逐点交代前提条件
每个unsafe调用点都配有精确的SAFETY:注释,例如:
// SAFETY: path.as_ptr() cannot be NULL. let dir = unsafe { ffi::opendir(path.as_ptr()) }; // SAFETY: self.dir is never NULL. let dirent = unsafe { ffi::readdir(self.dir) }; // SAFETY: dirent is not NULL and dirent.d_name is NUL terminated. let d_name = unsafe { CStr::from_ptr((*dirent).d_name.as_ptr()) };CString::new保证生成的缓冲区以\0结尾,因此as_ptr()非空;DirectoryIterator的不变量是dir指针永不为 NULL(构造失败时返回Err,成功时才持有该指针);d_name以 NUL 结尾是dirent的内存布局与readdir(3)契约共同保证的。
用 RAII 收尾:Drop 里关闭句柄
impl Drop for DirectoryIterator { fn drop(&mut self) { // SAFETY: self.dir is never NULL. if unsafe { ffi::closedir(self.dir) } != 0 { panic!("Could not close {:?}", self.path); } } }在Drop中调用closedir,把"释放目录句柄"的职责绑定到类型生命周期上——即使迭代中途 panic,句柄也不会泄漏。这正是课程在 unsafe.md 中强调的总体原则的落地:
Unsafe 代码应当小而隔离,正确性要仔细记录,并用安全抽象层包裹。
最后,课程提醒:真实的 FFI 绑定通常由bindgen这类工具自动生成,而不是手写;本例手写是为了在在线 playground 中教学演示。
配套测试与运行方式
参考实现还附带了三组单元测试(见 exercise.rs 中的mod tests),用于验证安全封装的正确性:
test_nonexisting_directory:不存在的目录应返回Err;test_empty_directory:空目录迭代结果应为["." , ".."];test_nonempty_directory:写入foo.txt、bar.png、crab.rs后,迭代结果应包含全部条目。
该测试使用了tempfilecrate(声明于 Cargo.toml 的[dev-dependencies],版本 3.27.0),并通过[[bin]]把exercise.rs注册为名为listdir的可执行程序。你可以在仓库中按常规方式运行与验证:
cargo run --bin listdir # 在 src/unsafe-rust 下运行,列出当前目录 cargo test # 运行三组 FFI 封装测试小结
回到课程主线:unsafe 函数只是"把前提条件的责任移交给你",而不是"随便写的代码"。本讲的核心结论可以浓缩为四点:
- 两类来源:Rust 自身声明的
unsafe fn,以及unsafe extern块中声明的外部函数; - 契约精神:文档用
# Safety小节写明前提条件,代码用SAFETY:注释解释每个 unsafe 块为何安全;缺少安全注释、可由安全代码触发 UB 的函数是unsound的; - 语法演进:Rust 2024 edition 要求在
unsafe fn内显式写unsafe块(可用#[deny(unsafe_op_in_unsafe_fn)]在旧版本强制);Rust 1.82 起 extern 块必须是unsafe extern "C",其中无前提条件的函数可标记为safe fn; - 工程实践:unsafe 代码应小而隔离、包在安全抽象层里(如
DirectoryIterator用 RAII 封装opendir/readdir/closedir),优先使用标准库安全替代品,优化型 unsafe 要有基准测试支撑。
如果你想继续深入,同一课程的后续内容还覆盖了解引用裸指针(dereferencing.md)、可变静态变量(mutable-static.md)、union字段访问(unions.md)以及 unsafe trait(unsafe-traits.md)等其余四种 Unsafe 能力。
【免费下载链接】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),仅供参考