不安全的 Rust
我们迄今为止讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 有一种隐藏在其中的第二种语言,它不强制执行这些内存安全保证:它被称为 不安全的 Rust,它就像普通的 Rust 一样工作,但赋予我们额外的超能力。
Unsafe Rust 存在的原因在于,静态分析本质上是保守的。当编译器尝试确定代码是否遵守保证时,拒绝一些有效的程序总比接受一些无效的程序要好。虽然代码可能是正确的,但如果 Rust 编译器没有足够的信息来确信,它将拒绝该代码。在这种情况下,您可以使用不安全的代码来告诉编译器,“相信我,我知道自己在做什么。” 但是请注意,您使用不安全的 Rust 时风险自负:如果您不正确地使用不安全的代码,可能会由于内存不安全问题(如空指针解引用)而出现问题。
另一个原因是底层计算机硬件本质上是不安全的。如果 Rust 不允许你执行不安全的操作,你就无法完成某些任务。Rust 需要允许你进行低级系统编程,例如直接与操作系统交互,甚至编写你自己的操作系统。进行低级系统编程是该语言的目标之一。让我们探索使用不安全的 Rust 可以做什么以及如何做。
不安全的超级能力
要切换到不安全的 Rust,使用 unsafe
关键字,然后开始一个新的块
来包含不安全的代码。在不安全的 Rust 中,你可以执行五种在安全的 Rust 中无法执行的操作,我们称之为 不安全的超能力。这些超能力包括:
- 解引用原始指针
- 调用不安全的函数或方法
- 访问或修改可变静态变量
- 实现一个不安全的特质
- 访问
union
的字段
理解 unsafe
并不会关闭借用检查器或禁用 Rust 的其他任何安全检查:如果你在不安全代码中使用引用,它仍然会被检查。unsafe
关键字只给你访问这五个特性,这些特性不会被编译器检查以确保内存安全。你仍然会在不安全块内获得一定程度的安全性。
此外,unsafe
并不意味着块内的代码一定是危险的或肯定会有内存安全问题:其意图是,作为程序员,您将确保 unsafe
块内的代码将以有效的方式访问内存。
人们会犯错,但通过要求这五种不安全操作必须位于带有 unsafe
注解的块中,您将知道任何与内存安全相关的错误都必须在 unsafe
块内。保持 unsafe
块小;当您日后调查内存错误时,您会感谢自己。
为了尽可能地隔离不安全的代码,最好将此类代码封装在安全的抽象中,并提供一个安全的API,我们将在本章后面讨论不安全的函数和方法时详细探讨。标准库的某些部分是通过安全抽象实现的,这些抽象覆盖了已经经过审核的不安全代码。将不安全的代码封装在安全的抽象中,可以防止unsafe
的使用泄露到你或你的用户可能想要使用通过unsafe
代码实现的功能的所有地方,因为使用安全的抽象是安全的。
让我们逐一看看这五种不安全的超能力。我们还将看看一些提供安全接口的抽象来访问不安全代码。
解引用原始指针
在“悬垂引用”一章中,我们提到编译器确保引用始终有效。不安全的 Rust 引入了两种新的类型,称为原始指针,它们与引用类似。与引用一样,原始指针可以是不可变的或可变的,分别写为*const T
和*mut T
。星号不是解引用运算符;它是类型名称的一部分。在原始指针的上下文中,不可变意味着指针在解引用后不能直接赋值。
不同于引用和智能指针,原始指针:
- 允许通过同时拥有不可变和可变指针或多个可变指针来忽略借用规则。
- 不一定指向有效的内存
- 允许为空
- 不要实现任何自动清理
通过选择不使用 Rust 强制执行这些保证,您可以放弃保证的安全性,以换取更高的性能或与另一种语言或硬件接口的能力,这些语言或硬件不受 Rust 的保证约束。
列表 20-1 显示了如何创建不可变和可变的原始指针。
fn main() { let mut num = 5; let r1 = &raw const num; let r2 = &raw mut num; }
请注意,我们在代码中没有包含 unsafe
关键字。我们可以在安全代码中创建原始指针;我们只是不能在 unsafe
块之外解引用原始指针,你将在稍后看到这一点。
我们通过使用原始借用操作符创建了原始指针:&raw const num
创建一个 *const i32
不可变原始指针,而 &raw mut num
创建一个 *mut i32
可变原始指针。因为它们是直接从一个局部变量创建的,我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针都做出这样的假设。
为了演示这一点,接下来我们将创建一个原始指针,其有效性我们不能那么确定,使用 as
将值进行类型转换,而不是使用原始借用操作符。列表 20-2 展示了如何创建指向内存中任意位置的原始指针。尝试使用任意内存是未定义的:该地址可能有数据,也可能没有,编译器可能会优化代码以不访问内存,或者程序可能会以段错误终止。通常,编写这样的代码没有充分的理由,特别是在可以使用原始借用操作符的情况下,但这是可能的。
fn main() { let address = 0x012345usize; let r = address as *const i32; }
回想一下,我们可以在安全代码中创建原始指针,但我们不能解引用
原始指针并读取被指向的数据。在示例 20-3 中,我们对需要一个 unsafe
块的原始指针使用了解引用运算符 *
。
fn main() { let mut num = 5; let r1 = &raw const num; let r2 = &raw mut num; unsafe { println!("r1 is: {}", *r1); println!("r2 is: {}", *r2); } }
unsafe
block创建指针本身并无害;只有当我们尝试访问它所指向的值时,我们才可能会遇到无效值。
请注意,在清单 20-1 和 20-3 中,我们创建了 *const i32
和 *mut i32
原始指针,它们都指向存储 num
的同一内存位置。如果我们尝试创建一个不可变引用和一个可变引用到 num
,代码将无法编译,因为 Rust 的所有权规则不允许同时存在可变引用和任何不可变引用。使用原始指针,我们可以创建一个可变指针和一个不可变指针到同一位置,并通过可变指针更改数据,这可能会导致数据竞争。小心!
有了所有这些危险,为什么还要使用原始指针?一个主要的用例是在与C代码接口时,正如你将在下一节中看到的, “调用不安全的函数或方法。” 另一个用例是在构建借用检查器不理解的安全抽象时。 我们将介绍不安全的函数,然后看一个使用不安全代码的安全抽象示例。
调用不安全的函数或方法
在unsafe块中可以执行的第二种操作是调用unsafe函数。Unsafe函数和方法看起来与普通的函数和方法完全一样,但它们在定义的其余部分之前有一个额外的unsafe
。在这种上下文中,unsafe
关键字表示该函数有我们需要在调用此函数时遵守的要求,因为Rust不能保证我们已经满足了这些要求。通过在unsafe
块中调用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 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
。
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]); }
split_at_mut
function我们不能仅使用安全的 Rust 来实现这个函数。尝试实现可能看起来像列表 20-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);
}
split_at_mut
using only safe Rust这个函数首先获取切片的总长度。然后它通过检查索引是否小于或等于长度来断言作为参数给出的索引在切片范围内。这个断言意味着,如果我们传递一个大于长度的索引来分割切片,函数将在尝试使用该索引之前崩溃。
然后我们返回一个元组,其中包含两个可变切片:一个从原始切片的开始到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
的实现工作。
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); }
split_at_mut
function回想第 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 的切片。
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) }; }
我们不拥有这个任意位置的内存,也没有保证这段代码创建的切片包含有效的i32
值。尝试将values
作为有效切片使用会导致未定义行为。
使用 extern
函数调用外部代码
有时,您的 Rust 代码可能需要与用其他语言编写的代码进行交互。为此,Rust 有关键字 extern
,它有助于创建和使用 Foreign Function Interface (FFI)。FFI 是一种编程语言定义函数并使另一种(外部)编程语言能够调用这些函数的方式。
列表 20-8 展示了如何设置与 C 标准库中的 abs
函数的集成。在 extern
块中声明的函数通常从 Rust 代码调用时是不安全的,因此 extern
块也必须标记为 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
function defined in another language在 unsafe extern "C"
块中,我们列出想要调用的来自其他语言的外部函数的名称和签名。"C"
部分定义了外部函数使用的 应用程序二进制接口 (ABI):ABI 定义了如何在汇编级别调用函数。"C"
ABI 是最常见的一种,遵循 C 编程语言的 ABI。有关 Rust 支持的所有 ABI 的信息,请参阅 Rust 参考手册。
在 unsafe extern
块中声明的每个项都隐式地是 unsafe
的。
然而,一些 FFI 函数 是 安全调用的。例如,C 标准库中的 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)); }
safe
within an unsafe extern
block and calling it safely将函数标记为safe
并不会使其本身变得安全!相反,这像是你对Rust做出的一个承诺,即它是安全的。你仍然有责任确保这个承诺得以兑现!
从其他语言调用Rust函数
我们还可以使用 extern
创建一个接口,允许其他语言调用 Rust 函数。我们不需要创建一个完整的 extern
块,而是在相关函数的 fn
关键字之前添加 extern
关键字并指定要使用的 ABI。我们还需要添加一个 #[unsafe(no_mangle)]
注解,以告诉 Rust 编译器不要对这个函数的名称进行名称修饰。名称修饰是指编译器将我们给定的函数名称更改为包含更多信息的另一个名称,这些信息供编译过程的其他部分使用,但可读性较差。每种编程语言的编译器对名称的修饰方式略有不同,因此为了让 Rust 函数能够被其他语言命名,我们必须禁用 Rust 编译器的名称修饰。这是不安全的,因为没有内置的名称修饰,库之间可能会发生名称冲突,因此我们必须确保选择的名称在不进行名称修饰的情况下是安全导出的。
在以下示例中,我们将 call_from_c
函数编译为共享库并从 C 代码链接后,使其可以从 C 代码访问:
#![allow(unused)] fn main() { #[unsafe(no_mangle)] pub extern "C" fn call_from_c() { println!("Just called a Rust function from C!"); } }
这种 extern
的用法只需要在属性中使用 unsafe
,而不需要在 extern
块上使用。
访问或修改可变静态变量
在本书中,我们还没有讨论全局变量,虽然 Rust 确实支持全局变量,但它们可能会与 Rust 的所有权规则产生冲突。如果两个线程访问同一个可变全局变量,可能会导致数据竞争。
在 Rust 中,全局变量被称为 静态 变量。列表 20-10 显示了一个使用字符串切片作为值的静态变量的声明和使用示例。
static HELLO_WORLD: &str = "Hello, world!"; fn main() { println!("name is: {HELLO_WORLD}"); }
静态变量类似于我们在“常量”中讨论的常量。按照惯例,静态变量的名称使用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
块中。这段代码编译并打印 COUNTER: 3
,正如我们所期望的那样,因为它是在单线程中运行的。如果多个线程访问 COUNTER
,可能会导致数据竞争,因此这是未定义的行为。因此,我们需要将整个函数标记为 unsafe
,并记录安全限制,以便调用该函数的任何人都知道他们可以和不可以安全地做什么。
每当我们编写一个不安全的函数时,习惯上会写一个以 SAFETY
开头的注释,解释调用者需要做什么才能安全地调用该函数。同样,每当执行不安全操作时,习惯上也会写一个以 SAFETY
开头的注释,解释如何遵守安全规则。
此外,编译器不允许你创建指向可变静态变量的引用。你只能通过原始指针访问它,而原始指针是使用其中一个原始借用操作符创建的。这包括在引用被隐式创建的情况下,例如在本代码列表中的 println!
中使用时。要求只能通过原始指针创建对静态可变变量的引用,有助于使使用它们的安全要求更加明显。
对于全局可访问的可变数据,很难确保没有数据竞争,这就是为什么 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全的智能指针,以便编译器检查从不同线程访问数据是否安全。
实现一个不安全的特质
我们可以使用 unsafe
来实现一个不安全的特质。当特质的至少一个方法具有一些编译器无法验证的不变量时,该特质就是不安全的。我们通过在 trait
前添加 unsafe
关键字来声明一个特质是 unsafe
的,并且也将特质的实现标记为 unsafe
,如清单 20-12 所示。
unsafe trait Foo { // methods go here } unsafe impl Foo for i32 { // method implementations go here } fn main() {}
通过使用 unsafe impl
,我们承诺将维护编译器无法验证的不变性。
例如,回想我们在“使用 Sync
和 Send
特性实现可扩展的并发”中讨论的 Sync
和 Send
标记特性:如果我们的类型完全由实现了 Send
和 Sync
的其他类型组成,编译器会自动实现这些特性。如果我们实现的类型包含一个未实现 Send
或 Sync
的类型,例如原始指针,并且我们希望将该类型标记为 Send
或 Sync
,我们必须使用 unsafe
。Rust 无法验证我们的类型是否满足可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查,并使用 unsafe
指示这一点。
访问联合体的字段
最后一种仅与 unsafe
一起使用的行为是访问 联合体 的字段。一个 union
类似于一个 struct
,但在特定实例中一次只能使用一个声明的字段。联合体主要用于与 C 代码中的联合体进行接口。访问联合体字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中的数据类型。您可以在 Rust 参考手册 中了解有关联合体的更多信息。
使用Miri检查不安全代码
在编写不安全代码时,您可能希望检查您编写的内容实际上是安全和正确的。做到这一点的最好方法之一是使用Miri,这是一种官方的Rust工具,用于检测未定义行为。虽然借用检查器是一种在编译时工作的静态工具,但Miri是一种在运行时工作的动态工具。它通过运行您的程序或其测试套件,并检测您违反其理解的Rust应如何工作的规则来检查您的代码。
使用Miri需要Rust的夜间构建版本(我们将在附录G:Rust的制作和“夜间Rust”中详细讨论)。您可以通过键入rustup +nightly component add miri
来安装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 `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3
Miri 正确地警告我们,我们对可变数据有共享引用。在这里,Miri 仅发出警告,因为在这种情况下这不一定是未定义行为,并且它没有告诉我们如何修复问题。但至少我们知道存在未定义行为的风险,并可以考虑如何使代码安全。在某些情况下,Miri 还可以检测出明显的错误——肯定是错误的代码模式——并提出关于如何修复这些错误的建议。sure
Miri 并不能捕捉到你在编写不安全代码时可能犯的所有错误。Miri 是一个动态分析工具,因此它只能捕捉到实际运行的代码中的问题。这意味着你需要将其与良好的测试技术结合使用,以提高你对你编写的不安全代码的信心。Miri 也不能覆盖你的代码可能存在的所有不安全方式。
换句话说:如果 MIRI 确实 捕获到一个问题,你就知道有 bug,但 仅仅因为 MIRI 没有 捕获到 bug 并不意味着没有问题。它 可以捕获很多问题。尝试在本章其他不安全代码示例上运行它,看看它怎么说!
您可以在其 GitHub 仓库了解更多关于 Miri 的信息。
何时使用不安全代码
使用 unsafe
来使用上述五个超级能力之一并不是错误的,甚至也不被反对,但要使 unsafe
代码正确会更棘手,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe
代码时,你可以这样做,而显式的 unsafe
注解使得在出现问题时更容易追踪问题的来源。每当你编写不安全的代码时,你可以使用 Miri 来帮助你更有信心地确保你编写的代码符合 Rust 的规则。
对于如何有效地使用不安全的 Rust 进行更深入的探索,请阅读 Rust 的官方指南,Rustonomicon。