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:
#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
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_castis banned in constant expressions. Casting inside a function is fine — after inlining it's a constant address in an instruction. volatilemakes everyread()andwrite()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 askrequires { R::write(0u); }and getfalse.
Without volatile the classic busy-wait disappears completely:
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:
Mode::fast), en = 1. Hover a field for its type.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));
}
};& ~mask, the old mode bits would survive the OR. (Low 16 bits shown.)writeis a read-modify-write: read once, clear the field (& ~mask— OR alone can only set bits), insert the value, write once.& low_maskon the value keeps a too-wide value from spilling into the neighbour.Width == 32is special-cased:1u << 32is undefined behaviour. As aconstexprit'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).Tcan be anenum class—Field<Ctrl, 4, 2, Mode>reads and writesMode::fast, not 2. Convert withstd::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:
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 lrReads 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]].
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:
#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 guessinguint8_tvsint).std::bit_cast<To>(from)— reinterpret the bytes of a value (a float's bits, a packed struct) without undefined behaviour; it isconstexpr, unlikereinterpret_cast.std::byteswap(x)— big-endian bus data to little-endian: onerev.std::endian::native— check before you swap.
byteswap reverses the order.#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.
volatilefor 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.