跳到主要内容

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​

Send 和 Sync 是 Rust 中重要的 marker trait:

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

如果一个类型完全由 Send 和 Sync 类型组成,编译器会自动为其实现这些 trait。如果类型包含非 Send 或非 Sync 的类型(如原始指针),并且你想将其标记为 Send 或 Sync,必须使用 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. 创建一个使用可变静态变量的计数器,并考虑如何在多线程环境中安全使用

参考资料​