A UART at 115200 baud moves a byte every ~87 µs. A Cortex-M4 at 100 MHz runs about 8700 instructions in that time. A driver that polls — spin until the transmit buffer is free, write a byte, repeat — throws all of that away:
The interrupt-driven version hands the UART one byte, goes back to useful work, and is called back when that byte has gone out. This skill builds that driver and the patterns around it: ring buffers between thread and interrupt, starting and stopping an interrupt source, backpressure, and DMA.
1. The hardware contract
The board's UART (an Arm CMSDK APB UART, mps2::uart0() at 0x40004000)
has five registers:
Three details decide whether your driver works:
- The TX interrupt fires when a byte has finished sending. An idle UART has nothing in flight, so enabling the TX interrupt alone produces nothing. Whoever starts transmission must write the first byte.
intstatusis write-one-to-clear (W1C). Writing1to bit 0 acknowledges; writing0does nothing. An ISR that doesn't acknowledge returns into the same interrupt immediately.ctrlis shared. Bit 2 lives next to the transmitter and receiver enables; change it with a read-modify-write.
The TX interrupt is external interrupt 1 (mps2::irq::uart0_tx); its
handler is extern "C" void UART0_TX_IRQHandler(), a name the startup code's
vector table already points at (unhandled interrupts spin in a default
handler).
Here is one interrupt per transmitted byte, counted:
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
volatile int tx_irqs = 0;
extern "C" void UART0_TX_IRQHandler() {
mps2::uart0().intstatus = 1; // acknowledge (W1C)
tx_irqs = tx_irqs + 1;
}
int main() {
auto& u = mps2::uart0();
u.bauddiv = 16;
u.ctrl = 1 | 4; // TX on, TX interrupt on
mps2::nvic_enable(mps2::irq::uart0_tx);
for (volatile int i = 0; i < 1000; i = i + 1) {}
std::printf("idle UART, interrupt enabled: %d interrupts\n", tx_irqs);
for (const char* p = "abc\n"; *p; ++p) u.data = static_cast<unsigned char>(*p);
for (volatile int i = 0; i < 1000; i = i + 1) {}
std::printf("after 4 bytes: %d interrupts\n", tx_irqs);
mps2::nvic_disable(mps2::irq::uart0_tx);
u.ctrl = 1;
}(qemu's UART transmits instantly — real ones take the full byte time, and the
state TX-full bit matters. Write the driver as if it were real.)
2. A ring buffer between thread and interrupt
write() runs in thread mode and produces bytes; the ISR consumes them.
That's a single-producer, single-consumer queue, and a ring buffer is the
classic shape:
buf[head % N]). 16 slots drawn; the build uses a power of two too.Use free-running unsigned indices and reduce them only when indexing:
std::array<std::uint8_t, 64> buf;
std::uint32_t head = 0, tail = 0; // never reset, never compared modulo
bool full() { return head - tail == buf.size(); }
bool empty() { return head == tail; }
void push(std::uint8_t b) { buf[head % 64] = b; ++head; }head - tail is the number of queued items even after head wraps past
2³² — unsigned subtraction is modular. It also distinguishes full from
empty without wasting a slot. Make the capacity a power of two: % 64 is an
and with 63; % 60 is a multiply by a reciprocal, a shift and a subtract
(check the asm-count item).
3. Starting and stopping the interrupt
The transmitter has two states, and the TX-interrupt enable follows them:
- Start in
write(): if idle and there's data, setctrlbit 2 and write the first byte todatayourself — the kick. - Run in the ISR: acknowledge, then send one byte.
- Stop in the ISR when the queue is empty: clear
ctrlbit 2. On most real UARTs the TX interrupt means "transmit buffer empty" and is level-triggered: left enabled with nothing to send, it fires forever and main never runs again — an interrupt storm.
4. The race in write()
write() reads "is the transmitter active?" and the ISR may change that
answer at any instruction boundary:
Make the check-and-act atomic with respect to the ISR by masking interrupts
for those few instructions — and restore the previous mask rather than
unconditionally re-enabling, so write() can be called with interrupts
already off:
std::uint32_t pm = mps2::primask(); // 1 if interrupts were already masked
mps2::irq_disable(); // cpsid i
/* … queue bytes, maybe kick … */
if (!pm) mps2::irq_enable(); // cpsie iThe ISR needs no lock: nothing preempts it in this program (more on
priorities in the critical-sections skill). Indices shared with the ISR are
volatile here so the compiler re-reads them; std::atomic with acquire/release
is the portable alternative.
5. Backpressure
When the queue is full, write() must do something:
| policy | how | good for | never from |
|---|---|---|---|
| drop & report | accept what fits, return the count | logs, best-effort output | — |
| block | while (full()) wfi(); |
protocols that must not lose data | an ISR (it would wait for itself) |
| overwrite oldest | advance tail too |
latest-value telemetry | byte streams with framing |
The build task uses drop & report; callers that can't lose data loop.
Formatting without the heap
printf-style output into a fixed buffer, then queue the buffer:
#include <cstdio>
#include <format>
int main() {
char line[48];
auto r = std::format_to_n(line, sizeof line - 1, "t={:>6} adc={:#06x}\n", 1234, 0x3ff);
*r.out = '\0';
std::fputs(line, stdout);
int n = std::snprintf(line, sizeof line, "v=%d.%02d V\n", 3, 30);
std::printf("%s(%d chars)\n", line, n);
}format_to_n and snprintf write at most the given size and report how much
would have been written, so truncation is detectable. Neither allocates.
6. Timer-driven periodic work
A hardware timer turns "every 1 ms" into an interrupt. The CMSDK timer counts
value down from reload at the system clock and interrupts at zero (IRQ 8,
TIMER0_IRQHandler). The ISR does the minimum — sets a flag or bumps a
counter — and the main loop does the work:
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
volatile unsigned ticks = 0;
extern "C" void TIMER0_IRQHandler() {
mps2::timer0().intstatus = 1; // acknowledge
ticks = ticks + 1;
}
int main() {
auto& t = mps2::timer0();
t.reload = 2000 - 1; // every 2000 clock cycles
t.value = t.reload;
t.ctrl = 1 | 8; // enable, interrupt enable
mps2::nvic_enable(mps2::irq::timer0);
unsigned handled = 0;
while (handled < 5) {
if (ticks != handled) { // an event to process
++handled;
std::printf("tick %u\n", handled);
}
}
t.ctrl = 0;
mps2::nvic_disable(mps2::irq::timer0);
}Under qemu with -icount time is a function of instructions executed, so
the number of ticks a given program sees is reproducible run after run —
handy for tests, impossible on real hardware.
7. Event-driven state machines
Interrupts produce events; a state machine consumes them in thread mode:
enum class State { idle, sending, waiting_ack };
enum class Event { tx_done, timeout, ack };
State step(State s, Event e) {
switch (s) {
case State::sending: return e == Event::tx_done ? State::waiting_ack : s;
case State::waiting_ack: return e == Event::ack ? State::idle
: e == Event::timeout ? State::sending : s;
case State::idle: return s;
}
return s;
}step. Events not shown on an edge leave the state unchanged.Keeping transitions in one pure function makes them testable on the host and keeps ISRs tiny: they only enqueue events.
8. DMA, conceptually
A DMA controller moves memory to a peripheral without the CPU: you give it a descriptor — source, destination, count, flags — and get one interrupt when the whole buffer is done, instead of one per byte. qemu's MPS2 board has no DMA engine, so here is the shape with a fake one in RAM:
#include <cstdint>
#include <cstdio>
struct DmaDescriptor { // what real controllers read from memory
const std::uint8_t* src;
volatile std::uint32_t* dst; // a peripheral data register: not incremented
std::uint32_t count;
std::uint32_t flags; // bit0: interrupt when done
};
volatile std::uint32_t fake_uart_data;
int dma_irqs = 0;
void fake_dma_run(const DmaDescriptor& d) { // what the hardware does
for (std::uint32_t i = 0; i < d.count; ++i) *d.dst = d.src[i];
if (d.flags & 1) ++dma_irqs; // one completion interrupt
}
int main() {
static const std::uint8_t msg[] = "sixteen bytes!!\n";
DmaDescriptor d{msg, &fake_uart_data, 16, 1};
fake_dma_run(d);
std::printf("last byte %d, completion interrupts %d\n", static_cast<int>(fake_uart_data), dma_irqs);
}The rules carry over from the ring buffer: the buffer must stay alive and unmodified until the completion interrupt (lifetime!), the CPU must not cache stale copies of what the DMA wrote (on cores with data caches: clean/invalidate — the M4 has none), and descriptors are typically chained so the next buffer starts without a gap.
Checklist
- The ISR acknowledges first (W1C: write 1).
- Whoever starts the transmitter sends the first byte.
- The TX interrupt is disabled when there's nothing to send.
- Shared CTRL bits change by read-modify-write.
- Thread-mode check-and-act on shared state runs with interrupts masked; PRIMASK is restored, not forced on.
- Capacity is a power of two; indices free-run.
- Each test or program disables the interrupt sources it enabled.