Chapter 15: MMIO with Volatile
On most systems a device is controlled through memory-mapped I/O (MMIO): its registers appear at fixed physical addresses, and a load or store to one of those addresses talks to the device instead of to RAM. Reading a UART's status register asks the hardware a question; writing its data register sends a byte. The compiler must therefore treat these accesses as side effects, not as ordinary memory traffic.
This chapter covers the two MMIO tools in @mode(freestanding):
- the
Volatile[T]handle, created withVolatile[T].at(addr)and accessed with.read()and.write(v); - the
volatile borrowparameter, for functions that work on a whole register block;
and then the three things volatile does not do: it does not make a read-modify-write atomic, it does not order your accesses against the CPU's own reordering, and it does not exist as an @volatile annotation.
Why registers need volatile
An optimizing compiler is allowed to treat ordinary memory as if only your program can change it. It may:
- keep the result of the first load in a register and skip the second load;
- drop a store that is overwritten before anything reads it;
- hoist a load out of a loop whose body never writes that location;
- reorder or merge accesses to different addresses.
Each of these is wrong for a device register. A status bit changes because the hardware changed it, a write to a command register starts an operation even if you write the same register again a moment later, and two adjacent byte registers must not be merged into one 16-bit store. A volatile access is one the compiler must perform exactly as written: once, at the declared width, in program order relative to other volatile accesses (Language Reference §3-3.8, §3-3.9).
Volatile[T].at(addr)
Volatile[T].at(addr) makes a handle for the register of type T at address addr. .write(v) performs one volatile store of size_of[T]() bytes; .read() performs one volatile load of the same width. No import is needed.
The 16550-compatible UART on QEMU's RISC-V virt machine is a good first device: its registers are one byte each, starting at 0x1000_0000. The transmit register is at offset 0 and the line status register at offset 5, where bit 5 means "ready for the next byte".
@mode(freestanding)
# NS16550A UART on QEMU `virt` (riscv64).
const UART0: uint = 0x1000_0000
const UART_THR: uint = 0 # transmit holding register (write)
const UART_LSR: uint = 5 # line status register (read)
const LSR_THR_EMPTY: uint8 = 0x20u8
func uartPutc(c: uint8): void {
let lsr = Volatile[uint8].at(UART0 + UART_LSR)
let thr = Volatile[uint8].at(UART0 + UART_THR)
while (((lsr.read() as uint8) & LSR_THR_EMPTY) == 0u8) { }
thr.write(c)
}
func uartPuts(s: string): void {
for (c in s) {
uartPutc(c as uint8)
}
}
Listing 15-1: Polling a UART through two Volatile[uint8] handles
The while loop reads the status register on every iteration; without volatile, the compiler could load it once and spin forever on a stale value.
A few practical points:
- Keep addresses as
constintegers and build the handle where you use it. A handle holds only the address (the element type is part of its type), so creating one where you use it is cheap. A module-levellet LSR = Volatile[uint8].at(...)works too, but it needs the module initializer to have run before the first use; constants never do. read()is typedintby the 1.25 checker, whateverTis; the load itself has the width ofT. Convert withas uint8(or the register's width) before you mix the value with typed constants, as Listing 15-1 does.- x86's COM1 at
0x3F8is not MMIO. It is an I/O port, reached with thein/outinstructions (chapter 14). Wrapping0x3F8in aVolatilewrites to RAM at that address and prints nothing. The MMIO examples in this chapter use RISC-V and Arm devices for that reason.
There is no @volatile
C programmers write volatile on a type and Rust programmers reach for read_volatile; older Loup material showed an @volatile annotation on a parameter. A reader who has seen any of these will write something like this:
@mode(freestanding)
@repr(C)
struct Ns16550 {
var data: uint8;
var ier: uint8;
var fcr: uint8;
var lcr: uint8;
var mcr: uint8;
var lsr: uint8;
}
func uartPut(@volatile borrow uart: Ns16550, c: uint8): void {
while ((uart.lsr & 0x20u8) == 0u8) { }
uart.data = c
}
error[E0001]: expected identifier
--> src/main.lpfree:13:14
|
13 | func uartPut(@volatile borrow uart: Ns16550, c: uint8): void {
| ^
|
error[E0002]: expected `{`
--> src/main.lpfree:13:14
|
13 | func uartPut(@volatile borrow uart: Ns16550, c: uint8): void {
| ^
|
error[E0001]: expected identifier
--> src/main.lpfree:13:15
|
13 | func uartPut(@volatile borrow uart: Ns16550, c: uint8): void {
| ^^^^^^^^
|
error[E0001]: expected expression
--> src/main.lpfree:16:1
|
16 | }
| ^
|
The parser stops at the @, because an annotation cannot appear inside a parameter list, and the rest of the function cascades into more errors. (Chapter 11 met the same mistake.) volatile is a keyword that precedes borrow, with no @:
func uartPut(volatile borrow uart: Ns16550, c: uint8): void {
while ((uart.lsr & 0x20u8) == 0u8) { }
uart.data = c
}
Listing 15-2: The volatile borrow parameter form
The section on volatile borrow below explains how such a function gets called.
Register width and byte order
A Volatile[T] access touches exactly size_of[T]() bytes, so a Volatile[uint8] store never disturbs the neighbouring register and a Volatile[uint32] load is one 32-bit transaction (§3-3.9, "Access width").
For anything wider than a byte there is a second question: in which byte order does the device expect the value? A little-endian device on a little-endian CPU needs no swap, but the same driver compiled for a big-endian target would silently byte-swap every register. Loup asks you to say which order the device uses. Here is the CMSDK UART of Arm's MPS3 AN547 board (Cortex-M55, which QEMU emulates as mps3-an547), written without an order:
@mode(freestanding)
# CMSDK APB UART0 on MPS3 AN547.
const CMSDK_UART0: uint = 0x4930_3000
func uartEnableTx(): void {
let ctrl: Volatile[uint32] = Volatile[uint32].at(CMSDK_UART0 + 0x08)
ctrl.write(1u32)
}
warning[E_VOLATILE_ENDIAN_UNDECLARED]: E_VOLATILE_ENDIAN_UNDECLARED: plain `Volatile[uint32]` in freestanding declares no byte order — a host-endian access over a fixed-endian device register can silently byte-swap. Declare the order with `Volatile[uint32, Big|Little|Native]` (or use a `Volatile.view` over an `@interchange` block, §3-3.9); single-byte access is exempt
--> src/main.lpfree:7:15
|
7 | let ctrl: Volatile[uint32] = Volatile[uint32].at(CMSDK_UART0 + 0x08)
| ^^^^^^^^^^^^^^^^^
|
It is a warning, so the program still builds, but the fix is one word: the second type argument names the device's byte order. Little and Big insert a swap when they differ from the target's order; Native says the device always matches the CPU.
# CMSDK APB UART0 on MPS3 AN547: DATA +0x0, STATE +0x4, CTRL +0x8, BAUDDIV +0x10.
const CMSDK_UART0: uint = 0x4930_3000
const STATE_TX_FULL: uint32 = 0x1u32
const CTRL_TX_ENABLE: uint32 = 0x1u32
func uartInit(): void {
Volatile[uint32, Little].at(CMSDK_UART0 + 0x10).write(16u32) # baud divider
Volatile[uint32, Little].at(CMSDK_UART0 + 0x08).write(CTRL_TX_ENABLE)
}
func uartPutc(c: uint32): void {
let state = Volatile[uint32, Little].at(CMSDK_UART0 + 0x04)
while (((state.read() as uint32) & STATE_TX_FULL) != 0u32) { }
Volatile[uint32, Little].at(CMSDK_UART0 + 0x00).write(c)
}
Listing 15-3: 32-bit little-endian registers on a Cortex-M UART
The warning appears only where Volatile[uint32] is written as a type; the expression Volatile[uint32].at(...) on its own is not checked. Declare the order in both places.
Register blocks
A device usually has a block of registers, and it is tempting to describe the block as a struct and wrap the whole struct:
@mode(freestanding)
@repr(C)
struct Ns16550 {
var data: uint8;
var ier: uint8;
var fcr: uint8;
var lcr: uint8;
var mcr: uint8;
var lsr: uint8;
}
func uartReady(): bool {
let regs = Volatile[Ns16550].at(0x1000_0000)
return (regs.read().lsr & 0x20u8) != 0u8
}
error[E0001]: type 'int' does not support member access
--> src/main.lpfree:15:13
|
15 | return (regs.read().lsr & 0x20u8) != 0u8
| ^^^^^^^^^^^^^^^
|
read() on a Volatile returns an integer, and the integer has no field lsr. A struct-typed Volatile is not a register block: one volatile access cannot read six registers, and some of those registers change state when they are read (reading the receive register consumes a byte). Written as a type annotation, Volatile[Ns16550] is rejected outright with E_VOLATILE_ENDIAN_STRUCT.
There are three working shapes, each with its own use:
- One handle per register, built from the block's base address plus an offset constant (Listings 15-1 and 15-3). This is the right default: each access is explicit and touches one register.
- A
volatile borrowparameter of a@repr(C)struct type, when a group of functions works on the same block (next section). Volatile.view[I].at(addr)over an@interchangestruct, when you want to read or write a whole record as one snapshot with a declared byte order. The view reads the record's bytes with volatile accesses and unpacks each field with the struct's declared endianness (§3-3.9).
The third form has a limit that decides where it fits: in 1.25 the view reads and writes one byte at a time. Many device registers must be accessed at their full width — the virtio-mmio registers, for example, are defined for 32-bit accesses only — so do not use a view on registers. It fits memory that a device shares with the CPU: descriptors and mailboxes in RAM that the device reads and writes by DMA. A network card's receive descriptor is a typical case: sixteen little-endian bytes that the card fills in when a frame arrives.
# A legacy e1000 receive descriptor: 16 bytes in RAM, written back by the NIC.
@interchange(endianness: Little)
struct RxDescriptor {
@field(encoding: Binary, length: 8) pub var bufferAddr: uint64 = 0;
@field(encoding: Binary, length: 2) pub var length: uint16 = 0;
@field(encoding: Binary, length: 2) pub var checksum: uint16 = 0;
@field(encoding: Binary, length: 1) pub var status: uint8 = 0;
@field(encoding: Binary, length: 1) pub var errors: uint8 = 0;
@field(encoding: Binary, length: 2) pub var special: uint16 = 0;
}
const RXD_STATUS_DD: uint8 = 0x01u8 # descriptor done: the NIC has written it back
# The length of the received frame, or 0 while the NIC still owns the descriptor.
func receivedLength(desc: uint): uint16 {
let first: RxDescriptor = Volatile.view[RxDescriptor].at(desc).read()
if ((first.status & RXD_STATUS_DD) == 0u8) {
return 0u16
}
# DD is written last, so a second read after seeing it gets the whole write-back.
let done: RxDescriptor = Volatile.view[RxDescriptor].at(desc).read()
return done.length
}
Listing 15-4: Reading a DMA descriptor as one @interchange snapshot
Volatile matters here even though the descriptor is ordinary RAM: the card writes it behind the compiler's back, so every read must really happen. A snapshot is not atomic — the card may write the descriptor while the CPU is part-way through reading it — which is why the function reads again after it has seen the "done" bit. On a weakly ordered CPU, put a read barrier between the two reads (see "Volatile orders the compiler, not the CPU" below).
volatile borrow parameters
A volatile borrow parameter refers to device memory: every read and write of its fields through that parameter is a volatile access, performed in program order and never merged or removed. The struct describes the register layout, so it must be @repr(C) (or @repr(packed)) to match the datasheet.
volatile is valid only on a borrow parameter. A by-value parameter would hand the callee a copy of the registers, and a copy that volatile faithfully guards is not the device:
@mode(freestanding)
@repr(C)
struct Ns16550 {
var data: uint8;
var ier: uint8;
var fcr: uint8;
var lcr: uint8;
var mcr: uint8;
var lsr: uint8;
}
func uartPut(volatile uart: Ns16550, c: uint8): void {
uart.data = c
}
error[E_VOLATILE_NOT_BORROW]: `volatile` parameter `uart` of function `uartPut` has no borrow modifier — `volatile` is valid only on a `borrow` / `borrow[~name]` parameter (§3-3.8): the guarantee covers accesses through a REFERENCE to the device; write `volatile borrow uart: …` (E_VOLATILE_NOT_BORROW)
--> src/main.lpfree:13:14
|
13 | func uartPut(volatile uart: Ns16550, c: uint8): void {
| ^^^^^^^^^^^^^^^^^^^^^^
|
The message spells out the fix: volatile borrow uart: Ns16550.
The other rule is about where a volatile reference comes from. Loup has no way to turn an integer into a volatile borrow, and passing an ordinary struct value to a volatile parameter is rejected, because the callee would be writing to that value, not to the device:
@mode(freestanding)
@repr(C)
struct Ns16550 {
var data: uint8;
var ier: uint8;
var fcr: uint8;
var lcr: uint8;
var mcr: uint8;
var lsr: uint8;
}
func uartPut(volatile borrow uart: Ns16550, c: uint8): void {
while ((uart.lsr & 0x20u8) == 0u8) { }
uart.data = c
}
func hello(): void {
var regs = Ns16550(data = 0u8, ier = 0u8, fcr = 0u8, lcr = 0u8, mcr = 0u8, lsr = 0u8)
uartPut(regs, 0x48u8)
}
error[E_VOLATILE_CONTRACT_ERASED]: argument at position 0 is not a volatile-tagged binding, but the callee parameter is `volatile` — passing a non-volatile value erases the MMIO contract (E_VOLATILE_CONTRACT_ERASED)
--> src/main.lpfree:20:5
|
20 | uartPut(regs, 0x48u8)
| ^^^^^^^^^^^^^^^^^^^^^
|
So no Loup code can start a volatile call chain. The first function in the chain is entered by the platform — a boot trampoline, an interrupt vector, or a few lines of assembly that load the device address into the first argument register — and is kept in the image with @section, because nothing in Loup calls it. Inside the chain, a volatile function passes its parameter on to another volatile function, and the callee receives the same reference, never a copy:
# The 16550 register file as the device lays it out: one byte per register.
@repr(C)
struct Ns16550 {
var data: uint8; # +0 THR on write, RBR on read
var ier: uint8; # +1 interrupt enable
var fcr: uint8; # +2 FIFO control
var lcr: uint8; # +3 line control
var mcr: uint8; # +4 modem control
var lsr: uint8; # +5 line status
}
const LSR_THR_EMPTY: uint8 = 0x20u8
func uartPut(volatile borrow uart: Ns16550, c: uint8): void {
while ((uart.lsr & LSR_THR_EMPTY) == 0u8) { }
uart.data = c
}
func uartPuts(volatile borrow uart: Ns16550, s: string): void {
for (c in s) {
uartPut(uart, c as uint8)
}
}
# Entered from the trampoline below with a0 = the UART's address.
@section(".text.drivers")
func uartDriverMain(volatile borrow uart: Ns16550): void {
uart.ier = 0u8 # polled mode: no UART interrupts
uartPuts(uart, "uart driver up\n")
}
# The platform end of the chain: load the device address, tail-call the driver.
@naked
@unsafe("naked: RISC-V LP64 ABI; a0 carries the UART base; the driver returns to our caller")
func enterUartDriver(): void {
asm {
"li a0, 0x10000000"
"tail uartDriverMain"
}
}
Listing 15-5: A volatile borrow driver entered from an assembly trampoline
Kernel code calls enterUartDriver() like any other function. @naked and @unsafe are covered in chapter 16.
Which form to use? Volatile[T] handles are the everyday tool: any function can make one from an address. volatile borrow suits a driver whose functions all operate on one register block handed over by the platform, and it lets those functions read like ordinary field accesses.
A read-modify-write is not atomic
Setting one bit in a shared control register takes three steps: read the register, OR in the bit, write it back. Volatile makes each access happen, but it does nothing to keep another writer out between the read and the write. If an interrupt handler (or another CPU) updates the same register in that window, its change is lost when you write back your stale copy.
The Language Reference specifies a modify method that packages the three steps, and a reader who has seen it will try it:
@mode(freestanding)
# SiFive GPIO controller (QEMU `sifive_u`): output_val register at +0x0C.
const GPIO_BASE: uint = 0x1006_0000
const GPIO_OUTPUT: uint = 0x0C
func setPin(pin: uint32): void {
let out = Volatile[uint32, Little].at(GPIO_BASE + GPIO_OUTPUT)
out.modify(func(v: uint32): uint32 => v | (1u32 << pin))
}
error[E0001]: type 'Volatile' has no method 'modify'
--> src/main.lpfree:9:5
|
9 | out.modify(func(v: uint32): uint32 => v | (1u32 << pin))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
⚠ Planned.
Volatile[T].modify(f)andVolatile[T].addr()(Language Reference §3-3.9) are not implemented in Loup 1.25; the handle hasat,readandwrite. Even whenmodifylands it will be a plain read followed by a write, not an atomic operation.
Write the three steps yourself, and decide what keeps other writers out. On a single core, the only other writer is an interrupt handler, so masking interrupts around the update is enough. InterruptGuard (chapter 16) masks them for the length of a with block and restores the previous state afterwards:
# SiFive GPIO controller (QEMU `sifive_u`): output_val register at +0x0C.
const GPIO_BASE: uint = 0x1006_0000
const GPIO_OUTPUT: uint = 0x0C
func setPin(pin: uint32): void {
with (g = InterruptGuard.disable()) {
let out = Volatile[uint32, Little].at(GPIO_BASE + GPIO_OUTPUT)
let current = out.read() as uint32
out.write(current | (1u32 << pin))
}
}
Listing 15-6: A read-modify-write protected from interrupt handlers
On a multi-core system, masking interrupts on one CPU does not stop another CPU; you need a lock around every writer of that register (chapters 19 and 24). The best fix is often in the hardware: many controllers provide separate set and clear registers, where writing a 1 to bit n sets or clears pin n and leaves the others alone. A single write to such a register needs no lock at all.
Volatile orders the compiler, not the CPU
Volatile accesses stay in program order in the instructions the compiler emits. The CPU may still reorder memory operations, and weakly ordered architectures (AArch64, RISC-V) do. The classic failure is a DMA doorbell: the driver writes a descriptor into ordinary RAM, then writes a device register telling the device to fetch it. If the CPU makes the doorbell write visible before the descriptor writes, the device fetches a half-written descriptor.
The specified tool is a hardware fence from native.atomic. In 1.25 that module is a host.native module, so a freestanding file cannot import it:
@mode(freestanding)
from native.atomic import (hwFence);
const NIC_DOORBELL: uint = 0x1000_2050
func ringDoorbell(slot: uint32): void {
hwFence()
Volatile[uint32, Little].at(NIC_DOORBELL).write(slot)
}
error[E0001]: `@mode(freestanding)` module cannot import `@mode(host.native)` module
--> src/main.lpfree:3:1
|
3 | from native.atomic import (hwFence);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
⚠ Planned.
native.atomic.hwFence(MemoryOrder.DeviceRelease)and theMemoryOrderenum (Language Reference §3-3.20) are not available in freestanding code in 1.25. The shippednative.atomicmodule is x86-64host.nativecode withhwFence(),hwFenceAcquire(),hwFenceRelease()andcompilerFence().
Until then, write the barrier instruction for your architecture in an asm block. RISC-V's fence w, o orders earlier memory writes before later device output; AArch64's dmb oshst (or the stronger dsb st) does the same job:
const NIC_DOORBELL: uint = 0x1000_2050
# Descriptor writes (ordinary memory) must be visible before the doorbell.
func ringDoorbell(slot: uint32): void {
asm { "fence w, o" clobbers: ["memory"] } # RISC-V
Volatile[uint32, Little].at(NIC_DOORBELL).write(slot)
}
Listing 15-7: A device-ordering barrier before an MMIO doorbell write (RISC-V)
The "memory" clobber also stops the compiler from moving ordinary memory accesses across the barrier. For ordering between CPUs rather than between a CPU and a device, use the atomic intrinsics and atomicFence in chapter 19. On x86_64 the CPU does not reorder one store with another, so before an uncached doorbell write the compiler barrier alone (asm { "" clobbers: ["memory"] }) is enough.
Summary
- MMIO registers need volatile accesses: exactly as written, at the declared width, in program order.
Volatile[T].at(addr)makes a handle;.read()and.write(v)are single accesses ofsize_of[T]()bytes. Build handles fromconstaddresses where you use them; convertread()results to the register width withas.- There is no
@volatile. The parameter form isvolatile borrow name: Regs, valid only on aborrow(E_VOLATILE_NOT_BORROW); a plain value cannot be passed to it (E_VOLATILE_CONTRACT_ERASED), so the platform starts the chain. - Declare the byte order of multi-byte registers:
Volatile[uint32, Little](E_VOLATILE_ENDIAN_UNDECLAREDotherwise). - A struct-typed
Volatileis not a register block. Use one handle per register, avolatile borrowof a@repr(C)struct, or — for byte-addressable shared memory such as DMA descriptors —Volatile.view[I]over an@interchangestruct, which reads one byte at a time. - A read-modify-write is three steps, never atomic.
modifyandaddrare planned. Mask interrupts or take a lock, or use the device's set/clear registers. - Volatile does not stop the CPU from reordering.
hwFenceis planned for freestanding; use the architecture's barrier instruction inasm. - x86 COM1 is port I/O, not MMIO.
Next: interrupts and naked functions.