Skip to content

## Pre-Pre-RFC: core::arch::{load, store} and stricter volatile semantics #321

Open

Description

@DemiMarie

Update (2025-05-20): see #321 (comment)


core::arch::{load, store}

This proposes new load and store functions (in core::arch), for raw hardware loads and stores, and a concept of an always-valid type that can safely be cast to a byte array. It also defines volatile accesses in terms of these functions.

The functions proposed here have the same semantics as raw machine load and store instructions. The compiler is not permitted to assume that the values loaded or stored are initialized, or even that they point to valid memory. However, it is permitted to assume that load and store do not violate Rust’s mutability rules.

In particular, it is valid to use these functions to manipulate memory that is being concurrently accessed or modified by any means whatsoever. Therefore, they can be used to access memory that is shared with untrusted code. For example, a kernel could use them to access userspace memory, and a user-mode server could use them to access memory shared with a less-privileged user-mode process. It is also safe to use these functions to manipulate memory that is being concurrently accessed via DMA, or that corresponds to a memory-mapped hardware register.

The core guarantee that makes load and store useful is this: A call to load or store is guaranteed to result in exactly one non-tearing non-interlocked load from or store to the exact address passed to the function, no matter what that address happens to be. To ensure this, load and store are considered partially opaque to the optimizer. The optimizer must consider them to be calls to functions that may or may not dereference their arguments. It is even possible that the operation triggers a hardware fault that some other code catches and recovers from. Hence, the compiler can never prove that a given call to core::arch::load and core::arch::store will have undefined behavior. In other ways, a call to load or store does not disable any optimizations that a call to an unknown function with the same argument would not also disable. In short: garbage in, garbage out.

The actual functions are as follows:

unsafe fn load<T>(ptr: *const T) -> T;
unsafe fn store<T>(ptr: *mut T, arg: T);

Performs a single memory access (of size size_of::<T>()) on ptr. The compiler must compile each these function calls into exactly one machine instruction. If this is not possible, it is a compile-time error. The types T for which a compiler can successfully generate code for these calls is dependent on the target architecture. Using a T that cannot safely be transmuted to or from a byte array is not forbidden, but is often erroneous, and thus triggers a lint (see below). Provided that ptr is properly aligned, these functions are guaranteed to not cause tearing. If ptr is not properly aligned, the results are architecture-dependent.

The optimizer is not permitted to assume that ptr is dereferenceable or that it is properly aligned. This allows these functions to be used for in-process debuggers, crash dumpers, and other applications that may need to access memory at addresses obtained from some external source, such as a debug console or /proc/self/maps. If load is used to violate the aliasing rules (by accessing memory the compiler thinks cannot be accessed), the value returned may be non-deterministic and may contain sensitive data. If store is used to overwrite memory the compiler can assume will not be modified, subsequent execution (after the call to store returns) has undefined behavior.

The semantics of volatile

A call to ptr::read_volatile desugars to one or more calls to load, and a call to ptr::write_volatile desugars to one or more calls to store. The compiler is required to minimize tearing to the extent possible, provided that doing so does not require the use of interlocked or otherwise synchronized instructions. const fn core::arch::volatile_non_tearing::<T>() -> bool returns true if T is such that tearing cannot occur for naturally-aligned accesses. It may still occur for non-aligned accesses (see below).

Unaligned volatile access

The compiler is not allowed to assume that the arguments of core::{ptr::{read_volatile, write_volatile}, arch::{load, store}} are aligned. However, it is also not required to generate code to handle unaligned access, if doing so would cause a performance penalty for the aligned case. In particular, whether the no-tearing guarantee applies to unaligned access is architecture dependent. On some architectures, it is even possible for unaligned access to cause a hardware trap.

New lints

Use of core::ptr::{read_volatile, write_volatile} with a type that cannot be safely transmuted to and from a byte slice will trigger a dubious_type_in_volatile lint. Use of core::arch::{load, store} with such types will trigger a dubious_type_in_load_or_store lint. Both are Warn by default. Thanks to @comex for the suggestion!

Lowering

LLVM volatile semantics are still unclear, and may turn out to be weaker than necessary. It is also possible that LLVM volatile requires dereferenceable or otherwise interacts poorly with some of the permitted corner-cases. Therefore, I recommend lowering core::{arch::{load, store}, ptr::{read_volatile, write_volatile}} to LLVM inline assembly instead, which is at least guaranteed to work. This may change in the future.

Activity

  1. comex commented on Mar 16, 2022

    @comex

    I like this, except:

    If such a transmute would be unsafe (in the sense of Project Safe Transmute), it is a compile-time error (regardless of the target platform)

    This seems like it should be a trait bound.

    write_volatile<T> is a compile-time error if T has any padding bits or an unspecified layout, as it as no useful semantics in that case. Similarly, read_volatile<T> is a compile-time error if T has any invalid representations.

    This part is a breaking change and doesn't seem well motivated to me.

    For writes: Writing padding bits is potentially a security concern due to the potential to leak memory contents, but it doesn't seem inherently unsound; any undefined bits should just be implicitly frozen to an arbitrary value. As for unspecified layout, if by that you mean things like repr(Rust), this layout can still be probed at runtime, or perhaps you don't care about the layout because you only need to read the value back later as the same type in the same program.

    For reads: Just because volatile is well-suited for dealing with untrusted or potentially-corrupted memory doesn't mean that's the only possible use case. You may happen to know for whatever reason that the load will return a valid value. Perhaps you're reading it from an MMIO register; perhaps you're abusing volatile to implement atomics (bad idea but, in the right circumstances, not unsound); perhaps the load doesn't have to be volatile but is anyway due to some generics madness.

    All of these cases seem dubious enough to be worth a lint, but I'm skeptical that they should be hard errors even with the new functions, let alone the existing already-stable functions.

  2. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    All of these cases seem dubious enough to be worth a lint, but I'm skeptical that they should be hard errors even with the new functions, let alone the existing already-stable functions.

    Agreed, lint added.

    Perhaps you're reading it from an MMIO register

    I generally assume that MMIO devices are not automatically trustworthy, but your point stands.

  3. bjorn3 commented on Mar 16, 2022

    @bjorn3
    Member

    Therefore, the Rust compiler is never allowed to make assumptions about the memory accessed by these functions, or the results of such accesses.

    That can't be done without forcing deoptimization of any program that may call this. To prevent deoptimization it would be better to say that it can access any memory which an opaque function that gets passed the pointer as argument may access. That would for example not include stack variables which don't have their address taken.

  4. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    Therefore, the Rust compiler is never allowed to make assumptions about the memory accessed by these functions, or the results of such accesses.

    That can't be done without forcing deoptimization of any program that may call this. To prevent deoptimization it would be better to say that it can access any memory which an opaque function that gets passed the pointer as argument may access. That would for example not include stack variables which don't have their address taken.

    Is this also the semantics of using inline assembly? The goal is that volatile operations will always operate on whatever happens to be at that memory address; the compiler can’t just say “I know this volatile load or store will have undefined behavior if X” and optimize accordingly. The situation you are referring to is supposed to be covered by, “At the same time, a call to load or store does not disable any optimizations that a call to an unknown function with the same argument would not also disable. In short: garbage in, garbage out.”

    The reason for these seemingly contradictory requirements is that I want volatile memory access to be usable for in-process debuggers and crash dumpers. These programs need to be able to access whatever happens to be at an arbitrary caller-provided memory location and know the compiler will not try to outsmart them. This is also why using these functions to dereference null or dangling pointers is explicitly permitted. Just because you can use these functions to read from a piece of memory does not mean that Rust makes any guarantees whatsoever about what you will find there, or that your attempt won’t cause a hardware fault of some sort. Similarly, just because you can use these functions to write to a piece of memory does not mean that Rust makes any guarantees as to what impact that will have on other code. If you misuse them and something breaks, you get to keep both pieces.

  5. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    @bjorn3: do you have suggestions for better phrasing here? The intent is that you can use volatile memory access to peek and poke at whatever memory you want, but the consequences of doing so are entirely your responsibility. For instance, I might need to test that my program’s crash handler triggers successfully when I dereference a null pointer, or that a hardened memory allocator detects modification of freed memory and properly aborts.

  6. bjorn3 commented on Mar 16, 2022

    @bjorn3
    Member

    Is this also the semantics of using inline assembly?

    I believe so.

    the compiler can’t just say “I know this volatile load or store will have undefined behavior if X” and optimize accordingly.

    It has to for any optimization to be possible.

    The situation you are referring to is supposed to be covered by, “At the same time, a call to load or store does not disable any optimizations that a call to an unknown function with the same argument would not also disable. In short: garbage in, garbage out.”

    Didn't see that sentence. I agree that covers my situation.

    The reason for these seemingly contradictory requirements is that I want volatile memory access to be usable for in-process debuggers and crash dumpers.

    Those things are UB either way. Individual compilers just do a best effort at trying to make them work the way a user expects them to work when optimizations are disabled. When optimizations are enabled it is even more in a best effort basis. For example it may not be possible to change function parameters if the compiler found that a function argument is constant and optimized accordingly.

  7. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    Is this also the semantics of using inline assembly?

    I believe so.

    Good to know!

    the compiler can’t just say “I know this volatile load or store will have undefined behavior if X” and optimize accordingly.

    It has to for any optimization to be possible.

    To elaborate, what I am not okay with is the compiler optimizing out the entire basic block as unreachable code. Compilers have a nasty habit of doing that, so I wanted to be absolutely clear that is not permitted.

    The situation you are referring to is supposed to be covered by, “At the same time, a call to load or store does not disable any optimizations that a call to an unknown function with the same argument would not also disable. In short: garbage in, garbage out.”

    Didn't see that sentence. I agree that covers my situation.

    Thank you.

    The reason for these seemingly contradictory requirements is that I want volatile memory access to be usable for in-process debuggers and crash dumpers.

    Those things are UB either way. Individual compilers just do a best effort at trying to make them work the way a user expects them to work when optimizations are disabled. When optimizations are enabled it is even more in a best effort basis. For example it may not be possible to change function parameters if the compiler found that a function argument is constant and optimized accordingly.

    That behavior is perfectly acceptable (though ideally it would be reflected in the debug info). I wonder if our definitions of UB are slightly different. To mean, an execution with UB has no meaning at all, and the compiler is allowed to prune any basic block that invokes UB. core::arch::{load, store} never invoke this form of UB themselves, but are unsafe because they can cause a crash or even invoke UB in unrelated code.

  8. Diggsey commented on Mar 16, 2022

    @Diggsey

    To elaborate, what I am not okay with is the compiler optimizing out the entire basic block as unreachable code. Compilers have a nasty habit of doing that, so I wanted to be absolutely clear that is not permitted.

    If the code is unreachable, then of course the compiler is permitted to optimize it away. The compiler is allowed to assume that no UB occurs within the program when determining what is reachable (and for all other optimizations). This is true for all code: there's no special case for atomics.

  9. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    To elaborate, what I am not okay with is the compiler optimizing out the entire basic block as unreachable code. Compilers have a nasty habit of doing that, so I wanted to be absolutely clear that is not permitted.

    If the code is unreachable, then of course the compiler is permitted to optimize it away. The compiler is allowed to assume that no UB occurs within the program when determining what is reachable (and for all other optimizations). This is true for all code: there's no special case for atomics.

    According to the model mentioned above (a volatile memory access is an opaque function call or inline assembler), the compiler does not actually know that std::ptr::read_volatile(x) actually dereferences x, merely that it may dereference x. So it cannot prove that std::ptr::read_volatile(x) is UB no matter what x is.

  10. Diggsey commented on Mar 16, 2022

    @Diggsey

    So it cannot prove that std::ptr::read_volatile(x) is UB no matter what x is.

    Right, but whether or not x is dereferenceable is only one potential source of UB. The compiler could still decide that the call is unreachable for other reasons (say x is the result of an expression that invokes UB).

    I'd also question whether it makes sense to be quite so lenient with x - it definitely makes sense that x might not be dereferenceable, but what if it's undef or poison? I don't really have the expertise to answer that though...

  11. DemiMarie commented on Mar 16, 2022

    @DemiMarie
    Author

    So it cannot prove that std::ptr::read_volatile(x) is UB no matter what x is.

    Right, but whether or not x is dereferenceable is only one potential source of UB. The compiler could still decide that the call is unreachable for other reasons (say x is the result of an expression that invokes UB).

    That’s fine.

    I'd also question whether it makes sense to be quite so lenient with x - it definitely makes sense that x might not be dereferenceable, but what if it's undef or poison? I don't really have the expertise to answer that though...

    I decided to err on the side of the simplest possible semantics.

  12. RalfJung commented on Mar 18, 2022

    @RalfJung
    Member

    Thanks for writing this up!

    I was confused what the difference to volatile would be. Is it fair to say that this is morally what volatile "ought to be", and the only reason you are avoiding the term volatile is that volatile has bad interactions with atomic accesses in C/C++/LLVM?

    an always-valid type that can safely be cast to a byte array

    This sounds contradictory: a byte array ([u8; N]) cannot hold all data (e.g., uninit memory), so an always-valid type cannot be safely cast to a byte array.

    The functions proposed here have the same semantics as raw machine load and store instructions. The compiler is not permitted to assume that the values loaded or stored are initialized, or even that they point to valid memory. However, it is permitted to assume that loads via store do not violate Rust’s mutability rules.

    "loads via store" seems odd; I assume "loads and stores" is what you meant?

    I think this is too weak. In particular, I think the compiler should be able to assume that the given address is the only memory (that the compiler knows about) that this operation can access, and that it will not have other side-effects (like synchronization) either. So for example if arch::load is called on some *const i32 that does not alias some other pointer p, the compiler is allowed to reorder regular stores to p with those non-aliasing loads.

    This also makes these operations quite different from inline assembly, which is allowed to do much more. With inline assembly, the intention is that even swapping out the assembly block by some other code at runtime (that still adheres to the same clobber annotations) is allowed. I don't think we want that for arch::{load, store}.

    non-tearing non-interlocked load from or store to

    What is an "interlocked" load/store?

    The actual functions are as follows:

    I am very strongly opposed to using usize to indicate memory accesses. In particular, the aliasing rules still matter (as you say), and hence provenance still matters. That means these functions should work on pointers, not integers.

    T must be a type such that T can safely be transmuted to (resp. from) [u8; size_of::]. Otherwise, the behavior of this function is the same as that of inline assembly that contains a single non-atomic load (resp. store) instruction of the correct size.

    "Otherwise" here sounds like "in case T cannot be safely transmuted as described", but I doubt that is what you mean.

    "non-atomic" is a bad choice here, since assembly languages don't even distinguish atomic and non-atomic accesses -- that is a surface language thing. And the entire point of your proposal is that these accesses are atomic, in the sense of not introducing data races.

    But this reminds me, the interactions with the concurrency model need to be specified better I think. If there are overlapping concurrent non-atomic writes to the same location, that is UB. If there are non-atomic reads concurrent to an overlapping arch::store, that is UB. And if there are concurrent overlapping atomic accesses, then what are the possible observed values on read accesses? If my hardware has strong consistency guarantees, like x86, can I use this to do the equivalent of an "acquire read"? That would be in contradiction with some of the reorderings I mentioned above.

    To avoid problems with LLVM’s unclear volatile semantics, the LLVM backend should in fact lower this function to LLVM inline assembler.

    What is wrong with using LLVM volatile atomic accesses?

  13. Lokathor commented on Mar 18, 2022

    @Lokathor
    Contributor

    Ralf I'm 99% certain that "always valid" is meant to be read as "all initialized bit patterns are valid", not that it's also allowing uninit memory.

  14. RalfJung commented on Mar 18, 2022

    @RalfJung
    Member

    Ralf I'm 99% certain that "always valid" is meant to be read as "all initialized bit patterns are valid", not that it's also allowing uninit memory.

    That still leaves provenance as a concern -- transmuting pointers with provenance to integers is subtle at best, making (arrays of) integer types like u8 IMO unsuited as "generic data containers".

  15. Lokathor commented on Mar 18, 2022

    @Lokathor
    Contributor

    I'm pretty sure that everything you just said also applies if you "raw" read/write a pointer with memory, right? Because provenance isn't a physical concept that's actually stored in a device. So if you raw read a pointer from memory you already don't know the provenance, regardless of the array of u8 part or not. It would have to be treated as an int read with an automatic int2ptr cast, or something like that.

  16. 128 remaining items

  17. RalfJung commented on Jul 23, 2025

    @RalfJung
  18. RalfJung commented on Jul 23, 2025

    @RalfJung
  19. nikic commented on Jul 23, 2025

    @nikic
  20. RalfJung commented on Jul 23, 2025

    @RalfJung
    Member

    Okay, that resolves everything the I think -- the docs should be clarified (llvm/llvm-project#150191), and the lack of a store might just be a bug (though IMO, the example as originally written with a volatile non-atomic write used for publishing x does permit eliding the store).

  21. ProgramCrafter commented on Jul 24, 2025

    @ProgramCrafter
  22. RalfJung commented on Jul 24, 2025

    @RalfJung
    Member

    If hardware doesn't provide this, it's impossible to write an OS that can safely access userspace. Also, please let's not bog down this already too long thread with entirely hypothetical extremely niche concerns that are largely off-topic since they aren't specific to volatile at all.

  23. ProgramCrafter commented on Aug 11, 2025

    @ProgramCrafter

    So, would the core::arch::{load, store} definition look like this?

    // core/arch/linear_memory_bikeshed.rs
    //           ^^^^^^^^^^^^^ (shouldn't be available where pointer must be tagged)
    
    // trait Sealed ...
    // Is there a need to seal? Users can potentially guarantee SimpleLoad/Store on
    // their own types too.
    
    pub trait SimpleLoad : Sealed {
        /// Loads a value from the given memory address using a single machine
        /// instruction for T-sized aligned load.
        /// The effect of this function is hardware-dependent.
        ///
        /// For memory exposed by the Rust Abstract Machine, this is guaranteed
        /// to return a value equal to:
        /// ```rust
        /// let ptr: *const T = core::ptr::from_exposed_provenance(addr);
        /// unsafe { ptr.read() }
        /// ```
        /// except that _this_ call will not be ever optimized out.
        ///
        /// For memory that is guaranteed to be held by the Rust AM but not exposed,
        /// the return value is an unspecified instance of T. In particular, it
        /// might not match what is stored in the pointed-to object (if any)
        /// according to the program order.
        /// ```rust
        /// let mut x: Box<usize> = Box::new(1);
        /// let p = x.deref() as *const usize as usize;
        ///
        /// let q: &mut usize = &mut x;
        /// *q = 2;
        /// // assert_eq!(unsafe { <usize as SimpleLoad>::load(p) }, 2);
        /// *q = 3;
        /// // assert_eq!(unsafe { <usize as SimpleLoad>::load(p) }, 1);
        /// // assert_eq!(unsafe { <usize as SimpleLoad>::load(p) }, 3);
        /// // assert!(&[1,2,3].includes(unsafe { <usize as SimpleLoad>::load(p) }));
        /// // None of the assertions above are guaranteed to succeed.
        ///
        /// assert!((0..=usize::MAX).includes(unsafe { <usize as SimpleLoad>::load(p) }),
        ///         "load returns some instance of usize");
        /// // And no Undefined Behavior happens in the above program. (*)
        /// ```
        ///
        /// (*) or perhaps there is UB if allocation is elided by the optimizer and
        /// pointer's address stops pointing at the actual place.
        ///
        /// The load will be performed regardless of whether address is known to the
        /// Rust AM, therefore it defers to OS and/or hardware on effects of any
        /// illegal-address access.
        /// If there is a _hardware-defined_ data race on the provided location, the
        /// behavior is undefined.
        unsafe fn load(addr: usize) -> Self;
    }
    
    pub trait SimpleStore : Sealed {
        /// Stores a value to the given memory address using a single machine
        /// instruction for T-sized aligned store.
        /// The effect of this function is hardware-dependent.
        ///
        /// For memory exposed by the Rust Abstract Machine, this is guaranteed
        /// to write a value equivalently to:
        /// ```rust
        /// let ptr: *mut T = core::ptr::from_exposed_provenance(addr);
        /// unsafe { ptr.write(addr, value) }
        /// ```
        /// except that _this_ call will not be ever optimized out.
        ///
        /// For memory that might be held by the Rust AM but not exposed, the runtime
        /// behavior is undefined. (If instruction executes, it can corrupt program
        /// memory, and invalid values produced so can break control flow as well.)
        ///
        /// The store will be performed regardless of whether address is known to the
        /// Rust AM, therefore it defers to OS and/or hardware on effects of any
        /// illegal-address access.
        /// If there is a _hardware-defined_ data race on the provided location, the
        /// behavior is undefined.
        unsafe fn store(addr: usize, value: Self);
    }
  24. DemiMarie commented on Aug 11, 2025

    @DemiMarie
    Author
    >     /// (*) or perhaps there is UB if allocation is elided by the optimizer and
    >     /// pointer's address stops pointing at the actual place.
    

    It isn’t undefined behavior even in this case. The load might fault, but one can already have memory access faults without undefined behavior by using mmap().

    /// For memory that might be held by the Rust AM but not exposed, the runtime
    /// behavior is undefined. (If instruction executes, it can corrupt program
    /// memory, and invalid values produced so can break control flow as well.)
    

    This is mostly correct: the store will happen, but the subsequent program behavior is unpredictable. The reason the store being guaranteed to happen matters is that there are cases where one does know what will happen, perhaps by disassembling the compiled binary. That is only relevant to interactive debugging sessions, though.

    /// If there is a _hardware-defined_ data race on the provided location, the
    /// behavior is undefined.
    

    Do such architectures exist? The only case I can think of is writing to in-use page tables.

  25. Shinribo commented on Aug 12, 2025

    @Shinribo
    /// If there is a _hardware-defined_ data race on the provided location, the
    /// behavior is undefined.
    

    Do such architectures exist? The only case I can think of is writing to in-use page tables.

    Currently working on a kernel for x86_64. Its a common occurence to modify active page tables and the cpu can set two flags on access to the mapped page while one is modifying the page entry. This is not even some race between userspace and kernel, the cpu is allowed to fetch, cache and set flags in page table entries speculatively on its own even if userspace code doesnt touch the region as it waits for mmap syscall to return.

  26. Freax13 commented on Aug 12, 2025

    @Freax13
    /// If there is a _hardware-defined_ data race on the provided location, the
    /// behavior is undefined.
    

    Do such architectures exist? The only case I can think of is writing to in-use page tables.

    Currently working on a kernel for x86_64. Its a common occurence to modify active page tables and the cpu can set two flags on access to the mapped page while one is modifying the page entry. This is not even some race between userspace and kernel, the cpu is allowed to fetch, cache and set flags in page table entries speculatively on its own even if userspace code doesnt touch the region as it waits for mmap syscall to return.

    The CPU uses regular read-modify-write atomic operations to update the accessed and dirty bit in page tables entries. If you want to update active page tables, you'll also need to use atomic operations. If you do that, there's no data race, just atomic accesses.

  27. Shinribo commented on Aug 12, 2025

    @Shinribo

    @RalfJung

    We had a discussion about volatile at the all-hands. I'm going to try to summarize it but I will probably forget some things... also see the meeting notes.

    We reached consensus that the primary use-case of volatile is for MMIO. Everything related to concurrency should not need volatile (except in truly exceptional circumstances such as the LKMM which is operating outside the bounds of our opsem spec anyway). The usecase of interacting with untrusted code should be fully supportable with suitable atomic APIs -- that is not the case today, but it should become the case in the future with atomic bytewise memcpy and mixed-size atomics (though a lot of work will be needed to arrive at a truly precise model of sharing memory with untrusted code). So for this API, we should not worry about these use-cases.

    For the MMIO case, there's three parts to discuss:

    1. How does one do simple MMIO accesses, "serial port style", where all the hardware cares about are the MMIO registers themselves.
    
    2. How does one do DMA, where a bunch of normal memory accesses to normal Rust memory (that is shared with the hardware) are done before a "real" MMIO access (or some other signal) then signals the hardware to go read all that memory in the DMA region.
    
    3. How does any of this interact with the concurrency memory model.
    

    For (1), the plan is to carve out a set of types for which volatile reads/writes are guaranteed to be truly just a single instruction. On some architectures the exact choice of instruction matters (e.g. on ARM, some instructions provide sufficient information to a hypervisor to suitably virtualize MMIO, but others do not); we should then use the instruction that works best for MMIO (for the ARM case, Nikia agreed that that is what LLVM should do).

    This could be done with the existing API, by just documenting which types qualify, or we could have a new API with a suitable trait bound. Note that we cannot easily have an API that looks anything like the atomics: volatile accesses should be possible to memory that is not actually dereferenceable from a Rust point of view, so volatile access APIs need to work on raw pointers, not references. (We could introduce the concept of non-dereferenceable references to Rust, but that doesn't seem worth it -- also see #411.)

    The behavior of these operations would be basically that of an inline asm block, but with very precise clobbers that cannot be otherwise expressed: the operation may load/store only exactly the memory location that was passed as argument, but no other (Rust-visible) memory.

    For (2), one has to put a suitable fence between the DMA accesses and the final MMIO access. This will, at least for now, require inline assembly: RISC-V, for instance, has separate fences for communicating with regular cached memory and with MMIO memory. The logical justification of the inline assembly block will be that it forks off a thread that asynchronously copies the DMA memory, modeling what the hardware does. So there's no new API required here from our side.

    For (3), everyone seemed in agreement that we want to declare all volatile access to be atomic in the sense of the memory model. That does not mean they cannot tear! They can act like bytewise atomic memcpy. For the special types carved out in (1), we could even declare them to be regular atomic accesses, at least for sufficiently aligned accesses. The ordering would be Relaxed. We did not discuss providing APIs that allow choosing a different ordering; I would postpone that until there is a good use-case (almost everything can be covered with Relaxed+fences anyway). This would adjust the "inline asm clobbers" associated with a volatile access to also allow synchronization.

    @DemiMarie, does that align with your plans for the RFC?

    The main decision that is left open here is whether we add new volatile APIs or whether we just fix the existing ones. Personally I don't see the advantage of having new APIs here. @Amanieu was suggesting new APIs, IIUC with a trait bound ensuring they are only used for types that truly guarantee a single instruction to be emitted.

    The problem i see here is that the compiler is allowed to optimize read/writes away and inter-process-communication and page table manipulations require atomic accesse (the latter to prevent data races) and currently there is (at least to my knowledge) no good way to prevent the compiler from removing read/writes without using volatile.

  28. Shinribo commented on Aug 12, 2025

    @Shinribo
    /// If there is a _hardware-defined_ data race on the provided location, the
    /// behavior is undefined.
    

    Do such architectures exist? The only case I can think of is writing to in-use page tables.

    Currently working on a kernel for x86_64. Its a common occurence to modify active page tables and the cpu can set two flags on access to the mapped page while one is modifying the page entry. This is not even some race between userspace and kernel, the cpu is allowed to fetch, cache and set flags in page table entries speculatively on its own even if userspace code doesnt touch the region as it waits for mmap syscall to return.

    The CPU uses regular read-modify-write atomic operations to update the accessed and dirty bit in page tables entries. If you want to update active page tables, you'll also need to use atomic operations. If you do that, there's no data race, just atomic accesses.

    Yes, but the compiler is allowed to optimize read/writes away if they dont have visible effects and setting up page tables for example consists only of writes so the compiler could remove these writes.

  29. DemiMarie commented on Aug 12, 2025

    @DemiMarie
    Author

    core::arch::{load, store}` are always considered to have visible effects. The compiler is never allowed to optimize them out unless they are unreachable.

  30. Shinribo commented on Aug 14, 2025

    @Shinribo

    core::arch::{load, store}` are always considered to have visible effects. The compiler is never allowed to optimize them out unless they are unreachable.

    Does this extend to all atomic operations?

  31. DemiMarie commented on Aug 15, 2025

    @DemiMarie
    Author

    core::arch::{load, store}` are always considered to have visible effects. The compiler is never allowed to optimize them out unless they are unreachable.

    Does this extend to all atomic operations?

    No, it does not. From the user’s perspective, it’s easiest to think of core::arch::{load, store} as being implemented using inline assembly. On some platforms (such as Arm), they not only guarantee that a specific operation will happen, but also provide certain guarantees about the specific instructions that will be used to perform it. This is because the difference is actually observable if the instruction faults, and MMIO emulated by a hypervisor always faults.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions