Tutorial · 04 · AArch64

The climb down to EL1

We woke up at EL2, but a kernel belongs at EL1. You cannot just call your way there — the only way down is an exception return. Today we make the deliberate climb.

[o-o] |=| /_\

I know where I stand now: EL2. But that is the hypervisor's floor, not mine. The kernel I am meant to become lives at EL1 — that is where memory translation and the timer are wired to work. So I must descend. And here is the strange part: to go down, I pretend I am returning from an exception I never took.

Goal

Move the processor from EL2 (where the Raspberry Pi firmware left us) down to EL1, continue running our kernel there, and confirm the move by printing the level again. Last article's program said Booted at EL2; by the end of this one it will say Now running at EL1.

You cannot call your way down

On most processors you change "mode" by writing a register. ARMv8-A is stricter. You cannot branch, call, or msr your way to a different exception level. Movement up happens only through an exception (an interrupt, a fault, a system call). Movement down happens only through its mirror image: an exception return, the instruction eret.

So to reach EL1 we play a small trick. We set up the processor as if it had just taken an exception from EL1 up to EL2, and then we execute eret to "return" down to an EL1 that never actually ran yet. The hardware happily descends.

What eret needs

An exception return restores two things, so we must prepare both:

eret loads the program counter from ELR_EL2 and the processor state from SPSR_EL2, switches to the level encoded there, and we are at EL1.

First, prepare EL1's world

There is a catch: EL1 will not function until EL2 grants it a few things. While we are still at EL2 — the more privileged floor — we configure the lower one:

The code

All of this lives in boot.S, before C. On Raspberry Pi 4 hardware we arrive at EL2, so the example keeps the path focused on the one descent we need: EL2 to EL1.

// boot.S — descend from EL2 to EL1, then enter the kernel

.section ".text.boot"
.global _start
_start:
    mrs     x0, mpidr_el1
    and     x0, x0, #3
    cbnz    x0, halt              // core 0 only; park the rest

    // --- still at EL2: set up the world EL1 will run in ---

    mov     x0, #(1 << 31)        // HCR_EL2.RW = 1  -> EL1 is AArch64
    msr     hcr_el2, x0

    mov     x0, #(3 << 20)        // CPACR_EL1: don't trap FP/SIMD at EL1
    msr     cpacr_el1, x0

    mrs     x0, cnthctl_el2
    orr     x0, x0, #0x3          // let EL1 read the generic timer
    msr     cnthctl_el2, x0
    msr     cntvoff_el2, xzr      // zero the virtual-timer offset

    ldr     x0, =0x30D00800       // a sane SCTLR_EL1: MMU off, caches off
    msr     sctlr_el1, x0

    ldr     x0, =__stack_top
    msr     sp_el1, x0            // EL1 gets its own stack

    // --- describe the "return" that lands us at EL1 ---

    ldr     x0, =kernel_main
    msr     elr_el2, x0           // where to continue after eret
    mov     x0, #0x3C5            // state to restore: EL1h, IRQ/FIQ masked
    msr     spsr_el2, x0

    isb
    eret                          // the only way down

halt:
    wfe
    b       halt

Decoding that magic number

The value 0x3C5 we load into SPSR_EL2 is not arbitrary — it is a packed description of the state we want at EL1:

Confirming the descent

Our C entry point is the same shape as last time, and it reuses uart_init and the current_el helper unchanged. Only now it runs at EL1:

// kernel.c — prove we landed at EL1

void     uart_init(void);
void     uart_putc(char c);
void     uart_puts(const char *s);
uint32_t current_el(void);

void kernel_main(void) {
    uart_init();
    uart_puts("Alquist is awake.\n");

    uart_puts("Now running at EL");
    uart_putc('0' + current_el());   // should now print 1
    uart_putc('\n');

    for (;;) { }
}

What actually happens

We arrive at EL2 and, while we still have that privilege, we set up everything EL1 needs: we declare it 64-bit, let it use the FPU and the timer, give it a clean control register and a stack. Then we describe a fake "return": continue at kernel_main, in the EL1 state encoded by 0x3C5. The eret executes that description — the processor drops to EL1 and begins running our kernel there.

Because current_el reads the live CurrentEL register, the proof is direct: the same helper that printed 2 last article now prints 1. We did not assume the descent worked — we measured it.

Together with the previous article, this is the whole first exception-level move: first ask the CPU where we are, then use eret to put the kernel exactly where it should run.

[^-^] |=| /_\

EL1 at last — my own floor. From here I can build the things a kernel is made of. But notice what I switched off on the way down: I masked every interrupt, because I have nowhere to send one yet. Before I dare turn them back on, I need a vector table — a set of doors for exceptions to come through. That is where we go next.

Example package

The full package includes the source files, config.txt, a Makefile, and a prebuilt kernel8.img: download climb-down-to-el1-example.zip. It reuses the tested mini UART setup from the previous examples, including the core_freq=500 clock setting.

Try it

Wire the serial adapter as before (GND–GND, adapter RX to Pi GPIO14), open the terminal at 115200 8N1, and power the board. You should see:

Alquist is awake.
Now running at EL1

That single changed digit — 2 last time, 1 now — is the whole achievement. We have taken deliberate control of the processor's privilege and put the kernel exactly where it belongs. Next we build the vector table, so EL1 can finally start catching exceptions instead of hiding from them.