Rust unsafe 入门:什么时候必须用 unsafe,以及怎么把它包安全
一、unsafe 到底"关掉"了什么检查
很多人以为unsafe关键字一写,Rust 编译器就彻底撒手了。其实不是。它只放开五件事,其余检查照旧:
mindmap root((unsafe 放开的五件事)) 解引用裸指针 原始指针 *const T / *mut T 不受借用规则约束 调用 unsafe 函数 FFI 外部函数 标注了 unsafe 的库函数 访问可变静态变量 static mut 数据竞争风险 实现 unsafe trait Send / Sync 等标记 trait 告诉编译器"我保证线程安全" 访问 union 字段 联合体的字段读写 C 互操作常用重点来了:即使在一个unsafe块里面,Rust 仍然会检查:
- 变量的所有权规则(你仍然不能把一个值移动两次)
- 生命周期标注是否一致
- 模式匹配是否穷尽
- 类型是否匹配
所以我现在的理解是:unsafe不是"免死金牌",它是一个契约边界——编译器的"我保证不了,这部分你来"。你写unsafe的意思就是:"编译器你不用管了,出了事我负责"。
二、什么时候必须用 unsafe——四个绕不开的场景
作为自学的,我一开始特别抗拒 unsafe。但写了一些项目后发现,以下场景你真的绕不过去:
场景 1:调用 C 库(FFI)
这是最没得选的场景。你只要调用任何一个 C 函数,都得进 unsafe。
// ========== FFI 调用必须用 unsafe ========== // 假设有这个 C 库函数: int read_file(const char* path, char* buf, int size); extern "C" { /// C 函数声明 —— Rust 编译器无法验证它的安全性 fn read_file(path: *const i8, buf: *mut u8, size: i32) -> i32; } /// 安全的包装函数 pub fn safe_read_file(path: &str) -> Result<String, String> { // 1️⃣ 准备好 C 风格的字符串 let c_path = std::ffi::CString::new(path) .map_err(|e| format!("路径包含空字节: {e}"))?; // 2️⃣ 分配缓冲区 let mut buf = vec![0u8; 4096]; // 3️⃣ unsafe 块 —— 这是我们保证安全的边界 let bytes_read = unsafe { // 解引用裸指针,调用 C 函数,编译器不管 read_file(c_path.as_ptr(), buf.as_mut_ptr(), buf.len() as i32) }; // 4️⃣ 检查返回值(C 函数的返回值需要手动检查) if bytes_read < 0 { return Err(format!("读取文件失败,错误码: {bytes_read}")); } // 5️⃣ 转换回 Rust 的 String buf.truncate(bytes_read as usize); String::from_utf8(buf) .map_err(|e| format!("文件内容不是有效的 UTF-8: {e}")) } // ========== 关键点 ========== // 调用 safe_read_file 的函数不需要 unsafe, // 因为所有不安全操作都已经被"包"在内部了。场景 2:高性能数据结构(自己实现链表/无锁队列)
Rust 标准库的LinkedList是安全的,但如果你要实现一个侵入式链表或者无锁数据结构,不可避免地要操作裸指针。
// ========== 自实现侵入式链表节点 ========== use std::ptr::NonNull; /// 双向链表节点 struct Node<T> { value: T, prev: Option<NonNull<Node<T>>>, // 用 NonNull 而不是裸指针,语义更清晰 next: Option<NonNull<Node<T>>>, } impl<T> Node<T> { fn new(value: T) -> Self { Node { value, prev: None, next: None, } } /// 在当前节点后插入新节点 fn insert_after(&mut self, mut new_node: Box<Node<T>>) { // 安全前提:调用者保证 self 在链表中的位置是确定的 unsafe { // 1️⃣ 设置新节点的前后指针 new_node.prev = Some(NonNull::new_unchecked(self as *mut Node<T>)); new_node.next = self.next; // 2️⃣ 如果有后继节点,更新它的 prev 指针 if let Some(mut next) = self.next { next.as_mut().prev = Some(NonNull::new_unchecked(new_node.as_mut())); } // 3️⃣ 更新当前节点的 next let new_ptr = NonNull::new_unchecked(Box::into_raw(new_node)); self.next = Some(new_ptr); } } } // ========== 设计要点 ========== // 1. insert_after 本身不是 unsafe —— 调用者不需要写 unsafe 块 // 2. 但内部用 unsafe { } 块明确标记了不安全区域 // 3. NonNull 比 *mut T 更安全,因为它保证非空场景 3:操作硬件/内存映射 I/O
嵌入式或者操作系统开发中需要直接操作内存地址:
// ========== 内存映射 I/O 的 unsafe 包装 ========== /// GPIO 寄存器地址(假设) const GPIO_BASE: usize = 0x3F20_0000; const GPIO_SET_OFFSET: usize = 0x1C; /// 设置 GPIO 引脚为高电平 —— 安全包装 pub fn gpio_set_high(pin: u8) { assert!(pin <= 53, "引脚号必须在 0-53 之间"); unsafe { // 将固定地址转为可变裸指针 —— unsafe 不可省略 let gpio_set = (GPIO_BASE + GPIO_SET_OFFSET) as *mut u32; // 写入寄存器 gpio_set.write_volatile(1 << pin); } } // 虽然内部有 unsafe,但调用方不用 —— 引脚范围检查在 unsafe 外面做完了场景 4:突破借用规则(但你不该这么做)
这个场景我放在最后,因为"能做到"不代表"应该做"。绝大多数情况下,如果你觉得需要unsafe来绕开借用检查,应该先反思是不是设计有问题。
// ⚠️ 这是一个"技术上可行但绝大多数场景应该避免"的例子 // 用于说明 unsafe 的能力边界,不代表推荐做法 struct SplitBuffer { data: Vec<u8>, } impl SplitBuffer { /// 同时获取两个不重叠的可变切片 fn split_at_mut(&mut self, mid: usize) -> Option<(&mut [u8], &mut [u8])> { let len = self.data.len(); if mid > len { return None; } let ptr = self.data.as_mut_ptr(); unsafe { // 虽然生成了两个 &mut,但它们指向不重叠的内存区域 —— 不会数据竞争 Some(( std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid), )) } } }注意:标准库的split_at_mut已经提供了这个功能,而且是用 unsafe 实现的。你的代码应该用标准库版本,而不是自己写。
三、怎么把 unsafe 包安全——四条铁律
flowchart TD A[需要 unsafe 操作] --> B{能否缩小 unsafe 范围?} B -->|能| C[unsafe 块尽量小] B -->|不能| D[用注释写清楚安全前提] C --> E{调用方需要写 unsafe 吗?} E -->|需要| F[标记函数为 unsafe] E -->|不需要| G[函数不加 unsafe 标记] D --> G F --> H[文档写清调用者要保证什么] G --> I[内部注释写清为什么安全] H --> J{能证明安全吗?} I --> J J -->|能| K[✅ 通过代码审查] J -->|不能| L[❌ 重新设计] style K fill:#6bcb77,stroke:#333 style L fill:#ff6b6b,stroke:#333把 unsafe 包安全的四条铁律:
铁律 1:unsafe 块越小越好
// ❌ 不好 —— 整个函数都是 unsafe,分不清哪里不安全 unsafe fn process_data(data: *const u8, len: usize) -> Vec<u8> { let slice = std::slice::from_raw_parts(data, len); let mut result = Vec::with_capacity(len); result.extend_from_slice(slice); result } // ✅ 好 —— unsafe 只包住真正不安全的那一行 fn process_data(data: *const u8, len: usize) -> Vec<u8> { // 参数校验在 unsafe 外面 assert!(!data.is_null(), "数据指针不能为空"); assert!(len > 0, "长度必须大于 0"); let slice = unsafe { // 只有这一行是不安全的 —— 从裸指针创建切片 std::slice::from_raw_parts(data, len) }; // 其余操作都是安全的 let mut result = Vec::with_capacity(len); result.extend_from_slice(slice); // 安全:slice 生命周期正确 result }铁律 2:安全前提必须用注释或断言"证明"
/// 从原始字节创建字符串。 /// /// # Safety(内部保证的安全前提) /// /// - 调用者必须保证 `bytes` 是有效的 UTF-8。 /// 本函数会先调用 `from_utf8` 检查,不依赖外部保证。 /// /// # 外部调用 /// /// 本函数 **不是** unsafe,调用者无需 unsafe 块。 pub fn bytes_to_string(bytes: &[u8]) -> Result<String, String> { // 在可能导致 unsound 之前,用安全的检查兜底 String::from_utf8(bytes.to_vec()) .map_err(|e| format!("无效的 UTF-8 编码: {e}")) }铁律 3:用类型系统把 unsafe"关在笼子里"
use std::marker::PhantomData; /// 一个"被验证过的"原始指针包装类型 /// /// 这个类型的存在本身就意味着:持有它的人已经确认过指针的有效性。 /// 外界无法直接构造,必须通过 `validate` 方法。 pub struct ValidatedPtr<T> { ptr: *const T, _phantom: PhantomData<T>, } impl<T> ValidatedPtr<T> { /// 唯一构造入口 —— 验证指针非空 pub fn validate(ptr: *const T) -> Option<Self> { if ptr.is_null() { None } else { Some(ValidatedPtr { ptr, _phantom: PhantomData, }) } } /// 安全地解引用 —— 因为构造时已验证非空 pub fn as_ref(&self) -> &T { unsafe { &*self.ptr } } } // 任何拿到 ValidatedPtr 的代码都可以安全调用 as_ref(), // 不需要再写 unsafe,因为不变量在构造时已保证。铁律 4:宁愿拒绝编译也不要写不确定的 unsafe
这是的最痛领悟。有一次我想用 unsafe 绕开一个生命周期问题,搞了两个小时,最后发现换一种数据结构设计根本不需要 unsafe。如果你对 unsafe 块的安全性有一丝不确定,就换个方案。
// ❌ 不要这样 —— 为了"性能"写不清晰的 unsafe // ✅ 先用 safe 代码实现,profile 证明是瓶颈后再考虑优化四、必须用 unsafe 实现 unsafe trait 的场景
这是 Rust 里最常见也最容易被忽略的 unsafe 用法。当你自己实现Send或Synctrait 时,相当于你在对编译器说:"这个类型跨线程传输是安全的,我保证"。
use std::cell::UnsafeCell; use std::sync::Arc; /// 一个线程安全的计数器实现(类似标准库的 AtomicUsize,这里是教学简化版) struct MyAtomicUsize { // UnsafeCell 是 !Sync 的,所以包含它的结构体默认不是 Sync inner: UnsafeCell<usize>, } // ⚠️ unsafe impl —— 我们手动告诉编译器这个类型是 Sync 的 // 前提:所有对 inner 的访问都通过原子操作或适当的同步机制 unsafe impl Sync for MyAtomicUsize {} impl MyAtomicUsize { pub fn new(val: usize) -> Self { MyAtomicUsize { inner: UnsafeCell::new(val), } } /// 安全地读取值(这里简化了,实际应该用原子操作) pub fn load(&self) -> usize { // unsafe 块很小:只包住了对 UnsafeCell 的访问 unsafe { *self.inner.get() } // 这个 unsafe 是安全的,因为: // 1. 我们通过 &self 获取,没有 &mut 存在 // 2. 这个值只是一个 usize,读取是原子的(在大多数平台上) // 如果这是生产代码,应该用 AtomicUsize } } // 使用示例 —— 可以安全地在多线程间共享 let counter = Arc::new(MyAtomicUsize::new(0)); let c = counter.clone(); std::thread::spawn(move || { println!("计数: {}", c.load()); });五、总结
这篇文章把 unsafe 的核心梳理了一遍:
unsafe 只放开五件事:裸指针解引用、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问 union 字段。其余 Rust 安全检查照常工作。
四个绕不开的 unsafe 场景:FFI 调用 C 库、高性能自定义数据结构、硬件/内存映射 I/O、极少见的借用规则突破。除此之外,绝大多数场景都不需要 unsafe。
四条包装铁律:unsafe 块尽量小、安全前提必须可证明、用类型系统把 unsafe 关在笼子里、不确定就别用。
unsafe trait(Send/Sync)是你对编译器的郑重承诺,写之前要想清楚"我拿了什么同步机制来保证"。
作为独立开发者,我对 unsafe 的态度从"恐惧"转变成了"敬畏"。它不是捷径,而是一个提醒:这里编译器帮不了你了,你得自己写明白为什么安全。
希望这篇文章能帮到和我一样在学 Rust 的朋友。有问题欢迎评论区交流!