ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Comprehensive Rust 教程:深入理解 Unsafe Functions(安全前提、调用规范与 FFI 声明)

Comprehensive Rust 教程:深入理解 Unsafe Functions(安全前提、调用规范与 FFI 声明) 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, UBUnsafe 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 fnUnsafe 外部函数extern-c.md 讲解第二类不安全函数通过unsafe extern声明外部foreign函数。之所以需要 unsafe是因为编译器无法推断外部函数的行为。课程示例同时展示了safe fn与unsafe fn两种声明use std::ffi::c_char; unsafe extern C { // abs doesnt deal with pointers and doesnt 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(cString.as_ptr())); } }课程在这段代码的讲解details中给出四点关键知识历史演变Rust 曾经把所有 extern 函数一律视为 unsafeRust 1.82 引入unsafe extern块之后extern 块中的每个函数必须显式标记为safe或unsafe取决于它是否带有安全使用的前提条件。abs为什么必须写safe因为它是外部FFI函数默认继承块级的不安全属性而像abs这样不碰指针、没有任何安全要求的函数可以也应该显式标记为safe从而允许在安全代码中直接调用。需要注意的是任何 C 函数都可能在任意情况下出现未定义行为所以该函数是否安全需要逐个函数判断不能想当然。C是 ABI 名称本示例使用的是 C ABIRust 参考手册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而不是硬编码i32C 标准规定int可能是i16c_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 中最核心的一个环节——字符串类型转换。课程给出了对照表TypesEncodingUsestr和StringUTF-8Rust 内的文本处理CStr和CStringNUL 结尾与 C 函数通信OsStr和OsString操作系统相关与操作系统通信需要在上述类型之间完成一系列转换每一步都有明确目的str→CString需要为结尾的\0分配空间CString→*const c_char得到可传给 C 函数的指针*const c_char→CStr借以找到结尾的\0CStr→[u8]字节切片是未知数据的通用接口[u8]→OsStr借助OsStrExt创建向OsString过渡OsStr→OsString克隆数据因为下一次readdir调用会复用缓冲。仓库中的参考实现 exercise.rs 展示了这个安全封装在源码层面的完整形态几个值得对照学习的要点extern 块声明ANCHOR: ffimod 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 typeRust 侧只需要知道它是一个指针大小的句柄内部布局不对外暴露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不存在的目录应返回Errtest_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 traitunsafe-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),仅供参考
返回列表