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:
- Finishes the current instruction
- Pushes registers (PC, LR, PSR, R0–R3, R12 on ARM Cortex-M) onto the stack
- Looks up the ISR address in the vector table
- Jumps to the ISR
- 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
volatileon 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-O0and reappears at-O2. - Calling
printffrom an ISR —printfuses a global buffer and is not reentrant. If main is insideprintfwhen the ISR fires and callsprintfagain, 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
volatileis 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
- volatile Keyword in C – Why Embedded C Needs It
- FreeRTOS Tutorial in C – Tasks, Queues, Semaphores, and Mutexes
- Memory-Mapped I/O in C – How Embedded C Controls Hardware
- MISRA C Rules – Safe Coding Guidelines for Embedded and Automotive C
- Bit Manipulation in C – Set, Clear, Toggle, and Count Bits
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.