The Rust programming language 读书笔记——不安全 Rust

Rust 内部隐藏了一种不会强制实施内存安全保障的语言:不安全 Rust
其之所以存在,是因为静态分析从本质上讲是保守的,它宁可错杀一些合法程序也不会接受可能非法的代码。
使用不安全代码的缺点在于程序员需要对自己的行为负责。若错误地使用了不安全代码,就可能引发不安全的内存问题,如空指针解引用等。

另一个原因在于底层计算机硬件固有的不安全性。若 Rust 不允许进行不安全的操作,则某些底层任务可能根本就完成不了。

不安全 Rust 允许你执行 4 种在安全 Rust 中不被允许的操作:

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

可以在代码块前使用关键字 unsafe 来切换到不安全模式。unsafe 关键字并不会关闭借用检查器或禁用任何其他 Rust 安全检查。unsafe 仅仅令你可以访问上述 4 种不会被编译器检查的特性。因此即便处于不安全的代码块中,也仍然可以获得一定程度的安全性。

unsafe 并不意味着块中的代码一定就是危险的或一定会导致内存安全问题,它仅仅是将责任转移到了程序员的肩上。
通过对 4 种不安全操作标记上 unsafe,可以在出现内存相关的错误时快速地将问题定位到 unsafe 代码块中。
应当尽量避免使用 unsafe 代码块

为了尽可能地隔离不安全代码,可以将其封装在一个安全的抽象中并提供一套安全的 API。实际上某些标准库功能同样使用了不安全代码,并以此为基础提供了安全的抽象接口。

解引用裸指针

不安全 Rust 拥有两种类似于引用的新指针类型,都被叫做裸指针(raw pointer)。与引用类似,裸指针要么是可变的,要么是不可变的,分别写作 *const T*mut T。这里的星号 * 是类型名的一部分而不代表解引用操作。

裸指针与引用、智能指针的区别:

  • 允许忽略借用规则,可以同时拥有指向同一个内存地址的可变和不可变指针,或者拥有指向同一个地址的多个可变指针
  • 不能保证自己总是指向了有效的内存地址
  • 允许为空
  • 没有实现任何自动清理机制

在避免 Rust 强制执行某些保障后,就能够以放弃安全保障为代价换取更好的性能,或者与其他语言、硬件进行交互的能力。

通过引用创建裸指针

1
2
3
4
5
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
}

上述代码中并没有使用 unsafe 关键字。你可以在安全代码内合法地创建裸指针,但不能在不安全代码块外解引用裸指针。

创建一个指向任意内存地址的裸指针,这个地址可能有数据,也可能没有数据,因此无法确定其有效性。

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

为了使用 * 解引用裸指针,需要添加一个 unsafe 块:

1
2
3
4
5
6
7
8
9
10
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);
}
}

在使用裸指针时,我们可以创建同时指向同一地址的可变指针和不可变指针,并通过可变指针来修改数据。这样的修改操作会导致潜在的数据竞争。
裸指针主要用来与 C 代码接口进行交互,或者构造一些借用检查器无法理解的安全抽象。

调用不安全函数或方法

除了在定义前面要标记 unsafe,不安全函数或方法看上去与正常的函数或方法几乎一模一样。
这里的 unsafe 关键字意味着我们需要在调用该函数时手动满足一些先决条件,因为 Rust 无法对这些条件进行验证。通过在 unsafe 代码块中调用不安全函数,我们向 Rust 表明自己确实理解并实现了相关约定。

1
2
3
4
5
unsafe fn dangerous() {}

unsafe {
dangerous();
}

因为不安全函数的函数体也是 unsafe 代码块,你可以直接在一个不安全函数中执行其他不安全操作而无需添加额外的 unsafe 代码块。

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

函数中包含不安全代码并不意味着我们需要将整个函数都标记为不安全的。实际上,将不安全代码封装在安全函数中是一种十分常见的抽象。

比如标准库中使用了不安全代码的 split_at_mut 函数。这个安全方法被定义在可变切片上,它接收一个切片并从给定的索引参数处将其分割为两个切片。

1
2
3
4
5
6
7
8
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]);
}

我们无法仅仅使用安全 Rust 来实现这个函数。比如尝试用安全代码将 split_at_mut 实现为函数,并只处理 i32 类型的切片:

1
2
3
4
5
6
7
fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();

assert!(mid <= len);

(&mut slice[..mid], &mut slice[mid..])
}

这个函数会首先取得整个切片的长度,并通过断言检查给定的参数是否小于或等于当前切片的长度。若大于则会在尝试使用该索引前触发 panic。
我们会返回一个包含两个可变切片的元组,一个从原切片的起始位置到 mid 索引的位置,另一个则从 mid 索引的位置到原切片的末尾。

尝试编译上述代码会触发 error[E0499]: cannot borrow `*slice` as mutable more than once at a time 错误。
Rust 的借用检查器无法理解我们正在借用一个切片的不同部分,它只知道我们借用了两次同一个切片。借用一个切片的不同部分从原理上来讲是没有任何问题的,因为没有交叉的地方。但 Rust 没有足够智能到理解这些信息。此类场景即适用于不安全代码。

使用 unsafe、裸指针及一些不安全函数实现 split_at_mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::slice;

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();

assert!(mid <= len);

unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.offset(mid as isize), len - mid),
)
}
}

fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = split_at_mut(r, 3);

assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}

在 unsafe 代码中,slice::from_raw_parts_mut 函数接收一个裸指针和长度来创建一个切片。这里使用该函数从 ptr 处创建了一个拥有 mid 个元素的切片,接着又在 ptr 上使用 mid 作为偏移量参数调用 offset 方法得到了一个从 mid 处开始的裸指针,并基于它创建了另外一个起始于 mid 处且拥有剩余所有元素的切片。

函数 slice::from_raw_parts_mut 接收一个裸指针作为参数并默认该参数的合法性,所以它是不安全的。裸指针的 offset 方法默认此地址的偏移量也是一个有效的指针,它也是不安全的。
因此我们必须在 unsafe 代码块中调用上述两个函数。通过审查代码并添加断言,我们可以确定 unsafe 中的裸指针都会指向有效的切片数据且不会产生数据竞争。这就是一个恰当的 unsafe 使用场景。

代码没有将 split_at_mut 函数标记为 unsafe,因此我们可以在安全 Rust 中调用该函数。这就是对不安全代码的安全抽象。

与上述代码相反,下面对 slice::from_raw_parts_mut 函数的调用就很有可能导致崩溃。其试图用一个随意的内存地址来创建拥有 10000 个元素的切片。

1
2
3
4
5
6
7
8
9
use std::slice;

fn main() {
let address = 0x01234usize;
let r = address as *mut i32;

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

使用 extern 函数调用外部代码

Rust 代码可能需要与另外一种语言编写的代码进行交互。Rust 为此提供了 extern 关键字来简化创建和使用外部函数接口(FFI)的过程。
任何 extern 块中声明的函数都是不安全的。因为其他语言不会强制执行 Rust 遵守的规则,Rust 又无法对它们进行检查。因此保证安全的责任就落到了开发者身上。

下面的代码集成了 C 标准库中的 abs 函数。

1
2
3
4
5
6
7
8
9
extern "C" {
fn abs(input: i32) -> i32;
}

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

访问或修改静态变量

Rust 支持全局变量,但在使用的过程中可能因为所有权机制而产生某些问题。如果两个线程同时访问同一个可变的全局变量,就会产生数据竞争。
全局变量也被称为静态(static)变量

1
2
3
4
5
static HELLO_WORLD: &str = "Hello World";

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

静态变量必须要标注类型,访问一个不可变的静态变量是安全的。
静态变量的值在内存中拥有固定的地址,使用它的值总会访问到同样的数据。而常量则允许在任何被使用到的时候复制其数据。
与常量不同的是,静态变量是可变的。但访问和修改可变的静态变量是不安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static mut COUNTER: u32 = 0;

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

fn main() {
add_to_count(3);

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

在上述代码中,任何读写静态变量 COUNTER 的代码都必须位于 unsafe 代码块中。

实现不安全 trait

当某个 trait 中存在至少一个方法拥有编译器无法校验的不安全因素时,我们就称这个 trait 是不安全的。可以在 trait 定义的前面加上 unsafe 关键字来声明一个不安全 trait,同时该 trait 也只能在 unsafe 代码块中实现。

1
2
3
4
5
6
7
unsafe trait Foo {
// 某些方法
}

unsafe impl Foo for i32 {
// 对应的方法实现
}

通过使用 unsafe impl,我们向 Rust 保证我们会手动维护好那些编译器无法验证的不安全因素。

参考资料

The Rust Programming Language