跳到主要内容

Unsafe Rust

到目前为止,我们讨论的所有代码都在编译时受到 Rust 内存安全保证的保护。然而,Rust 内部还隐藏着另一种语言,它不强制执行这些内存安全保证:这就是 Unsafe Rust

为什么需要 Unsafe Rust

Unsafe Rust 存在的原因有两点:

第一,静态分析本质上是保守的。 当编译器试图判断代码是否遵守安全保证时,与其接受一些无效程序,不如拒绝一些有效程序。虽然代码可能没问题,但如果 Rust 编译器没有足够的信息来确定,它就会拒绝这些代码。在这种情况下,你可以使用 unsafe 代码告诉编译器:"相信我,我知道我在做什么。"

第二,底层计算机硬件本质上是不安全的。 如果 Rust 不允许你进行不安全的操作,你就无法完成某些任务。Rust 需要允许你进行底层系统编程,比如直接与操作系统交互,甚至编写自己的操作系统。

注意

使用 unsafe Rust 需要自担风险:如果你不正确地使用 unsafe 代码,可能会因内存不安全而导致问题,例如空指针解引用。

Unsafe 超能力

要切换到 unsafe Rust,使用 unsafe 关键字,然后开始一个包含 unsafe 代码的新块。在 unsafe Rust 中,你可以执行五项在安全 Rust 中无法执行的操作,我们称之为 unsafe 超能力

  1. 解引用原始指针
  2. 调用 unsafe 函数或方法
  3. 访问或修改可变静态变量
  4. 实现 unsafe trait
  5. 访问 union 的字段

重要理解

  • unsafe 并不会关闭借用检查器或禁用任何其他 Rust 安全检查
  • 如果在 unsafe 代码中使用引用,它仍然会被检查
  • unsafe 关键字只是让你访问这五个不被编译器检查内存安全的特性
  • unsafe 并不意味着代码一定是危险的或肯定会有内存安全问题

解引用原始指针

Unsafe Rust 有两种类似于引用的新类型,称为原始指针(raw pointers)。与引用一样,原始指针可以是不可变的或可变的,分别写作 *const T*mut T。星号不是解引用运算符,它是类型名称的一部分。

原始指针与引用的区别

与引用和智能指针不同,原始指针:

  • 允许忽略借用规则,可以同时拥有不可变和可变指针,或多个指向同一位置的可变指针
  • 不保证指向有效的内存
  • 允许为空
  • 不实现任何自动清理

通过选择不使用 Rust 强制执行的这些保证,你可以放弃保证的安全性,以换取更好的性能,或者与 Rust 保证不适用的另一种语言或硬件交互的能力。

创建原始指针

fn main() {
let mut num = 5;

// 从引用创建原始指针(不需要 unsafe)
let r1 = &num as *const i32; // 不可变原始指针
let r2 = &mut num as *mut i32; // 可变原始指针

// Rust 2021 新语法:原始借用运算符
let r3 = &raw const num; // 等同于 &num as *const i32
let r4 = &raw mut num; // 等同于 &mut num as *mut i32
}

注意:我们可以在安全代码中创建原始指针,只是不能在 unsafe 块之外解引用原始指针。

创建指向任意内存地址的原始指针

fn main() {
let address = 0x012345usize;
let r = address as *const i32; // 指向任意内存地址
}

这段代码创建了一个指向任意内存地址的原始指针。尝试使用任意内存是未定义行为:该地址可能有数据,也可能没有,编译器可能会优化代码导致没有内存访问,或者程序可能因段错误而终止。通常没有好的理由写这样的代码。

解引用原始指针

fn main() {
let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
println!("r1 is: {}", *r1); // 解引用需要 unsafe
println!("r2 is: {}", *r2);
}
}

为什么原始指针有用?

  • 与 C 代码交互(FFI)
  • 构建借用检查器不理解的安全抽象
  • 性能关键的场景

原始指针示例:同时拥有可变和不可变指针

fn main() {
let mut num = 5;

// 如果使用引用,这段代码无法编译
// 因为 Rust 不允许同时存在可变和不可变引用

// 但使用原始指针,这是允许的
let r1 = &num as *const i32; // 不可变指针
let r2 = &mut num as *mut i32; // 可变指针

unsafe {
println!("r1: {}", *r1);
println!("r2: {}", *r2);
}
}

警告:这可能导致数据竞争,请小心使用!

调用 Unsafe 函数

第二种 unsafe 操作是调用 unsafe 函数。Unsafe 函数看起来与常规函数完全相同,但在定义前有一个额外的 unsafe 关键字。

基本 unsafe 函数

unsafe fn dangerous() {
// unsafe 函数体
}

fn main() {
// 必须在 unsafe 块中调用
unsafe {
dangerous();
}
}

如果在 unsafe 块之外调用 unsafe 函数,会得到编译错误。

在 unsafe 函数中使用 unsafe 操作

Rust 2024 Edition 变更

从 Rust 2024 Edition 开始,unsafe_op_in_unsafe_fn lint 默认警告。这意味着在 unsafe fn 内部调用其他 unsafe 操作时,仍然需要显式的 unsafe {} 块。

为什么需要这个变更?

虽然 unsafe fn 本身表明函数有安全隐患,但函数内部的 unsafe 操作应该被明确标记,这样:

  1. 更容易审查:可以清楚地看到哪些操作是 unsafe 的
  2. 更安全:避免意外遗漏 unsafe 操作
  3. 更好的文档性:代码意图更清晰
// Rust 2024+ 推荐写法
unsafe fn dangerous_work(ptr: *const i32) -> i32 {
// 显式标记 unsafe 操作
unsafe {
*ptr // 解引用原始指针
}
}

// Rust 2024 之前的写法(现在会产生警告)
// unsafe fn old_style(ptr: *const i32) -> i32 {
// *ptr // 警告:需要在 unsafe 块中
// }
unsafe fn dangerous_work(ptr: *const i32) -> i32 {
// unsafe 函数内部可以直接进行 unsafe 操作
// 但 Rust 2024+ 建议添加显式的 unsafe 块
unsafe { *ptr }
}

fn main() {
let x = 42;
let ptr = &x as *const i32;

unsafe {
let result = dangerous_work(ptr);
println!("Result: {}", result);
}
}

创建安全抽象

仅仅因为函数包含 unsafe 代码并不意味着我们需要将整个函数标记为 unsafe。实际上,将 unsafe 代码包装在安全函数中是一种常见的抽象模式。

示例:实现 split_at_mut

use std::slice;

// 这是一个安全函数,内部使用 unsafe 代码
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();

assert!(mid <= len);

unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}

fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);

println!("Left: {:?}", left); // [1, 2, 3]
println!("Right: {:?}", right); // [4, 5, 6]
}

解释

  • as_mut_ptr() 获取切片的原始指针
  • slice::from_raw_parts_mut 是 unsafe 函数,从原始指针创建切片
  • ptr.add(mid) 是 unsafe 方法,进行指针运算
  • 整个函数是安全的,因为我们在内部正确使用了 unsafe 代码

FFI:调用外部函数

Rust 支持外部函数接口(Foreign Function Interface,FFI),允许与其他语言交互:

Rust 2024 Edition 变更

从 Rust 2024 Edition 开始,extern 块必须使用 unsafe 关键字标记:

// Rust 2024+: extern 块需要 unsafe
unsafe extern "C" {
fn abs(input: i32) -> i32;
}

这是因为在 extern 块中声明的外部函数的正确性无法由 Rust 编译器验证,调用这些函数可能导致未定义行为。

迁移提示:运行 cargo fix --edition 可以自动添加 unsafe 关键字。

// 声明外部 C 函数
unsafe extern "C" {
fn abs(input: i32) -> i32;
}

fn main() {
unsafe {
println!("C 的 abs(-3) 结果: {}", abs(-3));
}
}

"C" 定义了外部函数使用的应用程序二进制接口(ABI)。C ABI 是最常见的,遵循 C 编程语言的 ABI。

从其他语言调用 Rust 函数

// 允许其他语言调用此 Rust 函数
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("从 C 调用了 Rust 函数!");
}
Rust 2024 Edition 变更

从 Rust 2024 Edition 开始,以下属性必须使用 unsafe(...) 包装:

  • #[export_name = "..."]
  • #[link_section = "..."]
  • #[no_mangle]

这些属性可能影响链接器行为或与其他语言的交互,不当使用可能导致未定义行为。

// Rust 2024+: 这些属性需要 unsafe
#[unsafe(export_name = "my_function")]
#[unsafe(no_mangle)]
pub extern "C" fn my_function() {}
  • #[no_mangle] 告诉 Rust 编译器不要修改函数名
  • extern "C" 使函数使用 C ABI

访问可变静态变量

在 Rust 中,全局变量称为静态变量

不可变静态变量

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
println!("Message: {}", HELLO_WORLD);
}

访问不可变静态变量是安全的。

静态变量与常量的区别

特性常量 (const)静态变量 (static)
内存位置可以被复制固定地址
可变性永远不可变可以是可变的
大小编译时内联有固定内存位置

可变静态变量

访问或修改可变静态变量是 unsafe 的:

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}

fn main() {
add_to_count(3);

unsafe {
println!("COUNTER: {}", COUNTER);
}
}

为什么这是 unsafe 的?

如果多个线程访问 COUNTER,很可能会导致数据竞争。拥有全局可访问的可变数据很难确保没有数据竞争,这就是 Rust 将可变静态变量视为 unsafe 的原因。

Rust 2024 Edition 变更

从 Rust 2024 Edition 开始,对 static mut 的引用会产生编译错误。之前这只是警告,现在是硬错误。

static mut DATA: i32 = 0;

// Rust 2024: 这是错误的!
// let ref_to_static = unsafe { &DATA }; // 编译错误

// 正确做法:使用原始指针
let ptr_to_static = unsafe { &raw const DATA }; // Rust 2024+
// 或者使用 std::ptr::addr_of!
let ptr = unsafe { std::ptr::addr_of!(DATA) };

替代方案:推荐使用 std::sync::atomic 类型或 std::sync::Mutex 来实现线程安全的全局状态:

use std::sync::atomic::{AtomicU32, Ordering};

// 使用原子类型替代 static mut
static COUNTER: AtomicU32 = AtomicU32::new(0);

fn increment() {
COUNTER.fetch_add(1, Ordering::SeqCst);
}

fn main() {
increment();
println!("Counter: {}", COUNTER.load(Ordering::SeqCst));
}

更好的替代方案:使用第 16 章讨论的并发技术和线程安全智能指针,让编译器检查跨线程的数据访问是否安全。

实现 Unsafe Trait

当 trait 的至少一个方法有编译器无法验证的不变量时,该 trait 就是不安全的。

// 声明 unsafe trait
unsafe trait Foo {
fn method(&self);
}

// 实现 unsafe trait 也需要 unsafe
unsafe impl Foo for i32 {
fn method(&self) {
println!("Foo method called on {}", self);
}
}

fn main() {
let x = 42;
// 使用 trait 的代码是安全的
unsafe { x.method(); }
}

Send 和 Sync Trait

SendSync 是 Rust 中重要的 marker trait:

  • Send:类型可以安全地在线程间转移所有权
  • Sync:类型可以安全地从多个线程访问(&T 可以发送到其他线程)

如果一个类型完全由 SendSync 类型组成,编译器会自动为其实现这些 trait。如果类型包含非 Send 或非 Sync 的类型(如原始指针),并且你想将其标记为 SendSync,必须使用 unsafe:

struct MyBox<T>(T);

// 原始指针不是 Send/Sync,但如果我们确定它是安全的
unsafe impl<T: Send> Send for MyBox<T> {}
unsafe impl<T: Sync> Sync for MyBox<T> {}

通过使用 unsafe impl,我们承诺会维护编译器无法验证的不变量。

访问 Union 字段

Union 类似于结构体,但在特定实例中一次只使用一个声明的字段。Union 主要用于与 C 代码中的 union 交互。

union IntOrFloat {
i: i32,
f: f32,
}

fn main() {
let mut u = IntOrFloat { i: 42 };

unsafe {
println!("Integer: {}", u.i);

// 写入不同的字段
u.f = 3.14;
println!("Float: {}", u.f);

// 注意:现在读取 u.i 是未定义行为!
}
}

访问 union 字段是 unsafe 的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。

何时使用 Unsafe

使用 unsafe 来执行上述五种操作(超能力)并没有错,也不被反对。但要正确编写 unsafe 代码更加棘手,因为编译器无法帮助维护内存安全。

何时需要使用 unsafe

  1. 与 C 代码交互:调用 C 库函数
  2. 性能优化:在关键路径上避免边界检查
  3. 实现底层抽象:如智能指针、容器
  4. 嵌入式开发:直接访问硬件
  5. 实现并发原语:如互斥锁、通道

最佳实践

1. 保持 unsafe 块最小化

// 不推荐:整个函数都是 unsafe
unsafe fn bad_example() {
// 很多安全代码...
let x = 5;
let y = 10;
// 只有一行需要 unsafe
*std::ptr::null::<i32>(); // 这会崩溃
}

// 推荐:只包围必要的代码
fn good_example() {
// 安全代码
let x = 5;
let y = 10;

// 只在需要时使用 unsafe
unsafe {
// unsafe 操作
}
}

2. 提供安全抽象

// 内部使用 unsafe,但提供安全 API
pub struct SafeWrapper {
ptr: *mut i32,
}

impl SafeWrapper {
pub fn new(value: i32) -> Self {
let boxed = Box::new(value);
Self {
ptr: Box::into_raw(boxed),
}
}

pub fn get(&self) -> i32 {
unsafe { *self.ptr }
}

pub fn set(&mut self, value: i32) {
unsafe {
*self.ptr = value;
}
}
}

impl Drop for SafeWrapper {
fn drop(&mut self) {
unsafe {
drop(Box::from_raw(self.ptr));
}
}
}

3. 文档化安全不变量

/// # Safety
///
/// 调用者必须确保:
/// - `ptr` 指向有效内存
/// - 内存已正确初始化
/// - 在此函数返回前,内存不会被释放
pub unsafe fn read_value(ptr: *const i32) -> i32 {
*ptr
}

4. 使用工具检查 unsafe 代码

  • Miri:Rust 的未定义行为检测工具
  • Clippy:linter,可以检测某些 unsafe 问题
  • loom:并发代码测试工具
# 使用 Miri 检测未定义行为
cargo +nightly miri test

小结

本章我们学习了:

  1. Unsafe Rust 存在的原因:静态分析的局限性和底层系统编程需求
  2. 五种 unsafe 超能力
    • 解引用原始指针
    • 调用 unsafe 函数
    • 访问可变静态变量
    • 实现 unsafe trait
    • 访问 union 字段
  3. 原始指针*const T*mut T
  4. FFI:与其他语言交互
  5. 最佳实践:保持 unsafe 块最小化、提供安全抽象

记住:使用 unsafe 不是坏事,但需要格外小心。unsafe 关键字的存在是为了帮助你追踪潜在问题的来源。

练习

  1. 编写一个函数,使用原始指针交换两个变量的值
  2. 实现一个简单的 MyBox<T> 智能指针类型
  3. 使用 FFI 调用一个 C 标准库函数
  4. 创建一个使用可变静态变量的计数器,并考虑如何在多线程环境中安全使用

参考资料