CodeRigor

FREE LESSON

Typed registers: zero-cost MMIO in modern C++

Wrap memory-mapped registers in templates — Reg<address, access>, Field<reg, offset, width, type> — so wrong accesses don't compile and right ones cost exactly the ldr/bfi/str you'd write by hand.

A peripheral is a block of memory-mapped registers: write a word to 0x40004000 and a byte goes out of the UART; read 0x40004004 and bit 0 tells you whether the transmit buffer is full. The classic way to program them is a vendor header full of macros:

C++
#define UART0_CTRL   (*(volatile uint32_t*)0x40004008u)
#define UART_CTRL_TXEN  (1u << 0)
#define UART_CTRL_MODE_Pos 4
#define UART_CTRL_MODE_Msk (3u << UART_CTRL_MODE_Pos)

UART0_CTRL = (UART0_CTRL & ~UART_CTRL_MODE_Msk) | (2u << UART_CTRL_MODE_Pos);

Every line is a chance to use the wrong mask with the right register, to forget the ~, to write a read-only status register, or to shift a value that doesn't fit. Nothing checks any of it. This lesson builds the C++ alternative: types carry the address, the access rights and the field layout, wrong accesses don't compile, and right ones compile to exactly the instructions you'd have written by hand.

1. One register

C++
namespace reg {
struct ro {}; struct wo {}; struct rw {};          // access policies

template <std::uintptr_t Addr, class Access = rw>
struct Reg {
    using access = Access;
    static std::uint32_t read() requires(!std::same_as<Access, wo>) {
        return *reinterpret_cast<volatile std::uint32_t*>(Addr);
    }
    static void write(std::uint32_t v) requires(!std::same_as<Access, ro>) {
        *reinterpret_cast<volatile std::uint32_t*>(Addr) = v;
    }
};
}
using UartState = reg::Reg<0x40004004, reg::ro>;

Three ideas are packed in here:

  • The address is a template parameter, an integer. You can't make the pointer constexpr: reinterpret_cast is banned in constant expressions. Casting inside a function is fine — after inlining it's a constant address in an instruction.
  • volatile makes every read() and write() one real load or store, in program order. The device can change a register behind the compiler's back (a status bit) and a write can have side effects (a byte goes out), so the compiler may neither cache, merge nor delete accesses.
  • Access rights are constraints. UartState::write(1) has no viable function, so it's a compile error at the call site — and code can ask requires { R::write(0u); } and get false.

Without volatile the classic busy-wait disappears completely:

Text
while (!(*status & 1)) {}        volatile:            plain pointer:
                                 mov  r2, #0x40004000   bx lr
                             .L4: ldr  r3, [r2, #4]
                                 lsls r3, r3, #31      (no load, no loop:
                                 bpl  .L4               a side-effect-free
                                 bx   lr                infinite loop is UB,
                                                        so it "can't" happen)

2. Fields

A field is Width bits starting at Offset:

CTRL as fields: Field<Ctrl, Offset, Width>
Each field is a mask and a shift. The example value has prescale = 0x0a, mode = 2 (Mode::fast), en = 1. Hover a field for its type.
CTRL as fields: Field<Ctrl, Offset, Width>
Each field is a mask and a shift. The example value has prescale = 0x0a, mode = 2 (Mode::fast), en = 1. Hover a field for its type.
C++
template <class R, unsigned Offset, unsigned Width, class T = std::uint32_t>
struct Field {
    static_assert(Width >= 1 && Offset < 32 && Width <= 32 - Offset);
    static constexpr std::uint32_t low_mask = Width == 32 ? ~0u : (1u << Width) - 1u;
    static constexpr std::uint32_t mask = low_mask << Offset;

    static T read() { return static_cast<T>((R::read() >> Offset) & low_mask); }
    static void write(T v) requires std::same_as<typename R::access, rw> {
        R::write((R::read() & ~mask) | ((to_bits(v) & low_mask) << Offset));
    }
};
ModeF::write(Mode::fast): read, clear, insert, write
One volatile load, three ALU steps, one volatile store. Without the & ~mask, the old mode bits would survive the OR. (Low 16 bits shown.)
ModeF::write(Mode::fast): read, clear, insert, write
One volatile load, three ALU steps, one volatile store. Without the & ~mask, the old mode bits would survive the OR. (Low 16 bits shown.)
  • write is a read-modify-write: read once, clear the field (& ~mask — OR alone can only set bits), insert the value, write once.
  • & low_mask on the value keeps a too-wide value from spilling into the neighbour.
  • Width == 32 is special-cased: 1u << 32 is undefined behaviour. As a constexpr it's a compile error; in a run-time helper it's a bug that the hardware happens to hide (ARM gives 0, x86 gives 1).
  • T can be an enum class — Field<Ctrl, 4, 2, Mode> reads and writes Mode::fast, not 2. Convert with std::to_underlying (C++23).

3. What it costs

Nothing, once optimised. GCC's -O2 output for ModeF::write(m) and for the hand-written mask-and-shift are the same five instructions:

Text
mov  r2, #0x20000000     @ the register's address
ldr  r3, [r2]            @ read
bfi  r3, r0, #4, #2      @ bit-field insert: clear bits 4–5, put m there
str  r3, [r2]            @ write
bx   lr

Reads compile to ldr + ubfx (unsigned bit-field extract). At -O0, though, every layer is a real call (bl Field::write → bl Reg::read …): "zero-cost" abstractions are zero-cost after inlining. Debug firmware that must meet timing is often built with -Og or marks the helpers [[gnu::always_inline]].

Zero cost only after inlining
The same ModeF::write(m): at -O0 every layer is a real call; at -O2 the template machinery disappears into one bfi.
Zero cost only after inlining
The same ModeF::write(m): at -O0 every layer is a real call; at -O2 the template machinery disappears into one bfi.

Each Field::write is a separate volatile read-modify-write. Three field writes are three loads and three stores — the device sees the intermediate values. When that matters, compute the new value once (one load, one store); real libraries offer Ctrl::modify(Enable{1}, Mode{fast}) that folds the masks at compile time.

4. Driving a real peripheral

The CMSDK UART on this board has DATA at +0x0, STATE at +0x4 (bit 0: TX buffer full), CTRL at +0x8 (bit 0: TX enable) and BAUDDIV at +0x10. Whatever UART0 transmits appears in the program's output:

C++
#include <concepts>
#include <cstdint>
#include <cstdio>

namespace reg {
struct ro {}; struct wo {}; struct rw {};
template <std::uintptr_t Addr, class Access = rw>
struct Reg {
    using access = Access;
    static std::uint32_t read() requires(!std::same_as<Access, wo>) {
        return *reinterpret_cast<volatile std::uint32_t*>(Addr);
    }
    static void write(std::uint32_t v) requires(!std::same_as<Access, ro>) {
        *reinterpret_cast<volatile std::uint32_t*>(Addr) = v;
    }
};
template <class R, unsigned Offset, unsigned Width>
struct Field {
    static constexpr std::uint32_t low_mask = Width == 32 ? ~0u : (1u << Width) - 1u;
    static constexpr std::uint32_t mask = low_mask << Offset;
    static std::uint32_t read() { return (R::read() >> Offset) & low_mask; }
    static void write(std::uint32_t v) requires std::same_as<typename R::access, rw> {
        R::write((R::read() & ~mask) | ((v & low_mask) << Offset));
    }
};
}  // namespace reg

namespace uart0 {
using Data = reg::Reg<0x40004000, reg::wo>;
using State = reg::Reg<0x40004004, reg::ro>;
using Ctrl = reg::Reg<0x40004008>;
using BaudDiv = reg::Reg<0x40004010>;
using TxFull = reg::Field<State, 0, 1>;
using TxEnable = reg::Field<Ctrl, 0, 1>;
}  // namespace uart0

void put(const char* s) {
    for (; *s; ++s) {
        while (uart0::TxFull::read()) {}   // wait for room
        uart0::Data::write(static_cast<unsigned char>(*s));
    }
}

int main() {
    uart0::BaudDiv::write(16);
    uart0::TxEnable::write(1);
    put("hello from the UART, through typed registers\n");
    // uart0::State::write(0);   // doesn't compile: State is read-only
    // uart0::Data::read();      // doesn't compile: Data is write-only
    std::printf("CTRL = %u\n", unsigned(uart0::Ctrl::read()));
}

5. Small tools that help

  • std::to_underlying(e) — an enum's value as its underlying type (no guessing uint8_t vs int).
  • std::bit_cast<To>(from) — reinterpret the bytes of a value (a float's bits, a packed struct) without undefined behaviour; it is constexpr, unlike reinterpret_cast.
  • std::byteswap(x) — big-endian bus data to little-endian: one rev.
  • std::endian::native — check before you swap.
std::byteswap(0x11223344): one rev instruction
On a little-endian core the least significant byte comes first in memory; a big-endian bus sends the most significant first. byteswap reverses the order.
std::byteswap(0x11223344): one rev instruction
On a little-endian core the least significant byte comes first in memory; a big-endian bus sends the most significant first. byteswap reverses the order.
C++
#include <bit>
#include <cstdint>
#include <cstdio>
#include <utility>

enum class Mode : std::uint8_t { off, slow, fast, turbo };

int main() {
    static_assert(std::endian::native == std::endian::little);
    constexpr std::uint32_t one = std::bit_cast<std::uint32_t>(1.0f);   // constexpr: fine
    std::printf("%08x %u %08x\n", unsigned(one), unsigned(std::to_underlying(Mode::fast)),
                unsigned(std::byteswap(0x11223344u)));
}

6. Why not bit-fields?

struct Ctrl { uint32_t en : 1; uint32_t : 3; uint32_t mode : 2; } looks like a register map, but the standard leaves the bit order and the width of each volatile access to the implementation, and every assignment is an invisible read-modify-write. Shifts and masks on a volatile uint32_t say exactly which bits and exactly which accesses — and with templates, you write them once.

Summary

  • Address as an integer template parameter; cast at the point of access.
  • volatile for every access: one source access = one bus access.
  • Access rules as constraints, so misuse fails to compile and can be detected with requires.
  • Fields: low_mask, mask, read = shift then mask, write = read, clear, insert, write. Special-case full width.
  • Verify "zero-cost" in the compiler explorer: the template version and the hand-written one should be identical at -O2.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Using classes, base classes and templates to give register blocks a type-safe interface with zero overhead.

Compile-time register/field abstractions in C++20 for embedded — type-safe and zero-cost.

Modern C++ techniques (templates, constexpr, strong types) applied to embedded register access.

C structs as register maps and the CMSIS conventions vendors use.