Rust 认为什么是“未定义”以及什么不是“不安全”?

大家应该都听说过 Rust 语言是以安全(Safe)作为特性之一的,但是由于一个悲哀的事实——硬件是不安全(Unsafe)的,所以其实所有的“安全”一定是在“不安全”之上的封装,这也导致了完全意义上的“Safe”是很难做到且功能极其受限的。

那让我们来看看,Rust 的 Safe 边界在哪里。

Rust 认为什么不是“不安全”?

什么是安全的 Rust 相信大家都了解,这里不再赘述;实际上,有一些行为虽然我们会认为是预期之外甚至不安全的,但是 Rust 不会:

  • 死锁
  • 内存、资源泄露
  • 未执行析构就退出
  • 由于指针泄漏,暴露了随机的基地址
  • 整型溢出
  • 逻辑错误

前四个都好理解,特别是内存泄漏这个,在 The Book 中就有提到(而且可以看下,标准库的std::mem:leak都不是 unsafe 的);这里特别要讨论的是,整型溢出和逻辑错误这两个问题。

整型溢出

如果一段代码包含算术溢出,那是程序员的锅。在下面的讨论中,我们需要区分算术溢出和包装算术(wrapping arithmetic)。前者是错误的,而后者是预期之中的。

当程序员启用了debug_assert!断言(例如,debug 模式下的编译),编译器会在运行时插入动态检查,如果发生了溢出会 panic。其他类型的构建(如 release 模式下)可能会导致 panic 或在溢出时啥都不做。

在隐式包装溢出的情况下,实现者必须通过使用二补数的溢出约定来提供定义明确的(即使仍然被认为是错误的)结果。

Rust 标准库为整型提供了一些方法,允许程序员明确地执行包装算术。例如,i32::wrapping_add提供了二补、包装加法。

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

关于整数溢出的错误条件、原理和更多细节,可以参考RFC 560

逻辑错误

安全代码可以有一些额外的逻辑约束,这些约束在编译时和运行时都无法检查。如果一个程序破坏了这样的约束,其行为可能是未指定的,但不会导致未定义行为。这可能包括 panic、不正确的结果、非预期的中止或者死循环。这种行为也可能在不同的运行、构建或构建种类之间有所不同。

例如,实现HashEq要求相等的值一定要有相等的哈希值。另一个例子是像BinaryHeapBTreeMapBTreeSetHashMapHashSet这样的数据结构,它们针对在它们 Key 中的对象的修改定义了一些约束。违反这样的约束不被认为是不安全的,然而程序的行为是不可预测的,随时有可能挂。

Rust 认为什么是“未定义”

未定义(Undefined Behaviour)是一个很有意思的定义,算是写 C 和 C++ 程序员的老朋友了,甚至很多代码会依赖未定义行为。

如果 Rust 代码有以下列表中的任何行为,那么它就是不正确的,包括 unsafe 中的代码。unsafe 只意味着避免未定义的行为是由程序员负责的;它没有改变任何关于 Rust 程序决不能引起未定义行为的要求。换言之,无论是否使用 unsafe,都不应该有未定义的行为出现。

在编写 unsafe 代码时,程序员有责任确保任何与不安全代码交互的安全代码不能触发这些行为。对于任何安全的调用者来说,满足这一属性的不安全代码被称为健全(sound)的;如果不安全代码可以被安全代码滥用而表现出未定义的行为,那么它就是不健全的。

要注意,下面的列表并不是详尽的。对于不安全代码中允许和不允许的行为,Rust 的语义并没有正式的模型,所以可能有更多的行为被认为是不安全的。下面的列表只是我们确定的未定义行为。在编写不安全代码之前,请阅读死灵书(Rustonomicon)。

  • 数据竞争(Data races)

  • 在一个悬空或不对齐的原始指针上执行解引用表达式(*expr),即使是在地址表达式上下文中(例如addr_of!(&*expr))。

  • 破坏了指针别名规则。&mut T&T遵循 LLVM 的作用域noalias模型,除非&T包含一个UnsafeCell<U>

  • 修改不可变的数据。const 项中的所有数据都是不可变的。此外,所有共享引用的数据或由不可变的绑定所拥有的数据都是不可变的,除非该数据包含在一个UnsafeCell<U>中。

  • 通过编译器的内建指令调用未定义的行为。

  • 执行当前平台不支持的平台特性编译的代码(见target_feature,这通常会导致 SIGILL)。

  • 调用具有错误调用规约(ABI)的函数或 unwind 具有错误 unwind ABI 的函数。

  • 产生一个无效的值,哪怕是在私有字段和局部字段中。一个值被分配到一个地方或从一个地方读出、传递到一个函数 / 原始操作(primitive operation)或从一个函数 / 原始操作返回都会“产生”一个值。以下的值是无效的:

    • bool 中除 false(0)或 true(1)以外的值。

    • 类型定义中没有包括的枚举中的判别式。

    • 一个空的 fn 指针。

    • char 中的一个值是代用的(surrogate)或高于char::MAX的。

    • ! (所有的值对这个类型来说都是无效的)。

    • 一个整数、浮点值,或从未初始化的内存中获得的原始指针,或str中未初始化的内存。

    • 一个引用或Box<T>是悬空的、不对齐的,或者指向一个无效的值。

    • 泛引用、Box<T>或原始指针中无效的元数据。

      • 如果一个dyn Trait指针 / 引用指向的 vtable 和对应 Trait 的 vtable 不匹配,那么dyn Trait的元数据是无效的。
      • 如果 Slice 的长度不是有效的 usize(比如,从未初始化的内存中读取的 usize),那么 Slice 的元数据是无效的。
    • 对于一个具有自定义的无效值的类型来说是无效的值(看着有点绕),比如在标准库中的NonNull<T>NonZero*

      注:Rustc 通过不稳定的rustc_layout_scalar_valid_range_*属性实现了这一点。

注意:对于任何具有受限的有效值集的类型,未初始化的内存也是隐式无效的。换句话说,唯一允许读取未初始化内存的情况是在 union 内和padding中(一个类型的字段 / 元素之间的空隙)。

注:未定义行为会影响整个程序。例如,在 C 语言中调用一个表现出 C 语言未定义行为的函数,意味着你的整个程序包含未定义行为,这也会影响 Rust 代码。反之亦然,Rust 中的未定义行为会对其他语言的任何 FFI 调用所执行的代码造成不良影响。

悬垂指针

如果一个引用 / 指针是空的,或者它所指向的所有地址并非合法的地址(比如 malloc 分配出的内存),那么它就是悬垂的。它所指向的范围是由指针值和被指向类型的大小决定的(使用size_of_val)。因此,如果指向的范围是空的,悬垂非空是一样的。

要注意,切片和字符串指向它们的整个范围,所以它们的长度不可能很大。内存分配的长度、切片和字符串的长度不能大于isize::MAX字节。