ARM64 OS Handbook
🔍

Chapter 10: Exception Levels

What You Will Learn in This Chapter
  • What exception levels are and what each one does
  • How exception levels implement privilege separation
  • How to transition between exception levels
  • The security model: Secure World vs Normal World
  • How virtualization uses EL2
  • How our kernel manages EL1 (kernel) and EL0 (user space)
  • The PSTATE register and the M field for exception level encoding

10.1 What are Exception Levels?

Exception levels (EL) are privilege levels on ARM64. They define what a program running on the CPU can and cannot do. Higher exception levels have more privileges. Lower levels have fewer.

ARM64 defines four exception levels:

LevelNamePurpose
EL0User spaceApplications run here. Most restricted.
EL1OS kernelOperating system kernel runs here. Our kernel.
EL2HypervisorVirtual machine monitor (optional). Used for virtualization.
EL3Secure monitorLowest-level firmware. Manages secure vs non-secure world.

Each exception level adds capabilities:

  • EL0 can only access its own memory and use unprivileged instructions
  • EL1 has access to system registers (MMU, interrupt control, timer) and can enable/disable interrupts
  • EL2 can configure virtualization, trap EL1 accesses, and manage multiple guest OSes
  • EL3 controls the secure monitor, can switch between Secure and Normal worlds

10.2 The Privilege Model

The exception level model is hierarchical. Code running at a higher level can:

  • Access all system registers accessible to lower levels, plus additional ones
  • Configure what lower levels can and cannot do
  • Intercept and emulate accesses from lower levels (trap)
  • Read and write memory belonging to lower levels
  • Change the exception level of the current CPU

Code running at a lower level:

  • Cannot access system registers of higher levels
  • Cannot read or write memory belonging to higher levels (if MMU is configured)
  • Cannot change its own exception level
  • Can only access restricted system registers

This hierarchy is what makes operating systems possible. A bug in a user-space program at EL0 cannot corrupt kernel memory at EL1. A bug in the kernel at EL1 cannot corrupt the hypervisor at EL2.

10.3 How Transitions Work

Exception level transitions happen in two directions:

Lower to Higher (Exception)

When code at a lower level needs a service from a higher level, it triggers an exception. The CPU:

  1. Saves the return address in ELR_ELx (e.g., ELR_EL1 for EL0 to EL1)
  2. Saves the current processor state in SPSR_ELx
  3. Changes to the target exception level
  4. Sets the stack pointer to the target level's SP
  5. Jumps to the exception vector for the target level

The exception can be triggered by:

  • SVC (supervisor call): EL0 to EL1 (system call)
  • HVC (hypervisor call): EL1 to EL2
  • SMC (secure monitor call): EL2 to EL3
  • Hardware interrupts: IRQ, FIQ, SError (asynchronous)
  • Synchronous exceptions: undefined instruction, page fault, alignment fault

Higher to Lower (Return)

When the higher level finishes handling the exception, it returns to the lower level using the ERET instruction:

/* Return from EL1 to EL0 */
msr ELR_EL1, x0     /* set the return address */
msr SPSR_EL1, x1    /* set the processor state to restore */
eret                 /* jump to the return address at EL0 */

The ERET instruction:

  1. Loads the processor state from SPSR_ELx
  2. Loads the return address from ELR_ELx
  3. Changes to the lower exception level encoded in SPSR
  4. Jumps to the return address

10.4 Reading the Current Exception Level

The CurrentEL system register reports the current exception level:

mrs x0, CurrentEL
lsr x0, x0, #2      /* EL field is in bits 3:2 */
/* x0 is now 0, 1, 2, or 3 */

CurrentEL is a read-only register accessible from any exception level. This is how our startup code detects whether we started at EL1 or EL2.

10.5 PSTATE and the M Field

PSTATE is the processor state. It is not a single register but a collection of fields that describe the current state of the CPU. One important field is M[3:0], which encodes the current exception level and execution state (AArch64 or AArch32).

M[3:0]Meaning
0b0000EL0t (AArch64, using SP_EL0)
0b0100EL1t (AArch64, using SP_EL0)
0b0101EL1h (AArch64, using SP_EL1)
0b1000EL2t (AArch64, using SP_EL0)
0b1001EL2h (AArch64, using SP_EL2)
0b1100EL3t (AArch64, using SP_EL0)
0b1101EL3h (AArch64, using SP_EL3)

The "t" suffix means the exception level uses SP_EL0 (the stack pointer from EL0). The "h" suffix means it uses its own dedicated stack pointer (SP_EL1, SP_EL2, or SP_EL3). Our kernel uses "h" mode at EL1 (EL1h), meaning it uses SP_EL1.

The PSTATE M field is used in two places:

  • SPSR_ELx: stores the M field when an exception occurs, so ERET knows which exception level to return to
  • After ERET: the CPU sets PSTATE.M to the value from SPSR
/* Example: returning from EL1 to EL0 */
msr ELR_EL1, x0         /* return address */
mov x1, #0x00000000      /* M=0b0000: EL0t (AArch64 user space) */
msr SPSR_EL1, x1
eret                      /* jump to EL0 */

10.6 Secure World vs Normal World

ARM64 has a security model that divides the system into two worlds:

  • Secure World: trusted code (secure boot, DRM, trusted applications)
  • Normal World: the main OS and applications (our kernel)

EL3 controls which world is active. The Secure Monitor at EL3 manages transitions between worlds using SMC (secure monitor call) instructions.

Each world has its own set of exception levels:

WorldEL0EL1EL2EL3
SecureSecure appsTrusted OSSecure hypervisorSecure monitor
NormalUser appsOur kernelHypervisor(not accessible)

Our kernel runs in Normal World, EL1. We do not interact with Secure World directly, except through SMC calls if needed for power management on real hardware.

10.7 Virtualization and EL2

EL2 is used for virtualization. A hypervisor at EL2 can run multiple guest operating systems at EL1, each thinking it has full control of the hardware.

The hypervisor does this by:

  • Trapping EL1 accesses to system registers that would affect other guests
  • Emulating device accesses from EL1
  • Managing the stage-2 MMU (second level of address translation)

Our kernel does not use EL2 directly. We run on bare metal at EL1. However, when we run on QEMU, QEMU's firmware may pass through EL2 before reaching our kernel. That is why our startup code checks for EL2 and drops to EL1 if necessary.

10.8 Stack Pointer Selection

Each exception level has two possible stack pointers:

  • SP_EL0: a shared stack pointer (used by EL0 and optionally by higher levels)
  • SP_ELx: a dedicated stack pointer for each exception level (EL1, EL2, EL3)

The SPSel register (bit 0 of PSTATE) selects which one is used:

SPSelStack Pointer UsedMode Name
0SP_EL0t mode (e.g., EL1t)
1SP_ELx (dedicated)h mode (e.g., EL1h)

Our kernel uses SPSel = 1 at EL1, giving us a dedicated kernel stack separate from the user stack. When we enter user space at EL0, we switch to SPSel = 0 so the user program uses SP_EL0.

/* Set EL1 to use SP_EL1 (dedicated kernel stack) */
msr SPSel, #1

/* Now 'mov sp, x0' modifies SP_EL1 */

10.9 Exception Level Transition Rules

Transitions follow strict rules:

  • Upward transitions (lower to higher): can only go to the next higher level directly. EL0 -> EL1, EL1 -> EL2, EL2 -> EL3. You cannot jump from EL0 to EL3 directly.
  • Downward transitions (higher to lower): can go to any lower level. EL3 can return to EL2, EL1, or EL0. EL1 can return to EL0.
  • EL3 can only be entered through an SMC instruction or a hardware reset.
  • EL2 can only be entered through an HVC instruction, an exception from a lower level, or a reset.

This one-hop rule for upward transitions ensures that each level can inspect and validate the request before passing it higher. For example, the kernel at EL1 can decide whether to forward an HVC from EL0 to the hypervisor at EL2.

10.10 Our Implementation

Our kernel uses two exception levels:

Exception LevelWhat Runs Here
EL0User-space applications (future chapters)
EL1Our kernel: scheduler, memory manager, device drivers, system call handlers

We do not use EL2 or EL3 directly. QEMU's firmware handles EL3 and EL2 before dropping to EL1 where our kernel starts.

EL0 to EL1: System Calls

When a user-space program needs kernel services (like reading a file or printing to the screen), it executes an SVC instruction:

/* User-space code making a system call */
mov x0, #1              /* syscall number: write */
mov x1, #msg            /* argument 1: buffer pointer */
mov x2, #13             /* argument 2: buffer length */
svc #0                  /* trap to EL1 */

The CPU transitions to EL1, and the kernel's exception handler determines which system call was requested and executes it.

EL1 to EL0: Returning to User Space

When the kernel has finished handling a system call or is starting a new process, it returns to EL0 using ERET:

/* Return to user space */
msr ELR_EL1, x0         /* address of first user instruction */
mov x1, #0              /* M=0: EL0t (user AArch64) */
msr SPSR_EL1, x1        /* rest of PSTATE: all interrupts enabled */
eret

Checking Exception Level at Runtime

Our kernel provides a function to read the current exception level for debugging:

uint64_t get_current_el(void) {
    uint64_t el;
    __asm__("mrs %0, CurrentEL" : "=r" (el));
    return (el >> 2) & 3;
}

Exception Level Diagram

            graph TD
                subgraph EL0 [User Space - EL0]
                    A[Application]
                end
                subgraph EL1 [Kernel - EL1]
                    B[System Call Handler]
                    C[Exception Handler]
                    D[Scheduler]
                end
                subgraph EL2 [Hypervisor - EL2]
                    E[QEMU Firmware / Hypervisor]
                end
                subgraph EL3 [Secure Monitor - EL3]
                    F[ARM Trusted Firmware]
                end

                A -->|SVC| B
                B -->|ERET| A
                C -->|ERET| A
                D -->|ERET| A
                E -->|Boot| D
                F -->|Boot| E
            

Figure 10.1: Exception level usage in our kernel. Applications at EL0 request services via SVC. The kernel at EL1 handles them. EL2 and EL3 are managed by firmware.

10.11 Exercises

Exercise 1: Read Current Exception Level

Add code to your kernel that reads CurrentEL and prints the exception level as "EL0", "EL1", "EL2", or "EL3". Run it and verify you are at EL1.

Exercise 2: Read SPSel

Read the SPSel register to determine whether EL1 is using SP_EL0 or SP_EL1. Since SPSel is bit 0 of PSTATE, you can read it with MRS x0, SPSel. Print the result.

Exercise 3: EL0 to EL1 Transition

Write a minimal user-space program (a .S file with code at EL0) that executes SVC #0. Set up the kernel's exception handler to catch this SVC and print "System call received". This is the foundation for our system call mechanism (Chapter 14).

Exercise 4: EL1 to EL0 and Back

Write a function that starts a user-space task at EL0, waits for it to make an SVC, handles the SVC in EL1, and returns to EL0. Loop this 5 times and then halt. Print the current exception level at each step.

Exercise 5: Trap Undefined Instruction from EL0

Execute an undefined instruction (like .word 0xDEAD1234) at EL0. The CPU should trap to EL1 with an undefined instruction exception. Read ESR_EL1 in the exception handler and print the exception class to confirm it is an undefined instruction (EC = 0x00).

Exercise 6: EL2 Detection and Handling (Challenge)

Run QEMU with a configuration that starts the kernel at EL2 (using a custom EL2 boot stub). Verify that your _start code correctly detects EL2 and drops to EL1. Print a message at each step: "Started at EL2", "Dropping to EL1", "Now at EL1".

10.12 Summary

In this chapter, we learned about ARM64 exception levels, the privilege model that makes operating systems secure and stable. The four exception levels (EL0 through EL3) provide hierarchical protection, where higher levels can control and monitor lower levels.

Our kernel runs at EL1 (kernel space) and manages user-space applications at EL0. Transitions from EL0 to EL1 happen through exceptions (specifically the SVC instruction for system calls). Transitions from EL1 to EL0 happen through the ERET instruction, which restores the processor state from SPSR_EL1 and jumps to the return address in ELR_EL1.

The PSTATE M field encodes the current exception level and stack pointer selection. Our kernel uses EL1h mode (dedicated kernel stack via SP_EL1). The stack pointer selection (SPSel) determines whether an exception level uses SP_EL0 or its own dedicated SP_ELx.

We also learned about Secure World vs Normal World (controlled by EL3) and the virtualization support at EL2. While our kernel does not directly use EL2 or EL3, understanding them is important for boot flow and future hardware support.

This chapter concludes the "Booting the System" section. In the next section, we will look at how exceptions and interrupts are handled in detail, starting with Chapter 11: Exception Handling.