CodeRigor

FREE LESSON

Interrupt-driven I/O

Drivers that don't busy-wait: an interrupt-driven UART transmitter fed from a ring buffer, starting and stopping the TX interrupt, backpressure, formatting without the heap, timer-driven work, event-driven state machines, and DMA as a concept.

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:

Polling burns the wait; interrupts give it back
Each byte takes ~87 µs on the wire either way. Polling spends that time spinning on STATE; the interrupt version spends a few µs in the ISR per byte and does other work in between.
Polling burns the wait; interrupts give it back
Each byte takes ~87 µs on the wire either way. Polling spends that time spinning on STATE; the interrupt version spends a few µs in the ISR per byte and does other work in between.

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:

UART0's five registers
Hover a register for its bits. The driver uses ctrl bit 2 (TX irq enable) and intstatus bit 0 (TX irq pending, write 1 to clear).
UART0's five registers
Hover a register for its bits. The driver uses ctrl bit 2 (TX irq enable) and intstatus bit 0 (TX irq pending, write 1 to clear).

Three details decide whether your driver works:

  1. 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.
  2. intstatus is write-one-to-clear (W1C). Writing 1 to bit 0 acknowledges; writing 0 does nothing. An ISR that doesn't acknowledge returns into the same interrupt immediately.
  3. ctrl is 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:

C++
#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:

write() produces at head, the ISR consumes at tail
queued = head − tail, with free-running unsigned indices reduced only when indexing (buf[head % N]). 16 slots drawn; the build uses a power of two too.
write() produces at head, the ISR consumes at tail
queued = head − tail, with free-running unsigned indices reduced only when indexing (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:

C++
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:

The transmitter's two states
Whoever moves idle → active sends the first byte (the kick). While active, each TX interrupt acknowledges and sends the next byte; the one that finds the queue empty disables the TX interrupt.
The transmitter's two states
Whoever moves idle → active sends the first byte (the kick). While active, each TX interrupt acknowledges and sends the next byte; the one that finds the queue empty disables the TX interrupt.
  • Start in write(): if idle and there's data, set ctrl bit 2 and write the first byte to data yourself — the kick.
  • Run in the ISR: acknowledge, then send one byte.
  • Stop in the ISR when the queue is empty: clear ctrl bit 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:

Check-then-act, split by the ISR
write() decided the transmitter was active, so it didn't kick. The ISR drained the queue and went idle in between. The new byte sits in the queue and nobody will ever send it.
Check-then-act, split by the ISR
write() decided the transmitter was active, so it didn't kick. The ISR drained the queue and went idle in between. The new byte sits in the queue and nobody will ever send it.

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:

C++
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 i

The 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:

C++
#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:

C++
#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:

C++
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(State, Event) as a graph
Interrupts only enqueue events; the main loop feeds them to step. Events not shown on an edge leave the state unchanged.
step(State, Event) as a graph
Interrupts only enqueue events; the main loop feeds them to 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:

C++
#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);
}
DMA: one descriptor, one interrupt
The CPU writes a descriptor and goes back to work; the DMA engine moves all 16 bytes to the UART's data register; one completion interrupt arrives at the end — instead of 16.
DMA: one descriptor, one interrupt
The CPU writes a descriptor and goes back to work; the DMA engine moves all 16 bytes to the UART's data register; one completion interrupt arrives at the end — instead of 16.

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.

Watch & read

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

UART driver backed by an interrupt-safe ring buffer on a Cortex-M, written from scratch.

The foreground/background (superloop) architecture: ISRs produce, the main loop consumes.

Interrupt-driven input from a peripheral, including acknowledging/clearing the interrupt source.