Repository navigation
Volatile not respected in some edge cases #21033
Description
Zig Version
0.13.0
Steps to Reproduce and Observed Behavior
Taking a volatile pointer to a nested array of packed structs (such as an array of machine registers) causes loads to not be volatile which can lead to bad machine code like infinite loops being generated
The minimal repro I could achieve is this:
https://godbolt.org/z/dcrae4639
This while loop doesn't execute the ldr(line 5 in the 0.12 asm) again each loop on arm or riscv32 since at least 0.13, which causes an infinite loop if polling a machine register for a change. In 0.12 it could be worked around by populating the loop.
In the latest versions it can still be worked around by putting a memory clobber in the loop. Notably this doesn't repro on x86 but does repro on at least aarch64, thumb, and riscv32.
This affects the mmio.Mmio struct used for accessing machine registers in Microzig in some cases, so does have some impact for common embedded use cases.
Copy of code in case godbolt gets lost:
// Build args: -target thumb-freestanding -OReleaseSmall
const struct_view = packed struct(u32) {
raw: u32,
};
const Nested = extern struct {
bad: [1]struct_view,
good: struct_view,
};
export fn main_bad() void {
const mmio_ptr: *volatile Nested = @ptrFromInt(0x40000000);
while (mmio_ptr.bad[0].raw == 0) {} // Skips the load on repeated loops
// ldr r0, [r0] // <- Load outside the loop
// .LBB0_1:
// cbnz r0, .LBB0_3
// b .LBB0_1
// .LBB0_3:
}
export fn main_good() void {
const mmio_ptr: *volatile Nested = @ptrFromInt(0x40000000);
while (mmio_ptr.good.raw == 0) {} // Does volatile load of register
// .LBB0_1:
// ldr r2, [r0] // <- Load inside the loop
// cbnz r2, .LBB0_3
// b .LBB0_1
// .LBB0_3:
}Expected Behavior
Expect loads through *volatile to always be treated as volatile.
Activity
MatthiasPortzel commentedon Aug 12, 2024
For a little bit more background:
while (gpio.num(18).read() == 0) {}(RP2040 target in microzig) is a footgun because the read just gets optimized out. Spinning the CPU while waiting for a MMIO value to update is a very common pattern, and I would expect it to work.Because of this issue, when writing Zig code for embedded you basically have to use debug mode (what I do) or include a
asm volatile ("" ::: "memory");inside any loops (which microzig does, https://github.com/search?q=repo%3AZigEmbeddedGroup%2Fmicrozig+tight_loop_contents&type=code).MatthiasPortzel commentedon Aug 28, 2024
This effects my confidence in Zig's suitability for my uses (embedded robotics), so I wanted to look into it more.
I've come up with a minimal version of my struct example, and by looking at the LLVM IR I have a better idea what's going on.
In this example, Zig (incorrectly) outputs LLVM that does not have any volatile restrictions, so as soon as it is complied in a release mode, LLVM moves the struct access outside of the loop, breaking the intention of the code.
https://godbolt.org/z/8W4dxY8WW
Compiling in debug mode, we can see that in this case, Zig outputs LLVM IR corresponding to
getelementptrthenloadfor the double-lookup ofperipherals.raw.value(with novolatilein sight). With only a single-level struct (e.g.peripherals.value) Zig outputs insteadload volatile.andrewrk commentedon Aug 28, 2024
Workaround:
Also, this use case requires types that have well-defined memory layouts, otherwise the load/store may occur at a different address than intended. I added
externkeywords to the structs to demonstrate this.dansalvato commentedon Jul 26, 2025
I ran into this issue as well, but with a particularly bizarre workaround that might shed some light. Apparently, the illegal read occurs only when writing the packed struct using an anonymous struct literal. Specifying the type explicitly instead of anonymous causes the correct behavior.
It's behaving as though the compiler doesn't understand that the anonymous struct is the same layout as the destination struct, so it's using bitmasks to write to the destination, causing the illegal reads.
I believe this behavior occurs regardless of build mode (Debug, ReleaseFast, ReleaseSmall). Also, if the
volatilequalifier is removed, both functions lower to the exact same IR.https://godbolt.org/z/3cnaYhGMK
Copy of code:
Resulting LLVM IR:
MatthiasPortzel commentedon Oct 5, 2025
I believe this has been resolved, at least the reproductions here, by #25154.