高级 The Rust Project Developers 著,rustwiki.org 中译 2026-09-13 14:59:08 · 0 阅读

第16章 非安全性

非安全操作(Unsafe operations)是那些可能潜在地违反 Rust 静态语义里的和内存安全保障相关的操作。

以下语言级别的特性不能在 Rust 的安全(safe)子集中使用:

  • 读取或写入可变静态变量;读取或写入或外部静态变量。
  • 访问[联合体(union)]的字段,注意不是给它的字段赋值。
  • 调用一个非安全(unsafe)函数(包括外部函数和和内部函数(intrinsic))。
  • 实现非安全(unsafe) trait.

非安全函数

非安全函数是指在任何可能的上下文和/或任何可能的输入中可能出现不安全情况的函数。这样的函数必须以关键字 unsafe 前缀修饰,并且只能在非安全(unsafe)块或其他非安全(unsafe)函数中调用此类函数。

非安全块

一个代码块可以以关键字 unsafe 作为前缀,以允许在安全(safe)函数中调用非安全(unsafe)函数或对裸指针做解引用操作。

当程序员确信某些潜在的非安全操作实际上是安全的,他们可以将这段代码(作为一个整体)封装进一个非安全(unsafe)块中。编译器将认为在当前的上下文中使用这样的代码是安全的。

非安全块用于封装外部库、直接操作硬件或实现语言中没有直接提供的特性。例如,Rust 提供了实现内存安全并发所需的语言特性,但是线程和消息传递的实现(没在语言中实现,而)是在标准库中实现的。

Rust 的类型系统是动态安全条款(dynamic safety requirements)的保守近似值,因此在某些情况下使用安全代码会带来性能损失。例如,双向链表不是树型结构,那在安全代码中,只能妥协使用引用计数指针表示。通过使用非安全(unsafe)块,可以将反向链接表示为原始指针,这样只用一层 box封装就能实现了。

未定义的行为

如果 Rust 代码出现了下面列表中的任何行为,则此代码被认为不正确。这包括非安全(unsafe)块和非安全函数里的代码。非安全只意味着避免出现未定义行为(undefined behavior)的责任在程序员;它没有改变任何关于 Rust 程序必须确保不能写出导致未定义行为的代码的事实。

在编写非安全代码时,确保任何与非安全代码交互的安全代码不会触发下述未定义行为是程序员的责任。对于任何使用非安全代码的安全客户端(safe client),如果当前条件满足了此非安全代码对于安全条件的要求,那此此非安全代码对于此安全客户端就是健壮的(sound);如果非安全(unsafe)代码可以被安全代码滥用以致出现未定义行为,那么此非安全(unsafe)代码对这些安全代码来说就是不健壮的(unsound)

警告: 下面的列表并非详尽无遗地罗列了 Rust 中的未定义行为。而且对于在非安全代码中什么是明确不允许的,目前 Rust 还没有正式的语义模型,因此将来可能会有更多的行为被认为是不安全的。下面的列表仅仅是我们当前确定知晓的未定义行为。在编写非安全代码之前,请阅读 Rustonomicon

  • 数据竞争。
  • 悬垂或未对齐的裸指针上执行解引用操作 (*expr),甚至位置表达式(e.g. addr_of!(&*expr))上也不安全。
  • 破坏指针别名规则&mut T&T 遵循 LLVM 的作用域无别名(noalias)模型(scoped noalias model),除非 &T 内部包含 UnsafeCell<U> 类型。
  • 修改不可变的数据。常量(const)项内的所有数据都是不可变的。此外,所有通过共享引用接触到的数据或不可变绑定所拥有的数据都是不可变的,除非该数据包含在 UnsafeCell<U> 中。
  • 通过编译器内部函数(compiler intrinsics)调用未定义行为。1
  • 执行基于当前平台不支持的平台特性编译的代码(参见 target_feature)。
  • 用错误的 ABI约定来调用函数,或使用错误的 ABI展开约定来从某函数里发起展开(unwinding)。
  • 产生非法值(invalid value),即使在私有字段和本地变量中也是如此。“产生”值发生在这些时候:把值赋给位置表达式、从位置表达式里读取值、传递值给函数/基本运算(primitive operation)或从函数/基本运算中返回值。 以下值非法值(相对于它们各自的类型来说):
    • 布尔型bool中除 false (0) 或 true (1) 之外的值。

    • 不包括在该枚举(enum)类型定义中的判别值。

    • 指向为空(null)的函数指针(fn pointer)。

    • 代理码点(Surrogate)或码点大于 char::MAX 的字符(char)值。

    • !类型的值(任何此类型的值都是非法的)。

    • 未初始化的内存中,或从字符串切片(str)的未初始化部分获取的整数(i*/u*)、浮点值(f*)或裸指针。

    • 引用或 Box<T> (代表的指针)指向了悬垂(dangling)、未对齐或指向非法值。

    • 宽(wide)引用、Box<T> 或原始指针中带有非法元数据(metadata):

      • 如果 trait对象(dyn Trait)的元数据不是指向 Trait 的虚函数表(vtable)(该虚函数表与该指针或引用所指向的实际动态 trait 相匹配)的指针,则 dyn Trait 元数据非法。
      • 如果切片的长度不是有效的 usize,则该切片的元数据是非法的(也就是说,不能从它未初始化的内存中读取它)。
    • 带有非法值的自定义类型的值非法。在标准库中,这条促成了 NonNull<T>NonZero* 的出现。

      注意rustc 是使用还未稳定下来的属性 rustc_layout_scalar_valid_range_* 来验证这条规则的。

注意: 未初始化的内存对于任何具有有限有效值集的类型来说也隐式非法。也就是说,允许读取未初始化内存的情况只发生在联合体(union)内部和“对齐填充区(padding)”里(类型的字段/元素之间的间隙)。

注意:未定义行为影响整个程序。例如,在 C 中调用一个 C函数已经出现了未定义行为,这意味着包含此调用的整个程序都包含了未定义行为。如果 Rust 再通过 FFI 来调用这段 C程序/代码,那这段 Rust 代码也包含了未定义行为。反之亦然。因此 Rust 中的未定义行为会对任何其他通过 FFI 过来调用的代码造成不利影响。

悬垂指针

如果引用/指针为空或者它指向的所有字节不是同一次分配(allocation)的一部分(因此,它们都必须是某次分配的一部分),那么它就是“悬垂”的。它指向的字节跨度(span)由指针本身和指针所指对象的类型的尺寸决定(此尺寸可使用 size_of_val 检测)。因此,如果这个字节跨度为空,则此时“悬垂”与“非空(non-null)”相同。请注意,切片和字符串指向它们的整个区间(range),因此切片的长度元数据永远不要太大这点很重要。因此切片和字符串的分配不能大于 isize::MAX 个字节。

1

因为编译器内部函数(compiler intrinsics)很多都是和平台相关的,移植性差。

不被认为是非安全的行为

虽然程序员可能(应该)发现下列行为是不良的、意外的或错误的,但 Rust 编译器并不认为这些行为是非安全的(unsafe)

死锁(Deadlocks)
内存和其他资源的泄漏(Leaks of memory and other resources)
退出而不调用析构函数(Exiting without calling destructors)
通过指针泄漏暴露随机基地址(Exposing randomized base addresses through pointer leaks)
整数溢出(Integer overflow)

如果程序包含算术溢出(arithmetic overflow),则说明程序员犯了错误。在下面的讨论中,我们将区分算术溢出和包装算法(wrapping arithmetic)。前者是错误的,而后者是有意为之的。

当程序员启用了 debug_assert! 断言(例如,通过启用非优化的构建方式),相应的实现就必须插进来以便在溢出时触发 panic。而其他类型的构建形式有可能也在溢出时触发 panics,也有可能仅仅隐式包装一下溢出值,对溢出过程做静音处理。也就是说具体怎么对待溢出由插进来的编译实现决定。

在隐式包装溢出的情况下,(编译器实现的包装算法)实现必须通过使用二进制补码溢出(two's complement overflow)约定来提供定义良好的(即使仍然被认为是错误的)溢出包装结果。

整型提供了一些固有方法(inherent methods),允许程序员显式地执行包装算法。例如,i32::wrapping_add 提供了使用二进制补码溢出约定算法的加法,即包装类加法(wrapping addition)。

标准库还提供了一个 Wrapping<T> 的新类型,该类型确保 T 的所有标准算术操作都具有包装语义。

请参阅 RFC 560 以了解错误条件、基本原理以及有关整数溢出的更多详细信息。

逻辑错误(Logic errors)

安全代码可能会被添加一些既不能在编译时也不能在运行时检查到的逻辑限制。如果程序打破了这样的限制,其表现可能是未指定的(unspecified),但不会导致未定义行为(undefined behavior)。这些表现可能包括 panics、错误的结果、程序中止(aborts)和程序无法终止(non-termination)。并且这些表现在运行期、构建期或各种构建期之间的的具体表现也可能有所不同。

例如,同时实现 HashEq 就要求被认为相等的值具有相等的散列。另一个例子是像 BinaryHeapBTreeMapBTreeSetHashMapHashSet 这样的数据结构,它们描述了在数据结构中修改键的约束。违反这些约束并不被认为是非安全的,但程序(在逻辑上却)被认为是错误的,其行为是不可预测的。

评论 (0)