volatile: allow accesses to non-AM memory to trap
Project: rust-lang/rust | Language: Rust | Concepts: error-handling, refactoring, compiler-internals | Difficulty: senior
Link: https://github.com/rust-lang/rust/pull/160564
The problem
Rust's documentation and implementation for volatile memory accesses previously required that such accesses "must not trap" (i.e., must not cause the program to abort or otherwise terminate unexpectedly). However, on some hardware and in certain use cases, volatile accesses to non-addressable memory (non-AM) can legitimately trap (e.g., by causing a hardware fault). The language and compiler needed to be updated to clarify that trapping is allowed and to ensure that undefined behavior (UB) cannot be reordered across volatile accesses, especially on older LLVM versions that did not treat volatile loads as potentially trapping.
Why it's non-trivial
A naive fix would simply update documentation to say that volatile accesses may trap. However, the compiler backend (LLVM) can make optimizations based on its understanding of whether certain operations can trap or not. On older LLVM versions, volatile loads are considered willreturn (guaranteed to return), which allows LLVM to move code (including UB) across volatile loads, potentially resulting in "time-traveling UB" where observable side effects (like I/O or volatile access) appear to happen after UB, violating Rust's safety guarantees. The implementation must therefore ensure that, even on older LLVMs, volatile accesses are treated as potentially trapping, preventing such reordering.
The approach
1. Documentation changes: Allow trapping
The documentation for volatile reads and writes was updated to explicitly allow trapping, removing the previous requirement that "the access must not trap":
-/// irrelevant, and it can be created with [`without_provenance`]. The access must not trap. It
-/// can cause side-effects, but those must not affect Rust-allocated memory in any way.
+/// irrelevant, and it can be created with [`without_provenance`]. The access is allowed to trap,
+/// which must immediately abort the process. It can also cause other side-effects, but those
+/// must not affect Rust-allocated memory in any way.
This change was made in several places, including core::ptr::read_volatile, core::ptr::write_volatile, and related atomic documentation.
2. Compiler backend: Prevent reordering on older LLVMs
To prevent LLVM from incorrectly optimizing around volatile loads on versions prior to 23 (which do not treat volatile loads as potentially trapping), the code generator inserts a "black box" inline assembly after volatile loads on old LLVMs. This assembly is treated by LLVM as a potential trap and thus prevents reordering:
impl<'ll, 'tcx> Builder<'_, 'll, 'tcx> {
fn black_box(&mut self, result: PlaceRef<'tcx, &'ll Value>, span: Span) {
let result_val_span = [result.val.llval];
let (constraint, inputs): (&str, &[_]) = if result.layout.is_zst() {
("~{memory}", &[])
} else {
("r,~{memory}", &result_val_span)
};
crate::asm::inline_asm_call(
self,
"",
constraint,
inputs,
self.type_void(),
&[],
true,
false,
llvm::AsmDialect::Att,
&[span],
false,
None,
None,
)
.unwrap_or_else(|| bug!("failed to generate inline asm call for `black_box`"));
}
}
This function inserts an inline assembly "black box" that acts as a barrier to LLVM optimizations, ensuring no code is reordered across it.
This is then used after volatile loads when compiling with LLVM < 23:
- } else if let BackendRepr::Scalar(scalar) = result_layout.backend_repr {
- let load = self.volatile_load(self.type_from_scalar(scalar), ptr, ptr_align);
- self.to_immediate_scalar(load, scalar)
+ } else if let BackendRepr::Scalar(scalar) = result_layout.backend_repr
+ && !need_black_box
+ {
+ let load = self.volatile_load(self.type_from_scalar(scalar), ptr, ptr_align);
+ self.to_immediate_scalar(load, scalar)
} else {
// ...
let llval = self.volatile_load(llty, ptr, ptr_align);
self.store(llval, temp.val.llval, abi_align);
+ if need_black_box {
+ // LLVM up until v22 considers volatile reads `willreturn` and hence can
+ // move UB from further down up across this read. To prevent that, insert an
+ // inline asm block that, as far as LLVM is concerned, might not terminate,
+ // and hence should prevent such reordering.
+ self.black_box(temp, span);
+ }
On LLVM < 23, after a volatile load, a black box is inserted to prevent reordering of UB across the volatile access.
3. Test and build system updates
Tests that depend on the new semantics are marked as requiring LLVM 23 or later:
+//@ min-llvm-version: 23
This ensures that tests for volatile semantics are only run on LLVM versions that support the correct trapping behavior.
From the review thread
cuviper:
I think the intent here is mainly to acknowledge the existence of bugs in LLVM which independent packagers might want to patch for Rust's sake. It seems quite different to use this for actual UB semantics, because now you're talking about safety guarantees that cannot be upheld (IIUC).
RalfJung:
We do sometimes have soundness bugs that are fixed by an LLVM update (so you still have the bug if you use an older LLVM).
But yeah this is different since we'd basically be introducing a new way for rustc+LLVM22 to be wrong rather than just failing to fix an old way. I just don't like having to wait another 6 months for this. :/
I wonder if there's something we can do by using more creative codegen when we are running with LLVM22, like an inline asm block after the volatile read, with that asm not beingwillreturn. That should prevent LLVM from reordering stuff?
RalfJung:
I have added a little hack for LLVM22 and older that inserts ablack_boxafter the volatile read. That should prevent LLVM from moving later UB to before the volatile read, and it makes the intrinsic as a whole non-willreturn. At least I think that's the case, would be good to have a review by an LLVM expert. :)
Takeaways
- Compiler semantics must match both documentation and backend behavior: When changing language semantics (e.g., allowing traps), ensure the compiler backend (here, LLVM) is instructed accordingly to prevent unsound optimizations.
- When backend support is version-dependent, use code generation tricks to enforce safety: If the backend does not natively support a required semantic (like trapping), insert constructs (e.g., inline assembly barriers) to enforce the desired behavior.
- Documentation and implementation must stay in sync for safety-critical primitives: Especially for low-level operations like volatile memory access, keep documentation, code, and tests aligned to avoid misunderstandings and latent UB.