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.
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:
-
ELR_EL2— the exception link register. It holds the address to continue at after the return. We point it at the kernel code we want running at EL1. -
SPSR_EL2— the saved program status register. It holds the processor state to restore: which exception level to land in, and which interrupts are masked.
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:
- Make EL1 64-bit.
HCR_EL2.RW = 1tells the chip that EL1 runs AArch64, not 32-bit. - Allow FP/SIMD.
CPACR_EL1must let EL1 use the floating-point unit, or ordinary compiled code will trap. - Hand over the timer.
CNTHCTL_EL2lets EL1 read the generic timer, and we zeroCNTVOFF_EL2so its clock starts clean. - Give EL1 a sane control register.
SCTLR_EL1gets a known reset value with the MMU and caches off — we turn those on much later. - Give EL1 a stack. EL1 has its own stack pointer,
SP_EL1, separate from EL2's.
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:
- The low bits
0b0101select EL1h: run at EL1, using EL1's own stack pointer (SP_EL1) rather than EL0's. - The four
DAIFbits are all set, masking every interrupt. We do not want an interrupt firing before we have set up a vector table to catch it — that comes in a later article.
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.