Memory-Mapped I/O¶
A device driver accesses its hardware through registers that the platform maps at fixed physical addresses. In C, this is conventionally done by casting an integer address to a pointer to a register structure, a pattern that the compiler cannot check: nothing relates the address to the structure supposedly located there, and nothing prevents the same address from being cast to two different types in two different places. Termina supports memory-mapped I/O directly, with located fields that carry their type in the class definition and receive their address when the system is assembled.
Located fields¶
A located field is declared inside a resource class with the loc keyword
before its type. The class below is a driver for a UART whose device exposes
three 32-bit registers:
void CUartDrv__enable(const termina__event_t * const termina__ev,
void * const termina__this) {
CUartDrv * self = (CUartDrv *)termina__this;
termina__lock_t termina__lock = termina__resource__lock(&termina__ev->owner,
&self->_lock_type);
self->registers->ctrl = 1U;
termina__resource__unlock(&termina__ev->owner, &self->_lock_type,
termina__lock);
return;
}
Within the class, a located field is read and written like any ordinary field
of a structure type. Reading or writing a device register is an interaction
with the hardware and not a plain memory operation. In the generated code, the
field therefore becomes a pointer to the mapped structure, declared volatile
so that the C compiler performs every access the program writes, in the order
it writes them, rather than caching or eliminating accesses whose effect it
cannot see.
Binding the address¶
The class does not say where the registers live; that is a property of each
instance, fixed when the application is assembled. In the instance
declaration, a located field is bound to its physical address with the @
operator instead of being initialized with a value:
The address is recorded during system initialization, before any task or handler runs:
Because the address belongs to the instance and not to the class, a board with two UARTs is served by two instances of the same driver class, each bound to its own base address.
The address of a device is given by the memory map of the target platform,
so a class with located fields is meaningful on the embedded targets, where
the linker places nothing at the device addresses. The posix-gcc platform
accepts the declarations, which keeps a project transpilable during
development, but dereferencing an arbitrary address on a host system has no
useful effect.
Located arrays¶
A located field may also be an array, which suits devices that expose a region of memory rather than a record of registers, for example a bank of RAM, a frame buffer, or a DMA window. The driver below covers a 4-KiB bank:
void CMemBank__write_byte(const termina__event_t * const termina__ev,
void * const termina__this, const size_t offset,
const uint8_t value) {
CMemBank * self = (CMemBank *)termina__this;
termina__lock_t termina__lock = termina__resource__lock(&termina__ev->owner,
&self->_lock_type);
if (offset < 4096U) {
self->memory[offset] = value;
}
termina__resource__unlock(&termina__ev->owner, &self->_lock_type,
termina__lock);
return;
}
The instance binds the array to the base address of the region,
memory @ 0x40100000, and accesses to it keep the bounds checking that
applies to every Termina array, so a driver cannot access memory outside the
region its type declares. In write_byte the if keeps offset below 4096,
and the transpiler writes the access without a check; an index that the code
does not bound in this way goes through termina__check__array_index.
Drivers as resources¶
A device is shared state: its registers are read and modified by whichever tasks and handlers use the device, and those accesses must not interleave. For this reason, a located field can only appear in a resource class. Because the driver is a resource, the transpiler applies the protection analysis described in the reactive model: a driver used by several tasks is serialized with a priority-ceiling mutex, and one shared between a task and an interrupt handler is protected by disabling the interrupt.
A complete driver typically combines the elements of this chapter with two
that earlier chapters introduced: a handler wired to the device's hardware
interrupt (irq_N), which performs the immediate part of the work, and a
task that the handler notifies through a channel for the rest. The UART
driver of the on-board software examples follows this structure: the
interrupt handler reads the received bytes out of the device through the
driver resource and forwards them to a protocol task for processing.