Rust 学习笔记:Unsafe Rust

Rust 学习笔记:Unsafe Rust

到目前为止,我们讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 内部隐藏着另一种语言,它不强制执行这些内存安全保证:它被称为 Unsafe Rust,和常规 Rust 一样工作,但给了我们额外的超能力。

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

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

Unsafe 能做什么

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

  • 解引用原始指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现一个不安全的 trait
  • 访问 union 的字段

重要的是要明白,不安全不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果你在不安全代码中使用引用,它仍然会被检查。unsafe 关键字只允许你访问这五个特性,然后编译器不会对它们进行内存安全检查。在不安全块中仍然可以获得一定程度的安全性。

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

人是会犯错的,错误是会发生的,但是通过要求这五个不安全操作放在带有不安全注释的块中,你就会知道任何与内存安全相关的错误都必须在不安全块中。

为了尽可能地隔离不安全的代码,最好将这样的代码封装在一个安全的抽象中,并提供一个安全的 API。标准库的某些部分是作为安全抽象实现的,这些安全抽象位于已审计的不安全代码之上。将不安全代码包装在安全抽象中,可以防止不安全的使用泄漏到你可能希望使用使用不安全代码实现的功能的所有地方,因为使用安全抽象是安全的。

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

解引用原始指针

Unsafe Rust 有两种新类型,称为原始指针(Raw Pointer),与引用类似。与引用一样,原始指针可以是不可变的,也可以是可变的,分别写成 *const T 和 *mut T。* 不是解引用操作符;它是类型名称的一部分。在原始指针的上下文中,不可变意味着指针在解引用后不能直接赋值。

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

  • 可以忽略借用规则(同时拥有指向同一位置的不可变和可变指针或多个可变指针)
  • 不能保证指向有效的内存
  • 允许为空
  • 不实现任何自动清理(没有 Drop)

通过选择不让 Rust 强制执行这些保证,你可以放弃保证的安全性,以换取更高的性能或与 Rust 保证不适用的其他语言或硬件接口的能力。

下面代码展示了如何创建一个不可变指针和一个可变原始指针。

    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

我们没有在这段代码中包含 unsafe 关键字。我们可以在安全代码中创建原始指针,但我们不能在不安全块之外解引用原始指针。

&raw const num 创建了一个 *const i32 不可变原始指针,&raw mut num 创建了一个 *mut i32 可变原始指针。

因为我们直接从一个局部变量创建了它们,所以我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针做出这样的假设。

为了演示这一点,接下来我们将创建一个我们不能确定其有效性的原始指针,使用 as 来强制转换值,而不是使用原始的 & 操作符。下面代码显示了如何创建一个指向内存中任意位置的原始指针。

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

尝试使用任意内存是未定义的:该地址可能有数据,也可能没有数据,编译器可能会优化代码,因此没有内存访问,或者程序可能会因分段错误而终止。通常,没有很好的理由编写这样的代码,特别是在可以使用原始借用操作符的情况下,但这是可能的。

回想一下,我们可以在安全代码中创建原始指针,但不能解引用原始指针并读取所指向的数据。在下面代码中,我们对需要不安全块的原始指针使用解引用操作符 *。

    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }

创建指针没有害处,只有当我们试图访问它所指向的值时,我们才可能最终处理一个无效的值。

我们创建了 *const i32 和 *mut i32 原始指针,它们都指向存储 num 的相同内存位置。如果我们尝试创建对 num 的一个不可变引用和一个可变引用,代码将无法编译,因为 Rust 的所有权规则不允许在任何不可变引用的同时使用可变引用。使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,这可能会导致数据竞争,需要格外小心!

有了这些危险,为什么还要使用原始指针呢?一个主要的用例是与 C 代码接口时。另一种情况是在构建借用检查器无法理解的安全抽象时。我们将介绍不安全函数,然后看一个使用不安全代码的安全抽象示例。

调用不安全的函数或方法

不安全函数和方法看起来与常规函数和方法完全一样,但它们在定义的其余部分之前具有额外的不安全。unsafe 关键字表示函数具有我们在调用该函数时需要维护的需求,因为 Rust 不能保证我们满足了这些需求。

下面是一个名为 dangerous 的不安全函数,它在函数体中不做任何事情:

    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }

必须在单独的不安全块中调用 dangerous 函数。如果尝试在没有不安全块的情况下调用 unsafe 函数,将得到一个错误:error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block。

要在不安全函数体中执行不安全操作,仍然需要使用不安全块,就像在常规函数中一样,如果忘记了,编译器会发出警告。这有助于使不安全块尽可能小,因为不安全操作可能不需要在整个函数体中进行。

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

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

作为一个例子,让我们研究标准库中的 split_at_mut 函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全的方法是在可变切片上定义的:它接受一个切片,并通过在作为参数给出的索引处分割切片,将其变成两个切片。下面代码显示了如何使用 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]);

我们不能只用安全的 Rust 实现这个函数。为简单起见,我们将把 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..])
}

代码相对简单,但是返回元组里同时出现了 2 个对 value 数组的可变引用,Rust 编译器报错:error[E0499]: cannot borrow `*values` as mutable more than once at a time。

借用切片的不同部分基本上是可以的,因为两个切片不重叠,但是 Rust 不够聪明,无法知道这一点。当我们知道代码是可以的,但 Rust 不允许的时候,就该考虑不安全的代码了。

下面代码展示了如何使用不安全块、原始指针和对不安全函数的一些调用来实现 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),
        )
    }
}

回想一下,切片是指向某些数据和切片长度的指针。使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的原始指针。在本例中,因为我们有一个指向 i32 值的可变切片,所以 as_mut_ptr 返回一个 *mut i32 类型的原始指针,我们将其存储在 ptr 中。

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

函数 slice::from_raw_parts_mut 是不安全的,因为它接受一个原始指针,并且必须相信这个指针是有效的。原始指针上的 add 方法也不安全,因为它必须相信偏移位置也是一个有效的指针。因此,我们必须在对 slice::from_raw_parts_mut和add 的调用周围放置一个不安全的块,以便我们可以调用它们。

通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以看出,在不安全块中使用的所有原始指针都将是指向片内数据的有效指针。这是一种可接受的、适当的不安全用法。

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

相反,下面代码中对 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) };

我们不拥有这个任意位置的内存,也不能保证这段代码创建的片包含有效的 i32 值。试图像使用有效切片一样使用 values 会导致未定义的行为。

使用外部函数调用外部代码

有时,Rust 代码可能需要与用另一种语言编写的代码进行交互。为此,Rust 使用了关键字 extern 来简化外部函数接口(FFI)的创建和使用。FFI 是一种编程语言定义函数并允许不同(外部)编程语言调用这些函数的方法。

下面代码演示了如何设置与 C 标准库中的 abs 函数的集成。在外部块中声明的函数通常在 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));
    }
}

在 unsafe extern “C” 块中,我们列出了想要调用的另一种语言的外部函数的名称和签名。“C” 部分定义外部函数使用哪个应用程序二进制接口(ABI): ABI 定义如何在汇编级别调用函数。“C” ABI 是最常见的,遵循 C 编程语言的 ABI。

在 unsafe extern 块中声明的每个项都是隐式不安全的。然而,一些 FFI 函数可以安全调用。例如,C 标准库中的 abs 函数没有任何内存安全考虑,我们知道它可以在任何 i32 中调用。在这种情况下,我们可以使用 safe 关键字来说明这个特定的函数可以安全调用,即使它位于 unsafe extern 块中。一旦进行了更改,就不再需要在一个不安全的块才能调用它。

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

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

将函数标记为 safe 并不意味着它就一定安全!相反,它就像是你对 Rust 做出的安全承诺。你仍然有责任确保诺言得以实现。

从其他语言调用 Rust 函数

我们还可以使用 extern 创建一个接口,允许其他语言调用 Rust 函数。我们没有创建整个 extern 块,而是在相关函数的 fn 关键字之前添加了 extern 关键字并指定了要使用的 ABI。我们还需要添加一个 #[unsafe(no_mangle)] 注释来告诉 Rust 编译器不要篡改这个函数的名称。

mangle 是指编译器将我们给函数的名称更改为不同的名称,该名称包含更多信息,供编译过程的其他部分使用,但不太容易读懂。每种编程语言的编译器对名称的混淆都略有不同,因此,为了使 Rust 函数能够被其他语言命名,我们必须禁用 Rust 编译器的名称混淆。这是不安全的,因为在没有内置混淆的情况下,库之间可能存在名称冲突,因此我们有责任确保我们选择的名称在没有混淆的情况下可以安全导出。

在下面的例子中,我们让 call_from_c 函数在编译成共享库并从 C 链接之后,可以从 C 代码访问:

#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}

extern 的这种用法只要求在属性中不安全,而不是在 extern 块中不安全。

访问或修改可变静态变量

虽然我们还没有讨论全局变量,但 Rust 确实支持全局变量,但在 Rust 的所有权规则中可能会有问题。如果两个线程访问同一个可变全局变量,就会导致数据争用。

在 Rust 中,全局变量被称为静态变量。下面代码显示了一个静态变量的声明和使用示例,该变量的值是字符串切片。

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

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

静态变量类似于常量。静态变量只能存储具有“静态生存期”的引用,这意味着 Rust 编译器可以计算出生存期,我们不需要显式地注释它。访问不可变静态变量是安全的。

常量和不可变静态变量之间的一个微妙区别是,静态变量中的值在内存中有一个固定的地址。使用该值将始终访问相同的数据。另一方面,允许常量在使用时复制它们的数据。另一个区别是静态变量可以是可变的。访问和修改可变静态变量是不安全的。

下面代码展示了如何声明、访问和修改名为 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 读取或写入的代码都必须在不安全块内。

考虑到让多个线程访问 COUNTER 可能会导致数据竞争,因此这是一种未定义的行为。因此,我们需要将整个函数标记为 unsafe。

每当我们编写一个不安全的函数时,习惯做法是编写一个以 SAFETY 开头的注释,并解释调用者需要做什么才能安全地调用该函数。同样,每当我们执行不安全操作时,通常都会编写以 SAFETY 开头的注释来解释如何维护安全规则。

此外,编译器不允许创建对可变静态变量的引用,只能通过创建原始指针来访问它,这包括在不可见地创建引用的情况下。对静态可变变量的引用只能通过原始指针创建,这一要求有助于使使用静态可变变量的安全需求更加明显。

对于全局可访问的可变数据,很难确保没有数据竞争,这就是为什么 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用并发技术和线程安全的智能指针,这样编译器就可以检查来自不同线程的数据访问是否安全。

实现不安全的 trait

当 trait 的至少一个方法具有编译器无法验证的不变量时,trait 就是不安全的。我们通过在 trait 之前添加 unsafe 关键字并将 trait 的实现也标记为 unsafe 来声明 trait 是不安全的。

unsafe trait Foo {
    // methods go here
}

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

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

举个例子,回想一下 Sync 和 Send 这两个 marker trait:如果我们的类型完全由实现了 Send 和 Sync 的其他类型组成,编译器就会自动实现这些 trait。如果实现的类型包含不实现 Send 或 Sync 的类型(如原始指针),并且希望将该类型标记为 Send 或 Sync,则必须使用不安全类型。Rust 不能验证我们的类型是否保证它可以安全地跨线程发送或从多个线程访问。因此,我们需要手动执行这些检查,并使用 unsafe 来指示。

访问 union 的字段

最后一个只适用于不安全的操作是访问 union 的字段。union 类似于结构体,但在一个特定实例中一次只能使用一个声明的字段。

union 主要用于 C 代码中的接口。访问 union 字段是不安全的,因为 Rust 不能保证当前存储在联合实例中的数据的类型。

更多参考:https://doc.rust-lang.org/reference/items/unions.html

使用 Miri 检查不安全代码

在编写不安全代码时,你可能希望检查所编写的代码是否安全且正确。最好的方法之一是使用 Miri,这是一个用于检测未定义行为的官方 Rust 工具。借用检查器是在编译时工作的静态工具,而 Miri 是在运行时工作的动态工具。它通过运行你的程序或它的测试套件来检查你的代码,并检测你何时违反了它所理解的 Rust 应该如何工作的规则。

使用 Miri 需要 Rust 的夜间构建,你可以通过输入 rustup +nightly component add miri来安装 Rust 的夜间版本和 Miri 工具。

在这里插入图片描述

这不会改变项目使用的 Rust 版本,它只是将工具添加到系统中,以便在需要时使用它。你可以通过输入 cargo +nightly miri run 或者 cargo +nightly miri test 在项目上运行 Miri。

示例:

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

执行 cargo run

在这里插入图片描述

执行 cargo +nightly miri run

在这里插入图片描述

对比两次结果,我们发现 Miri 多提示了一个 integer-to-pointer cast 警告,并给出了一些帮助提示。

在这里,Miri 只发出一个警告,因为在这种情况下不能保证这是未定义的行为,而且它没有告诉我们如何修复这个问题。但至少我们知道存在未定义行为的风险,并且可以考虑如何使代码安全。在某些情况下,Miri 还可以检测彻底的错误——那些肯定是错误的代码模式,并就如何修复这些错误提出建议。

在编写不安全代码时,Miri 不会捕获所有可能出错的内容。Miri 是一个动态分析工具,因此它只捕获实际运行的代码中的问题。这意味着需要将它与良好的测试技术结合使用,以增加你对所编写的不安全代码的信心。Miri 也没有涵盖代码不健全的所有可能方式。

换句话说:如果 Miri 发现了一个问题,你就知道存在一个 bug,但是仅仅因为 Miri 没有发现 bug 并不意味着没有问题。不过,它可以捕捉到很多东西。

更多关于 Miri 的内容,参考 Github / Miri

何时使用不安全代码

使用不安全来使用刚才讨论的五种超能力中的一种并没有错,但是使不安全代码正确是比较棘手的,因为编译器不能帮助维护内存安全。

当你有理由使用不安全代码时,你可以这样做,并且具有显式的 unsafe 注释使得在问题发生时更容易跟踪问题的根源。

无论何时编写不安全代码,都可以使用 Miri 来帮助你更加确信所编写的代码符合 Rust 规则。

要更深入地了解如何有效地使用不安全的 Rust,请阅读 Rust 的官方指南

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值