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 或 Sync trait 时,相当于你在对编译器说:"这个类型跨线程传输是安全的,我保证"。
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 的朋友。有问题欢迎评论区交流!
4417



被折叠的 条评论
为什么被折叠?



