不安全的 Rust


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


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


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


不安全的超能力


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


  • 取消引用原始指针

  • 调用不安全的函数或方法

  • 访问或修改可变静态变量

  • 实现 unsafe trait

  • 联合的访问字段


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


此外,unsafe 并不意味着块内的代码一定是危险的,或者它肯定会有内存安全问题:目的是作为程序员,您将确保 unsafe 块内的代码能够以有效的方式访问内存。


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


为了尽可能地隔离不安全代码,最好将不安全代码包含在安全抽象中并提供安全的 API,我们将在本章后面研究不安全函数和方法时讨论这一点。标准库的某些部分作为已审计的不安全代码的安全抽象实现。将 unsafe 代码包装在 safe 抽象中可以防止使用 unsafe 防止泄漏到您或您的用户可能想要使用的所有位置 使用 unsafe 代码实现的功能,因为使用 safe 抽象是安全的。


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


取消引用原始指针


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


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


  • 允许通过同时拥有不可变和可变指针或指向同一位置的多个可变指针来忽略借用规则

  • 不保证指向有效内存

  • 允许为 null

  • 不实施任何自动清理


通过选择不让 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 关键字。我们可以在安全代码中创建原始指针;我们只是不能在 unsafe 块之外取消引用 Raw 指针,你稍后会看到。


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


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

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


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


回想一下,我们可以在安全代码中创建原始指针,但我们不能取消引用 raw 指针并读取所指向的数据。在示例 19-3 中,我们使用 dereference 运算符 * 在需要 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 中,我们创建了 *const i32*mut i32 原始指针,这两个指针都指向相同的内存位置,其中 num 为 数据处理。如果我们改为尝试创建一个不可变的和一个对 num 时,代码不会编译,因为 Rust 的所有权规则不允许可变引用与任何不可变引用同时存在。使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,从而可能造成数据竞争。小心!


面对所有这些危险,您为什么还要使用原始指针呢?一个主要用途 case 在与 C 代码交互时,如下一节所示, “调用 Unsafe 函数或 方法。另一种情况是当构建 borrow checker 无法理解的安全抽象时。我们将介绍 unsafe 函数,然后看一个使用 unsafe 代码的安全抽象示例。


调用 Unsafe 函数或方法


您可以在 unsafe 块中执行的第二种作是调用 unsafe 函数。不安全的函数和方法看起来和常规的函数和方法一模一样,但是在定义的其余部分之前,它们有一个额外的 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 块,因此要执行其他 unsafe作,我们不需要再添加一个 unsafe 块。


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


仅仅因为一个函数包含不安全的代码并不意味着我们需要将整个函数标记为不安全。事实上,将不安全代码包装在安全函数中是一种常见的抽象。例如,让我们研究 standard 库中的 split_at_mut 函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全的方法定义在可变 slices 上:它接受一个 slice,并通过在作为参数给出的索引处拆分 slice 来使其成为两个。示例 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:使用 safe 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:尝试实现 split_at_mut仅使用安全的 Rust


此函数首先获取切片的总长度。然后,它通过检查索引是否小于或等于长度来断言作为参数给出的索引在切片内。断言意味着,如果我们传递的索引大于分割切片的长度,函数将在尝试使用该索引之前 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 的 borrow checker 无法理解我们正在借用 slice 的不同部分;它只知道我们从同一个 slice 借用了两次。从根本上说,借用 slice 的不同部分是可以的,因为两个 slice 没有重叠,但 Rust 不够聪明,无法知道这一点。当我们知道代码是可以的,但 Rust 不是时,就该伸手去寻找不安全的代码了。


示例 19-6 展示了如何使用一个 unsafe 块、一个原始指针和一些对 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 章的 “The Slice Type” 部分,切片是指向某些数据和切片长度的指针。我们使用 len 方法来获取切片的长度和as_mut_ptr 方法来访问 slice 的原始指针。在这种情况下,因为我们有一个 mutable slice 设置为 i32 值,as_mut_ptr返回一个类型为 *mut i32 中,我们将其存储在变量 ptr 中。


我们保持 mid 索引在 slice 内的断言。然后我们来看看不安全的代码:slice::from_raw_parts_mut 函数接受一个原始指针和一个长度,然后创建一个切片。我们使用这个函数来创建一个从 ptr 开始,长度为 mid items 的切片。然后我们将 add 调用 方法,以获取从 mid,我们使用该指针和 mid 之后的剩余项目数作为长度来创建一个 slice。


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


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


相反,示例 19-7 中使用 slice::from_raw_parts_mut 在使用 slice 时可能会崩溃。此代码采用任意内存位置并创建一个 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:从任意内存位置创建 slice


我们不拥有这个任意位置的内存,并且无法保证此代码创建的 slice 包含有效的 i32 值。尝试使用 ,就好像它是一个有效的切片一样,会导致未定义的行为。


使用 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 块,而是添加 extern 关键字并指定要在相关函数的 fn 关键字之前使用的 ABI。我们还需要添加一个 #[no_mangle] 注解来告诉 Rust 编译器不要破坏这个函数的名称。Mangling 是指编译器将我们赋予函数的名称更改为不同的名称,该名称包含更多信息供编译过程的其他部分使用,但人类可读性较差。每个编程语言编译器对名称的修改都略有不同,因此要使 Rust 函数可被其他语言命名,我们必须禁用 Rust 编译器的名称修改。


在以下示例中,我们将 call_from_c 函数编译为共享库并从 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 显示了一个示例声明和静态变量的使用,其中字符串 slice 作为值。


文件名: src/main.rs

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

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


示例 19-9:定义和使用不可变静态变量


静态变量类似于常量,我们在 “变量和 Constants“部分。静态变量的名称按照约定SCREAMING_SNAKE_CASE。static 变量只能存储带有 'static lifetime,这意味着 Rust 编译器可以计算出生命周期,而我们 不需要显式注释它。访问不可变的 static 变量是安全的。


常量和不可变静态变量之间的一个细微区别是 static 变量中的值在内存中具有固定地址。使用值 将始终访问相同的数据。另一方面,常量是允许的 每当使用数据时复制数据。另一个区别是静态的 变量可以是可变的。访问和修改可变静态变量是 不安全。示例 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 trait。当 trait 的至少一个方法具有编译器无法验证的不变量时,trait 就是不安全的。我们通过在 trait 之前添加 unsafe 关键字来声明 trait 是不安全的 并将 trait 的实现也标记为 unsafe,如示例 19-11 所示。

unsafe trait Foo {
    // methods go here
}

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

fn main() {}


示例 19-11:定义和实现 unsafe trait


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


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


访问联合的字段


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


何时使用不安全代码


使用 unsafe 来采取刚才讨论的五个行动(超能力)之一并没有错,甚至不受欢迎。但是,正确获取不安全代码更棘手,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe 代码时,你可以这样做,并且具有显式的 unsafe 使用 Annotation 可以更轻松地在问题发生时跟踪问题的根源。