Skip to content

不安全 Rust

Rust 还隐藏有第二种语言,它不会强制执行这类内存安全保证:这被称为不安全 Rust(unsafe Rust).它与常规 Rust 代码无异,但是会提供额外的超能力.

不安全 Rust 之所以存在,是因为静态分析本质上是保守的.当编译器尝试确定一段代码是否支持某个保证时,拒绝一些合法的程序比接受无效的程序要好一些.这必然意味着有时代码可能是合法的,但如果 Rust 编译器没有足够的信息来确定,它将拒绝该代码.在这种情况下,可以使用不安全代码告诉编译器,"相信我,我知道自己在干什么." 不过千万注意,使用不安全 Rust 风险自担:如果不安全代码出错了,比如解引用空指针,可能会导致不安全的内存使用.

另一个 Rust 存在不安全一面的原因是底层计算机硬件固有的不安全性.如果 Rust 不允许进行不安全操作,那么有些任务则根本完成不了.Rust 需要能够进行像直接与操作系统交互甚至于编写你自己的操作系统这样的底层系统编程.底层系统编程也是 Rust 语言的目标之一.

执行不安全的超能力

要切换到不安全 Rust,可以使用 unsafe 关键字,然后开启一个包含不安全代码的新块.这里有五类可以在不安全 Rust 中进行而不能用于安全 Rust 的操作,它们称之为不安全的超能力(unsafe superpowers).这些超能力包括:

注意:unsafe 并不会关闭借用检查器或禁用任何其他 Rust 安全检查.如果在不安全代码中使用引用,它仍会被检查.unsafe 关键字只是提供了那五个不会被编译器检查内存安全的功能,你仍然能在不安全块中获得某种程度的安全.

本质上不安全的行为主要是 3 种:解引用裸指针访问和修改可变静态变量访问 union 字段,但归根到底都是因为裸指针.而调用不安全的函数或方法实现不安全 trait 则是因为这些函数或 trait 的实现里面可能包含了上述不安全的行为,所以调用它们或实现它们也被认为是不安全的行为.

解引用裸指针

裸指针概念

裸指针(Raw Pointers) 是最接近 C 语言指针的东西,是 Rust 提供的底层指针类型.它绕过了 Rust 编译器的大部分安全检查,赋予你直接操作内存地址的权力.

裸指针是 Rust 给开发者的"后门",让你在有极致性能要求或处理硬件/外部接口时,有能力接管内存管理.

两种基本类型:

类型说明
*const T不可变裸指针
*mut T可变裸指针

*是类型的一部分,不是解引用操作符.

裸指针与引用、智能指针的区别在于:

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

定义裸指针

  • 旧语法:通过引用转换(Coercion) 得到裸指针
  • 新语法:使用裸指针借用操作符(raw borrow operators) 创建裸指针

创建裸指针是安全的,但解引用裸指针必须在 unsafe 块中进行.

rust
fn main() {
  let mut num = 5;

  // 1. 旧语法:将引用强制转换为裸指针
  let r1 = &num as *const i32;     // 不可变裸指针
  let r2 = &mut num as *mut i32;   // 可变裸指针

  // 2. 新语法:使用裸指针借用操作符创建裸指针
  let r1 = &raw const num;
  let r2 = &raw mut num;

  // 3. 创建一个指向任意内存地址的裸指针(慎用!)
  let address = 0x012345usize;
  let r3 = address as *const i32;

  // 4. 解引用裸指针必须在 unsafe 块中
  unsafe {
    println!("r1 points to: {}", *r1);
  }
}

在旧语法中,如果你写 &num as *const i32,编译器实际上会先创建一个引用(即遵守借用规则),然后再把它转换成裸指针.而 &raw 的意义在于:它直接创建裸指针,跳过了"先创建引用"这一步.

使用 &raw 主要有两个好处:

  1. 避免产生临时的悬垂引用:如果你指向的是一个尚未完全初始化的内存,或者是处于某些极端不安全状态的地址,旧写法 &x as *const T 可能会触发未定义行为(UB),因为引用必须始终指向有效值.&raw 就像 C 语言的 & 运算符,不带任何 Rust 的安全负担.

  2. 语义更清晰:它明确告诉编译器和阅读代码的人:"我这里就是要操作底层地址,不需要借用检查."

应用场景

  • 调用外部代码(FFI):在 Rust 里调用 C 语言写的库时,C 语言只认裸指针,不认识 Rust 的引用.
  • 绕过借用检查器:当需要两个指针同时指向同一个位置,且其中一个还要修改数据时(这在安全 Rust 中是被禁止的),裸指针可以帮助实现.
  • 构建底层数据结构:编写像 VecHashMap 这种底层容器,或者实现链表时,为了追求极致性能和精细的内存控制,往往需要手动操作裸指针.

访问或修改可变静态变量

Rust 支持全局静态变量,但可变静态变量的访问和修改是不安全的,因为多线程访问可能导致数据竞争.

变量和可变性中详细讲解了静态变量以及与常量的区别.

rust
// 定义一个可变静态变量
static mut COUNTER: u32 = 0;

/// SAFETY: 同时在多个线程调用这个函数是未定义行为,
/// 你*必须*保证同一时间只有一个线程在调用它.
unsafe fn add_to_count(inc: u32) {
  unsafe {
    COUNTER += inc;
  }
}

fn main() {
  unsafe {
    // SAFETY: 该函数只在 `main` 这一个线程中被调用.
    add_to_count(3);
    println!("COUNTER: {}", *(&raw const COUNTER));
  }
}

读取或修改可变静态变量是不安全的.

编译器不会允许你创建一个可变静态变量的引用,你只能通过裸指针解引用操作符访问它.这包括引用的创建是不可见的情况,例如上面示例中用于 println! 的情况.可变静态变量只能通过裸指针访问的要求,有助于确保使用它们时的安全要求更为明确.

访问联合体中的字段

union 类似于 struct,但在同一时刻只有一个字段被使用,主要用于与 C 语言的联合体进行交互.访问联合体字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中的数据类型.

rust
// 定义一个联合体
union MyUnion {
  f1: u32,
  f2: f32,
}

fn main() {
  // 创建一个联合体实例
  let u = MyUnion { f1: 1 };

  // 访问联合体字段必须在 unsafe 块中
  unsafe {
    println!("union field f1: {}", u.f1);
  }
}

调用不安全函数或方法

这类函数或方法包含了一些编译器无法在编译阶段百分之百保证内存安全的操作(主要是前述 3 种不安全行为).它们通常由开发者通过文档承诺:"只要你按照我要求的规则调用,就是安全的,但编译器没法帮你检查."

使用 unsafe 定义和调用

不安全函数在定义时需要在 fn 前加上 unsafe 关键字,调用时也必须在 unsafe 块中进行.

rust
unsafe fn dangerous() {
  println!("This is an unsafe function!");
}

fn main() {
  unsafe {
    dangerous();
  }
}

创建不安全代码的安全抽象

仅仅因为函数包含不安全代码并不意味着整个函数都需要标记为不安全的.事实上,将不安全代码封装进安全函数是一种常见的抽象方式.通过这种方式,调用者不需要担心底层的不安全细节,只要遵循安全函数的使用约定即可.

rust
fn safe_wrapper() {
  // 在安全函数中封装不安全代码
  unsafe {
    dangerous();
  }
}

// 调用安全函数,无需 unsafe 块
fn main() {
  safe_wrapper();
}

使用 extern 调用外部代码

Rust 可以通过 extern 关键字与其他语言的代码进行交互(FFI,外部函数接口).调用外部函数始终是不安全的:

  • extern "C":指定使用 C 语言的 ABI.
  • extern "Rust":指定使用 Rust 的 ABI(默认).

ABI: 应用二进制接口(Application Binary Interface),定义了程序如何调用函数、传递参数、返回值等细节.

rust
extern "C" {
  fn abs(input: i32) -> i32;
}

fn main() {
  unsafe {
    println!("Absolute value of -3: {}", abs(-3));
  }
}

使用 safe 承诺安全

如果确定某个外部函数绝对安全(如 abs),可以在声明时加上 safe 关键字,这样调用处就不再需要 unsafe 块.

rust
// 声明外部 C 库中的 abs 函数,并承诺其是安全的
unsafe extern "C" {
  safe fn abs(input: i32) -> i32;
}

fn main() {
  // 无需 unsafe 块
  println!("Absolute value of -3 according to C: {}", abs(-3));
}

其他语言调用 Rust 函数

可以将 Rust 函数导出,供 C 或其他语言使用.

  • #[unsafe(no_mangle)]:必须添加.告诉编译器不要"混淆(mangle)"函数名,确保其他语言能通过原始名称找到该函数.
  • extern "C":指定导出函数所使用的 ABI.
rust
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
  println!("从 C 语言调用了 Rust 函数!");
}

#[unsafe(no_mangle)] 禁用名称混淆可能导致链接时的命名冲突,因此该属性也涉及安全性考量.

使用 SAFETY 注释说明

每当编写不安全函数或进行不安全操作时,习惯做法是编写一个以 SAFETY 开头的注释,解释调用者需要满足哪些前提条件才能安全地调用该函数.

rust
/// SAFETY:
/// 调用者必须确保传入的指针是有效且非空的,
/// 并且在解引用期间该内存不会被释放或修改.
unsafe fn deref_ptr(ptr: *const i32) -> i32 {
  // SAFETY: 调用者保证 ptr 有效且非空
  *ptr
}

fn main() {
  let x = 42;
  let ptr = &raw const x;

  unsafe {
    // SAFETY: ptr 指向有效的局部变量 x,且 x 的生命周期覆盖此处
    let value = deref_ptr(ptr);
    println!("value: {}", value);
  }
}

使用 SAFETY 注释是一种社区约定,帮助开发者理解不安全代码的前提条件和潜在风险.

实现不安全 trait

当某个 trait 至少有一个方法包含编译器无法验证的不变式(invariant)时(主要是前述3种不安全行为),该 trait 是不安全的.

  • 使用 unsafe trait 定义这类 trait
  • 使用 unsafe impl 实现这类 trait
rust
unsafe trait Foo {
  // 包含某些不安全约定的方法
}

unsafe impl Foo for i32 {
  // 对应实现,由实现者保证安全性
}

标准库中的 SendSync trait 就是典型的不安全 trait.如果你的类型包含了不能自动实现 SendSync 的类型(如裸指针),则需要使用 unsafe impl 手动实现.

使用 Miri 检查不安全代码

Miri 是一个用于检测不安全 Rust 代码中未定义行为的工具,它是 Rust 的官方解释器,可以在运行时检测内存错误.

安装并运行 Miri:

bash
rustup component add miri
cargo miri run
cargo miri test

Miri 可以检测以下问题:

  • 越界内存访问
  • 使用未初始化的内存
  • 数据竞争
  • 违反指针对齐要求

注意:Miri 只能检测实际执行路径上的错误,无法静态分析所有代码路径.

正确使用不安全代码

使用不安全代码时,应遵循以下原则:

  1. 最小化不安全范围:将不安全代码封装在小的、有文档说明的函数中,对外暴露安全的接口.
  2. 添加注释说明:在每个 unsafe 块中添加 SAFETY 注释,解释为什么这段代码是安全的,以及依赖了哪些不变量.
  3. 充分测试:使用 Miri、地址消毒器(AddressSanitizer)等工具对不安全代码进行严格测试.
  4. 遵循 Rustonomicon:参考 The Rustonomicon 了解编写正确不安全 Rust 代码的详细指南.
rust
/// 安全性:调用者必须保证 `ptr` 指向有效的、已初始化的 `T` 类型数据,
/// 且在返回的引用生命周期内,该内存不会被释放或修改.
unsafe fn as_ref_unchecked<'a, T>(ptr: *const T) -> &'a T {
  &*ptr
}

不安全代码本身并不是坏的,但应当谨慎使用,并确保其正确性由程序员来保证,而非编译器.

基于 MIT 协议发布