Comprehensive Rust 课程精讲:Unsafe Rust 的五大能力与安全封装实战
Comprehensive Rust 课程精讲Unsafe Rust 的五大能力与安全封装实战【免费下载链接】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本文基于 Comprehensive RustGoogle Android 团队用于快速教授 Rust 的课程中 Unsafe Rust 章节 的系统讲解深入剖析 Rust 语言中 Safe 与 Unsafe 两大组成部分的边界逐一拆解 Unsafe Rust 赋予开发者的五种新能力并结合仓库中的可运行代码示例与完整实战练习帮助你掌握小而隔离、精心注释、以安全抽象层包裹的正确使用姿势。两种 RustSafe Rust 与 Unsafe RustRust 语言由两部分构成这是理解后续一切内容的前提Safe Rust安全 Rust内存安全不可能发生未定义行为undefined behavior。本课程前面绝大部分内容都属于这一范畴编译器会强制保证内存安全规则。Unsafe Rust非安全 Rust一旦违反前置条件preconditions就可能触发未定义行为。需要特别澄清的是Unsafe Rust并不等于代码是错误的。它意味着开发者关闭了部分编译器安全检查compiler safety features必须依靠自己写出正确的代码——编译器不再强制推行 Rust 的内存安全规则责任从编译器转移到了开发者身上。正因如此课程的总体指导原则非常明确Unsafe 代码应当小而隔离small and isolated其正确性必须被仔细地文档化并且应当被包装在一层安全抽象safe abstraction layer之中。这条原则贯穿整个章节也是 dereferencing.md解引用裸指针、mutable-static.md可变静态变量、unions.mdunion、unsafe-traits.mdunsafe trait以及 unsafe-functions.mdunsafe 函数所有子页的一致主线。五大新能力总览进入 Unsafe Rust 世界后你将获得 Safe Rust 中不具备的五种新能力这也是整个章节的核心骨架序号能力子文档1解引用裸指针Dereference raw pointersdereferencing.md2访问或修改可变静态变量Access or modify mutable static variablesmutable-static.md3访问union字段Accessunionfieldsunions.md4调用unsafe函数包括extern外部函数Callunsafefunctions, includingexternfunctionsunsafe-functions.md5实现unsafetraitImplementunsafetraitsunsafe-traits.md接下来逐一展开讲解。若需更完整的权威资料原文档建议进一步参阅 Rust Book 第 19.1 章Unsafe Rust与 The Rustonomicon。能力一解引用裸指针创建指针是安全的解引用必须 unsafe在 Safe Rust 中创建裸指针是完全安全的但解引用dereference则必须放在unsafe块中。课程给出了一个最小示例fn main() { let mut x 10; let p1: *mut i32 raw mut x; let p2 p1 as *const i32; // SAFETY: p1 和 p2 由指向局部变量的裸指针转换而来因此保证 // 非空、已对齐且指向单个栈上分配的对象。 // // 裸指针底层的对象在整个函数生命周期内存在因此裸指针存在期间 // 对象不会被释放对象也不会通过引用被访问也没有其他线程并发访问。 unsafe { dbg!(*p1); *p1 6; // 通过裸指针观察可变性是有健全性的就像 C 语言一样。 dbg!(*p2); } }注意p1与p2的声明p1是可变裸指针*mut i32p2通过as转换得到只读裸指针*const i32。两者都先于unsafe块创建只有真正读写指向内存时才进入unsafe。指针解引用的健全性soundness要求课程强调每次解引用裸指针都必须满足valid有效 的全部条件指针必须非空non-null指针必须是可解引用的dereferenceable即落在单个已分配对象的边界之内底层对象不得已被释放deallocated不得存在对同一位置的并发访问如果指针是通过转换引用reference得到的那么底层对象必须仍然存活live并且不得再通过任何引用访问该内存。在大多数情况下指针还必须满足**对齐aligned**要求。一个典型的 UB 陷阱从裸指针创建引用示例末尾用注释标注了一段UNSOUND非健全的代码切勿照做// UNSOUND. DO NOT DO THIS. let r: i32 unsafe { *p1 }; dbg!(r); x 50; dbg!(r); // 引用底层对象已被修改这是 UB。这段代码之所以是 UB是因为天真地取裸指针解引用的引用会绕过编译器对引用实际指向对象的跟踪借用检查器并不知道这个引用指向x因此不会冻结x导致在存在引用的同时仍然可以修改它形成别名与可变性同时存在的违例。课程明确指出从指针创建引用需要格外小心。SAFETY 注释规范这也是 Android Rust 风格指南的硬性要求每个unsafe块都应配一条注释说明其中的代码如何满足所执行 unsafe 操作的安全性要求。上面示例中的// SAFETY:注释正是这一实践的标准写法calling.md 与 rust.md 中所有示例也都贯彻了这一约定。能力二可变静态变量只读静态变量是安全的读取不可变immutable静态变量是安全操作无需unsafestatic HELLO_WORLD: str Hello, world!; fn main() { println!(HELLO_WORLD: {HELLO_WORLD}); }可变静态变量为什么必须 unsafe然而读写可变静态变量static mut是 unsafe 的因为多个线程可能在没有同步的情况下并发访问构成数据竞争data race。使用可变静态变量意味着要在没有编译器帮助的情况下自行推理并发正确性static mut COUNTER: u32 0; fn add_to_counter(inc: u32) { // SAFETY: 不存在其他可能访问 COUNTER 的线程。 unsafe { COUNTER inc; } } fn main() { add_to_counter(42); // SAFETY: 不存在其他可能访问 COUNTER 的线程。 unsafe { dbg!(COUNTER); } }课程进一步给出了若干关键事实上面的程序之所以健全是因为它是单线程的但 Rust 编译器逐函数推理无法自行假设这一点。你可以尝试去掉unsafe观察编译器如何解释从多个线程访问可变静态是未定义行为。Rust 2024 版更进一步默认情况下通过引用访问可变静态变量会成为编译错误。使用可变静态变量很少是个好主意通常应改用内部可变性interior mutability例如Cell、RefCell、Mutex等类型。在某些底层no_std代码中它可能是必要的例如实现堆分配器或与某些 C API 协作——此时应使用指针而非引用来访问。这正是本仓库 bare-metal 章节 讨论的嵌入式场景。能力三访问 union 字段union类似于枚举但哪个字段是活跃的active field需要你自己跟踪。读取 union 的任何字段都必须放在unsafe块中#[repr(C)] union MyUnion { i: u8, b: bool, } fn main() { let u MyUnion { i: 42 }; println!(int: {}, unsafe { u.i }); println!(bool: {}, unsafe { u.b }); // 未定义行为 }第二行输出u.b是未定义行为42作为u8写入但作为bool读出时并非合法的布尔表示bool只能取值0或1这正说明了跟踪活跃字段是调用方的责任编译器无法代劳。课程的补充建议非常实用Rust 中union 很少需要因为枚举enum是更优越的替代方案union 偶尔用于与 C 库 API 交互如果你只是想把字节重新解释为另一种类型更合适的做法是使用std::mem::transmute或者使用提供安全封装的 crate如zerocopy。后者恰好也是 unsafe-traits.md 中 unsafe trait 的示例来源可见这两项能力在实际 FFI 场景中常常配套出现。能力四调用 unsafe 函数两类 unsafe 函数函数或方法可以被标记为unsafe条件是它带有你必须遵守以避免未定义行为的额外前置条件。unsafe 函数可能来自两个来源见 unsafe-functions.mdRust 自身声明为 unsafe 的函数extern C块中声明的非安全外部函数。自定义 unsafe Rust 函数当你的函数要求特定前置条件以避免未定义行为时可以将其标记为unsafe并配# Safety文档段rust.md/// 交换给定指针指向的值。 /// /// # Safety /// /// 指针必须有效、正确对齐并且在函数调用期间不得被其他方式访问。 unsafe fn swap(a: *mut u8, b: *mut u8) { // SAFETY: 我们的调用方承诺指针有效、正确对齐且没有其他访问。 unsafe { let temp *a; *a *b; *b temp; } } fn main() { let mut a 42; let mut b 66; // SAFETY: 指针来自引用因此有效、对齐且唯一。 unsafe { swap(mut a, mut b); } println!(a {}, b {}, a, b); }课程提示两点这个swap用安全的引用方式就能实现实际工程中不会用裸指针此外Rust 2021 及更早版本允许 unsafe 函数内部直接写 unsafe 代码而无需unsafe块这一行为在2024 版被改变。在旧版本中可以用#[deny(unsafe_op_in_unsafe_fn)]来禁止可尝试添加后观察编译结果。调用 unsafe 函数的常见错误元素数量还是字节数calling.md 用一个故意非健全的示例警示读者未满足安全要求会破坏内存安全#[derive(Debug)] #[repr(C)] struct KeyPair { pk: [u16; 4], // 8 字节 sk: [u16; 4], // 8 字节 } 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()); }这里的致命错误是slice::from_raw_parts的第二个参数是元素数量而不是字节数本意是读取 8 字节的公钥却传入了 8 作为元素个数导致读取越过了pk数组的末尾、侵入相邻的sk数组——这是未定义行为因为读取超出了指针所源自对象的边界。该示例要点总结log_public_key应当被声明为unsafe因为pk_ptr必须满足某些前置条件才能避免未定义行为一个能引发未定义行为的安全函数被称为unsound非健全。它的 safety 文档应当说明指针必须有效、指向足够多的元素且对齐等。标准库中有大量底层 unsafe 函数尽可能优先使用安全替代方案。如果出于优化目的使用 unsafe 函数务必添加基准测试benchmark来证明收益。每个unsafe块都要配安全注释说明它为什么是安全的本例缺失注释且非健全是反面教材。unsafe extern 外部函数FFI用unsafe extern可以声明外部函数供 Rust 调用extern-c.md。之所以 unsafe是因为编译器无法推理外部函数的行为。extern块中声明的函数必须根据其是否存在安全使用的前置条件被标记为safe或unsafeuse std::ffi::c_char; unsafe extern C { // abs 不涉及指针也没有任何安全要求。 safe fn abs(input: i32) - i32; /// # Safety /// /// s 必须是指向 NUL 结尾的 C 字符串的指针该字符串在本次函数调用 /// 期间必须有效且不被修改。 unsafe fn strlen(s: *const c_char) - usize; } fn main() { println!(Absolute value of -3 according to C: {}, abs(-3)); unsafe { // SAFETY: 我们传入指向 C 字符串字面量的指针该字面量在整个程序 // 生命周期内有效。 println!(String length: {}, strlen(cString.as_ptr())); } }几个值得强调的历史与细节过去 Rust 把所有 extern 函数都视为 unsafe这一变化始于 Rust 1.82 引入的unsafe extern块abs必须显式标记为safe因为它是外部函数FFI。调用外部函数本身只有在函数对指针做了可能违反 Rust 内存模型的操作时才会出问题但原则上任何 C 函数都可能在任意情况下表现出未定义行为示例中的C是ABIRust 参考手册中还有其他可用 ABIRust 不会验证函数签名与外部定义是否一致——这完全取决于你自己。能力五实现 unsafe trait与函数类似trait 也可以标记为unsafe条件是实现方必须保证特定条件以避免未定义行为unsafe-traits.md。课程以zerocopycrate 的IntoBytestrait 为原型给出了高度提炼的示意实现use std::{mem, slice}; /// ... /// # Safety /// 类型必须有确定的表示defined representation且无填充padding。 pub unsafe trait IntoBytes { fn as_bytes(self) - [u8] { let len mem::size_of_val(self); let slf: *const Self self; unsafe { slice::from_raw_parts(slf.cast::u8(), len) } } } // SAFETY: u32 有确定的表示且无填充。 unsafe impl IntoBytes for u32 {}要点总结trait 的 Rustdoc 中应包含# Safety段说明实现该 trait 的安全要求真实的IntoBytes安全文档远比示意版本更长、更复杂内置的Send与Sync就是 unsafe trait——自动实现与否由编译器判定手动实现则必须自己保证线程安全语义。实战用安全 FFI 包装器把理论落地课程的 exercise.md 提供了一个 30 分钟的综合练习把上面五大能力中的多项extern 函数、裸指针、SAFETY 注释、安全抽象层串成一条完整的实战链路为libc的目录读取函数构建一个安全的 Rust 包装器完整实现见 exercise.rs官方讲解见 solution.md。目标与涉及的系统调用你将包装三个 C 函数opendir(3)打开目录返回DIR*readdir(3)读取下一个目录项返回struct dirent*closedir(3)关闭目录。字符串类型的转换链条练习的核心难点之一是FFI 字符串类型转换需要熟悉std::ffi模块中的类型家族类型编码用途str与StringUTF-8Rust 中的文本处理CStr与CStringNUL 结尾与 C 函数通信OsStr与OsString操作系统相关与操作系统通信需要完成的转换链条为str→CString需要为结尾的\0分配空间CString→*const c_char需要指针来调用 C 函数*const c_char→CStr需要能定位结尾\0的类型CStr→[u8]字节切片是某种未知数据的通用接口[u8]→OsStr使用 Unix 平台OsStrExt扩展创建OsStr→OsString克隆数据后才能返回并再次调用readdir。FFI 声明与平台差异FFI 声明模块是理解底层细节的样板见 exercise.rs不透明类型DIR用#[repr(C)]的空数组加PhantomData模拟 C 的不透明结构体指针_data: [u8; 0]表示无已知字段dirent结构体布局Linux 与 macOS 的struct dirent字段不同。Linux 版本为d_inoc_ulong、d_offc_long、d_reclenc_ushort、d_typec_uchar、d_name: [c_char; 256]macOS 版本则使用d_fileno、d_seekoff、d_namlen等字段d_name长度为 1024macOS x86_64 的特殊处理readdir在该平台上需要#[link_name readdir$INODE64]链接改名以匹配 macOS 的 64 位 inode ABI相关背景见 libc crate 的 issue 414 与 macOS 的_DARWIN_FEATURE_64_BIT_INODE。安全抽象层的三个关键设计完整的DirectoryIterator把不安全操作全部收敛在内部对外呈现安全接口RAII 资源管理Drop实现Drop自动调用closedir保证迭代器离开作用域时不泄漏文件描述符impl Drop for DirectoryIterator { fn drop(mut self) { // SAFETY: self.dir 永远不为 NULL。 if unsafe { ffi::closedir(self.dir) } ! 0 { panic!(Could not close {:?}, self.path); } } }安全构造new把str转换为CString处理内嵌 NUL 的失败再调用opendir空指针即返回Err同时用字段dir: *mut ffi::DIR保存指针用path: CString保持底层数据存活。迭代器接口Iterator实现IteratorItem OsStringnext反复调用readdir遇到 NULL 指针返回None目录项名称通过CStr::from_ptr包装后用OsStr::from_bytes转为 OS 字符串// SAFETY: dirent 不为 NULL且 dirent.d_name 以 NUL 结尾。 let d_name unsafe { CStr::from_ptr((*dirent).d_name.as_ptr()) }; let os_str OsStr::from_bytes(d_name.to_bytes()); Some(os_str.to_owned())这样调用方只需DirectoryIterator::new(.)?并collect()即可安全地拿到目录项列表——所有裸指针、extern 调用都被小而隔离地封装在安全抽象之内正是 Unsafe Rust 的最佳实践范本。测试与验证练习附带三个单元测试见 exercise.rs 末尾的tests模块依赖tempfilecrate可用cargo add --dev tempfile添加test_nonexisting_directory打开不存在的目录应返回Errtest_empty_directory空目录只包含.与..test_nonempty_directory写入foo.txt、bar.png、crab.rs后迭代结果应精确等于.、..、bar.png、crab.rs、foo.txt。课程还指出真实的 FFI 绑定代码通常由bindgen之类的工具生成而非手写只是在线 playground 无法运行 bindgen因此本练习才采用手写方式教学。最佳实践总结综合 unsafe.md 及其全部子文档可以提炼出以下可操作的实践准则小而隔离把 unsafe 操作限制在最小范围集中封装绝不让不安全代码散落各处每个unsafe块都写// SAFETY:注释说明为什么该操作在此处安全这是审计友好auditing-friendly的行业标准做法也是 Android Rust 风格指南的强制要求用安全抽象层包裹像DirectoryIterator那样对外暴露安全的构造器与Iterator接口内部消化裸指针与 extern 调用小心从裸指针创建引用这会绕过借用检查器对对象的跟踪是最常见的 UB 来源之一明确区分元素数量与字节数slice::from_raw_parts的第二个参数是元素个数误用会越界读取优先安全替代方案union 可用枚举替代、static mut可用内部可变性替代、字节重解释可用transmute或zerocopy替代外部函数显式标注safe/unsafe自 Rust 1.82 起extern块中的每个函数都必须声明其安全性为 trait 编写# Safety文档unsafe trait 的实现条件必须明确可审计内置的Send/Sync就是典型 unsafe trait若为优化引入 unsafe务必用基准测试证明收益注意版本差异2024 edition 改变了 mutable static 引用访问与 unsafe 函数内部代码块的规则跨版本迁移时需关注。掌握这些准则你就能在需要与 C 互操作、编写底层no_std代码或实现安全关键抽象时既发挥 Unsafe Rust 的能力又守住内存安全的底线。【免费下载链接】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),仅供参考