Skip to content

Volatile not respected in some edge cases #21033

Description

@kibels

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

  1. added
    bugObserved behavior contradicts documented or intended behavior
    on Aug 11, 2024
  2. added this to the 0.15.0 milestone on Aug 12, 2024
  3. MatthiasPortzel commented on Aug 12, 2024

    @MatthiasPortzel

    For a little bit more background:

    • This doesn't happen in debug mode. The Godbolt linked above uses release-small, since that's common for embedded
    • This is super easy to hit in microzig. 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.
    • I suspect this may be related to Zig 0.11.0 regression around @bitCast() / @intCast() in non-debug mode on embedded Cortex-M4 #17882.
    • I have a reproduction that doesn't involve arrays. It's not minimal-enough to be worth sharing, I don't think, but I suspect a slightly more general issue where maybe Zig is emitting a temporary in the IR when it can't access a value in a single load, and then the temporary loses the volatile information (but this is speculation).

    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).

  4. MatthiasPortzel commented on Aug 28, 2024

    @MatthiasPortzel

    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.

    const PeripheralType = struct {
        raw: struct {
            value: usize
        },
    };
    
    export fn struct_bad() void {
        const peripherals: *volatile PeripheralType = @ptrFromInt(0xd0000000);
    
        while (peripherals.raw.value == 0) {}
    }

    https://godbolt.org/z/8W4dxY8WW

    Compiling in debug mode, we can see that in this case, Zig outputs LLVM IR corresponding to getelementptr then load for the double-lookup of peripherals.raw.value (with no volatile in sight). With only a single-level struct (e.g. peripherals.value) Zig outputs instead load volatile.

  5. added
    miscompilationThe compiler reports success but produces semantically incorrect code.
    on Aug 28, 2024
  6. modified the milestones: 0.15.0, 0.14.0 on Aug 28, 2024
  7. andrewrk commented on Aug 28, 2024

    @andrewrk
    Member

    Workaround:

    const PeripheralType = extern struct {
        raw: extern struct { value: usize },
    };
    
    export fn struct_bad() void {
        const peripherals: *volatile PeripheralType = @ptrFromInt(0xd0000000);
        const derived_ptr = &peripherals.raw.value;
    
        while (derived_ptr.* == 0) {}
    }

    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 extern keywords to the structs to demonstrate this.

  8. modified the milestones: 0.14.0, 0.14.1 on Mar 1, 2025
  9. modified the milestones: 0.14.1, 0.15.0 on Apr 15, 2025
  10. modified the milestones: urgent, 0.15.0 on Jun 26, 2025
  11. dansalvato commented on Jul 26, 2025

    @dansalvato

    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 volatile qualifier is removed, both functions lower to the exact same IR.

    https://godbolt.org/z/3cnaYhGMK

    Copy of code:

    const custom: *volatile Custom = @ptrFromInt(0xdff000);
    
    const Bltsize = packed struct {
        width: u6,
        height: u10
    };
    
    const Custom = extern struct {
        bltsize: Bltsize = undefined
    };
    
    export fn illegalReadAndWrite(height: u8) void {
        custom.* = .{
            .bltsize = .{ .width = 40, .height = height }
        };
    }
    
    export fn legalWriteOnly(height: u8) void {
        custom.* = .{
            .bltsize = Bltsize{ .width = 40, .height = height }
        };
    }

    Resulting LLVM IR:

    ; Function Attrs: minsize nofree norecurse noredzone nounwind optsize
    define dso_local void @illegalReadAndWrite(i8 zeroext %0) local_unnamed_addr #0 {
      %2 = load i16, ptr inttoptr (i32 14675968 to ptr), align 4096
      %3 = and i16 %2, -64
      %4 = or disjoint i16 %3, 40
      store volatile i16 %4, ptr inttoptr (i32 14675968 to ptr), align 4096
      %5 = zext i8 %0 to i16
      %6 = shl nuw nsw i16 %5, 6
      %7 = or disjoint i16 %6, 40
      store volatile i16 %7, ptr inttoptr (i32 14675968 to ptr), align 4096
      ret void
    }
    
    ; Function Attrs: minsize nofree norecurse noredzone nounwind optsize
    define dso_local void @legalWriteOnly(i8 zeroext %0) local_unnamed_addr #0 {
      %2 = zext i8 %0 to i16
      %3 = shl nuw nsw i16 %2, 6
      %4 = or disjoint i16 %3, 40
      store volatile i16 %4, ptr inttoptr (i32 14675968 to ptr), align 4096
      ret void
    }
  12. modified the milestones: 0.15.0, 0.15.1 on Jul 29, 2025
  13. modified the milestones: 0.15.2, urgent on Sep 30, 2025
  14. MatthiasPortzel commented on Oct 5, 2025

    @MatthiasPortzel

    I believe this has been resolved, at least the reproductions here, by #25154.

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

    bugObserved behavior contradicts documented or intended behaviormiscompilationThe compiler reports success but produces semantically incorrect code.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions