Unsafe 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
的使用泄漏到您或您的用户可能想要使用使用 unsafe
代码实现的功能的所有位置,因为使用安全抽象是安全的。
让我们依次查看这五个不安全超能力。我们还将研究一些为不安全代码提供安全接口的抽象。
解引用原始指针
在第 4 章的 “悬垂引用”部分中,我们提到编译器确保引用始终有效。不安全 Rust 有两种称为原始指针的新类型,它们类似于引用。与引用一样,原始指针可以是不可变的或可变的,分别写为 *const T
和 *mut T
。星号不是解引用运算符;它是类型名称的一部分。在原始指针的上下文中,不可变意味着指针在被解引用后不能直接赋值。
与引用和智能指针不同,原始指针
- 允许忽略借用规则,可以同时拥有不可变指针和可变指针,或者指向同一位置的多个可变指针
- 不能保证指向有效的内存
- 允许为空
- 不实现任何自动清理
通过选择不让 Rust 强制执行这些保证,您可以放弃有保证的安全性,以换取更高的性能或与 Rust 的保证不适用的另一种语言或硬件接口的能力。
列表 20-1 展示了如何创建不可变和可变原始指针。
请注意,我们在此代码中没有包含 unsafe
关键字。我们可以在安全代码中创建原始指针;我们只是不能在不安全块之外解引用原始指针,您稍后会看到。
我们通过使用原始借用运算符创建了原始指针:&raw const num
创建了一个 *const i32
不可变原始指针,而 &raw mut num
创建了一个 *mut i32
可变原始指针。因为我们直接从局部变量创建了它们,所以我们知道这些特定的原始指针是有效的,但是我们不能对任何原始指针都做这样的假设。
为了演示这一点,接下来我们将创建一个原始指针,其有效性我们不能如此确定,使用 as
来转换值,而不是使用原始引用运算符。列表 20-2 展示了如何创建指向内存中任意位置的原始指针。尝试使用任意内存是未定义的:该地址可能存在数据,也可能不存在数据,编译器可能会优化代码,使其没有内存访问,或者程序可能会因段错误而报错。通常,没有充分的理由编写这样的代码,尤其是在您可以使用原始借用运算符的情况下,但这是可能的。
回想一下,我们可以在安全代码中创建原始指针,但我们不能解引用原始指针并读取正在指向的数据。在列表 20-3 中,我们在需要 unsafe
块的原始指针上使用了解引用运算符 *
。
unsafe
块内解引用原始指针创建指针不会造成危害;只有当我们尝试访问它指向的值时,我们才可能最终处理无效值。
另请注意,在列表 20-1 和 20-3 中,我们创建了都指向同一内存位置(存储 num
的位置)的 *const i32
和 *mut i32
原始指针。如果我们尝试创建指向 num
的不可变引用和可变引用,则代码将无法编译,因为 Rust 的所有权规则不允许在任何不可变引用存在的同时存在可变引用。使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,从而可能创建数据竞争。小心!
有了所有这些危险,您为什么要使用原始指针呢?一个主要的用例是与 C 代码接口,正如您将在下一节 “调用不安全函数或方法” 中看到的那样。另一种情况是构建借用检查器无法理解的安全抽象。我们将介绍不安全函数,然后查看使用不安全代码的安全抽象的示例。
调用不安全函数或方法
您可以在不安全块中执行的第二种操作是调用不安全函数。不安全函数和方法看起来与常规函数和方法完全一样,但是它们在定义的其余部分之前有一个额外的 unsafe
。此上下文中的 unsafe
关键字表示该函数具有我们在调用此函数时需要维护的要求,因为 Rust 无法保证我们已满足这些要求。通过在 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
函数,该函数需要一些不安全代码。我们将探讨我们如何实现它。此安全方法在可变切片上定义:它接受一个切片,并通过在作为参数给定的索引处拆分切片,使其成为两个切片。列表 20-4 展示了如何使用 split_at_mut
。
split_at_mut
函数我们无法仅使用安全 Rust 实现此函数。尝试可能类似于列表 20-5,但无法编译。为了简单起见,我们将 split_at_mut
实现为函数而不是方法,并且仅针对 i32
值的切片,而不是针对泛型类型 T
。
split_at_mut
此函数首先获取切片的总长度。然后,它通过检查作为参数给定的索引是否小于或等于长度来断言该索引在切片内。该断言意味着,如果我们传递的索引大于要拆分切片的长度,则该函数将在尝试使用该索引之前 panic。
然后,我们在元组中返回两个可变切片:一个从原始切片的开头到 mid
索引,另一个从 mid
到切片的末尾。
当我们尝试编译列表 20-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 不知道时,就该求助于不安全代码了。
列表 20-6 展示了如何使用 unsafe
块、原始指针和对不安全函数的一些调用来使 split_at_mut
的实现工作。
split_at_mut
函数的实现中使用不安全代码回想一下第 4 章 “切片类型”部分,切片是指向某些数据和切片长度的指针。我们使用 len
方法获取切片的长度,并使用 as_mut_ptr
方法访问切片的原始指针。在这种情况下,因为我们有一个指向 i32
值的可变切片,所以 as_mut_ptr
返回一个类型为 *mut i32
的原始指针,我们将其存储在变量 ptr
中。
我们保留了 mid
索引在切片内的断言。然后我们进入不安全代码:slice::from_raw_parts_mut
函数接受一个原始指针和一个长度,并创建一个切片。我们使用此函数创建一个从 ptr
开始且长度为 mid
项的切片。然后我们调用 ptr
上的 add
方法,以 mid
作为参数来获取一个从 mid
开始的原始指针,并使用该指针和 mid
之后剩余的项数作为长度来创建一个切片。
函数 slice::from_raw_parts_mut
是不安全的,因为它接受一个原始指针,并且必须信任该指针是有效的。原始指针上的 add
方法也是不安全的,因为它必须信任偏移位置也是有效的指针。因此,我们必须在对 slice::from_raw_parts_mut
和 add
的调用周围放置一个 unsafe
块,以便我们可以调用它们。通过查看代码并添加 mid
必须小于或等于 len
的断言,我们可以知道 unsafe
块中使用的所有原始指针都将是指向切片内数据的有效指针。这是 unsafe
的可接受且适当的用法。
请注意,我们不需要将生成的 split_at_mut
函数标记为 unsafe
,并且我们可以从安全 Rust 调用此函数。我们为不安全代码创建了一个安全抽象,函数的实现以安全的方式使用了 unsafe
代码,因为它仅从该函数可以访问的数据创建有效的指针。
相反,列表 20-7 中 slice::from_raw_parts_mut
的使用可能会在切片被使用时崩溃。此代码采用任意内存位置并创建一个长度为 10,000 项的切片。
我们不拥有此任意位置的内存,并且无法保证此代码创建的切片包含有效的 i32
值。尝试像使用有效切片一样使用 values
会导致未定义的行为。
使用 extern
函数调用外部代码
有时,您的 Rust 代码可能需要与另一种语言编写的代码进行交互。为此,Rust 具有关键字 extern
,它有助于创建和使用外部函数接口 (FFI)。FFI 是一种编程语言定义函数并使另一种(外部)编程语言能够调用这些函数的方式。
列表 20-8 演示了如何设置与 C 标准库中的 abs
函数的集成。在 extern
块中声明的函数通常从 Rust 代码调用是不安全的,因此它们也必须标记为 unsafe
。原因是其他语言不强制执行 Rust 的规则和保证,并且 Rust 无法检查它们,因此确保安全的责任落在程序员身上。
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
extern
函数在 unsafe extern "C"
块中,我们列出了我们要调用的另一种语言的外部函数的名称和签名。"C"
部分定义了外部函数使用的应用程序二进制接口 (ABI):ABI 定义了如何在汇编级别调用函数。"C"
ABI 是最常见的,并且遵循 C 编程语言的 ABI。
但是,此特定函数没有任何内存安全方面的考虑。实际上,我们知道对 abs
的任何调用对于任何 i32
始终是安全的,因此我们可以使用 safe
关键字来说明即使此特定函数在 unsafe extern
块中,也可以安全调用。一旦我们进行了更改,调用它不再需要 unsafe
块,如列表 20-9 所示。
unsafe extern "C" {
safe fn abs(input: i32) -> i32;
}
fn main() {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
unsafe extern
块中显式标记函数为 safe
并安全地调用它将函数标记为 safe
本身并不能使其安全!相反,这就像您向 Rust 承诺它是安全的。确保遵守该承诺仍然是您的责任!
从其他语言调用 Rust 函数
我们还可以使用 extern
来创建一个接口,允许其他语言调用 Rust 函数。我们无需创建整个 extern
块,而是在相关函数的 fn
关键字之前添加 extern
关键字并指定要使用的 ABI。我们还需要添加 #[unsafe(no_mangle)]
注解来告诉 Rust 编译器不要 mangle 此函数的名称。Mangling 是指编译器将我们给函数的名称更改为包含更多信息的不同名称,供编译过程的其他部分使用,但可读性较差。每种编程语言编译器对名称的 mangling 略有不同,因此为了使 Rust 函数可被其他语言命名,我们必须禁用 Rust 编译器的名称 mangling。这是不安全的,因为在没有内置 mangling 的情况下,库之间可能会发生名称冲突,因此我们有责任确保我们导出的名称可以安全导出而不会进行 mangling。
在以下示例中,我们将 call_from_c
函数从 C 代码访问,在将其编译为共享库并从 C 链接后
extern
的这种用法不需要 unsafe
。
访问或修改可变静态变量
在本书中,我们尚未讨论全局变量,Rust 确实支持全局变量,但全局变量在 Rust 的所有权规则中可能存在问题。如果两个线程正在访问同一个可变全局变量,则可能会导致数据竞争。
在 Rust 中,全局变量称为静态变量。列表 20-10 展示了静态变量的示例声明和使用,其中字符串切片作为值。
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {HELLO_WORLD}");
}
静态变量类似于常量,我们在第 3 章的 “常量”部分中讨论了常量。静态变量的名称按照惯例使用 SCREAMING_SNAKE_CASE
。静态变量只能存储具有 'static
生命周期 的引用,这意味着 Rust 编译器可以计算出生命周期,而我们不需要显式地对其进行注释。访问不可变静态变量是安全的。
常量和不可变静态变量之间的一个细微差别是,静态变量中的值在内存中具有固定的地址。使用该值将始终访问相同的数据。另一方面,常量允许在每次使用时复制其数据。另一个区别是静态变量可以是可变的。访问和修改可变静态变量是不安全的。列表 20-11 展示了如何声明、访问和修改名为 COUNTER
的可变静态变量。
static mut COUNTER: u32 = 0;
/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
unsafe {
// SAFETY: This is only called from a single thread in `main`.
add_to_count(3);
println!("COUNTER: {}", *(&raw const COUNTER));
}
}
与常规变量一样,我们使用 mut
关键字指定可变性。任何从 COUNTER
读取或写入的代码都必须在 unsafe
块内。列表 20-11 中的代码编译并打印 COUNTER: 3
,正如我们所期望的那样,因为它是单线程的。让多个线程访问 COUNTER
可能会导致数据竞争,因此它是未定义的行为。因此,我们需要将整个函数标记为 unsafe
,并记录安全限制,以便任何调用该函数的人都知道他们可以安全地做什么和不能做什么。
每当我们编写不安全函数时,习惯上都会编写以 SAFETY
开头的注释,并解释调用者需要做什么才能安全地调用该函数。同样,每当我们执行不安全操作时,习惯上都会编写以 SAFETY
开头的注释,以解释如何维护安全规则。
此外,编译器不允许您创建对可变静态变量的引用。您只能通过原始指针访问它,原始指针是使用原始借用运算符之一创建的。这包括在隐式创建引用的情况下,例如当它在此代码列表中用于 println!
时。对静态可变变量的引用只能通过原始指针创建的要求有助于使使用它们的安全要求更加明显。
对于全局可访问的可变数据,很难确保没有数据竞争,这就是为什么 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全智能指针,以便编译器检查从不同线程访问的数据是否安全完成。
实现不安全 trait
我们可以使用 unsafe
来实现不安全 trait。当 trait 的至少一种方法具有编译器无法验证的不变量时,该 trait 是不安全的。我们通过在 trait
之前添加 unsafe
关键字并将 trait 的实现标记为 unsafe
来声明 trait 是 unsafe
的,如列表 20-12 所示。
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
fn main() {}
通过使用 unsafe impl
,我们承诺我们将维护编译器无法验证的不变量。
例如,回想一下我们在第 16 章的 “使用 Sync
和 Send
Trait 的可扩展并发”部分中讨论的 Sync
和 Send
标记 trait:如果我们的类型完全由 Send
和 Sync
类型组成,则编译器会自动实现这些 trait。如果我们实现一个包含非 Send
或 Sync
类型(例如原始指针)的类型,并且我们想要将该类型标记为 Send
或 Sync
,则必须使用 unsafe
。Rust 无法验证我们的类型是否维护了可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查,并使用 unsafe
指示这一点。
访问 Union 的字段
仅适用于 unsafe
的最后一个操作是访问 union 的字段。union
类似于 struct
,但一次只使用一个声明的字段。Union 主要用于与 C 代码中的 union 接口。访问 union 字段是不安全的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。您可以在 Rust 参考 中了解有关 union 的更多信息。
使用 Miri 检查不安全代码
在编写不安全代码时,您可能想要检查您编写的代码实际上是否安全和正确。做到这一点的最佳方法之一是使用 Miri,这是一个用于检测未定义行为的官方 Rust 工具。借用检查器是一个在编译时工作的静态工具,而 Miri 是一个在运行时工作的动态工具。它通过运行您的程序或其测试套件来检查您的代码,并检测您何时违反了它理解的关于 Rust 应该如何工作的规则。
使用 Miri 需要 Rust 的 nightly 版本(我们在 附录 G:Rust 是如何制作的以及“Nightly Rust” 中对此进行了更多讨论)。您可以通过键入 rustup +nightly component add miri
来安装 nightly 版本的 Rust 和 Miri 工具。这不会更改您的项目使用的 Rust 版本;它只是将该工具添加到您的系统中,以便您可以在需要时使用它。您可以通过键入 cargo +nightly miri run
或 cargo +nightly miri test
在项目上运行 Miri。
为了说明这有多么有用,请考虑当我们针对列表 20-11 运行它时会发生什么
$ cargo +nightly miri run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `/Users/chris/.rustup/toolchains/nightly-aarch64-apple-darwin/bin/cargo-miri runner target/miri/aarch64-apple-darwin/debug/unsafe-example`
warning: creating a shared reference to mutable static is discouraged
--> src/main.rs:14:33
|
14 | println!("COUNTER: {}", COUNTER);
| ^^^^^^^ shared reference to mutable static
|
= note: for more information, see <https://doc.rust-lang.net.cn/nightly/edition-guide/rust-2024/static-mut-references.html>
= note: shared references to mutable statics are dangerous; it's undefined behavior if the static is mutated or if a mutable reference is created for it while the shared reference lives
= note: `#[warn(static_mut_refs)]` on by default
COUNTER: 3
它非常有帮助且正确地注意到我们有对可变数据的共享引用,并发出警告。在这种情况下,它没有告诉我们如何解决问题,但这意味着我们知道存在潜在问题,并且可以考虑如何确保它是安全的。在其他情况下,它实际上可以告诉我们某些代码肯定是错误的,并提出有关如何修复它的建议。
Miri 并不能捕获您在编写不安全代码时可能犯的所有错误。首先,由于它是一个动态检查,因此它仅捕获实际运行的代码的问题。这意味着您需要将它与良好的测试技术结合使用,以增加您对编写的不安全代码的信心。另一方面,它并没有涵盖您的代码可能不健全的所有可能方式。如果 Miri 确实捕获到问题,您就知道存在错误,但是仅仅因为 Miri 没有捕获到错误并不意味着不存在问题。但是,Miri 可以捕获很多错误。尝试在本章中的其他不安全代码示例上运行它,看看它说了什么!
何时使用不安全代码
使用 unsafe
来执行刚刚讨论的五个操作(超能力)并不是错误的,甚至不受谴责。但是,使 unsafe
代码正确起来更加棘手,因为编译器无法帮助维护内存安全。当您有理由使用 unsafe
代码时,您可以这样做,并且显式的 unsafe
注释使在问题发生时更容易追踪问题的来源。每当您编写不安全代码时,您都可以使用 Miri 来帮助您更加确信您编写的代码符合 Rust 的规则。
要更深入地探索如何有效地使用不安全 Rust,请阅读 Rust 的官方主题指南 Rustonomicon。