不安全 Rust

到目前为止,我们讨论的所有代码都强制执行了 Rust 的内存安全保证,这些保证是在编译时执行的。然而,Rust 内部隐藏着第二种语言,它不强制执行这些内存安全保证:它被称为不安全 Rust,其工作方式与常规 Rust 相同,但为我们提供了额外的超能力。

不安全 Rust 之所以存在,是因为本质上,静态分析是保守的。当编译器试图确定代码是否遵守保证时,最好拒绝一些有效的程序,而不是接受一些无效的程序。尽管代码可能没问题,但如果 Rust 编译器没有足够的信息来确定,它就会拒绝该代码。在这种情况下,你可以使用不安全的代码来告诉编译器,“相信我,我知道我在做什么”。但是,请注意,使用不安全 Rust 的风险由您自己承担:如果您不正确地使用不安全的代码,可能会由于内存不安全而出现问题,例如空指针解引用。

Rust 拥有不安全“第二自我”的另一个原因是,底层计算机硬件本质上是不安全的。如果 Rust 不允许你执行不安全的操作,你就无法完成某些任务。Rust 需要允许你进行低级系统编程,例如直接与操作系统交互,甚至编写你自己的操作系统。使用低级系统编程是该语言的目标之一。让我们来探索一下我们可以用不安全 Rust 做什么以及如何做。

不安全的超能力

要切换到不安全 Rust,请使用 unsafe 关键字,然后启动一个新代码块,其中包含不安全的代码。在不安全 Rust 中,你可以执行在安全 Rust 中不能执行的五个操作,我们称之为不安全的超能力。这些超能力包括以下能力:

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

重要的是要理解 unsafe 不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果在不安全代码中使用引用,它仍然会被检查。unsafe 关键字仅允许你访问这五个功能,编译器不会对这些功能进行内存安全检查。你仍然会在不安全代码块中获得一定程度的安全性。

此外,unsafe 并不意味着代码块内的代码必然是危险的或肯定会出现内存安全问题:其目的是作为程序员,你将确保 unsafe 代码块内的代码以有效的方式访问内存。

人是会犯错误的,错误会发生,但是通过要求这五个不安全的操作必须在用 unsafe 注释的代码块内,你将知道与内存安全相关的任何错误都必须在 unsafe 代码块内。保持 unsafe 代码块尽可能小;当你调查内存错误时,你稍后会感激不尽。

为了尽可能隔离不安全代码,最好将不安全代码封装在安全的抽象中并提供安全的 API,我们将在本章后面讨论不安全函数和方法时进行讨论。标准库的部分实现为经过审核的不安全代码的安全抽象。将不安全代码包装在安全抽象中可以防止 unsafe 的使用泄漏到您或您的用户可能想要使用不安全代码实现的所有功能的地方,因为使用安全抽象是安全的。

让我们依次看一下这五个不安全的超能力。我们还将查看一些为不安全代码提供安全接口的抽象。

解引用原始指针

在第 4 章的 “悬垂引用”节中,我们提到编译器确保引用始终有效。不安全 Rust 有两种称为原始指针的新类型,它们类似于引用。与引用一样,原始指针可以是不可变的或可变的,分别写为 *const T*mut T。星号不是解引用运算符;它是类型名称的一部分。在原始指针的上下文中,不可变意味着指针在被解引用后不能直接被赋值。

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

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

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

列表 19-1 显示了如何从引用创建不可变和可变原始指针。

fn main() {
    let mut num = 5;

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

列表 19-1:从引用创建原始指针

请注意,我们没有在此代码中包含 unsafe 关键字。我们可以在安全代码中创建原始指针;我们只是不能在不安全代码块之外解引用原始指针,正如您稍后会看到的那样。

我们通过使用 as 将不可变和可变引用强制转换为它们对应的原始指针类型来创建原始指针。因为我们直接从保证有效的引用创建了它们,所以我们知道这些特定的原始指针是有效的,但是我们不能对任何原始指针做出这种假设。

为了演示这一点,接下来我们将创建一个我们不能如此确定其有效性的原始指针。列表 19-2 显示了如何创建一个指向内存中任意位置的原始指针。尝试使用任意内存是未定义的:该地址可能存在数据,也可能不存在数据,编译器可能会优化代码,以便没有内存访问,或者程序可能会因段错误而报错。通常,没有充分的理由编写这样的代码,但这是可能的。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

列表 19-2:创建指向任意内存地址的原始指针

回想一下,我们可以在安全代码中创建原始指针,但是我们不能解引用原始指针并读取正在指向的数据。在列表 19-3 中,我们在需要 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);
    }
}

列表 19-3:在 unsafe 代码块中解引用原始指针

创建指针没有任何危害;只有当我们尝试访问它指向的值时,我们才可能会遇到无效的值。

还要注意,在列表 19-1 和 19-3 中,我们创建了都指向存储 num 的同一内存位置的 *const i32*mut i32 原始指针。如果我们尝试创建一个指向 num 的不可变引用和可变引用,则代码将无法编译,因为 Rust 的所有权规则不允许在任何不可变引用的同时存在可变引用。使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,这可能会导致数据竞争。小心!

在所有这些危险的情况下,你为什么还要使用原始指针?一个主要的用例是与 C 代码接口时,正如你在下一节 “调用不安全函数或方法” 中看到的那样。另一种情况是构建借用检查器无法理解的安全抽象时。我们将介绍不安全函数,然后看一个使用不安全代码的安全抽象的示例。

调用不安全函数或方法

你可以在不安全代码块中执行的第二种操作是调用不安全函数。不安全函数和方法看起来与常规函数和方法完全相同,但是它们的定义其余部分之前有一个额外的 unsafe。在这种上下文中,unsafe 关键字指示该函数具有我们需要在调用此函数时维护的要求,因为 Rust 不能保证我们满足了这些要求。通过在 unsafe 代码块中调用不安全函数,我们表示我们已经阅读了该函数的文档,我们了解如何正确使用它,并且我们已经验证了我们正在履行该函数的合同。

这是一个名为 dangerous 的不安全函数,它在其主体中不执行任何操作

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

    unsafe {
        dangerous();
    }
}

我们必须在单独的 unsafe 代码块中调用 dangerous 函数。如果我们尝试在没有 unsafe 代码块的情况下调用 dangerous,我们将收到错误

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe function or block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

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

使用 unsafe 代码块,我们向 Rust 断言我们已经阅读了该函数的文档,我们了解如何正确使用它,并且我们已经验证了我们正在履行该函数的合同。

不安全函数的主体实际上是 unsafe 代码块,因此要在不安全函数中执行其他不安全操作,我们无需添加另一个 unsafe 代码块。

在不安全代码上创建安全抽象

仅仅因为函数包含不安全代码并不意味着我们需要将整个函数标记为不安全。实际上,将不安全代码包装在安全函数中是一种常见的抽象。例如,让我们研究标准库中的 split_at_mut 函数,该函数需要一些不安全的代码。我们将探索我们如何实现它。此安全方法是在可变切片上定义的:它采用一个切片,并通过在作为参数给出的索引处拆分切片来将其设为两个切片。列表 19-4 显示了如何使用 split_at_mut

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]);
}

列表 19-4:使用安全的 split_at_mut 函数

我们无法仅使用安全的 Rust 实现此函数。尝试可能类似于列表 19-5,但它不会编译。为简单起见,我们将 split_at_mut 实现为函数,而不是方法,并且仅针对 i32 值的切片,而不是针对通用类型 T

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 vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

列表 19-5:尝试仅使用安全的 Rust 实现 split_at_mut

此函数首先获取切片的总长度。然后,它通过检查作为参数给出的索引是否小于或等于长度,来断言该索引在切片范围内。此断言意味着,如果传递一个大于长度的索引来分割切片,函数会在尝试使用该索引之前发生 panic。

然后,我们返回一个元组中的两个可变切片:一个从原始切片的开头到 mid 索引,另一个从 mid 到切片的结尾。

当我们尝试编译清单 19-5 中的代码时,会得到一个错误。

$ 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 的借用检查器无法理解我们正在借用切片的不同部分;它只知道我们正在从同一个切片借用两次。借用切片的不同部分在根本上是没问题的,因为这两个切片不重叠,但 Rust 不够聪明,无法知道这一点。当我们知道代码没问题,但 Rust 不知道时,就该使用不安全代码了。

清单 19-6 展示了如何使用 unsafe 块、原始指针以及对不安全函数的一些调用,以使 split_at_mut 的实现正常工作。

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),
        )
    }
}

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

清单 19-6:在 split_at_mut 函数的实现中使用不安全代码

回顾一下第 4 章“切片类型”部分,切片是指向某些数据的指针和切片的长度。我们使用 len 方法获取切片的长度,并使用 as_mut_ptr 方法访问切片的原始指针。在这种情况下,由于我们有一个指向 i32 值的可变切片,as_mut_ptr 返回一个类型为 *mut i32 的原始指针,我们将其存储在变量 ptr 中。

我们保留 mid 索引在切片范围内的断言。然后我们进入不安全代码:slice::from_raw_parts_mut 函数接受一个原始指针和一个长度,并创建一个切片。我们使用此函数创建一个从 ptr 开始且长度为 mid 项的切片。然后,我们以 mid 作为参数调用 ptr 上的 add 方法,以获取一个从 mid 开始的原始指针,并使用该指针和 mid 之后剩余的项数作为长度创建一个切片。

函数 slice::from_raw_parts_mut 是不安全的,因为它接受一个原始指针,并且必须信任该指针是有效的。原始指针上的 add 方法也是不安全的,因为它必须信任偏移位置也是一个有效的指针。因此,我们必须在对 slice::from_raw_parts_mutadd 的调用周围放置一个 unsafe 块,以便我们可以调用它们。通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以知道 unsafe 块中使用的所有原始指针都将是指向切片中数据的有效指针。这是 unsafe 的可接受且适当的用法。

请注意,我们不需要将生成的 split_at_mut 函数标记为 unsafe,并且我们可以从安全的 Rust 中调用此函数。我们使用以安全方式使用 unsafe 代码的函数实现,为不安全代码创建了一个安全的抽象,因为它仅从此函数有权访问的数据创建有效的指针。

相比之下,清单 19-7 中 slice::from_raw_parts_mut 的使用很可能会在切片被使用时崩溃。此代码采用任意内存位置并创建一个长度为 10,000 项的切片。

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

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

清单 19-7:从任意内存位置创建切片

我们不拥有此任意位置的内存,并且无法保证此代码创建的切片包含有效的 i32 值。尝试将 values 视为有效切片会导致未定义的行为。

使用 extern 函数调用外部代码

有时,您的 Rust 代码可能需要与用另一种语言编写的代码进行交互。为此,Rust 具有关键字 extern,它有助于创建和使用外部函数接口 (FFI)。FFI 是一种编程语言定义函数并使不同的(外部)编程语言能够调用这些函数的方式。

清单 19-8 演示了如何与 C 标准库中的 abs 函数建立集成。在 extern 块中声明的函数始终可以从 Rust 代码不安全地调用。原因是其他语言不强制执行 Rust 的规则和保证,而 Rust 无法检查它们,因此责任落在程序员身上以确保安全。

文件名:src/main.rs

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

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

清单 19-8:声明并调用另一种语言中定义的 extern 函数

extern "C" 块中,我们列出要调用的来自另一种语言的外部函数的名称和签名。"C" 部分定义了外部函数使用的应用程序二进制接口 (ABI):ABI 定义了如何在汇编级别调用函数。"C" ABI 是最常见的,它遵循 C 编程语言的 ABI。

从其他语言调用 Rust 函数

我们还可以使用 extern 创建一个接口,允许其他语言调用 Rust 函数。我们不是创建一个完整的 extern 块,而是在相关函数的 fn 关键字之前添加 extern 关键字并指定要使用的 ABI。我们还需要添加 #[no_mangle] 注解,以告诉 Rust 编译器不要更改此函数的名称。更改是指编译器将我们给函数的名称更改为包含更多信息的不同名称,以供编译过程的其他部分使用,但可读性较差。每种编程语言的编译器对名称的更改略有不同,因此,为了使 Rust 函数可被其他语言命名,我们必须禁用 Rust 编译器的名称更改。

在以下示例中,我们在将其编译为共享库并从 C 链接后,使 call_from_c 函数可从 C 代码访问

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

extern 的用法不需要 unsafe

访问或修改可变静态变量

在本书中,我们尚未讨论过全局变量,Rust 支持全局变量,但它可能与 Rust 的所有权规则存在问题。如果两个线程访问同一个可变全局变量,则可能会导致数据竞争。

在 Rust 中,全局变量称为静态变量。清单 19-9 显示了带有字符串切片值作为值的静态变量的示例声明和使用。

文件名:src/main.rs

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

fn main() {
    println!("name is: {HELLO_WORLD}");
}

清单 19-9:定义和使用不可变静态变量

静态变量类似于常量,我们在第 3 章的“变量和常量之间的区别”中讨论了常量部分。按照约定,静态变量的名称使用 SCREAMING_SNAKE_CASE。静态变量只能存储具有 'static 生命周期引用,这意味着 Rust 编译器可以计算出生命周期,我们不需要显式地对其进行注解。访问不可变静态变量是安全的。

常量和不可变静态变量之间的一个细微差别是,静态变量中的值在内存中具有固定的地址。使用该值将始终访问相同的数据。另一方面,允许常量在每次使用时复制其数据。另一个区别是,静态变量可以是可变的。访问和修改可变静态变量是不安全的。清单 19-10 显示了如何声明、访问和修改名为 COUNTER 的可变静态变量。

文件名:src/main.rs

static mut COUNTER: u32 = 0;

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

fn main() {
    add_to_count(3);

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

清单 19-10:从可变静态变量读取或写入是不安全的

与常规变量一样,我们使用 mut 关键字指定可变性。任何从 COUNTER 读取或写入的代码都必须在 unsafe 块内。此代码编译并按预期打印 COUNTER: 3,因为它是单线程的。让多个线程访问 COUNTER 很可能会导致数据竞争。

由于全局访问的可变数据很难确保没有数据竞争,因此 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全智能指针,以便编译器检查从不同线程访问的数据是否安全地完成。

实现不安全特性

我们可以使用 unsafe 来实现不安全特性。当一个特性的至少一个方法具有编译器无法验证的不变量时,该特性是不安全的。我们通过在 trait 之前添加 unsafe 关键字并将特性的实现也标记为 unsafe 来声明特性是 unsafe 的,如清单 19-11 所示。

unsafe trait Foo {
    // methods go here
}

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

fn main() {}

清单 19-11:定义和实现不安全特性

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

例如,回顾我们在第 16 章“通过 SyncSend 特性实现可扩展的并发”部分讨论的 SyncSend 标记特性:如果我们的类型完全由 SendSync 类型组成,则编译器会自动实现这些特性。如果我们实现包含非 SendSync 类型的类型(例如原始指针),并且我们想要将该类型标记为 SendSync,我们必须使用 unsafe。Rust 无法验证我们的类型是否维持了它可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查并用 unsafe 表示。

访问联合的字段

只能通过 unsafe 操作的最后一个操作是访问联合的字段。unionstruct 类似,但一次只有一个声明的字段在特定实例中使用。联合主要用于与 C 代码中的联合进行接口。访问联合字段是不安全的,因为 Rust 无法保证当前存储在联合实例中的数据的类型。您可以在Rust 参考手册中了解有关联合的更多信息。

何时使用不安全代码

使用 unsafe 来执行刚刚讨论的五个操作(超能力)之一并非错误,甚至不受反对。但是,要使 unsafe 代码正确比较棘手,因为编译器无法帮助维护内存安全。当您有理由使用 unsafe 代码时,您可以这样做,并且具有显式的 unsafe 注解可以更轻松地在问题发生时追踪问题的根源。