Peripheral Interrupts: UART, SPI, I2C, and ADC

Understanding Interrupts in Microcontrollers and Microprocessors — Part 5


            

In the previous parts we focused on external pin interrupts and timer interrupts. These are triggered by signals on GPIO pins or by hardware counters. But microcontrollers contain many other built-in peripherals — serial communication interfaces, analog-to-digital converters, and more — and all of them can generate interrupts too. In fact, peripheral interrupts are where interrupt-driven design truly proves its value. Without them, receiving data over a serial port, reading an ADC, or communicating with a sensor over SPI would require constant polling that wastes CPU time and risks missing data entirely. This part explains how the most common peripherals use interrupts, with practical examples across all five platforms.

The Core Idea: Let the Hardware Tell You When It Is Ready

Every communication peripheral and converter in a microcontroller has its own internal hardware state machine. It handles the low-level signalling — bit shifting, clock generation, start/stop conditions — independently of the CPU. When it finishes something meaningful — a byte received, a conversion complete, a transmission buffer empty — it raises an interrupt flag. That flag fires an interrupt, and your ISR collects the result or feeds the next piece of data.

This means the CPU is completely free while the peripheral does its work. A UART receiving data at 115200 baud takes about 87 microseconds per byte. Without interrupts, your CPU burns those 87 microseconds waiting. With interrupts, it does useful work and gets tapped on the shoulder only when the byte is ready. At higher baud rates and with multiple peripherals active simultaneously, this difference becomes enormous.

UART Interrupts

UART (Universal Asynchronous Receiver Transmitter) is the most common serial communication interface in embedded systems. It is the technology behind the Serial Monitor in Arduino, debug output on STM32, and communication between microcontrollers and modules like GPS receivers, Bluetooth adapters, and GSM modems.

A UART peripheral typically generates interrupts for two main events:

  • RX interrupt (receive): A new byte has arrived in the receive buffer and is ready to be read.
  • TX interrupt (transmit): The transmit buffer is empty and ready for the next byte to be sent.

The classic pattern for interrupt-driven UART reception is a ring buffer (also called a circular buffer) — a fixed-size array used as a queue. The ISR writes incoming bytes into the buffer, and the main loop reads them out at its own pace. This decouples the timing of reception from the timing of processing.

Arduino (AVR) — UART Interrupt with Ring Buffer

The Arduino Serial library already uses UART interrupts and a ring buffer internally. But understanding how it works underneath is valuable. Here is a simplified version of what Arduino does under the hood:

#define BUFFER_SIZE 64

volatile char rxBuffer[BUFFER_SIZE];
volatile uint8_t rxHead = 0;
volatile uint8_t rxTail = 0;

// UART receive ISR — fires when a byte arrives
ISR(USART_RX_vect) {
    char received = UDR0;               // Read byte from hardware register
    uint8_t nextHead = (rxHead + 1) % BUFFER_SIZE;
    if (nextHead != rxTail) {           // Check buffer is not full
        rxBuffer[rxHead] = received;
        rxHead = nextHead;
    }
    // If buffer is full, byte is silently dropped
}

// Call this from main loop to read a byte
char readByte(void) {
    while (rxHead == rxTail);           // Wait if buffer empty
    char c = rxBuffer[rxTail];
    rxTail = (rxTail + 1) % BUFFER_SIZE;
    return c;
}

void setup() {
    // Configure UART: 9600 baud, 8N1 at 16MHz
    UBRR0H = 0;
    UBRR0L = 103;                       // Baud rate register for 9600
    UCSR0B = (1 << RXEN0)  |           // Enable receiver
             (1 << TXEN0)  |           // Enable transmitter
             (1 << RXCIE0);            // Enable RX complete interrupt
    UCSR0C = (1 << UCSZ01) |
             (1 << UCSZ00);            // 8-bit data format
    sei();                             // Enable global interrupts
}

void loop() {
    if (rxHead != rxTail) {            // Data available in buffer
        char c = readByte();
        // Process character
    }
}

STM32 — UART Interrupt with HAL

STM32 HAL provides a clean interrupt-driven UART API. The most common approach is HAL_UART_Receive_IT(), which sets up the hardware to receive a specified number of bytes and fires a callback when done. For continuous reception of variable-length data, many developers use the IDLE line interrupt — a special STM32 feature that fires when the UART line goes silent after receiving data, signalling the end of a transmission.

// Receive 1 byte at a time via interrupt
uint8_t rxByte;
uint8_t rxBuffer[64];
uint8_t rxIndex = 0;

// In setup — start interrupt-driven reception
HAL_UART_Receive_IT(&huart2, &rxByte, 1);

// Callback fires each time a byte is received
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART2) {
        if (rxByte == '\n' || rxIndex >= 63) {
            rxBuffer[rxIndex] = '\0';   // Null-terminate string
            rxIndex = 0;
            // Signal main loop that a complete line is ready
            lineReady = 1;
        } else {
            rxBuffer[rxIndex++] = rxByte;
        }
        // Re-arm for next byte
        HAL_UART_Receive_IT(&huart2, &rxByte, 1);
    }
}

// Transmit a string — interrupt-driven (non-blocking)
void sendString(char *str) {
    HAL_UART_Transmit_IT(&huart2, (uint8_t*)str, strlen(str));
}

For high-speed or high-volume UART data, STM32 also supports DMA-driven UART where data is transferred directly to a memory buffer by the DMA controller with no CPU involvement at all, and an interrupt fires only when the buffer is full or half-full. This is the most efficient approach for applications like logging GPS NMEA sentences or receiving large data packets.

ESP32 — UART Interrupt

The ESP32 Arduino framework handles UART interrupts transparently through the Serial object, which uses an internal ring buffer. For lower-level control using ESP-IDF, you configure a UART driver with a dedicated event queue driven by interrupts:

#include "driver/uart.h"

#define UART_NUM     UART_NUM_1
#define BUF_SIZE     256
#define UART_TX_PIN  17
#define UART_RX_PIN  16

QueueHandle_t uart_queue;

void uart_event_task(void *pvParameters) {
    uart_event_t event;
    uint8_t data[BUF_SIZE];

    while (1) {
        // Block here waiting for UART interrupt events
        if (xQueueReceive(uart_queue, &event, portMAX_DELAY)) {
            if (event.type == UART_DATA) {
                int len = uart_read_bytes(UART_NUM, data,
                                          event.size, portMAX_DELAY);
                // Process received data
            }
        }
    }
}

void setup() {
    uart_config_t config = {
        .baud_rate  = 115200,
        .data_bits  = UART_DATA_8_BITS,
        .parity     = UART_PARITY_DISABLE,
        .stop_bits  = UART_STOP_BITS_1,
        .flow_ctrl  = UART_HW_FLOWCTRL_DISABLE
    };
    uart_param_config(UART_NUM, &config);
    uart_set_pin(UART_NUM, UART_TX_PIN, UART_RX_PIN,
                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);

    // Install driver with interrupt-driven event queue
    uart_driver_install(UART_NUM, BUF_SIZE * 2, BUF_SIZE * 2,
                        20, &uart_queue, 0);

    // Create task to process UART events
    xTaskCreate(uart_event_task, "uart_task", 2048,
                NULL, 12, NULL);
}

MSP430 — UART Interrupt

#include <msp430.h>

volatile char rxChar = 0;
volatile uint8_t rxReady = 0;

int main(void) {
    WDTCTL = WDTPW | WDTHOLD;

    // Configure UART on eUSCI_A0
    // Assumes 1MHz SMCLK, 9600 baud
    UCA0CTLW0  = UCSWRST;              // Hold in reset during config
    UCA0CTLW0 |= UCSSEL__SMCLK;       // Clock source: SMCLK
    UCA0BRW    = 6;                    // Baud rate divisor
    UCA0MCTLW  = 0x2081;              // Modulation settings for 9600
    UCA0CTLW0 &= ~UCSWRST;            // Release from reset
    UCA0IE    |= UCRXIE;              // Enable RX interrupt

    __bis_SR_register(GIE);

    while (1) {
        if (rxReady) {
            rxReady = 0;
            // Process rxChar
        }
    }
}

// eUSCI_A0 UART ISR
#pragma vector=USCI_A0_VECTOR
__interrupt void USCI_A0_ISR(void) {
    switch (__even_in_range(UCA0IV, USCI_UART_UCTXCPTIFG)) {
        case USCI_UART_UCRXIFG:        // RX interrupt
            rxChar  = UCA0RXBUF;       // Reading clears the flag
            rxReady = 1;
            break;
        default:
            break;
    }
}

PIC18 — UART Interrupt

#include <xc.h>

volatile char rxChar = 0;
volatile uint8_t rxReady = 0;

void __interrupt(high_priority) HighISR(void) {
    if (RC1IF && RC1IE) {              // UART1 receive interrupt
        rxChar  = RC1REG;              // Read clears the flag automatically
        rxReady = 1;
    }
}

void main(void) {
    // UART1 setup: 9600 baud at 16MHz Fosc
    SPBRG1 = 103;                      // Baud rate register
    TXSTA1bits.BRGH = 1;              // High speed mode
    RCSTA1bits.SPEN = 1;              // Serial port enable
    RCSTA1bits.CREN = 1;              // Continuous receive enable
    PIE1bits.RC1IE  = 1;              // Enable RX interrupt
    IPR1bits.RC1IP  = 1;              // High priority
    INTCONbits.GIEH = 1;             // Enable high priority interrupts
    INTCONbits.GIEL = 1;             // Enable low priority interrupts

    while (1) {
        if (rxReady) {
            rxReady = 0;
            // Process rxChar
        }
    }
}

SPI Interrupts

SPI (Serial Peripheral Interface) is a synchronous, full-duplex protocol commonly used with displays, SD cards, flash memory, and sensor modules. SPI is faster than UART and I2C but requires more pins. Interrupt-driven SPI is particularly useful when transferring large blocks of data — rather than polling for each byte transferred, the CPU is notified when the SPI hardware finishes shifting a byte and is ready for the next one.

STM32 — SPI Interrupt

uint8_t txData[] = {0x01, 0x02, 0x03, 0x04};
uint8_t rxData[4];
volatile uint8_t spiDone = 0;

// Start non-blocking SPI transfer
void startSPITransfer(void) {
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // CS low
    HAL_SPI_TransmitReceive_IT(&hspi1, txData, rxData, 4);
}

// Callback fires when all 4 bytes have been transferred
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
    if (hspi->Instance == SPI1) {
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // CS high
        spiDone = 1;
    }
}

// In main loop
while (1) {
    startSPITransfer();
    while (!spiDone);      // In real code, do useful work here instead
    spiDone = 0;
    // Process rxData
}

As with UART, STM32 also supports DMA-driven SPI for very high-speed transfers where even the per-byte interrupt overhead is too much. With DMA, an entire buffer is transferred with a single interrupt at the end.

Arduino — SPI Interrupt (as a Slave device)

Arduino as an SPI master typically uses blocking transfers via the SPI library. However, when Arduino acts as an SPI slave — receiving data from another master — interrupt-driven SPI becomes essential, since the slave has no control over when the master initiates a transfer.

#include <SPI.h>

volatile uint8_t spiReceived = 0;
volatile uint8_t spiData = 0;

// SPI Transfer Complete ISR (AVR)
ISR(SPI_STC_vect) {
    spiData     = SPDR;    // Read received byte
    spiReceived = 1;
}

void setup() {
    // Configure as SPI slave
    pinMode(MISO, OUTPUT);  // Slave drives MISO
    SPCR |= _BV(SPE);      // Enable SPI
    SPCR |= _BV(SPIE);     // Enable SPI interrupt
    sei();
}

void loop() {
    if (spiReceived) {
        spiReceived = 0;
        SPDR = spiData + 1; // Pre-load response for next transfer
    }
}

I2C Interrupts

I2C (Inter-Integrated Circuit) is a two-wire protocol used with a vast range of sensors, EEPROMs, RTCs, and displays. It is slower than SPI but needs only two wires and supports multiple devices on the same bus. I2C transactions involve multiple phases — start condition, address, data bytes, acknowledge bits, stop condition — making interrupt-driven I2C more complex than UART or SPI, but equally important for responsive firmware.

STM32 — I2C Interrupt with HAL

#define SENSOR_ADDR  0x68              // Example: MPU-6050 IMU sensor
uint8_t i2cTxBuf[2];
uint8_t i2cRxBuf[6];
volatile uint8_t i2cDone = 0;

// Write a register, then read back 6 bytes (e.g. accelerometer data)
void readSensorAsync(void) {
    i2cTxBuf[0] = 0x3B;               // Register address
    // Non-blocking write — ISR fires when complete
    HAL_I2C_Master_Transmit_IT(&hi2c1, SENSOR_ADDR << 1,
                                i2cTxBuf, 1);
}

// Fires when transmit phase completes
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c) {
    if (hi2c->Instance == I2C1) {
        // Now read 6 bytes — non-blocking
        HAL_I2C_Master_Receive_IT(&hi2c1, SENSOR_ADDR << 1,
                                   i2cRxBuf, 6);
    }
}

// Fires when receive phase completes
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c) {
    if (hi2c->Instance == I2C1) {
        i2cDone = 1;                   // Signal main loop
    }
}

// In main loop
while (1) {
    readSensorAsync();
    // Main loop free to do other work while I2C transfer happens
    if (i2cDone) {
        i2cDone = 0;
        processAccelData(i2cRxBuf);
    }
}

ADC Interrupts

Analog-to-digital conversion takes a measurable amount of time — typically a few microseconds to hundreds of microseconds depending on resolution and clock speed. Polling for ADC completion wastes every one of those microseconds. An ADC conversion-complete interrupt fires the moment the result is ready, letting the CPU do other work during the conversion.

Arduino (AVR) — ADC Interrupt

volatile uint16_t adcResult = 0;
volatile uint8_t adcReady  = 0;

// ADC conversion complete ISR
ISR(ADC_vect) {
    adcResult = ADC;       // Read 10-bit result (ADCL + ADCH combined)
    adcReady  = 1;
}

void setup() {
    // Configure ADC: AVcc reference, channel 0 (A0), prescaler 128
    ADMUX  = (1 << REFS0);                        // AVcc reference
    ADCSRA = (1 << ADEN)  |                       // Enable ADC
             (1 << ADIE)  |                       // Enable interrupt
             (1 << ADPS2) | (1 << ADPS1) |        // Prescaler 128
             (1 << ADPS0);
    sei();
    ADCSRA |= (1 << ADSC);                        // Start first conversion
}

void loop() {
    if (adcReady) {
        adcReady = 0;
        uint16_t value = adcResult;
        // Process ADC value
        ADCSRA |= (1 << ADSC);                    // Start next conversion
    }
}

STM32 — ADC Interrupt

volatile uint32_t adcValue = 0;
volatile uint8_t adcReady  = 0;

// Start a single non-blocking ADC conversion
HAL_ADC_Start_IT(&hadc1);

// Fires when conversion is complete
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) {
    if (hadc->Instance == ADC1) {
        adcValue = HAL_ADC_GetValue(&hadc1);
        adcReady = 1;
        // For continuous sampling, re-arm here:
        // HAL_ADC_Start_IT(&hadc1);
    }
}

// In main loop
while (1) {
    if (adcReady) {
        adcReady = 0;
        float voltage = (adcValue / 4095.0f) * 3.3f;
        // Use voltage reading
    }
}

STM32 also supports ADC with DMA in continuous mode — the ADC converts constantly, filling a buffer via DMA, and interrupts fire only when the buffer is half-full or full. This is ideal for audio sampling, vibration analysis, or any application requiring continuous high-speed analog acquisition.

MSP430 — ADC Interrupt

#include <msp430.h>

volatile unsigned int adcResult = 0;

int main(void) {
    WDTCTL = WDTPW | WDTHOLD;

    // Configure ADC12 on channel A0 (P1.0)
    ADC12CTL0  = ADC12SHT0_2 | ADC12ON;   // 16 clock cycles, ADC on
    ADC12CTL1  = ADC12SHP;                 // Sample signal from timer
    ADC12CTL2 |= ADC12RES_2;              // 12-bit resolution
    ADC12IER0 |= ADC12IE0;                // Enable interrupt on MEM0
    ADC12MCTL0 = ADC12INCH_0;             // Input channel A0
    ADC12CTL0 |= ADC12ENC;               // Enable conversion

    P1SEL1 |= BIT0;                        // Set P1.0 to ADC function
    P1SEL0 |= BIT0;

    __bis_SR_register(GIE);               // Enable global interrupts

    while (1) {
        ADC12CTL0 |= ADC12SC;             // Start conversion
        __bis_SR_register(LPM0_bits);     // Sleep until conversion done
        // adcResult is valid here after wakeup
        // Process adcResult
    }
}

#pragma vector=ADC12_B_VECTOR
__interrupt void ADC12_ISR(void) {
    switch (__even_in_range(ADC12IV, ADC12IV__ADC12IFG0)) {
        case ADC12IV__ADC12IFG0:
            adcResult = ADC12MEM0;
            __bic_SR_register_on_exit(LPM0_bits); // Wake up main loop
            break;
        default:
            break;
    }
}

Notice the elegant MSP430 pattern here: the main loop starts a conversion and immediately enters a low-power sleep mode (LPM0). The ADC ISR reads the result and wakes the CPU using __bic_SR_register_on_exit(). The CPU is only active for the bare minimum time needed — a hallmark of MSP430 design.

Key Takeaways for Peripheral Interrupts

  • Never poll a peripheral when an interrupt is available. Polling burns CPU cycles and risks missing data at higher speeds.
  • Use ring buffers for UART reception. They decouple the timing of data arrival from the timing of data processing and handle bursts gracefully.
  • Re-arm receive interrupts where required. HAL functions like HAL_UART_Receive_IT() and HAL_ADC_Start_IT() are one-shot — you must call them again inside the callback to keep receiving.
  • Consider DMA for high-speed or high-volume transfers. UART, SPI, and ADC on STM32 and ESP32 all support DMA, reducing interrupt frequency to once per buffer instead of once per byte.
  • Reading a receive register usually clears the interrupt flag. On most platforms, simply reading the data register (UDR0 on AVR, UCA0RXBUF on MSP430, RC1REG on PIC) clears the RX interrupt flag automatically. Check your datasheet to be sure.

What Is Coming in Part 6

Part 6 brings together everything we have learned and applies it to one of the most practically important topics in embedded systems: power management and sleep modes. You will see how interrupts are the key mechanism that allows a microcontroller to sleep with near-zero power consumption and wake instantly when something needs attention — with detailed examples on MSP430, ESP32, STM32, and Arduino. This is the part where interrupt-driven design pays off most clearly in real products.

Interrupt Priorities, Nesting, and the NVIC

In Parts 1 through 3 we learned what interrupts are, how to write safe ISRs, and how to use timer interrupts for precise periodic tasks. So far we have treated interrupts as if only one can exist at a time. In reality, a microcontroller may have dozens of interrupt sources, and several of them can […]

Comments are closed.