Exception Levels
In this part, we will learn how to transit from EL3 to EL2. Recall from previous part, the name for privilege in ARMv8 is exception level, often abbreviated to EL. The exception levels are numbered, normally abbreviated and referred to as EL

The exception level can only change when any of the following occur:
- Taking an exception
- Returning from an exception
- Processor reset
- During Debug state
- Exiting from Debug state
When taking an exception the EL can increase or stay the same. You can never move to a lower privilege level by taking an exception. When returning from an exception the EL can decrease or stay the same. You can never move to a higher privilege level by returning from an exception.
An exception is any event that can cause the currently executing program to be suspended. There are 4 exception types:
- Synchronous exceptions
- IRQ - standard external HW interrupts
- FIQ - fast external HW interrupts
- SError - system errors
Structure of Vector Table
For ARMv8-A profile (aarch64), vector table must be 2KB or 2^11 aligned. Each EL has its own table (set via the VBAR_ELx registers). Each vector table is divided into 4 blocks and each block contains 4 target exception types. This creates a matrix of exactly 16 vector entries. And each individual entry is allocated exactly 128 bytes (32 instructions) of dedicated space. This unique design means that if your handler is short (e.g., a few assembly lines to save registers and branch to C), we can write the code directly inside the vector slot rather than jumping out immediately.
For example, a vector table for EL3 can be defined as below:
.section ".text.vectors"
// Make the tables visible to other code
.global vector_table_el3
// Re-use the hang loop from other code for unresolved handlers
.extern hang
// -----------------------------------------------------------------------------
// Minimal Labeled Vector Table (Each entry must be exactly 128 bytes apart)
// -----------------------------------------------------------------------------
.align 11
vector_table_el3:
// Current EL with SP0: synchronous, irq, firq, system error exceptions
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Current EL with SPx
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch64
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch32
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
From EL3 to EL2
We must use a returning from an exception to decrease the EL by executing the eret instruction.
However, before executing eret, we must configure the following system registers to transit from EL3 to EL2.
Secure Configuration Register
The core bits inside the Secure Configuration Register (SCR_EL3) configure the environment for the lower levels:
- Bit 0 (NS - Non-Secure): Setting this to 1 transitions the lower exception levels (EL2/EL1/EL0) to the Non-Secure security state.
- Bit 8 (HCE - Hypervisor Call Enable): Setting this to 1 explicitly enables the
hvcinstruction at EL1 and EL2. - Bit 10 (RW - Register Width): Setting this to 1 forces the next lower exception level (EL2) to execute in AArch64 state.
So we need to set SCR_EL3 as following:
mov x0, #((1 << 10) | (1 << 8) | (1 << 0))
msr scr_el3, x0
Saved Program Status Register
ARMv8 has a concept of processor state known as PSTATE, it is stored in the Saved Program Status Register (SPSR) when an exception is taken.
When the EL3 exception handler finishes, it executes the eret instruction, which copies the saved state from SPSR_EL3 back to PSTATE to resume
previous execution. So, we need to store the target level and stack pointer of EL2 which are configured by the bits M[3:0] as below:
- Bits [3:2]: Determining the EL,
0b00(EL0),0b01(EL1),0b10(EL2),0b11(EL3) - Bit [1]: Determining the state, 0 = aarch64, and 1 = aarch32
- Bit [0]: Determining the stack pointer, 0 = SP_EL0, 1 = SP_ELx (using the target EL)
In addition, it is critical and standard practice to mask all exceptions including Debug, SError, IRQ, and FIQ. They are configured by bits M[9:6].
For bits M[5:4], they need to be set 0b00. So we need to set SPSR_EL3 as following:
mov x0, #0x3c9
msr spsr_el3, x0
Exception Link Register
Exception Link Register (ELR) stores the target memory address that the eret instruction reads and jumps.
So we set the target memory address to the entry point of the EL2 hypervisor as following:
ldr x0, =el2_entry
msr elr_el3, x0
Implementation
The full implementation of startup code, start.S:
.section ".text.boot"
.global _start
// References to the vector tables defined in vector.S
.global vector_table_el3
.global vector_table_el2
_start:
// 1. Check current Exception Level (should be EL3)
mrs x0, CurrentEL
and x0, x0, #0xc
cmp x0, #0xc // 0xC means EL3
b.ne hang // If not EL3, halt
// 2. Setup Stack Pointer for EL3
ldr x0, =__stack_el3_top
mov sp, x0
// 3. Set Vector Base Address Register for EL3
ldr x0, =vector_table_el3
msr vbar_el3, x0
// 4. Configure SCR_EL3 (Secure Configuration Register)
// Non-secure (bit 0), enabe HVC (bit 8), and EL2 is aarch64 (bit 10)
mov x0, #((1 << 10) | (1 << 8) | (1 << 0))
msr scr_el3, x0
// 5. Configure SPSR_EL3 (Saved Program Status Register)
mov x0, #0x3c9 // EL2h, all interrupts masked
msr spsr_el3, x0
// 6. Set preferred return address (the EL2 entry point)
ldr x0, =el2_entry
msr elr_el3, x0
// 7. Perform the pseudo-return to drop to EL2
eret
el2_entry:
// We are now running at EL2!
// Setup Stack Pointer for EL2
ldr x0, =__stack_el2_top
mov sp, x0
// Set Vector Base Address Register for EL2
ldr x0, =vector_table_el2
msr vbar_el2, x0
// Call our main C function
bl main
.global hang
hang:
wfe
b hang
The full implementation of vector tables, vector.S:
.section ".text.vectors"
// Make the tables visible to start.S
.global vector_table_el3
.global vector_table_el2
// Re-use the hang loop from start.S for unresolved handlers
.extern hang
// -----------------------------------------------------------------------------
// Minimal Labeled Vector Table (Each entry must be exactly 128 bytes apart)
// -----------------------------------------------------------------------------
.align 11
vector_table_el3:
// Current EL with SP0: synchronous, irq, firq, system error exceptions
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Current EL with SPx
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch64
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch32
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 11
vector_table_el2:
// Current EL with SP0
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Current EL with SPx
.align 7; b el2_sync_handler
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch64
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
// Lower EL using AArch32
.align 7; b hang
.align 7; b hang
.align 7; b hang
.align 7; b hang
el2_sync_handler:
b hang
The full implementation of main function, hypervisor.c:
#include <stdint.h>
// A simple function to read the current Exception Level
static inline unsigned int get_current_el(void) {
uint64_t current_el;
__asm__ volatile ("mrs %0, CurrentEL" : "=r" (current_el));
return (unsigned int)((current_el >> 2) & 0x3);
}
int main(void) {
volatile unsigned int current_el = get_current_el();
// Verify current_el equals 2 (EL2)
if (current_el == 2) {
// Safe to initialize hypervisor tasks, stage-2 MMU tables, etc.
volatile int success = 1;
}
// Baremetal payloads should never return
while(1) {
__asm__ volatile("wfi");
}
return 0;
}
And the linker script to run with QEMU virtual machine, linker.ld:
ENTRY(_start)
SECTIONS
{
/* Position codebase at common RAM start (e.g., QEMU virt machine base) */
. = 0x40000000;
.text : {
KEEP(*(.text.boot))
*(.text .text.*)
}
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : {
. = ALIGN(16);
__bss_start = .;
*(.bss .bss.*)
*(COMMON)
__bss_end = .;
}
/* Stacks dedicated to Exception Levels */
. = ALIGN(16);
. = . + 0x1000;
__stack_el3_top = .;
. = . + 0x1000;
__stack_el2_top = .;
/DISCARD/ : { *(.comment) *(.note.*) }
}
