首页 > 解决方案 > 读或写一个完整的 32 位字,即使我们只引用它的一部分,是否会导致未定义的行为?

问题描述

我试图了解 Rust 别名/内存模型到底允许什么。特别是,当访问您引用的范围之外的内存(可能被相同或不同线程上的其他代码别名)时,我感兴趣的是未定义的行为。

以下示例都在通常允许的范围之外访问内存,但如果编译器生成明显的汇编代码,则以安全的方式访问内存。此外,我认为编译器优化的潜在冲突很小,但它们仍可能违反 Rust 或 LLVM 的严格别名规则,从而构成未定义的行为。

这些操作都正确对齐,因此不能跨越缓存行或页面边界。

  1. 读取我们要访问的数据周围对齐的 32 位字,并丢弃我们允许读取的部分之外的部分。

    这种变体在 SIMD 代码中可能很有用。

    pub fn read(x: &u8) -> u8 {
        let pb = x as *const u8;
        let pw = ((pb as usize) & !3) as *const u32;
        let w = unsafe { *pw }.to_le();
        (w >> ((pb as usize) & 3) * 8) as u8
    }
    
  2. atomic_load与 1 相同,但使用内在函数读取 32 位字。

    pub fn read_vol(x: &u8) -> u8 {
        let pb = x as *const u8;
        let pw = ((pb as usize) & !3) as *const AtomicU32;
        let w = unsafe { (&*pw).load(Ordering::Relaxed) }.to_le();
        (w >> ((pb as usize) & 3) * 8) as u8
    }
    
  3. 使用 CAS 替换包含我们关心的值的对齐的 32 位字。它会用已经存在的内容覆盖我们被允许访问的部分之外的部分,因此它只会影响我们被允许访问的部分。

    这对于使用较大的原子类型来模拟小型原子类型可能很有用。我AtomicU32为了简单起见,在实践AtomicUsize中是有趣的。

    pub fn write(x: &mut u8, value:u8) {
        let pb = x as *const u8;
        let atom_w = unsafe { &*(((pb as usize) & !3) as *const AtomicU32) };
        let mut old = atom_w.load(Ordering::Relaxed);
        loop {
            let shift = ((pb as usize) & 3) * 8;
            let new = u32::from_le((old.to_le() & 0xFF_u32 <<shift)|((value as u32) << shift));
            match atom_w.compare_exchange_weak(old, new, Ordering::SeqCst, Ordering::Relaxed) {
                Ok(_) => break,
                Err(x) => old = x,
            }
        }
    }
    

标签: rustundefined-behaviorstrict-aliasingmemory-model

解决方案


这是一个非常有趣的问题。这些函数实际上有几个问题,由于各种形式的原因,它们不健全(即,不安全地公开)。同时,我实际上无法在这些函数和编译器优化之间构建有问题的交互。

越界访问

我想说所有这些功能都是不健全的,因为它们可以访问未分配的内存。我可以用&*Box::new(0u8)or调用它们中的每一个&mut *Box::new(0u8),从而导致越界访问,即访问超出使用malloc(或任何分配器)分配的内容。C 和 LLVM 都不允许此类访问。(我使用堆是因为我发现在那里考虑分配更容易,但同样适用于每个堆栈变量实际上是其自己的独立分配的堆栈。)

当然,LLVM 语言参考实际上并没有定义由于访问不在对象内部而导致负载何时具有未定义的行为。但是,我们可以在 的文档中getlementptr inbounds得到提示,其中说

已分配对象的边界内地址是指向该对象的所有地址,加上最后一个字节的地址。

我相当肯定,对于实际使用带有加载/存储的地址来说,处于界限内是必要但不充分的要求。

请注意,这与装配级别发生的事情无关;LLVM 将基于更高级别的内存模型进行优化,该模型根据分配的块(或 C 调用它们的“对象”)争论并保持在这些块的范围内。C(和 Rust)不是汇编,不可能对它们使用基于汇编的推理。大多数情况下,可能会从基于汇编的推理中得出矛盾(例如,请参阅LLVM 中的这个错误以获取一个非常微妙的示例:将指针转换为整数并返回不是一个NOP)。然而,这一次,我能想到的唯一例子是相当牵强的:例如,使用内存映射 IO,即使从一个位置读取也可能对底层硬件“意味着”某些东西,并且可能会有这样的读取- 敏感位置就在传入的位置旁边read。但实际上我对这种嵌入式/驱动程序开发了解不多,所以这可能完全不现实。

(编辑:我应该补充一点,我不是 LLVM 专家。可能 llvm-dev 邮件列表是确定他们是否愿意承诺允许此类越界访问的更好地方。)

数据竞赛

至少其中一些功能不健全还有另一个原因:并发性。从并发访问的使用来看,您显然已经看到了这一点。

两者read和在C11read_vol的并发语义下绝对是不健全的。想象一下a 的第一个元素,另一个线程在我们执行/的同时写入第二个元素。我们对整个 32 位字的读取与另一个线程的写入重叠。这是一个经典的“数据竞赛”:两个线程同时访问同一个位置,一个访问是写,一个访问不是原子的。在 C11 下,任何数据竞赛都是 UB,所以我们出局了。LLVM稍微宽松一些,因此两者都可能被允许,但现在 Rust 声明它使用 C11 模型x[u8]readread_volreadread_val

另请注意,“vol”是一个坏名字(假设您的意思是“volatile”的简写)——在 C 中,原子性与volatile! 当使用 volatile 而不是 atomics 时,实际上不可能编写正确的并发代码。不幸的是,Javavolatile是关于原子性的,但这与 C 中的非常不同volatile

最后,write还引入了原子读取-修改-更新和另一个线程中的非原子写入之间的数据竞争,因此它也是 C11 中的 UB。这一次它也是 LLVM 中的 UB:另一个线程可能正在从一个受影响的额外位置读取write,因此调用write会在我们的写入和另一个线程的读取之间引入数据竞争。LLVM 指定在这种情况下,读取返回undef. 因此,调用write可以在其他线程中对相同位置进行安全访问 return undef,并随后触发 UB。

我们有这些功能引起的问题的例子吗?

令人沮丧的部分是,虽然我发现有多种原因可以按照规范排除您的功能,但似乎没有充分的理由排除这些功能!LLVM的模型修复了并发问题(然而,与 C11 相比,它还有其他问题),但read在LLVM 中是非法的,因为读写数据竞争导致读取返回——在这种情况下,我们知道我们正在写相同的已经存储在这些其他字节中的值!LLVM 不能只说在这种特殊情况下(写入已经存在的值),读取必须返回该值吗?可能是的,但是这些东西足够微妙,如果这会使某些晦涩的优化无效,我也不会感到惊讶。read_volwriteundef

此外,至少在非嵌入式平台上,越界访问read不太可能造成实际问题。我想人们可以想象一种语义,它undef在读取保证与 in-bounds 位于同一页面上的越界字节时返回byte。但这仍然write是非法的,这是一个非常艰难的问题:write只有在这些其他位置的内存完全保持不变的情况下才能允许。可能有来自其他分配的任意数据,堆栈帧的一部分,等等。因此,不知何故,正式模型必须让您读取那些其他字节,不允许您通过检查它们来获得任何东西,而且还要验证您在使用 CAS 将它们写回之前没有更改字节。我不知道有什么模型可以让你这样做。但是我感谢你让我注意到这些令人讨厌的案例,知道在内存模型方面还有很多东西需要研究总是很高兴 :)

Rust 的别名规则

最后,您可能想知道的是这些函数是否违反了 Rust 添加的任何附加别名规则。问题是,我们不知道——这些规则仍在制定中。但是,到目前为止,我所看到的所有建议确实会排除您的功能:当您持有一个(例如,指向传递给/ /&mut u8的那个旁边的那个)时,别名规则提供了一个保证,即任何访问都不会除了你之外的任何人都发生在那个字节上。因此,您从内存中读取其他人可能持有的函数已经使它们违反了别名规则。readread_volwrite&mut u8

但是,这些规则的动机是符合 C11 并发模型和 LLVM 的内存访问规则。如果 LLVM 声明了 UB,我们也必须在 Rust 中使其成为 UB,除非我们愿意以一种避免 UB(并且通常会牺牲性能)的方式更改我们的代码生成器。此外,鉴于 Rust 采用了 C11 并发模型,这同样适用。所以对于这些情况,别名规则真的别无选择,只能使这些访问非法。一旦我们有了一个更宽松的内存模型,我们就可以重新审视这一点,但现在我们的手被束缚了。


推荐阅读