What's your favorite way of doing MMIO?

I’ll be working on an embedded target and I was wondering how I should access memory-mapped IO. For example let’s say there’s is at the base address 0x100 these registers:

offset register
0x00 A
0x04 B
0x08 C
0x20 D

In C, I would do something like this:

#define REGISTER_BASE (0x100)
#define REGISTER_A (*(volatile uint32_t *) (BASE + 0x00))
#define REGISTER_B (*(volatile uint32_t *) (BASE + 0x04))
#define REGISTER_C (*(volatile uint32_t *) (BASE + 0x08))
#define REGISTER_D (*(volatile uint32_t *) (BASE + 0x20))

// Writing
REGISTER_A = 5;
// Reading
uint32_t value = REGISTER_A;

Method 1

Macros in C allow to interact with registers with the same syntax as if they were normal variables. However, the closest thing I found in Zig would be:

const Registers = packed struct {
    a: u32,
    b: u32,
    c: u32,
    reserved: [5]u32,
    d: u32,
};
const registers: *volatile Registers = @ptrFromInt(0x100);

// Writing
registers.a = 5;
// Reading
const value = registers.d;

This method uses the automatic dereferencing to get a syntax similar to the one you’d get in C, but you need to make your declarations in the right order and also declare gaps in the memory map. It supports bitfields if you declare the register with a packed struct of the right size;

Method 2

The second way I’ve seen around is something like this

const Register = struct {
    ptr: *volatile u32,

    pub fn init(address: usize) Register {
        return .{ .ptr = @ptrFromInt(address) };
    }

    pub fn write(register: Register, value: u32) void {
        register.ptr.* = value;
    }

    pub fn read(register: Register) u32 {
        return register.ptr.*;
    }
};
const register_base = 0x100;
const register_a = Register.init(register_base + 0x00);
const register_b = Register.init(register_base + 0x04);
const register_c = Register.init(register_base + 0x08);
const register_d = Register.init(register_base + 0x20);

// Writing
register_a.write(5);
// Reading
const value = register_d.read();

This method is more verbose but makes it possible to have methods on the registers and can be modified to accomodate bitfields: microzig/core/src/mmio.zig at main · ZigEmbeddedGroup/microzig · GitHub

Method 3

Just using raw pointers

const register_base = 0x100;
const register_a: *volatile u32 = @ptrFromInt(register_base + 0x00);
const register_b: *volatile u32 = @ptrFromInt(register_base + 0x04);
const register_c: *volatile u32 = @ptrFromInt(register_base + 0x08);
const register_d: *volatile u32 = @ptrFromInt(register_base + 0x20);

// Writing
register_a.* = 5;
// Reading
const value = register_d.*;

This method is more verbose than the first method, but would allow a register to have two names and doesn’t need to model gaps. It supports bitfields like the others if you declare a register as a packed struct of size 32.

What is your favorite way of accessing them, what are the tradeoffs? Perhaps it’s not even mentionned here?

4 Likes

I use something akin to your method 2, but I also include a comptime type for the bit flags. The packed struct(u32) has been an incredibly useful pattern for catching size typos, and especially those that are documented wrong. I’m working on a PinePhone OS in Zig, and already have 300+ registers working nicely in this manner. The other advantage with the packed struct(u32) is you can just @bitcast from/to the *volatile u32. I think it makes the register/bits usage quite clean:

pub const UART0_DATA_ADDR = Reg32(DATA, UART0_ADDR + 0x00);
pub const DATA = packed struct(u32) {
    OUT: u8 = 0,
    _8: u24 = 0,
};

pub fn logB(v: u8) void {
    while (!UART0_LSR_ADDR.peek().THRE) {
    }
    UART0_DATA_ADDR.poke(.{ .OUT = v });
}
1 Like

This looks nice, I’ll probably use something like this if I don’t see something else I like more. Thanks for the feedback!

1 Like

See these posts:

2 Likes