Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

19.1 摆脱安全性限制的unsafe Rust

19.1.1 什么是 unsafe Rust

到目前为止,我们讨论过的所有代码都在编译时强制执行 Rust 的内存安全保证。然而,Rust 内部还隐藏着第二种语言,它并不强制执行这些内存安全保证。它被称为 unsafe Rust。它和普通 Rust 一样,但给了我们额外的“超能力”。

unsafe Rust 之所以存在,是因为:

  • 静态分析非常保守。编译器在判断一段代码是否安全时,宁可拒绝一个实际能正确运行的程序,也不会放过任何潜在不安全的代码。
  • 计算机硬件本身就是不安全的,如果 Rust 想达到和 C 一样的底层能力,就需要 unsafe Rust。换句话说,unsafe Rust 允许进行底层系统编程。

使用 unsafe Rust 就是在告诉编译器:“我知道自己在做什么,并接受相应风险。”

19.1.2 Unsafe Rust 的超能力

使用 unsafe 关键字切换到 unsafe Rust。它会开启一个代码块,写在这个块里的就是不安全代码。

unsafe Rust 可以做五件事,也就是它的超能力:

  • 解引用原始指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现不安全的 trait
  • 访问 union 的字段

注意:

  • unsafe Rust 并不会关闭借用检查器,也不会停用其它安全检查。如果你在代码里使用引用,这些引用仍然会被检查。unsafe 关键字只是允许你执行上面那五个编译器不会替你做内存检查的操作。所以即便在 unsafe 块中,你仍然保留一部分安全保证。
  • 任何与内存安全相关的错误都必须留在 unsafe 块里。
  • 尽可能隔离不安全代码。理想情况下,把它封装在安全的抽象中,并提供安全的 API。标准库中有些代码在内部使用了 unsafe 块,但在其上提供了安全抽象。这可以有效防止不安全代码泄漏到调用方,因为使用安全抽象是安全的,无论内部是否使用了 unsafe Rust

特性 1:解引用原始指针

unsafe Rust 提供了两种与引用类似的指针类型,称为原始指针(raw pointers)。只有在解引用原始指针时才需要 unsafe 块,因为这时可能出问题。创建原始指针本身不会造成问题,因此不必放在 unsafe 块中。

和引用一样,原始指针可以是可变的或不可变的:

  • 可变:*mut T
  • 不可变:*const T

*const T 表示这个指针可以被解引用,但不能通过该指针给指向的值赋值。

注意:这里的 * 是类型的一部分,不是解引用运算符。*const T 这三个记号合在一起才是一个类型,例如 *const String

*const T*mut T 的差别很小,可以彼此自由转换。Rust 引用(&mut T&T)在编译期间会被编译器转换成原始指针,这意味着无需进入 unsafe 块就能获得原始指针的性能

引用和原始指针的区别是:

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

如果放弃安全保证,就可以换取更好的性能,以及与其它语言或硬件接口的互操作性。

看一个例子:

fn main() {
    let mut num = 5;

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

这是一个创建原始指针的例子。在 main 中,我们同时创建了一个不可变原始指针和一个可变原始指针。

这段代码不在 unsafe 块中,但仍然可以编译。所以我们可以在不安全代码之外创建原始指针,但解引用它们只能在 unsafe 代码中进行。

这段代码在同一个作用域里同时存在指向同一块内存区域的可变指针和不可变指针,而 Rust 允许这样做。这意味着我们可以通过可变引用修改值,但必须非常小心。

创建原始指针时,我们先用引用语法写出它们,再用 as *constas *mut 转换成对应的原始指针。因为这两个原始指针来自有效引用,所以我们知道它们是有效的,但它们未必会一直有效。接下来,我们创建一个无法保证有效性的原始指针:

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;
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
}

这会产生错误 dereference of raw pointer is unsafe and requires unsafe function or block,意思是原始指针的解引用只能在不安全函数或不安全块中进行。

把原始指针的解引用放进 unsafe 块就可以了:

fn main() {
    let mut num = 5;

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

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

那对直接根据内存地址创建原始指针的例子,这样做也行吗?

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
    unsafe {
        println!("r = {}", *r);
    }
}

输出:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/unsafe-example`

thread 'main' (483665) panicked at src/main.rs:5:9:
misaligned pointer dereference: address must be a multiple of 0x4 but is 0x12345
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread caused non-unwinding panic. aborting.

创建原始指针本身没问题,但解引用一个任意地址属于未定义行为。那个地址上可能有有效数据,也可能没有;编译器可能把这次访问优化掉;程序也可能崩溃——例如上面本地运行中的未对齐指针 panic 并 abort(退出代码 134),或段错误(退出代码 139 / SIGSEGV)。具体表现会随系统、编译器和构建选项而变化。你可以在自己的电脑上试试。

既然原始指针这么危险,为什么还要用它们?原因是:

  • 与 C 进行接口交互
  • 构建借用检查器无法理解的安全抽象

特性 2:调用不安全的函数和方法

不安全函数和方法是用 unsafe 关键字声明的函数或方法。除此之外,它们与普通函数或方法没有太大区别。

在调用这类函数或方法之前,你必须手动满足一些条件,通常要靠阅读文档,因为 Rust 无法替你验证这些条件。此外,调用不安全函数或方法必须发生在 unsafe 块中。

看一个例子:

unsafe fn dangerous() {}

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

我们用 unsafe 关键字声明了一个 dangerous 函数,所以它是不安全函数。这意味着 main 必须在 unsafe 块中调用它。

函数内部包含不安全代码,并不意味着整个函数都必须标记为不安全。事实上,把不安全代码封装在安全函数中是一种常见的抽象。

例如:

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
  • main 中有一个名为 vVecr 是它的完整可变切片,然后对 r 调用了 split_at_mut
  • split_at_mut 接收一个元素类型为 i32 的切片 self 和一个 usize 值。它把这个 usize 当作把 self 切成两个可变切片的索引。在函数体内,它先检查传入的 usize 是否在有效范围内(不大于 self 的长度),然后返回前半段和后半段。

输出:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust 的借用检查器无法理解我们借用的是切片中两个不同且互不重叠的部分。它只知道我们从同一个切片借用了两次。所以我们需要使用 unsafe 块(同时保持外层函数是安全的):

#![allow(unused)]
fn main() {
use std::slice;

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),
        )
    }
}
}
  • as_mut_ptr 返回一个原始指针,具体是 *mut i32
  • 元组返回值使用了 unsafe 块、原始指针和指针运算。slice 模块中的 slice::from_raw_parts_mut 接收一个原始指针 ptr 和一个长度 mid 来创建切片:
    • slice::from_raw_parts_mut(ptr, mid) 创建一个从 ptr 开始、包含 mid 个元素的切片。
    • slice::from_raw_parts_mut(ptr.add(mid), len - mid) 创建一个从 ptr.add(mid) 开始、包含 len - mid 个元素的切片——也就是从 ptr 往后偏移 mid 个元素的位置,正好是第一个切片的结尾。

这个函数使用了 unsafe 块,但它本身并没有标记为 unsafe。这就是对不安全代码的安全抽象。

如果我们不使用安全抽象呢?

use std::slice;

fn main() {
    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &mut [i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

我们不一定拥有这个任意地址上的内存,也无法保证这段代码创建的切片包含有效的 i32 值。试图把 values 当作有效切片使用,可能导致未定义行为。


使用 extern 调用外部代码,或被外部代码调用

extern 关键字简化了定义和使用*外部函数接口(Foreign Function Interface,FFI)*的过程。

FFI 允许一种编程语言定义函数,并让其它编程语言调用这些函数。

看一个例子:

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

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
  • extern 块中声明的任何函数都是不安全的,因为其它语言不会强制执行 Rust 的规则,而 Rust 也无法检查它们。所以调用外部函数被隐式标记为不安全,安全责任落在开发者身上。
  • extern "C" 块中,我们列出想要调用的另一种语言中外部函数的名称和签名。"C" 部分定义了外部函数使用的应用程序二进制接口(Application Binary Interface,ABI)。ABI 定义了在汇编层面如何调用该函数。"C" ABI 最常见,它遵循 C 编程语言的 ABI。

既然 Rust 可以调用其它编程语言的函数,那其它编程语言能否调用 Rust 代码?答案是可以。

我们可以使用 extern 创建一个可供其它语言调用的接口。为此,在 fn 前添加 extern 关键字并指定 ABI。你还需要 #[no_mangle] 属性,这样 Rust 就不会在编译期间改变函数名。

mangle 指的是编译中的一个阶段,编译器会修改函数名,使其包含更多供后续编译阶段使用的信息。这些改名后的名字通常很难阅读,所以如果你希望其它语言能正常使用该函数,就必须阻止 Rust 改名。

看一个例子:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

特性 3:访问或修改可变静态变量

Rust 支持全局变量,但所有权规则可能带来一些问题,例如数据竞争。

Rust 中的全局变量叫做静态变量。它们用 static 关键字声明,遵循 UPPER_SNAKE_CASE 命名约定,并且在声明时必须标注类型。它们的生命周期是且只能是 'static,表示在整个程序运行期间都有效。你不必显式写出这一点,Rust 会自行推断。访问不可变静态变量是安全的。

例如:

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

fn main() {
    println!("name is: {HELLO_WORLD}");
}
  • HELLO_WORLD 是声明的全局变量,值为 "Hello, world!",类型是字符串切片 &str
  • main 打印了这个全局变量。

常量(const)和可变静态变量(static mut)的区别是:

  • 静态变量有固定的内存地址,因此使用它们的值时总会访问同一份数据。
  • 常量在使用时会被复制。
  • 静态变量可以是可变的,而访问或修改可变静态变量是不安全的,所以这些操作必须发生在 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 块中。

这里的输出显然是 3。但如果涉及多个线程,就很容易引入数据竞争。在多线程代码中,最好使用我们之前讨论过的并发技术,或像 Arc<T> 这样的线程安全智能指针,这样编译器就能安全地检查跨线程的数据访问。

特性 4:实现不安全的 trait

当一个 trait 中至少有一个方法包含编译器无法验证的不安全因素时,这个 trait 就被认为是不安全的。

声明不安全 trait 的方式是在 trait 定义前加上 unsafe 关键字。这样的 trait 只能在 unsafe 块中实现。

例如:

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
  • unsafe trait Foo 声明了一个名为 Foo 的不安全 trait。
  • i32 实现 Foo 必须发生在 unsafe 块中,因此需要 unsafe impl

特性 5:访问 union 字段

union 类似于 struct,但在给定实例中,一次只使用一个已声明的字段。union 主要用于与 C 代码中的 union 互操作。访问 union 字段是不安全的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。详情见 Rust Reference

19.1.3 何时使用 unsafe 代码

确保 unsafe 代码正确是棘手的,因为编译器无法帮助维护内存安全,而开发者自己也很难保证正确性。

当你有充分理由时再使用 unsafe 代码。显式的 unsafe 标注会让问题发生时更容易追踪根源。