探索不安全的 Rust 的超能力

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到不安全的 Rust。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将探索不安全的 Rust,这是一项允许我们绕过编译时强制实施的内存安全保证并赋予我们额外强大功能的特性,同时也要理解使用它所涉及的风险和责任。

不安全的 Rust

到目前为止我们讨论的所有代码在编译时都有 Rust 的内存安全保证。然而,Rust 内部隐藏着另一种语言,它并不强制执行这些内存安全保证:它被称为不安全的 Rust,其工作方式与普通 Rust 类似,但赋予了我们额外的强大功能。

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

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

不安全的强大功能

要切换到不安全的 Rust,使用 unsafe 关键字,然后开始一个新的块来包含不安全代码。在不安全的 Rust 中,你可以执行五项在安全的 Rust 中无法执行的操作,我们称之为不安全的强大功能。这些强大功能包括能够:

  1. 解引用裸指针
  2. 调用不安全的函数或方法
  3. 访问或修改可变静态变量
  4. 实现不安全的 trait
  5. 访问 union 的字段

需要理解的是,unsafe 并不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果你在不安全代码中使用引用,它仍然会被检查。unsafe 关键字只是让你能够使用这五项功能,然后编译器不会对其进行内存安全检查。在不安全块内部,你仍然会获得一定程度的安全性。

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

人都会犯错,错误难免会发生,但是通过要求这五项不安全操作必须在使用 unsafe 注释的块内,你就会知道任何与内存安全相关的错误肯定在一个 unsafe 块内。保持 unsafe 块短小;稍后当你调查内存错误时,你会为此感激自己。

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

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

解引用裸指针

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

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

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

通过选择不使用 Rust 来强制执行这些保证,你可以放弃有保证的安全性,以换取更高的性能,或者获得与 Rust 的保证不适用的其他语言或硬件进行交互的能力。

清单 19-1 展示了如何从引用创建不可变和可变的裸指针。

let mut num = 5;

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

清单 19-1:从引用创建裸指针

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

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

为了说明这一点,接下来我们将创建一个我们不能确定其有效性的裸指针。清单 19-2 展示了如何创建一个指向内存中任意位置的裸指针。尝试使用任意内存是未定义行为:该地址可能有数据,也可能没有,编译器可能会优化代码以至于没有内存访问,或者程序可能会因段错误而终止。通常,没有什么好理由编写这样的代码,但这是有可能的。

let address = 0x012345usize;
let r = address as *const i32;

清单 19-2:创建一个指向任意内存地址的裸指针

回想一下,我们可以在安全代码中创建裸指针,但我们不能解引用裸指针并读取其指向的数据。在清单 19-3 中,我们在一个需要 unsafe 块的裸指针上使用了解引用运算符 *

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 的不安全函数,其函数体中不做任何事情:

unsafe fn dangerous() {}

unsafe {
    dangerous();
}

我们必须在一个单独的 unsafe 块内调用 dangerous 函数。如果我们尝试在没有 unsafe 块的情况下调用 dangerous,我们会得到一个错误:

error[E0133]: call to unsafe function 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

使用 unsafe 块,我们向 Rust 断言我们已经阅读了该函数的文档,我们知道如何正确使用它,并且我们已经验证我们正在履行该函数的约定。

不安全函数的函数体实际上就是 unsafe 块,所以要在不安全函数内执行其他不安全操作,我们不需要再添加另一个 unsafe 块。

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

仅仅因为一个函数包含不安全代码,并不意味着我们需要将整个函数标记为不安全。事实上,将不安全代码包装在一个安全函数中是一种常见的抽象方式。例如,让我们研究一下标准库中的 split_at_mut 函数,它需要一些不安全代码。我们将探讨如何实现它。这个安全方法是在可变切片上定义的:它接受一个切片,并通过在作为参数给出的索引处分割切片,将其分成两个切片。清单 19-4 展示了如何使用 split_at_mut

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

清单 19-5:仅使用安全的 Rust 对 split_at_mut 的尝试实现

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

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

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

error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:9:31
  |
2 |     values: &mut [i32],
  |             - let's call the lifetime of this reference `'1`
...
9 |     (&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`

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]) {
  1 let len = values.len();
  2 let ptr = values.as_mut_ptr();

  3 assert!(mid <= len);

  4 unsafe {
        (
          5 slice::from_raw_parts_mut(ptr, mid),
          6 slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

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

从“切片类型”中回忆一下,切片是指向某些数据和切片长度的指针。我们使用 len 方法来获取切片的长度 [1],并使用 as_mut_ptr 方法来访问切片的裸指针 [2]。在这种情况下,因为我们有一个指向 i32 值的可变切片,as_mut_ptr 返回一个类型为 *mut i32 的裸指针,我们将其存储在变量 ptr 中。

我们保留 mid 索引在切片范围内的断言 [3]。然后我们进入不安全代码 [4]:slice::from_raw_parts_mut 函数接受一个裸指针和一个长度,并创建一个切片。我们用它来创建一个从 ptr 开始、长度为 mid 个元素的切片 [5]。然后我们以 mid 作为参数调用 ptr 上的 add 方法,以获取一个从 mid 开始的裸指针,并使用该指针和 mid 之后的剩余元素数量作为长度来创建一个切片 [6]。

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

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

相比之下,清单 19-7 中对 slice::from_raw_parts_mut 的使用在使用切片时可能会导致程序崩溃。这段代码获取一个任意的内存位置,并创建一个长度为 10000 个元素的切片。

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),这是一种编程语言定义函数并使另一种(外部)编程语言能够调用这些函数的方式。

清单 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 编译器的名称混淆。

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

#[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!("value is: {HELLO_WORLD}");
}

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

静态变量类似于我们在“常量”中讨论过的常量。按照惯例,静态变量的名称采用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 章中讨论过的并发技术和线程安全的智能指针,这样编译器就能检查不同线程对数据的访问是否安全。

实现不安全的 trait

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

unsafe trait Foo {
    // 方法定义在此处
}

unsafe impl Foo for i32 {
    // 方法实现在此处
}

清单 19-11:定义和实现一个不安全的 trait

通过使用 unsafe impl,我们承诺会遵守编译器无法验证的不变量。

例如,回忆一下我们在“使用 Send 和 Sync trait 实现可扩展并发”中讨论的 SendSync 标记 trait:如果我们的类型完全由 SendSync 类型组成,编译器会自动实现这些 trait。如果我们实现的类型包含一个不是 SendSync 的类型,比如裸指针,并且我们想将该类型标记为 SendSync,就必须使用 unsafe。Rust 无法验证我们的类型是否遵守可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查,并使用 unsafe 来表明这一点。

访问联合体的字段

仅与 unsafe 一起使用的最后一个操作是访问联合体的字段。联合体(union)类似于结构体(struct),但在特定实例中一次只使用一个声明的字段。联合体主要用于与 C 代码中的联合体进行交互。访问联合体字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中的数据类型。你可以在 Rust 参考文档(https://doc.rust-lang.org/reference/items/unions.html)中了解有关联合体的更多信息。

何时使用不安全代码

使用 unsafe 来运用刚刚讨论的五项“超能力”之一并没有错,甚至也不会遭人诟病,但要让不安全代码正确无误会更棘手,因为编译器无法帮助维护内存安全。当你有理由使用不安全代码时,你可以这么做,而显式的 unsafe 注释会让问题出现时更容易追踪到问题源头。

总结

恭喜你!你已完成“不安全的 Rust”实验。你可以在 LabEx 中练习更多实验来提升你的技能。