C Interrupt Handler – ISR Examples for GPIO, UART, and Timer

An interrupt is a hardware signal that suspends the CPU’s current execution, saves the processor state, runs a dedicated function called an Interrupt Service Routine (ISR), then restores the state and resumes where it left off. Interrupts let embedded firmware react to events — a byte arriving on UART, a button press, a timer expiry — in microseconds, without polling in a loop.

This page covers how to write ISRs in C, how the vector table works, the rules every ISR must follow, and three practical examples: GPIO input, UART receive, and a periodic timer tick.

How Interrupts Work

When a peripheral signals an interrupt, the CPU hardware does five things automatically before your ISR runs:

  1. Finishes the current instruction
  2. Pushes registers (PC, LR, PSR, R0–R3, R12 on ARM Cortex-M) onto the stack
  3. Looks up the ISR address in the vector table
  4. Jumps to the ISR
  5. On ISR return: pops registers, resumes the interrupted code

The vector table is an array of function pointers in flash, starting at address 0 (or relocated). Each position corresponds to one interrupt source.

/* Minimal Cortex-M vector table in C
   (normally in the startup file — shown here for clarity) */
#include <stdint.h>

extern uint32_t _stack_top;   /* defined in linker script */

void Reset_Handler(void);
void NMI_Handler(void)         { for (;;) { } }
void HardFault_Handler(void)   { for (;;) { } }
void SysTick_Handler(void);
void EXTI0_IRQHandler(void);
void USART1_IRQHandler(void);
void TIM2_IRQHandler(void);

__attribute__((section(".isr_vector"), used))
void (* const g_vectors[])(void) = {
    (void (*)(void))&_stack_top,  /*  0: initial stack pointer */
    Reset_Handler,                  /*  1: reset                 */
    NMI_Handler,                    /*  2: NMI                   */
    HardFault_Handler,              /*  3: hard fault            */
    0, 0, 0, 0, 0, 0, 0,           /*  4-10: reserved           */
    0,                              /* 11: SVCall                */
    0, 0,                           /* 12-13: reserved           */
    0,                              /* 14: PendSV                */
    SysTick_Handler,                /* 15: SysTick               */
    /* device-specific interrupts start at index 16 */
    EXTI0_IRQHandler,               /* 16+0: EXTI line 0         */
    0,                              /* 16+1: EXTI line 1         */
    /* ... more peripherals ... */
    [21] = USART1_IRQHandler,       /* 16+5: USART1              */
    [28] = TIM2_IRQHandler,         /* 16+12: TIM2               */
};

On STM32 and most Cortex-M devices, the startup file (written in C or assembly) provides this table. ISRs are identified by name — the linker resolves EXTI0_IRQHandler in your code to position 16 in the table.

Writing an ISR in C

ARM Cortex-M (STM32, nRF52, LPC, SAMD)

The function name must exactly match the name in the startup file’s vector table. No special keywords needed — the hardware saves and restores all context automatically on Cortex-M:

/* Cortex-M: function name IS the ISR — no attribute needed */
void EXTI0_IRQHandler(void)
{
    /* 1. Clear the interrupt pending flag FIRST (write-1-to-clear) */
    EXTI->PR = EXTI_PR_PR0;

    /* 2. Do the minimal work — set a flag, push to a queue */
    g_button_pressed = 1u;
}

AVR (ATmega, Arduino)

/* AVR: use the ISR() macro from avr/interrupt.h */
#include <avr/interrupt.h>

ISR(INT0_vect)
{
    /* INT0 = PD2 pin, falling-edge trigger */
    g_button_pressed = 1u;
}

/* Enable interrupts globally */
int main(void)
{
    EICRA |= (1u << ISC01);   /* falling edge on INT0 */
    EIMSK |= (1u << INT0);    /* enable INT0           */
    sei();                      /* global interrupt enable */
    for (;;) { }
    return 0;
}

Enabling an Interrupt on ARM (CMSIS)

/* CMSIS functions for Cortex-M NVIC */
NVIC_SetPriority(EXTI0_IRQn, 5u);   /* 0 = highest, 15 = lowest on Cortex-M4 */
NVIC_EnableIRQ(EXTI0_IRQn);         /* unmask in NVIC */

/* Configure EXTI0 to trigger on rising edge (platform-specific) */
EXTI->IMR  |= EXTI_IMR_MR0;    /* unmask line 0 */
EXTI->RTSR |= EXTI_RTSR_TR0;   /* rising edge   */

The ISR Rules — What You Must and Must Not Do

Rule Why
Keep ISRs short — do minimal work Longer ISR = more latency for other interrupts; deferred processing belongs in a task or main loop
Declare all shared variables volatile Without volatile, the compiler may cache the value in a register and never re-read memory
Never call printf, malloc, or any non-reentrant function These use internal state that is not interrupt-safe; calling them from an ISR corrupts that state
Clear the interrupt flag in hardware If the flag is not cleared, the ISR re-fires immediately on return — infinite loop
Never call FreeRTOS blocking APIs from an ISR Use the FromISR variants and portYIELD_FROM_ISR (see FreeRTOS tutorial)

volatile — Required for All ISR-Shared Variables

The compiler optimises code based on what it can see within a single execution thread. It cannot see that an ISR modifies a variable asynchronously. Without volatile, the optimiser may hoist a read of the variable out of a loop, read it once, and never check memory again:

/* Without volatile — BROKEN under optimisation */
uint32_t g_tick = 0u;   /* modified in ISR */

void SysTick_Handler(void) { g_tick++; }

void delay_ms(uint32_t ms)
{
    uint32_t start = g_tick;
    /* Compiler may load g_tick into a register once and loop forever:
       compiled to: while (register_value - start < ms) {}
       register_value never changes — infinite loop */
    while ((g_tick - start) < ms) { }
}

/* With volatile — CORRECT */
volatile uint32_t g_tick = 0u;
/* Now the compiler reads g_tick from memory on every loop iteration */

Critical Sections — Atomic Access for Multi-Byte Data

On 32-bit ARM, a 32-bit read or write is a single instruction — it is atomic. But reading a 64-bit variable (or two related 32-bit variables) takes two instructions. An ISR can fire between them, giving you a half-updated value:

/* 64-bit timestamp updated every millisecond from SysTick ISR */
volatile uint64_t g_timestamp_us = 0u;

/* BAD: non-atomic read — ISR can fire between the two 32-bit reads */
uint64_t read_timestamp_bad(void)
{
    return g_timestamp_us;   /* two loads — race condition */
}

/* GOOD: disable interrupts briefly (ARM CMSIS) */
uint64_t read_timestamp_safe(void)
{
    uint32_t primask;
    uint64_t ts;

    primask = __get_PRIMASK();   /* save interrupt enable state */
    __disable_irq();             /* PRIMASK = 1, blocks all maskable IRQs */
    ts = g_timestamp_us;         /* now atomic — no ISR can fire */
    __set_PRIMASK(primask);      /* restore previous state */
    return ts;
}

/* Same pattern for AVR */
uint16_t read_counter_safe_avr(void)
{
    uint16_t val;
    cli();                /* disable global interrupts */
    val = g_counter;      /* read multi-byte variable */
    sei();                /* re-enable */
    return val;
}

Keep critical sections as short as possible. Every microsecond with interrupts disabled adds latency to every other interrupt source.

Example 1 — GPIO Interrupt (Button Debounce)

A GPIO ISR should only record that an event occurred. The debounce logic and any action runs in the main loop or a task, where blocking and delays are safe:

#include <stdint.h>
#include "stm32f4xx.h"   /* or your platform header */

#define DEBOUNCE_MS  50u

volatile uint8_t  g_button_event = 0u;   /* set by ISR, cleared by main */

/* EXTI0 fires on PA0 rising edge (button press) */
void EXTI0_IRQHandler(void)
{
    EXTI->PR = EXTI_PR_PR0;   /* clear pending — MUST be first */
    g_button_event = 1u;
}

int main(void)
{
    uint32_t last_event_ms = 0u;

    gpio_button_init();    /* configure PA0 as input with EXTI0 */
    timer_init();          /* SysTick or TIM for get_tick_ms() */

    NVIC_SetPriority(EXTI0_IRQn, 10u);
    NVIC_EnableIRQ(EXTI0_IRQn);

    for (;;) {
        if (g_button_event != 0u) {
            g_button_event = 0u;

            /* Debounce: ignore events within 50 ms of last one */
            if ((get_tick_ms() - last_event_ms) >= DEBOUNCE_MS) {
                last_event_ms = get_tick_ms();
                handle_button_press();   /* safe: not in ISR */
            }
        }
    }
    return 0;
}

Example 2 — UART RX Interrupt with Circular Buffer

A UART RX ISR must read the received byte before the next byte arrives (at 115200 baud, that is about 87 µs). The standard pattern is a lock-free circular buffer: the ISR writes at the head, the main loop reads at the tail. Because head and tail indices are each a single 32-bit value updated by only one side, no mutex is needed on 32-bit ARM:

#include <stdint.h>

#define UART_BUF_SIZE 64u   /* must be a power of 2 */
#define UART_BUF_MASK (UART_BUF_SIZE - 1u)

static volatile uint8_t  g_rx_buf[UART_BUF_SIZE];
static volatile uint32_t g_rx_head = 0u;   /* written by ISR only  */
static volatile uint32_t g_rx_tail = 0u;   /* written by main only */

/* Called by USART1 RX-not-empty interrupt */
void USART1_IRQHandler(void)
{
    uint8_t  byte;
    uint32_t next_head;

    if (USART1->SR & USART_SR_RXNE) {
        byte = (uint8_t)(USART1->DR & 0xFFu);   /* reading DR clears RXNE */
        next_head = (g_rx_head + 1u) & UART_BUF_MASK;
        if (next_head != g_rx_tail) {    /* not full */
            g_rx_buf[g_rx_head] = byte;
            g_rx_head = next_head;       /* commit: written last, after data */
        }
        /* if full, byte is silently dropped (add an overrun flag if needed) */
    }
}

/* Main/task context — returns 1 and fills *byte if data available, else 0 */
int uart_read(uint8_t *byte)
{
    if (g_rx_tail == g_rx_head) {
        return 0;   /* empty */
    }
    *byte      = g_rx_buf[g_rx_tail];
    g_rx_tail  = (g_rx_tail + 1u) & UART_BUF_MASK;   /* advance tail */
    return 1;
}

/* Initialise USART1 and enable RX interrupt */
void uart_init(uint32_t baud)
{
    /* Platform-specific USART clock, GPIO, baud rate setup ... */
    USART1->CR1 |= USART_CR1_RXNEIE;   /* enable RX not-empty interrupt */
    USART1->CR1 |= USART_CR1_UE;       /* enable USART */
    NVIC_SetPriority(USART1_IRQn, 6u);
    NVIC_EnableIRQ(USART1_IRQn);
}

Why the write order matters: the ISR writes the byte to the buffer before updating g_rx_head. The reader sees either the old head (byte not yet there) or the new head (byte already written) — it can never see a stale byte.

Example 3 — Timer Interrupt (Periodic Tick)

A hardware timer interrupt is the standard way to create a software millisecond tick counter for timeouts and delays without blocking a core:

#include <stdint.h>

volatile uint32_t g_tick_ms = 0u;

/* TIM2 configured to fire every 1 ms */
void TIM2_IRQHandler(void)
{
    if (TIM2->SR & TIM_SR_UIF) {    /* update interrupt flag */
        TIM2->SR &= ~TIM_SR_UIF;    /* clear flag */
        g_tick_ms++;
    }
}

/* Non-blocking — returns current tick count */
uint32_t get_tick_ms(void)
{
    return g_tick_ms;   /* 32-bit read: atomic on ARM */
}

/* Blocking delay — spins in main loop context, never in ISR */
void delay_ms(uint32_t ms)
{
    uint32_t start = get_tick_ms();
    while ((get_tick_ms() - start) < ms) {
        /* could feed a watchdog here */
    }
}

/* Timeout check without blocking */
int timeout_elapsed(uint32_t start_ms, uint32_t timeout_ms)
{
    return ((get_tick_ms() - start_ms) >= timeout_ms);
}

void timer_init(void)
{
    /* TIM2: APB1 clock / prescaler → 1 MHz, auto-reload = 999 → 1 ms period */
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    TIM2->PSC     = (uint16_t)(SystemCoreClock / 1000000u - 1u);
    TIM2->ARR     = 999u;
    TIM2->DIER   |= TIM_DIER_UIE;   /* update interrupt enable */
    TIM2->CR1    |= TIM_CR1_CEN;    /* counter enable */
    NVIC_SetPriority(TIM2_IRQn, 8u);
    NVIC_EnableIRQ(TIM2_IRQn);
}

Deferring Work to a Task

When interrupt-triggered processing takes more than a few microseconds, defer it. The ISR records that the event happened (sets a flag, writes to a queue, gives a semaphore) and returns. A background task wakes up and does the heavy lifting:

/* ISR: records event and returns immediately */
void DMA1_Stream0_IRQHandler(void)
{
    DMA1->LIFCR = DMA_LIFCR_CTCIF0;   /* clear transfer-complete flag */
    g_dma_complete = 1u;               /* signal to main loop */
    /* OR: xSemaphoreGiveFromISR() if using FreeRTOS */
}

/* Main loop: does the heavy processing */
void main_loop(void)
{
    for (;;) {
        if (g_dma_complete) {
            g_dma_complete = 0u;
            process_dma_buffer();   /* can take as long as needed */
        }
    }
}

When using FreeRTOS, replace the flag with a binary semaphore or task notification — the task blocks at zero CPU cost until the ISR gives it. See the FreeRTOS tutorial for the complete ISR-to-task pattern.

Common Mistakes

  • Not clearing the interrupt flag — the ISR re-fires endlessly on return. Always clear the peripheral’s interrupt status register at the top of the handler.
  • Missing volatile on shared variables — the optimiser hoists the read out of the loop; the flag never changes from the main loop’s perspective. This bug disappears at -O0 and reappears at -O2.
  • Calling printf from an ISRprintf uses a global buffer and is not reentrant. If main is inside printf when the ISR fires and calls printf again, the output buffer is corrupted.
  • Long processing inside the ISR — adds latency to every other interrupt. If your ISR takes > 10 µs, move the work to a task or the main loop.
  • Non-atomic read of multi-byte variable without a critical section — symptoms are rare, intermittent, non-reproducible data corruption. Use __disable_irq()/__set_PRIMASK() around the read.
  • Wrong interrupt priority on Cortex-M — calling a FreeRTOS API from an ISR whose priority number is lower than configMAX_SYSCALL_INTERRUPT_PRIORITY (i.e., the interrupt is too fast/high-priority) will assert or corrupt the kernel.

Interrupt Priority on ARM Cortex-M

/* Cortex-M priority: lower number = higher priority
   Typical arrangement for a system with FreeRTOS: */

NVIC_SetPriority(DMA1_Stream0_IRQn,  1u);   /* fastest — must not call FreeRTOS */
NVIC_SetPriority(USART1_IRQn,        5u);   /* can call xQueueSendFromISR etc.   */
NVIC_SetPriority(EXTI0_IRQn,         6u);   /* can call FreeRTOS FromISR APIs    */
NVIC_SetPriority(TIM2_IRQn,          8u);   /* periodic tick — low urgency        */

/* configMAX_SYSCALL_INTERRUPT_PRIORITY in FreeRTOSConfig.h = 5 (shifted left 4)
   Interrupts with priority 0-4 are "too fast" for FreeRTOS — never call FromISR
   Interrupts with priority 5-15 may call FromISR variants safely             */

What This Teaches

  • Interrupts are the only way to guarantee tight response times: polling in a loop adds up to a full loop iteration of latency; an ISR responds within a handful of CPU cycles — typically under 1 µs on Cortex-M at 168 MHz
  • volatile is not optional for ISR-shared variables: the bug it prevents is compiler-optimisation silent and often invisible at low optimisation levels — it only shows up in release builds when it matters most
  • ISRs are glue, not logic: a well-written ISR clears a flag, writes one value to a buffer, or gives a semaphore — the real work happens in normal task context where blocking, delays, and non-reentrant functions are safe

Related Topics


As an Amazon Associate we earn from qualifying purchases.

Recommended Book

For a thorough grounding in the C language that powers all embedded firmware, The C Programming Language by Kernighan & Ritchie covers every construct used in ISR-level C — pointers, volatile, bit manipulation, and struct layout — with the concision and precision that embedded code demands. Also on Amazon.com.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>