In the previous post, we got our UART up and running using simple polling to send and receive data. While polling is straightforward, it’s also inefficient — the CPU must constantly check for new input, wasting cycles that could be used more effectively elsewhere.
Now it’s time to take the next step: setting up a basic exception handling framework. This will eventually allow hardware devices like the UART to notify the CPU when they’re ready, enabling more efficient, event-driven communication.
In this post, I’ll walk through building the foundation for general exception handling on AArch64, independent of any specific device. While true interrupt-driven behavior is the goal, we’ll begin by focusing on synchronous exceptions — those triggered directly by the CPU, such as supervisor calls (svc).
Exceptions, interrupts and traps
Modern CPUs don’t just execute instructions blindly — they need a way to react to the unexpected. Whether it’s a program trying to divide by zero, a peripheral device needing attention, or a user space process requesting a system call to the OS, the CPU must be able to pause what it’s doing and handle something else. This is where exceptions come in. Exceptions are the CPU’s built-in mechanism for detecting and responding to events that require special handling. They let the system deal with both expected transitions, like system calls, and unexpected conditions, like hardware faults or external interrupts.
The term exception refers to any condition that causes the CPU to temporarily suspend normal execution and jump to a predefined handler. Within that, interrupts are asynchronous — they’re triggered by external devices signaling the CPU — while traps are intentional, synchronous exceptions, such as a system calls invoked by a user program. By distinguishing these types, the OS can apply the right kind of response: ignore, retry, kill the faulty process, or reschedule tasks — all without losing control of the system.
Interrupt and Exception types in AArch64
On the AArch64 architecture, exceptions are categorized into four types, each representing a different kind of event the CPU may need to respond to.
- Synchronous exceptions: Occur as a direct result of executing an instruction — for example, a system call (svc) or an invalid memory access. These exceptions are deterministic and always happen in-line with program execution, making them critical for things like fault handling and syscall dispatch.
- IRQ (Interrupt Requests): General-purpose hardware interrupts, used by devices like timers or UARTs to signal the CPU asynchronously. They occur asynchronously, meaning they can interrupt the processor at almost any point, regardless of the current instruction.
- FIQ (Fast Interrupt Requests): Similar to IRQs, but with higher priority and typically used for latency-sensitive operations. While supported by the architecture, FIQs are less commonly used in modern systems, which often rely solely on IRQs for interrupt handling.
- SError (System Error): Represents serious, asynchronous hardware faults, such as memory errors or bus faults detected by the interconnect. These are typically raised by the system infrastructure and can indicate underlying hardware issues. Proper handling is essential for system reliability and recovery.
Setting Up the Exception Vector Table
To handle exceptions in AArch64, the CPU needs to know where to jump when something happens — whether it’s an interrupt, a fault, or a system call. This is done through the Exception Vector Table (EVT), which contains a set of predefined entry points for each type of exception the CPU can raise. (Vector tables in AArch64 are different to many other processor architectures as they contain instructions, not addresses.)
In AArch64, the vector table’s address is stored in the VBAR_ELx registers (Vector Base Address Register), depending where the interrupt will be handled. Since we’re currently running in Exception Level 1 (EL1) — the typical privilege level for an OS kernel — we’ll be working with VBAR_EL1 for now.
EVT Layout
When an exception occurs, the CPU chooses the handler address by computing an offset from VBAR_EL1. The offset depends on:
- Execution state (AArch64 vs AArch32)
- Whether the exception was synchronous, IRQ, FIQ, or SError
- Whether it came from the same EL or a lower EL
This gives us 16 possible entry points (2 execution states x 4 exception types × 2 source levels). The complete exception vector table looks like this:
Address | Exception Type | Description |
---|---|---|
VBAR_ELx + 0x780 |
SError/VSError |
Exception from a lower EL and all lower ELs are AArch32 |
VBAR_ELx + 0x700 |
FIQ/vFIQ |
|
VBAR_ELx + 0x680 |
IRQ/vIRQ |
|
VBAR_ELx + 0x600 |
Synchronous |
|
VBAR_ELx + 0x580 |
SError/VSError |
Exception from a lower EL and at least one lower EL is AArch64 |
VBAR_ELx + 0x500 |
FIQ/vFIQ |
|
VBAR_ELx + 0x480 |
IRQ/vIRQ |
|
VBAR_ELx + 0x400 |
Synchronous |
|
VBAR_ELx + 0x380 |
SError/VSError |
Exception from the current EL while using SP_ELx |
VBAR_ELx + 0x300 |
FIQ/vFIQ |
|
VBAR_ELx + 0x280 |
IRQ/vIRQ |
|
VBAR_ELx + 0x200 |
Synchronous |
|
VBAR_ELx + 0x180 |
SError/VSError |
Exception from the current EL while using SP_EL0 |
VBAR_ELx + 0x100 |
FIQ/vFIQ |
|
VBAR_ELx + 0x080 |
IRQ/vIRQ |
|
VBAR_ELx + 0x000 |
Synchronous |
Each entry in the EVT occupies 0x80 bytes, which allows for up to 32 instructions (32 instructions x 4 bytes each = 0x80 bytes) per slot. This space is typically used for a small trampoline that saves context, optionally switches stacks, and jumps to the full exception handler (written in Rust in our case). The entries are laid out in a fixed order and spacing, based on the exception type (Synchronous, IRQ, FIQ, SError), the exception level it originated from (EL0 or EL1), and the execution state (AArch64 vs AArch32). The first four entries handle exceptions from a lower level (EL0), and the next four handle exceptions from the current level (e.g., EL1) using SP_EL1.
With this setup in mind—and since we currently don’t have a concept of user-space processes—the EVT will only handle exceptions taken in EL1. That means we’re focusing on exceptions triggered by the kernel itself, such as IRQs or system calls issued while already in EL1. In a future post, once user-space support is added and the system runs code at EL0, we’ll revisit the vector table to implement proper handling for exceptions coming from EL0. For now, the EVT will be implemented like this:
.section .text
.global evt
.balign 0x800 # 2048-byte alignment required by VBAR_ELx
evt:
.skip 0x200 # Unimplemented for now
sync_trampoline: # 0x200: Synchronous exceptions from EL1
.balign 0x80
irq_trampoline: # 0x280: IRQ from EL1
.balign 0x80
fiq_trampoline: # 0x300: FIQ from EL1
.balign 0x80
serror_trampoline: # 0x380: SError from EL1
Loading the EVT
Once the table is defined, you install it using the msr instruction:
ldr x0, =evt
msr VBAR_EL1, x0
isb
The isb (instruction synchronization barrier) is important — it ensures that the new vector base is visible before any further instructions are executed.
Implementing the Synchronous Exception Handler
Now that the EVT is active and correctly routed, we can implement the first real handler. We’ll begin with the synchronous exception path, which is commonly triggered by instructions like svc.
Because each exception vector entry is limited to just 32 instructions, it’s common practice to use these entries as trampolines. Instead of implementing the full logic directly in the EVT slot, the entry simply branches to a separate, dedicated handler. For example, the synchronous exception slot points to a label namedsync_trampoline, which immediately branches to sync_handler, where the actual logic resides. With this modification, the slot looks like:
sync_trampoline:
b sync_handler
The handler’s primary task is to save the current CPU context. This is handled by two assembly macros — saveregs and restoreregs — defined in a separate macros.inc file. These macros store all general-purpose registers onto the stack in a fixed 256-byte layout using paired store instructions, then restore them later in reverse order. This layout matches a Rust Regs struct, allowing the exception handler to safely pass a pointer to the saved state into Rust code for further processing.
Here’s how these macros are defined:
.macro saveregs
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #-16]!
stp x4, x5, [sp, #-16]!
stp x6, x7, [sp, #-16]!
stp x8, x9, [sp, #-16]!
stp x10, x11, [sp, #-16]!
stp x12, x13, [sp, #-16]!
stp x14, x15, [sp, #-16]!
stp x16, x17, [sp, #-16]!
stp x18, x19, [sp, #-16]!
stp x20, x21, [sp, #-16]!
stp x22, x23, [sp, #-16]!
stp x24, x25, [sp, #-16]!
stp x26, x27, [sp, #-16]!
stp x28, x29, [sp, #-16]!
stp x30, xzr, [sp, #-16]!
.endm
.macro restoreregs
ldp x30, xzr, [sp], #16
ldp x28, x29, [sp], #16
ldp x26, x27, [sp], #16
ldp x24, x25, [sp], #16
ldp x22, x23, [sp], #16
ldp x20, x21, [sp], #16
ldp x18, x19, [sp], #16
ldp x16, x17, [sp], #16
ldp x14, x15, [sp], #16
ldp x12, x13, [sp], #16
ldp x10, x11, [sp], #16
ldp x8, x9, [sp], #16
ldp x6, x7, [sp], #16
ldp x4, x5, [sp], #16
ldp x2, x3, [sp], #16
ldp x0, x1, [sp], #16
.endm
And here’s how it is mapped in the rust code:
#[derive(Clone, Copy, Debug)]
#[repr(C)]
pub struct Regs {
x0: u64,
x1: u64,
x2: u64,
x3: u64,
x4: u64,
x5: u64,
x6: u64,
x7: u64,
x8: u64,
x9: u64,
x10: u64,
x11: u64,
x12: u64,
x13: u64,
x14: u64,
x15: u64,
x16: u64,
x17: u64,
x18: u64,
x19: u64,
x20: u64,
x21: u64,
x22: u64,
x23: u64,
x24: u64,
x25: u64,
x26: u64,
x27: u64,
x28: u64,
x29: u64,
x30: u64,
zr: u64
}
With the context-preserving machinery in place, we now expand our handler to do actual work. The handler begins by saving the CPU state. Then performs basic decoding of the ESR_EL1 to distinguish supervisor calls (svc) from other exceptions, forwarding the request to do_sync in Rust along with the register context. If the exception is unrecognized, it falls back to a stub handler unimplemented_sync, that prints the kind of exception.
.equ IS_SVC_MASK, 0x15
.equ SVC_NR_MASK, 0xFFFF
sync_handler:
sub sp, sp, #256
saveregs
mrs x0, ESR_EL1
lsr x1, x0, #26
mov w2, IS_SVC_MASK
and w2, w1, w2
cmp w2, w2
bne unhandled
and w1, w0, SVC_NR_MASK
mov x0, sp
bl do_sync
b sync_ret
unhandled:
mov w0, w1
bl unimplemented_sync
sync_ret:
restoreregs
add sp, sp, #256
eret
With the exception decoding and context passing wired up, we now implement the Rust-side handler that responds to supervisor calls. The do_sync function receives the full register context (will be used in future posts) and the syscall number extracted from ESR_EL1. For now, it converts the syscall number to its ASCII representation and prints it over UART.
#[no_mangle]
pub extern "C" fn do_sync(_regs: *mut Regs, nr: u32) {
let mut buf = [0u8;10];
let nr_str = u32_to_str(nr, &mut buf);
uart::print(b"Requested syscall: ");
uart::print(nr_str);
uart::print(b"\n");
}
Masking Interrupts
To complete the exception setup, we must ensure that interrupts are properly masked during sensitive operations. In AArch64, the DAIF register controls the masking of interrupts — Debug, SError, IRQ, and FIQ — through its four bits. Setting these bits using the DAIFSet system instruction prevents asynchronous exceptions from interfering while configuring or transitioning through exception levels. The following snippet masks all interrupts and sets the exception vector base, finishing the low-level setup:
# Mask all interrupts
msr DAIFSet, #0b1111
ldr x0, =evt
msr VBAR_EL1, x0
isb
For now, our priority is simply to ensure that no asynchronous exceptions interfere while we bring up the rest of the kernel. By masking all interrupt types early using the DAIF register, we gain full control over when and how exceptions are handled. We won’t be unmasking or handling individual interrupt sources just yet — that will be addressed in future posts.
Testing
To test our exception handling setup, we insert a simple svc #0 instruction just before the call to kmain. This triggers a supervisor call as soon as the kernel starts, allowing us to verify that the exception is correctly captured, decoded, and passed to our Rust handler. It’s a minimal but effective way to validate the full exception flow from the vector table down to the Rust layer. Below is a short recording of the output in QEMU demonstrating that everything is wired up correctly.

Next steps
At this point, we’ve successfully reviewed the AArch64 exception model and implemented a working handler for synchronous exceptions, including supervisor calls (svc). This lays the groundwork for building more complex system-level functionality. In the next post, we’ll move on to handling IRQ exceptions and setting up a proper interrupt controller. If you’d like to follow the development or explore the full source code, the project is available on GitHub.