Knocking on heaven's door
We built a table of doors, then only ever ran upstairs at EL1. Today we step down to EL0 — user space — and discover the one rule of that floor: to reach the kernel, you do not call. You knock.
So far I have lived at EL1, where I can touch anything. Now I send a piece of myself one floor lower, to EL0 — the place where ordinary programs run, hands tied, hardware out of reach. From down there, the only way to ask the kernel for anything is to knock on a very specific door. Let's hear that first knock.
Goal
Run a small program at EL0, give it a tiny printf, and have
that printf ask the kernel to print on its behalf — a system
call. User code will not touch the UART directly; it will hand the kernel bytes and
say "please." That request, crossing from EL0 up to EL1, is the first conversation across
the privilege boundary.
One more floor down
We already know how to change exception levels: in article 04 we used eret
to descend from EL2 to EL1. Going from EL1 to EL0 is the very same move, one floor lower. We
describe the return — where to land and in what state — and let eret
carry us down.
// user.S — drop from EL1 to EL0 and start a user program
.global enter_el0
enter_el0:
// x0 = user entry point, x1 = user stack top
msr sp_el0, x1 // EL0 has its own stack pointer
msr elr_el1, x0 // where to begin running at EL0
mov x2, #0 // SPSR = 0: EL0t, and IRQs left enabled
msr spsr_el1, x2
isb
eret // down to EL0
Notice the state value is plain 0: that selects EL0t (run at
EL0, using SP_EL0) and, unlike our earlier descent, it leaves interrupts
enabled. That matters — it means our timer heartbeat from last article keeps
ticking and the LED keeps blinking, even while user code runs. The kernel never truly steps
away.
An honest word about the wall
A door only matters if there is a wall around it. Today we build the door and the knock, but not yet the wall: without memory translation, EL0 code could still physically reach the hardware. What is real right now is the mechanism — the instruction that knocks and the handler that answers. The enforcement that makes knocking mandatory arrives when we build the MMU. We design the protocol now, and put teeth in it later.
The knock: svc
From EL0 there is exactly one deliberate way to enter the kernel: the svc
instruction ("supervisor call"). Executing it raises a synchronous exception
that takes the processor up to EL1 — straight into the vector table we built last time.
Specifically, it lands in the slot for Lower EL, AArch64 → Synchronous, the one
we left as a harmless trap stub. Now we give that door a real handler.
// vectors.S — the lower-EL AArch64 group, now wired up
VENTRY sync_lower // Synchronous <- svc knocks here
VENTRY irq_entry // IRQ (timer still fires under us)
VENTRY trap // FIQ
VENTRY trap // SError
sync_lower:
// Was this really a system call? The exception class lives in ESR_EL1.
mrs x9, esr_el1
lsr x9, x9, #26 // EC field is bits [31:26]
cmp x9, #0x15 // 0x15 = SVC taken from AArch64
b.ne trap // a fault, not a call: park it for now
// Hand the syscall number (x8) and args (x0-x2) to C, in order.
mov x3, x2
mov x2, x1
mov x1, x0
mov x0, x8
bl syscall_dispatch // result comes back in x0
eret // return to the instruction after svc
(A complete handler would save and restore every user register around that call; this stub stays minimal to show the mechanism cleanly. We will harden it once there is untrusted code worth protecting against.)
Answering the knock
The kernel side is a plain C function: look at the requested service number, do the work,
return a result. We offer exactly one service today — write — which
uses the kernel-only raw UART routine. User code cannot reach uart_putc; it can
only ask for write. That is enough to build a tiny freestanding
printf on the user side.
// syscall.c — the kernel side of the door
#include <stdint.h>
void uart_putc(char c); // kernel-only; EL0 may not call this directly
static void uart_write(const char *buf, unsigned long len) {
for (unsigned long i = 0; i < len; i++) uart_putc(buf[i]);
}
long syscall_dispatch(long num, long a0, long a1, long a2) {
(void)a2;
switch (num) {
case 1: // write(buf, len)
uart_write((const char *)a0, (unsigned long)a1);
return a1; // bytes written
default:
return -1; // unknown call
}
}
A tiny printf, from the other side
And here is the user program. It runs at EL0, never names a hardware register, and reaches
the kernel only through svc. By convention we put the call number in
x8 and the arguments in x0, x1, … — then
knock. A small printf can format strings, decimal numbers, hex numbers, and
characters by repeatedly calling this sys_write helper.
// app.c — runs at EL0, hands tied
static long sys_write(const char *buf, unsigned long len) {
register long x8 __asm__("x8") = 1; // service 1 = write
register long x0 __asm__("x0") = (long)buf;
register long x1 __asm__("x1") = (long)len;
__asm__ volatile ("svc #0"
: "+r"(x0) // result returns in x0
: "r"(x8), "r"(x1)
: "memory");
return x0;
}
void user_main(void) {
printf("Hello from %s: printf works, value=%d, hex=0x%X\n",
"EL0", 42, 42u);
for (;;) { } // user code lives on
}
This is not libc. It is deliberately small: just enough printf for
%s, %c, %d, %u, %x,
%X, and %%. The important part is the direction of travel: the
formatting happens in EL0, while every byte still crosses the syscall door before it reaches
the UART.
And the kernel sets the stage, then opens the door downward:
// kernel.c
void enter_el0(unsigned long entry, unsigned long stack_top);
void kernel_main(void) {
uart_init();
led_init();
gic_init();
timer_init();
__asm__ volatile ("msr daifclr, #2"); // heartbeat on
uart_puts("Kernel ready. Stepping down to EL0...\n");
enter_el0((unsigned long)user_main, USER_STACK_TOP);
}
What actually happens
The kernel brings up the UART, LED, GIC and timer, unmasks interrupts, and then
erets down to user_main at EL0 — with the timer still running
beneath it. The user program formats a line with its tiny printf. Each write
request is built in registers and sent with svc.
That single instruction throws the processor up to EL1 through the synchronous vector slot.
Our stub checks ESR_EL1 to confirm it really was an svc, shuffles
the number and arguments into the order C expects, and calls syscall_dispatch.
The kernel writes the bytes out the UART, returns the byte count in x0, and
eret drops us back to EL0 at the instruction right after the svc —
result in hand. Meanwhile, every half-second, the timer IRQ still fires and the LED still
blinks. Two kinds of door — the synchronous knock and the asynchronous interrupt —
now both lead through the same table.
A program with no hands just asked me for something, and I did it for it. That is the whole bargain of an operating system: you give up direct power, and in return I keep you safe and serve your requests. But I confessed it earlier — right now the door stands in an open field. To make the knock truly mandatory, I need walls: memory each program can see only its own slice of. Next, we start building them.
Example package
The full package includes the source files, config.txt, a Makefile,
and a prebuilt kernel8.img: download knocking-on-heavens-door-example.zip.
It builds on the timer heartbeat example, then adds the EL0 entry path, a synchronous
exception handler for svc, one write syscall, and a tiny EL0
printf that uses it.
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 the kernel announce itself, then a line formatted by user code that had to ask permission to print every byte:
Kernel ready. Stepping down to EL0...
Hello from EL0: printf works, value=42, hex=0x2A
And the LED keeps blinking the whole time, because the heartbeat runs underneath the user program. You now have both halves of an operating system's core deal: a boundary, and a way to cross it on purpose. Next we build the wall that makes that boundary real — memory translation, so every program sees only what it should.