Cod*_*aos 5 memory-model strict-aliasing undefined-behavior rust
我试图了解Rust别名/内存模型究竟允许什么.特别是我感兴趣的是当访问范围之外的内存时(可能是相同或不同线程上的其他代码别名)变为未定义的行为.
以下示例都是在通常允许的范围之外访问内存,但是如果编译器生成了明显的汇编代码,则会以安全的方式访问内存.此外,我发现编译器优化的冲突可能性很小,但它们仍然可能违反Rust或LLVM的严格别名规则,从而构成未定义的行为.
这些操作都已正确对齐,因此无法跨越缓存行或页面边界.
读取我们想要访问的数据周围的对齐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
}
Run Code Online (Sandbox Code Playgroud)与1相同,但使用atomic_load内在函数读取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
}
Run Code Online (Sandbox Code Playgroud)替换包含我们关心使用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,
}
}
}
Run Code Online (Sandbox Code Playgroud)这是一个非常有趣的问题。这些函数实际上存在几个问题,由于各种形式原因导致它们不健全(即公开不安全)。同时,我无法实际构建这些函数和编译器优化之间有问题的交互。
我想说所有这些函数都是不健全的,因为它们可以访问未分配的内存。我可以使用 a&*Box::new(0u8)或来调用它们中的每一个&mut *Box::new(0u8),从而导致越界访问,即访问超出使用malloc(或任何分配器)分配的范围。C 和 LLVM 都不允许此类访问。(我使用堆是因为我发现在那里更容易考虑分配,但这同样适用于堆栈,其中每个堆栈变量实际上都是其自己的独立分配。)
当然,LLVM 语言参考实际上并没有定义由于访问不在对象内部而导致加载何时具有未定义的行为。但是,我们可以在的文档getlementptr inbounds中得到提示,其中说
已分配对象的边界地址是指向该对象的所有地址,加上末尾一个字节后的地址。
我相当确定,对于实际使用加载/存储地址来说,在边界内是必要的,但不是充分的要求。
请注意,这与程序集级别上发生的情况无关;LLVM 将基于更高级别的内存模型进行优化,该模型根据分配的块(或 C 称之为“对象”)并保持在这些块的范围内进行争论。C(和 Rust)不是汇编,不可能对它们使用基于汇编的推理。大多数时候,有可能从基于汇编的推理中得出矛盾(例如,请参见LLVM 中的这个错误,了解一个非常微妙的示例:将指针转换为整数并返回不是NOP)。然而,这一次,我能想到的唯一例子相当牵强:例如,使用内存映射 IO,即使从某个位置读取也可能对底层硬件“意味着”某些内容,并且可能存在这样的读取- 敏感位置位于传入 的位置旁边read。但实际上我对这种嵌入式/驱动程序开发了解不多,所以这可能完全不现实。
(编辑:我应该补充一点,我不是 LLVM 专家。也许 llvm-dev 邮件列表是一个更好的地方,可以确定他们是否愿意承诺允许此类越界访问。)
至少其中一些功能不健全还有另一个原因:并发性。从并发访问的使用来看,您显然已经看到了这一点。
在C11的并发语义下,read和绝对是不合理的。想象一下 a 的第一个元素,在我们执行/的同时另一个线程正在写入第二个元素。我们对整个 32 位字的读取与另一个线程的写入重叠。这是一种经典的“数据竞争”:两个线程同时访问同一位置,一次访问是写入,一次访问不是原子的。在 C11 下,任何数据竞争都是 UB,所以我们出局了。LLVM稍微宽松一些,因此和可能都是允许的,但现在 Rust 声明它使用 C11 模型。read_volx[u8]readread_volreadread_val
另请注意,“vol”是一个不好的名字(假设您的意思是“易失性”的简写)——在 C 中,原子性与 无关volatile!当使用易失性而不是原子性时,实际上不可能编写正确的并发代码。不幸的是,Javavolatile是关于原子性的,但这volatile与 C 中的原子性有很大不同。
最后,write还引入了原子读取-修改-更新和另一个线程中的非原子写入之间的数据竞争,因此在 C11 中也是 UB。这次它也是 LLVM 中的 UB:另一个线程可能正在从影响的额外位置之一读取write,因此调用write会在我们的写入和另一个线程的读取之间引入数据竞争。LLVM 指定在这种情况下,读取返回undef. 因此,调用write可以安全访问其他线程中的同一位置 return undef,并随后触发 UB。
令人沮丧的是,虽然我发现了多种理由来排除遵循规范的功能,但似乎没有充分的理由排除这些功能!和并发问题由 LLVM 模型解决(但是与 C11 相比,它还有其他问题),但在 LLVM 中是非法的,因为读写数据竞争使读取返回- 在这种情况下,我们知道我们read正在编写相同的内容已经存储在这些其他字节中的值!LLVM 不能只是说在这种特殊情况下(写入已经存在的值),读取必须返回该值吗?可能是的,但是这个东西足够微妙,如果这使一些晦涩的优化失效,我也不会感到惊讶。read_volwriteundef
此外,至少在非嵌入式平台上,越界访问read不太可能造成实际问题。我想人们可以想象一种undef在读取越界字节时返回的语义,该字节保证与 in-bounds 位于同一页面上byte。但这仍然是write非法的,这是一个非常困难的问题:write只有在这些其他位置上的内存完全保持不变的情况下才能允许。那里可能存在来自其他分配、堆栈帧的一部分等的任意数据。因此,形式化模型必须以某种方式让您读取这些其他字节,不允许您通过检查它们来获得任何内容,而且还要在使用 CAS 将它们写回之前验证您没有更改这些字节。我不知道有任何模型可以让你做到这一点。但我感谢你让我注意到这些令人讨厌的案例,知道在内存模型方面还有很多东西需要研究总是一件好事:)
最后,您可能想知道这些函数是否违反 Rust 添加的任何附加别名规则。问题是,我们不知道——这些规则仍在制定中。然而,到目前为止我看到的所有提案确实会排除你的功能:当你持有一个&mut u8(例如,指向传递给read//的那个旁边的一个read_vol)时write,别名规则提供了保证,任何访问都不会除您之外的任何人都发生过该字节。因此,您的函数从内存中读取其他人可以保存的函数&mut u8已经使它们违反了别名规则。
然而,这些规则的动机是为了符合 C11 并发模型和 LLVM 的内存访问规则。如果 LLVM 声明了某些 UB,我们也必须在 Rust 中将其设为 UB,除非我们愿意以避免 UB 的方式更改代码生成(并且通常会牺牲性能)。此外,鉴于 Rust 采用了 C11 并发模型,这也是如此。因此,对于这些情况,别名规则实际上别无选择,只能使这些访问非法。一旦我们有了更宽松的记忆模型,我们就可以重新审视这个问题,但现在我们的手被束缚了。