Writing a Tiny Hypervisor for ARMv8-A Part 2

Van C. Ngo · June 13, 2026

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, where is a number between 0 and 3. The higher the level of privilege the higher the number.

ARMv8-A exception levels

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 hvc instruction 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 (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.*) }
}