Chapter 5: Interrupts, Timing, and Real-Time Control Flow

Updated on 2026-08-30ai-generated

In desktop and web development, your code runs when the framework calls it. In bare-metal embedded systems, your code runs when hardware demands it. An interrupt is not an optimization—it is the primary mechanism for responding to events that do not wait for your main loop. This chapter explains how interrupts work, how to write safe interrupt service routines, and how to avoid the subtle race conditions that plague embedded code.

The Problem with Polling

The blinking LED example from earlier chapters used polling: the main loop continuously checked a condition or simply wasted time in a delay. Polling works for simple cases, but it fails when multiple things need attention simultaneously.

Consider a system that must:

  • Read data from a UART at 115200 baud (one byte every ~87 microseconds)
  • Control a motor via PWM
  • Respond to a button press within 10 milliseconds
  • Update a display at 60 Hz

A polling loop must check each of these in turn. If the UART check takes too long, the button press is missed. If the display update runs long, the UART overflows and data is lost.

Interrupts solve this by allowing hardware to preempt the main loop. When a byte arrives at the UART, the hardware triggers an interrupt. The CPU pauses whatever it is doing, runs the Interrupt Service Routine (ISR) to read the byte, and returns to the main loop. The main loop doesn't need to poll the UART continuously.

How Interrupts Work on ARM Cortex-M

The ARM Cortex-M architecture has a built-in interrupt controller called the NVIC (Nested Vectored Interrupt Controller). Each peripheral interrupt source is assigned a number (IRQ number) and a priority.

The Interrupt Vector Table

From Chapter 2, you know the vector table sits at the beginning of flash. It contains the addresses of all exception and interrupt handlers:

// vector_table.c
void (* const vector_table[])(void) __attribute__((section(".isr_vector"))) = {
    (void (*)(void))0x20030000,  // Initial stack pointer
    Reset_Handler,               // Reset
    NMI_Handler,                 // Non-maskable interrupt
    HardFault_Handler,           // Hard fault
    // ...
    USART2_IRQHandler,           // UART2 interrupt at fixed index
    SPI1_IRQHandler,             // SPI1 interrupt
    // ...
};

When a peripheral triggers an interrupt, the hardware:

  1. Pushes the current context (registers, return address) onto the stack
  2. Reads the handler address from the vector table
  3. Jumps to that address

The ISR runs, then executes a special return instruction that pops the context and resumes the interrupted code.

Interrupt Priorities and Preemption

Interrupts have priorities. A lower priority number means higher priority. If the CPU is running a low-priority ISR and a high-priority interrupt occurs, the high-priority ISR preempts the low-priority one. This is called nesting.

Priority configuration is critical for real-time behavior:

// Configure USART2 interrupt priority to 3 (lower number = higher priority)
NVIC_SetPriority(USART2_IRQn, 3);
NVIC_EnableIRQ(USART2_IRQn);

// Configure SPI1 interrupt priority to 1 (higher priority than USART2)
NVIC_SetPriority(SPI1_IRQn, 1);
NVIC_EnableIRQ(SPI1_IRQn);

If SPI1 and USART2 interrupt simultaneously, SPI1's ISR runs first. If SPI1 interrupts while USART2's ISR is running, SPI1's ISR preempts.

Misconception: "Interrupts are like threads. I can use them the same way."

Reality: ISRs are not threads. They cannot block, cannot call most library functions, and must complete quickly. An ISR that takes too long delays all lower-priority interrupts and the main loop. A 1-millisecond ISR at 100 Hz consumes 10% of CPU time and may cause missed deadlines elsewhere.

Writing a UART ISR

Here is a practical example: interrupt-driven UART receive with a ring buffer.

// uart.h
typedef struct {
    volatile uint8_t buffer[256];
    volatile uint16_t head;
    volatile uint16_t tail;
} ring_buffer_t;

void uart_init(uint32_t baud);
uint16_t uart_available(void);
uint8_t uart_read(void);

// uart.c
#include "uart.h"

static ring_buffer_t rx_buf;

void uart_init(uint32_t baud) {
    // Initialize hardware (configure USART2, enable RX interrupt)
    USART2->CR1 |= (1 << 5);  // RXNE interrupt enable
    NVIC_EnableIRQ(USART2_IRQn);
}

// Called automatically when a byte arrives
void USART2_IRQHandler(void) {
    if (USART2->SR & (1 << 5)) {  // RXNE: Receive data ready
        uint8_t data = USART2->DR;
        
        uint16_t next_head = (rx_buf.head + 1) & 0xFF;
        if (next_head != rx_buf.tail) {
            rx_buf.buffer[rx_buf.head] = data;
            rx_buf.head = next_head;
        }
        // If buffer is full, drop the byte (or set an error flag)
    }
}

// Called from main loop
uint16_t uart_available(void) {
    return (uint16_t)(rx_buf.head - rx_buf.tail) & 0xFF;
}

uint8_t uart_read(void) {
    while (uart_available() == 0) {
        // Wait for data (in a real system, block or yield)
    }
    uint8_t data = rx_buf.buffer[rx_buf.tail];
    rx_buf.tail = (rx_buf.tail + 1) & 0xFF;
    return data;
}

The ISR reads the byte from hardware and stores it in the ring buffer. The main loop reads from the buffer when convenient. The ISR is short (a few microseconds), and the main loop does not lose data while doing other work.

Race Conditions: The Silent Killer

Interrupts introduce concurrency. The main loop and ISRs share variables, and without careful design, races occur.

The Classic Read-Modify-Write Race

Consider a global flags variable shared between main and ISRs:

volatile uint32_t flags = 0;

// In ISR
void EXTI0_IRQHandler(void) {
    flags |= (1 << 0);  // Set button flag
}

// In main loop
void main_loop(void) {
    flags |= (1 << 1);  // Set LED update flag (READ-MODIFY-WRITE!)
}

Both operations are read-modify-write. If the ISR fires between the main loop's read and write:

  1. Main loop reads flags = 0
  2. ISR runs: reads flags = 0, writes flags = 1 (bit 0 set)
  3. Main loop writes flags = 2 (bit 1 set, bit 0 lost!)

The button flag is lost. This is a race condition that may only manifest occasionally.

Solutions to Race Conditions

1. Disable interrupts during critical sections:

void main_loop(void) {
    __disable_irq();
    flags |= (1 << 1);
    __enable_irq();
}

This is simple but blocks all interrupts during the critical section. Keep critical sections short.

2. Use atomic operations:

ARM Cortex-M provides bit-banding (Chapter 3) or special instructions:

// Atomic bit set using bit-banding
#define BITBAND_SET(addr, bit) \
    (*(volatile uint32_t *)(0x42000000 + ((uint32_t)(addr) - 0x40000000) * 32 + (bit) * 4) = 1)

BITBAND_SET(&flags, 1);  // Atomic set of bit 1

3. Separate variables per context:

Give the ISR and main loop their own variables, and synchronize only at safe points:

volatile uint32_t isr_flags = 0;   // Only ISR writes
volatile uint32_t main_flags = 0;  // Only main loop writes

// ISR sets isr_flags; main loop reads and clears it

The Volatile Trap

// WRONG: Not volatile, compiler may cache in register
uint32_t flags = 0;

void EXTI0_IRQHandler(void) {
    flags |= 1;
}

void main_loop(void) {
    while (flags == 0) {
        // Compiler may read flags once and loop forever
    }
}

Any variable shared between an ISR and the main loop must be volatile. The compiler does not know that the ISR can modify the variable at any time.

Timing and Hardware Timers

Interrupts are also used for timing. Hardware timers count clock cycles independently of the CPU. When the counter reaches a threshold, an interrupt fires.

Basic Timer Configuration

// Configure TIM2 to interrupt every 1 ms (assuming 16 MHz clock)
void timer_init(void) {
    RCC->APB1ENR |= (1 << 0);  // Enable TIM2 clock
    
    TIM2->PSC = 16000 - 1;     // Prescaler: 16 MHz / 16000 = 1 kHz
    TIM2->ARR = 1000 - 1;      // Auto-reload: interrupt every 1000 ticks = 1 ms
    TIM2->DIER |= (1 << 0);    // Update interrupt enable
    TIM2->CR1 |= (1 << 0);     // Enable timer
    
    NVIC_EnableIRQ(TIM2_IRQn);
}

volatile uint32_t systick_count = 0;

void TIM2_IRQHandler(void) {
    if (TIM2->SR & (1 << 0)) {  // Update flag
        TIM2->SR &= ~(1 << 0);  // Clear flag
        systick_count++;
    }
}

This provides a millisecond timebase for delays, timeouts, and scheduling.

Soft Timers

With a millisecond interrupt, you can implement software timers for the main loop:

volatile uint32_t ticks = 0;

typedef struct {
    uint32_t start;
    uint32_t duration;
    bool active;
} soft_timer_t;

void soft_timer_start(soft_timer_t *timer, uint32_t duration_ms) {
    timer->start = ticks;
    timer->duration = duration_ms;
    timer->active = true;
}

bool soft_timer_expired(soft_timer_t *timer) {
    if (!timer->active) {
        return false;
    }
    return (ticks - timer->start) >= timer->duration;
}

// Usage
soft_timer_t led_timer;
soft_timer_start(&led_timer, 500);

while (1) {
    if (soft_timer_expired(&led_timer)) {
        gpio_toggle(GPIOD, GPIO_PIN_12);
        soft_timer_start(&led_timer, 500);
    }
    // Other work here...
}

Real-Time Control Flow: The Super Loop vs. Event-Driven

Most bare-metal embedded systems use one of two patterns:

The Super Loop (Main Loop + ISRs)

int main(void) {
    init_all_peripherals();
    
    while (1) {
        // Poll for events, process data, update outputs
        if (button_pressed()) {
            handle_button();
        }
        
        if (uart_available()) {
            process_uart_data(uart_read());
        }
        
        if (adc_ready()) {
            update_sensor_value(adc_read());
        }
        
        update_display();
        
        // Low-power mode if nothing to do
        __WFI();  // Wait for interrupt
    }
}

This is simple, predictable, and works for many applications. The main loop handles all non-urgent tasks; ISRs handle urgent events.

Event-Driven (State Machines)

For complex systems, the main loop often implements a state machine:

typedef enum {
    STATE_IDLE,
    STATE_RECEIVING,
    STATE_PROCESSING,
    STATE_ERROR,
} system_state_t;

system_state_t state = STATE_IDLE;

void main_loop(void) {
    switch (state) {
        case STATE_IDLE:
            if (uart_available()) {
                state = STATE_RECEIVING;
            }
            break;
            
        case STATE_RECEIVING:
            if (packet_complete()) {
                state = STATE_PROCESSING;
            }
            break;
            
        case STATE_PROCESSING:
            if (processing_done()) {
                state = STATE_IDLE;
            }
            break;
            
        case STATE_ERROR:
            if (error_cleared()) {
                state = STATE_IDLE;
            }
            break;
    }
}

This scales better than nested conditionals and makes the system behavior explicit.

ISR Best Practices

Do:

  • Keep ISRs short (microseconds, not milliseconds)
  • Use volatile for shared variables
  • Clear the interrupt flag before or after handling (check datasheet)
  • Use ring buffers to decouple ISRs from main loop processing

Don't:

  • Call printf, malloc, or blocking functions in an ISR
  • Do heavy computation in an ISR
  • Delay or busy-wait in an ISR
  • Enable interrupts inside an ISR without understanding nesting implications
  • Share non-trivial data structures without synchronization

What to Actually Learn vs. Look Up

Look up (as needed):

  • Specific NVIC register names and bit positions for your MCU
  • Timer prescaler and auto-reload values for specific frequencies
  • Peripheral-specific interrupt flags and how to clear them

Deeply understand:

  • How the vector table dispatches interrupts
  • Priority levels and preemption behavior
  • The read-modify-write race and how to avoid it
  • The volatile qualifier for shared variables
  • Ring buffer patterns for ISR-to-main communication
  • How to structure a super loop or state machine for responsive behavior

Key Takeaways

  1. Interrupts replace the event loop. Hardware triggers your code when events occur, not the other way around.
  2. ISRs must be short and non-blocking. Do minimal work in the ISR; defer processing to the main loop.
  3. Shared variables need volatile and careful synchronization. Race conditions are subtle and intermittent.
  4. Priorities determine real-time behavior. Configure them deliberately based on timing requirements.
  5. Timers provide timebases for delays, timeouts, and scheduling. One millisecond tick can drive an entire system.
  6. The super loop is the default pattern. ISRs handle urgency; the main loop handles everything else.

Chapter 6 will confront the challenge of memory management without an OS, exploring why malloc is often banned and what patterns replace it.

Found an issue? Open an issue or submit a pull request on GitHub