Chapter 15: Exceptions and the GIC¶
What: install a real ARMv7-A exception vector table, configure the GIC v2 distributor and CPU interface, route the UART1 interrupt to the core, and write an ISR that echoes received characters. GIC: ARM’s Generic Interrupt Controller, the Cortex-A interrupt router roughly analogous to NVIC on Cortex-M.
Why: every kernel, every RTOS, and most useful bare-metal programs are interrupt-driven. Polling works for hello-world. It falls apart the moment more than one peripheral needs attention.
Focus: the two-stage IRQ flow: the GIC routes the IRQ to the CPU, the CPU vectors to your handler, and the handler reads the GIC for the IRQ ID, dispatches, and writes EOI. Internalize this diagram and every A-profile system feels familiar. MCU bridge: Think of an IRQ like an EXTI/NVIC interrupt path, except Linux splits the hard interrupt from deferred work and must share lines across drivers. IRQ: interrupt request, the signal path that tells the CPU or interrupt controller that hardware needs service.
15.1 What is different from Cortex-M¶
In Cortex-M:
The NVIC is inside the CPU.
Hardware auto-stacks R0-R3, R12, LR, PC, xPSR on the active stack.
The vector table is an array of function pointers. The CPU loads PC directly from the slot.
BX LRwith the specialEXC_RETURNvalue tells hardware to unstack.
In Cortex-A7:
The GIC is outside the CPU (memory-mapped block).
No auto-stacking. Your handler must save and restore registers itself.
The vector table is an array of branch instructions, not function pointers.
Return is an explicit
rfeia sp!or equivalent.
The trade-off: A-profile gives you more flexibility (you can split handlers across modes, share register banks, etc.) at the cost of writing more entry and exit code. Linux’s arch/arm/kernel/entry-armv.S is several hundred lines of the same pattern. Correct, but intimidating to read the first time.
We will write a smaller version. The pattern is identical.
15.2 The exception vector table¶
ARMv7-A has eight exception entries, each 4 bytes (one instruction). They must be 32-byte aligned, and the CPU jumps to the appropriate offset based on what happened:
Offset |
Exception |
Triggered by |
|---|---|---|
|
Reset |
POR, soft reset |
|
Undefined instruction |
UND opcode |
|
SVC (Supervisor Call) |
|
|
Prefetch abort |
Instruction-fetch fault |
|
Data abort |
Load/store fault |
|
Reserved |
(Was an “address exception” in ARMv4, unused now) |
|
IRQ |
External IRQ asserted by GIC |
|
FIQ |
External FIQ asserted by GIC |
Each entry is one instruction. Universally that instruction is b <label> or ldr pc, =<label> (the latter for far branches).
The table can live at one of two locations:
Low vectors at virtual
0x00000000, historical default. Conflicts with our OCRAM/DRAM layout.High vectors at virtual
0xFFFF0000, set by SCTLR.V=1.VBAR (Vector Base Address Register), modern: set VBAR to any aligned address. We use this.
VBAR is a CP15 register:
ldr r0, =_vectors
mcr p15, 0, r0, c12, c0, 0 @ VBAR <- r0
After this write (plus an isb), exceptions vector to our table wherever we put it.
15.3 The new vector table¶
vectors.S:
.syntax unified
.cpu cortex-a7
.section .vectors, "ax"
.align 5 @ 32-byte aligned
.global _vectors
_vectors:
ldr pc, =reset_handler @ +0x00 Reset
ldr pc, =undef_handler @ +0x04 Undefined
ldr pc, =svc_handler @ +0x08 SVC
ldr pc, =prefetch_handler @ +0x0C Prefetch abort
ldr pc, =data_handler @ +0x10 Data abort
ldr pc, =unused_handler @ +0x14 (reserved)
ldr pc, =irq_entry @ +0x18 IRQ
ldr pc, =fiq_handler @ +0x1C FIQ
.text
.global reset_handler
reset_handler:
b _start @ defined in startup.S
undef_handler:
prefetch_handler:
data_handler:
unused_handler:
svc_handler:
fiq_handler:
b . @ stop here: branch to self forever
.global irq_entry
irq_entry:
/*
* On entry to IRQ mode:
* LR_irq = PC of interrupted instruction + 4
* SPSR_irq = saved CPSR
* CPSR.M = IRQ (0x12), CPSR.I = 1 (IRQs masked)
* r0..r12 = whatever was running
*/
sub lr, lr, #4 @ LR_irq = PC_interrupted + 4 on IRQ entry.
@ Subtract 4 so RFE resumes at the interrupted
@ instruction.
/* Save the interrupted state to the IRQ-mode stack as a "return frame". */
srsdb sp!, #0x12 @ store LR_irq and SPSR_irq to IRQ stack
/* Switch to SVC mode for the body of the handler (still with IRQs masked).
This way we use a more spacious stack and can call C functions safely. */
cpsid i, #0x13 @ mode=SVC, IRQ masked
push {r0-r3, r12, lr} @ save caller-saved regs
bl c_irq_dispatch @ <-- the C interrupt handler
pop {r0-r3, r12, lr}
/* Switch back to IRQ mode and return via RFE */
cpsid i, #0x12
rfeia sp! @ pop {LR_irq, SPSR_irq} -> PC, CPSR
What is happening:
The
ldr pc, =symform rather thanb symis used becausebhas a limited branch range, and our handler labels may be far away in flash or DRAM. This form loads the full handler address intopc.sub lr, lr, #4beforesrsdb. The CPU putPC_interrupted + 4inLR_irq. ARM defines a fixed return offset per exception: 4 for IRQ, 4 for prefetch abort, 8 for data abort, 0 for SVC. For IRQ we subtract 4 to land back on the interrupted instruction.srsdb sp!, #0x12stores{LR, SPSR}to the IRQ-mode stack pointer. Mode 0x12 = IRQ. Thedb(decrement-before) and!(writeback) make it a stack push.cpsid i, #0x13switches to SVC mode and masks IRQs (which were already masked, but explicit). After this, we are on the SVC-mode stack.push {r0-r3, r12, lr}saves the caller-saved registers AAPCS expects us to preserve across the C function call.bl c_irq_dispatchbranches to the C interrupt dispatcher and stores the return address inlr. When the C function returns,spis back where it was.cpsid i, #0x12moves back to IRQ mode (sorfeia sp!pops from the IRQ stack, where we pushed insrsdb).rfeia sp!pops two words: PC and CPSR. The CPU resumes with that PC and that mode/CPSR. Masking of IRQs is automatically restored from the SPSR we saved.
15.4 Setting up VBAR and a separate IRQ stack¶
In startup.S, after the existing prologue, add:
/* Install vector table */
ldr r0, =_vectors
mcr p15, 0, r0, c12, c0, 0 @ VBAR
isb
/* IRQ mode needs its own stack. Switch to IRQ mode, set sp_irq, return. */
cps #0x12 @ mode = IRQ (no mask change)
ldr sp, =_irq_stack_top
cps #0x13 @ back to SVC
In the linker script, reserve an IRQ stack:
SECTIONS
{
...
.irq_stack (NOLOAD) : ALIGN(8) {
. += 4096; /* 4 KB IRQ stack */
_irq_stack_top = .;
} > OCRAM
}
4 KB is generous. We will not stack deeply in an ISR.
15.5 The GIC v2 distributor + CPU interface¶
GIC v2 has two memory-mapped regions:
Distributor:
0x00A01000, 4 KB. Configures priorities, enables, sets targets, sees all interrupts in the system.CPU Interface:
0x00A02000, 4 KB. Acknowledges interrupts, ends interrupts, masks based on priority. Per-CPU on multi-core. Here we have one core.
Registers we will use (offsets within their region):
Distributor (GICD_*)¶
Register |
Offset |
Purpose |
|---|---|---|
|
|
Enable distributor (bit 0) |
|
|
Read: number of supported IRQs |
|
|
Enable bit per IRQ (bit |
|
|
Disable (write-1-to-clear) |
|
|
Set pending |
|
|
Clear pending |
|
|
Priority, 8 bits each, 256 bytes for 256 IRQs |
|
|
Target CPU mask. Per IRQ for SPI. Fixed for PPI and SGI. |
|
|
Trigger type (edge/level), 2 bits per IRQ |
CPU Interface (GICC_*)¶
Register |
Offset |
Purpose |
|---|---|---|
|
|
Enable CPU interface |
|
|
Priority mask. Must allow the IRQ priority. |
|
|
Binary point (we set 0 = full priority resolution) |
|
|
Read: pending IRQ ID + ack |
|
|
Write: end-of-interrupt |
|
|
Running priority |
|
|
Highest priority pending |
A typical IRQ flow:
peripheral asserts SPI line
↓
GIC distributor sees it, latches in ISPENDR
↓
distributor compares priority against running priority on each CPU
↓
selects highest-priority CPU, asserts IRQ signal to that core
↓
core takes IRQ exception → our irq_entry
↓
our handler reads GICC_IAR → gets the IRQ ID (e.g., 58 for UART1)
↓
dispatch on ID → call peripheral's ISR
↓
write IRQ ID to GICC_EOIR (signal "I'm done")
↓
return from exception → resume interrupted code
The pattern is identical in every GIC-based system, including the kernel.
15.6 GIC bring-up code¶
gic.h:
#ifndef GIC_H
#define GIC_H
#include <stdint.h>
typedef void (*irq_handler_t)(void);
void gic_init(void);
void gic_register(uint32_t irq_id, irq_handler_t fn);
void gic_enable_irq(uint32_t irq_id);
void gic_disable_irq(uint32_t irq_id);
void c_irq_dispatch(void); /* called from irq_entry assembly */
static inline void irq_enable(void) { asm volatile ("cpsie i" ::: "memory"); }
static inline void irq_disable(void) { asm volatile ("cpsid i" ::: "memory"); }
#endif
gic.c:
#include "gic.h"
#define REG(addr) (*(volatile uint32_t *)(addr))
#define GICD_BASE 0x00A01000
#define GICC_BASE 0x00A02000
#define GICD_CTLR (GICD_BASE + 0x000)
#define GICD_TYPER (GICD_BASE + 0x004)
#define GICD_ISENABLER(n) (GICD_BASE + 0x100 + 4*(n))
#define GICD_ICENABLER(n) (GICD_BASE + 0x180 + 4*(n))
#define GICD_IPRIORITYR(n) (GICD_BASE + 0x400 + (n))
#define GICD_ITARGETSR(n) (GICD_BASE + 0x800 + (n))
#define GICD_ICFGR(n) (GICD_BASE + 0xC00 + 4*(n))
#define GICC_CTLR (GICC_BASE + 0x000)
#define GICC_PMR (GICC_BASE + 0x004)
#define GICC_BPR (GICC_BASE + 0x008)
#define GICC_IAR (GICC_BASE + 0x00C)
#define GICC_EOIR (GICC_BASE + 0x010)
#define MAX_IRQ 192 /* GIC reports 32 + 32×N total */
static irq_handler_t handlers[MAX_IRQ];
void gic_init(void)
{
/* Read how many interrupts the distributor supports. */
uint32_t typer = REG(GICD_TYPER);
uint32_t num_lines = ((typer & 0x1F) + 1) * 32;
if (num_lines > MAX_IRQ) num_lines = MAX_IRQ;
/* Disable all interrupts at the distributor. */
for (uint32_t i = 0; i < num_lines; i += 32) {
REG(GICD_ICENABLER(i/32)) = 0xFFFFFFFFu;
}
/* Default priority = 0xA0 (medium-low), targets = CPU0 for all SPIs. */
for (uint32_t i = 32; i < num_lines; i++) {
((volatile uint8_t *)(GICD_BASE + 0x400))[i] = 0xA0;
((volatile uint8_t *)(GICD_BASE + 0x800))[i] = 0x01; /* CPU0 */
}
/* Enable distributor. */
REG(GICD_CTLR) = 1;
/* CPU interface: priority mask wide open, no binary point. */
REG(GICC_PMR) = 0xFF;
REG(GICC_BPR) = 0x00;
REG(GICC_CTLR) = 1;
}
void gic_register(uint32_t irq, irq_handler_t fn)
{
if (irq < MAX_IRQ) handlers[irq] = fn;
}
void gic_enable_irq(uint32_t irq)
{
REG(GICD_ISENABLER(irq/32)) = 1u << (irq & 0x1F);
}
void gic_disable_irq(uint32_t irq)
{
REG(GICD_ICENABLER(irq/32)) = 1u << (irq & 0x1F);
}
void c_irq_dispatch(void)
{
uint32_t iar = REG(GICC_IAR);
uint32_t irq = iar & 0x3FF;
if (irq == 1023) return; /* spurious: IAR returns 1023 when no IRQ is active */
if (irq < MAX_IRQ && handlers[irq]) handlers[irq]();
REG(GICC_EOIR) = iar; /* end of interrupt */
}
A note on the GICD_IPRIORITYR writes: each IRQ has one byte of priority, not one word. The array 0x400..0x4FF is 256 bytes covering IRQ 0..255. We index by byte, which is why the cast to volatile uint8_t * is there.
15.7 Hooking up the UART1 IRQ¶
UART1’s IRQ is shown as IRQ 26 in the i.MX6ULL RM Table 3-1 (interrupt assignments). That number is the SPI (Shared Peripheral Interrupt) offset. The GIC’s own ID space numbers SGIs 0-15, PPIs 16-31, and SPIs from 32 upward, so the GIC INTID for UART1 is 32 + 26 = 58. We pass 58 to gic_register() and gic_enable_irq(). Different SoC docs use one convention or the other. Once you internalize “RM number + 32 = GIC INTID for SPIs,” the rest is bookkeeping.
Modify uart_init (Chapter 12) to enable the RX-ready interrupt:
void uart_irq_enable(void)
{
REG(UART_UCR1) |= (1u << 9); /* RRDYEN: receive ready IRQ enable */
}
Write a UART ISR:
#include "uart.h"
#include "gic.h"
static volatile int rx_count;
static void uart1_isr(void)
{
while (REG(UART_USR2) & USR2_RDR) {
int c = REG(UART_URXD) & 0xFF;
uart_putc(c); /* echo */
rx_count++;
}
}
void uart1_install_isr(void)
{
gic_register(58, uart1_isr);
gic_enable_irq(58);
uart_irq_enable();
}
And in main():
int main(void)
{
/* ... clocks, ddr, etc. as before ... */
gic_init();
uart1_install_isr();
irq_enable(); /* unmask CPSR.I */
printf("Interrupt-driven echo. Type to test.\r\n");
for (;;) {
asm volatile ("wfi"); /* sleep until interrupt */
}
}
wfi (Wait For Interrupt) puts the core to sleep until an interrupt fires. After each ISR returns, we resume here, immediately re-enter wfi. Power-efficient idle.
The echo is now driven entirely by the UART1 ISR. The main thread only sleeps.
15.8 What happens when you type a character¶
Pin-level: dongle TX pulls UART1_RX_DATA from idle high to start bit.
UART1 receiver shifts in 8 bits, raises
USR2.RDR.Because we set
UCR1.RRDYEN, the UART asserts its interrupt line.GIC distributor: IRQ 58 becomes pending.
Distributor: IRQ 58’s priority (0xA0) ≤ CPU’s running priority mask (0xFF), so it asserts IRQ to CPU.
CPU takes IRQ exception:
CPSR ↦ SPSR_irq
CPSR.M ↦ IRQ, CPSR.I ↦ 1
SP and LR banked to IRQ-mode
PC ↦ VBAR + 0x18 ↦ our table’s IRQ slot ↦
irq_entry
irq_entryruns: subtracts 4 from LR, srsdb’s the return frame, switches to SVC mode, pushes scratch regs, callsc_irq_dispatch.c_irq_dispatchreadsGICC_IAR(returns 58), looks up handlers[58], callsuart1_isr.uart1_isrreads the character, callsuart_putc(c)(which polls TX), incrementsrx_count.Back to dispatch. Write 58 to
GICC_EOIR.Return to
irq_entry: pop scratch regs, switch to IRQ mode,rfeia sp!: restores SPSR (which has SVC mode and IRQ-unmasked), PC back to whatever was running.Resumed
wfireturns. Loop body runs. We hitwfiagain.
Eleven steps. Every Linux IRQ in user space follows the same pattern.
15.9 Lab¶
Build, push, type characters, see them echo. Confirm IRQ-driven.
Replace polling printf with IRQ-driven printf. Wrap
uart_putcin a small queue. When TX FIFO has space (UCR1.TRDYEN), drain queue from ISR. Now yourprintfreturns immediately.Count IRQs. Increment
rx_countin the ISR. After 1000 characters, dump it frommain. Confirm exact match.Try without
wfi. Replacefor(;;){wfi}withfor(;;). The program still works, but idle power is higher. You may not see the power difference without measuring it.Trigger a data abort. From
main, do*(volatile uint32_t *)0x1 = 0;. Confirmdata_handler(currentlyb .) is hit. Add aprintftodata_handler(it must run in ABT mode). The simple debug path is to halt and inspect with JTAG. The fuller path is to copy thecpsidpattern and switch to SVC.
MCU bridge: Think of JTAG like SWD debugging on Cortex-M: halt, read registers, set breakpoints. The Cortex-A path adds MMU state, privilege modes, and more complex reset behavior. JTAG: the hardware debug scan chain used to halt, inspect, and single-step CPUs.
Add an SVC instruction (
asm volatile ("svc #0")) and observe the SVC handler is hit. This is the foundation of syscalls.
15.10 Pitfalls¶
Forgetting
isbafter VBAR write. The CPU may keep using stale vectors. Alwaysisb.Wrong IRQ-mode return offset. IRQ uses
-4, prefetch abort uses-4, data abort uses-8. Mismatched: you re-execute or skip the faulting instruction.srsdbto the wrong mode. The encoded mode bits must match the current mode you’re saving for.0x12= IRQ.Not enabling at both distributor and CPU interface.
GICD_CTLR.EnableandGICC_CTLR.Enablemust both be 1.Priority mask too low.
GICC_PMR = 0blocks all interrupts.GICC_PMR = 0xFFallows all. Default mask of0xFFon init.Forgetting EOI. If you don’t write
GICC_EOIR, the GIC thinks the interrupt is still active and won’t deliver the next instance.Re-entrant ISR for a non-reentrant peripheral. Don’t enable IRQs inside an ISR unless you know what you’re doing.
cpsie iin user-mode code. PL0 can’t change CPSR.I. We’re always in SVC, so fine.
15.11 Going deeper¶
ARM IHI 0048B: Generic Interrupt Controller v2 Architecture Specification. The canonical reference.
ARM DDI 0464: Cortex-A7 MPCore TRM. The CPU side of interrupts.
Linux source:
arch/arm/kernel/entry-armv.S: the production-grade version ofirq_entry. Read it after this chapter.Linux source:
drivers/irqchip/irq-gic.c: kernel’s GIC driver. Same registers, vastly more abstraction.xv6-arm (an educational port of xv6 to ARMv7), has a small, readable interrupt subsystem.
Next chapter: Chapter 16: Timers (EPIT and GPT). A 1 ms tick and a free-running counter give us
udelay, profiling, and the foundation for any scheduler we might write.