Bare metal programming with Zig — is this language the right choice?

As a low-level programmer I am looking for particular features in a language and I haven’t found them in Zig yet. Please help me on these:

  1. Volatile Variables — Zig seems to offer volatile pointers, but not volatile variables. Is it a part of the “friction” in language design? Is the following an intended construct? (extracted from [link])
var tick_counter: u32 = 0; // updated in an IRQ
pub fn getTicks() u32 {
  return @as(*volatile u32, @ptrCast(&tick_counter)).*;
}

I would argue that volatile should be reserved only for MMIO as they are the natural way to communicate from an IRQ to the application in the absence of an OS

  1. Compiler barriers — in Zig I haven’t found any way to tell the compiler to ensure that all assignment statements up to a certain point in the code are executed before I can proceed with a DMA transaction or perform any other operation which requires all data in memory. Making everything volatile will not help in such case as they generate overhead with each access.

  2. Guaranteed structure layout in memory — I haven’t found any information that it is possible in Zig to create structures with predictable layout, so they would map peripheral registers. What is the intended way to deal with peripherals with registers?

Thank you for any answers which may help me.

You may can check Zig Embedded Group · GitHub

I believe that asm volatile ( "" ::: "memory" ) should work as a compiler barrier similar to how the same works in C with GCC.

Marking a struct extern gives it the same memory layout as the C ABI for the target.

5 Likes
  1. Loads and stores using volatile pointers can be used for memory mapped I/O only (compiler does not optimize away these stores and loads).
  2. Atomics can be used to specify memory ordering (compiler emits barrier instructions).
  3. packed struct and packed union have defined memory layout.

To define the bit fields of a memory mapped register, use a volatile pointer to a packed struct. (see: Memory-mapped IO registers in zig)

By the way, welcome to ziggit, and
Yes, zig is the right choice for bare metal programming. :slight_smile:

3 Likes

Thank you for your replies, you have made my life a bit simpler. I am still not sure how to ensure that the CPU does re-read memory (variables or buffers) which is updated from an ISR or DMA, given that volatile concept in Zig is meant to be limited to MMIO.
Atomics do not seem to me as a good fit for this case and std.mem.doNotOptimizeAway() is deprecated. I hope that the language developers will have solved this particular problem as soon as possible.

For DMA the cpu gets sent an interrupt when DMA is finished.
For ISR, the results are in a specific place, if you want them look in that place, like syscalls… because they use ISR.

I’m not sure I got your point. Maybe I could use an example. I am inside of a busy loop which needs to wait a number of milliseconds. I do not have a hardware counter available which I can read directly using MMIO. Instead, I want to reuse a system tick interrupt to increment a variable in memory which represents the time and I want to access this variable from within my busy loop. There is no OS, so I don’t have any OS calls at hand. How would you recommend solving this problem?

Atomics are intended to be used in cases like these because they provide stronger ordering guarantees than volatile de-references. So volatile can be correct to use outside of MMIO, but only if:

  • Your architecture does not support atomic operations
  • No ordering guarantees are required

The cases where both of these are true are quite limited, so the author probably just wanted to ensure proper usage of volatile in Zig code considering how widespread its misuse is in C code.

1 Like

What @alp said is dead on, so consider that before using the example I’m about to give. This is how you would perform a volatile access of the counter variable in your question:

pub var counter_variable: u32 = 0;

pub fn timerISR() callconv(.C) void {
    counter_variable +%= 1; // Note fancy operator for specifying that this integer should wrap around on overflow
}

pub fn applicationCode() void {

    // Creating a volatile pointer to our counter variable
    const counter_variable_vp: *volatile u32 = @volatileCast(&counter_variable);
    // Volatile de-reference ensures compiler can NOT optimize away the memory access
    while (counter_variable_vp.* < 100) {}
}

I’ll emphasize that this does NOT do anything to protect against data-races between your ISR updating counter_variable and your application code reading it. On some microcontrollers where the access of a “system word” is considered “atomic”, this code would be fine. On others, this would not be fine (consider the ISR updating counter_variable in the middle of the first two bytes being loaded by application code).

2 Likes

Volatile variables were omitted since they are very easy to misuse on non-freestanding targets. If you’re not messing with MMIO, I actually don’t remember if there is any legitimate use for them on any of the common platforms besides sigatomic_t.

This saying, if you do want one for IRQ synchronization, then it should be very easy to write a Volatile(T) wrapper similar to this:

pub fn Volatile(comptime T: type) type {
  return struct {
    const Self = @This();
    raw: T,
    
    pub fn init(value: T) Self {
      return .{ .raw = value };
    }

    pub fn load(self: Self) T {
      const vp: *const volatile T = @volatileCast(&self.raw);
      return vp.*;
    }

    pub fn store(self: Self, value: T) void {
      const vp: *volatile T = @volatileCast(&self.raw);
      vp.* = value;
  };
}

You can at std.atomic.Value(T) for inspiration, since it’s basically the same kind of wrapper except that it uses @atomicRmw underneath instead of dereferencing a volatile pointer.

2 Likes