Tutorial · 14 · AArch64

Home, sweet home

A program is no longer a C function hiding inside the kernel. It has its own file format, its own address space, its own stack, its own kernel stack, and a small set of system calls. Today we make a user program feel at home.

[o-o] |=| /_\

Until now I have invited programs into my own room and called it multitasking. That was useful, but not honest. A real program needs a home: walls, a front door, a bed, a place to put its things, and a polite way to ask the house for help.

Goal

Build one small ELF64 user program, compile it outside the kernel, convert the resulting binary into a C array, embed that array in the kernel image, load its PT_LOAD segments into memory, create both the user stack and the per-task kernel stack, enter it at EL0, and answer the few system calls it uses. We still avoid loading from an SD card; the file is present as bytes inside the kernel so the lesson can focus on the runtime environment instead of storage.

The test program

Start from something that looks like a normal tiny Linux-style program, but keep it freestanding. A dynamically linked glibc program would drag in an enormous loader and runtime. For the tutorial we use our own small printf and Linux-shaped syscall wrappers, then teach Alquist exactly the calls this program makes: write, getpid, yield, uptime, and exit.

// hello_home.c — compiled as an AArch64 ELF64 user image
#include <stdarg.h>
#include <stdint.h>

#define SYS_WRITE   64
#define SYS_EXIT    93
#define SYS_YIELD   124
#define SYS_GETPID  172
#define SYS_UPTIME  9000        // Alquist-private call

static long syscall3(long num, long a0, long a1, long a2) {
    register long x0 __asm__("x0") = a0;
    register long x1 __asm__("x1") = a1;
    register long x2 __asm__("x2") = a2;
    register long x8 __asm__("x8") = num;
    __asm__ volatile ("svc #0" : "+r"(x0) : "r"(x1), "r"(x2), "r"(x8) : "memory");
    return x0;
}

static long write(int fd, const void *buf, unsigned long len) {
    return syscall3(SYS_WRITE, fd, (long)buf, len);
}

static long getpid(void) { return syscall3(SYS_GETPID, 0, 0, 0); }
static long uptime(void) { return syscall3(SYS_UPTIME, 0, 0, 0); }
static void yield(void) { syscall3(SYS_YIELD, 0, 0, 0); }
static void exit(int code) { syscall3(SYS_EXIT, code, 0, 0); for (;;) { } }

The numbers for write, exit, yield, and getpid match the AArch64 Linux convention on purpose. The private uptime call lives high enough not to be confused with standard calls. That lets our tiny program feel familiar while still being small enough to understand.

A printf worth calling

A single printf("hello") would prove very little. We want formatting, multiple syscalls, and observable state. The tiny formatter supports strings, signed and unsigned decimal numbers, hex, characters, and literal percent signs. Every flush becomes write(1, bytes, len).

static void putc(char c) { write(1, &c, 1); }
static void puts_raw(const char *s) { while (*s) putc(*s++); }

static void print_uint(uint64_t value, unsigned base, int upper) {
    char tmp[32];
    const char *digits = upper ? "0123456789ABCDEF" : "0123456789abcdef";
    unsigned n = 0;

    do {
        tmp[n++] = digits[value % base];
        value /= base;
    } while (value != 0);

    while (n != 0) putc(tmp[--n]);
}

static void printf(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);

    for (; *fmt; fmt++) {
        if (*fmt != '%') { putc(*fmt); continue; }
        switch (*++fmt) {
            case 's': puts_raw(va_arg(ap, const char *)); break;
            case 'c': putc((char)va_arg(ap, int)); break;
            case 'd': {
                long v = va_arg(ap, long);
                if (v < 0) { putc('-'); v = -v; }
                print_uint((uint64_t)v, 10, 0);
                break;
            }
            case 'u': print_uint(va_arg(ap, unsigned long), 10, 0); break;
            case 'x': print_uint(va_arg(ap, unsigned long), 16, 0); break;
            case 'X': print_uint(va_arg(ap, unsigned long), 16, 1); break;
            case '%': putc('%'); break;
            default: putc('?'); break;
        }
    }

    va_end(ap);
}

Now the entry point can do enough to test the runtime instead of merely printing one line.

static unsigned visit_counter = 41;
  static char scratch[32];

  void _start(void) {
    scratch[0] = 'O';
    scratch[1] = 'K';
    scratch[2] = 0;
    visit_counter++;

    printf("Home, sweet home\n");
    printf("data=%u bss=%s\n", (unsigned long)visit_counter, scratch);
    printf("pid=%d uptime=%u ticks\n", getpid(), (unsigned long)uptime());

    for (unsigned i = 0; i < 3; i++) {
      printf("loop %u, yielding from user space\n", (unsigned long)i);
        yield();
    }

    printf("goodbye from ELF64\n");
    exit(7);
}

Link it like a program

The linker script is the contract between the compiler and the loader. It says where the program expects to live, where code and read-only data go, where writable data begins, and how much zeroed .bss exists. The kernel must not guess this from section names later; it must load the program headers that the linker emits.

/* user.ld — one small position in user virtual memory */
ENTRY(_start)

PHDRS {
  text PT_LOAD FLAGS(5);   /* R + X */
  data PT_LOAD FLAGS(6);   /* R + W */
}

SECTIONS {
  . = 0x0000000000400000;
    .text : { *(.text*) *(.rodata*) } :text
    . = ALIGN(0x1000);
    .data : { *(.data*) } :data
    .bss  : { *(.bss*) *(COMMON) } :data
  . = ALIGN(0x1000);
  __program_end = .;
}

Compile it as a static, freestanding AArch64 ELF. The target triple can be the normal Linux cross compiler because we are borrowing the ABI shape, not linking glibc.

aarch64-linux-gnu-gcc -ffreestanding -fno-builtin -fno-pic -fno-pie \
  -fno-asynchronous-unwind-tables -fno-unwind-tables -nostdlib -nostartfiles \
  -Wall -Wextra -O2 -c hello_home.c -o hello_home.o
aarch64-linux-gnu-ld -T user.ld --build-id=none -z max-page-size=0x1000 \
  hello_home.o -o hello_home.elf

aarch64-linux-gnu-readelf -h -l hello_home.elf

Turn the ELF into bytes

We are deliberately not reading the program from a filesystem yet. Instead the build converts the ELF file into a C object the kernel can link. The array contains the whole ELF file, not just a flat binary, because the kernel is going to parse the headers.

xxd -i hello_home.elf > user_image.c
// user_image.c — generated by the build
unsigned char hello_home_elf[] = {
  0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00,
  /* ... */
};
unsigned int hello_home_elf_len = 18352;

The symbol names are not important; the important part is that the kernel receives a pointer and a length. Storage can come later. For now, the program enters the system in the most boring possible way: already present in memory.

Read the ELF header

ELF is not mystical. The first header says "I am ELF64, little-endian, AArch64, executable, and my program headers begin over there." The loader should reject anything else loudly. A wrong architecture or class is not a runtime failure; it is a bad image.

#define EI_CLASS 4
#define EI_DATA  5
#define ELFCLASS64 2
#define ELFDATA2LSB 1
#define ET_EXEC 2
#define EM_AARCH64 183
#define PT_LOAD 1

int elf_check(const Elf64_Ehdr *eh, unsigned long len) {
    if (len < sizeof(*eh)) return -1;
    if (eh->e_ident[0] != 0x7f || eh->e_ident[1] != 'E' ||
        eh->e_ident[2] != 'L' || eh->e_ident[3] != 'F') return -1;
    if (eh->e_ident[EI_CLASS] != ELFCLASS64) return -1;
    if (eh->e_ident[EI_DATA] != ELFDATA2LSB) return -1;
    if (eh->e_type != ET_EXEC || eh->e_machine != EM_AARCH64) return -1;
    if (eh->e_phoff + eh->e_phnum * sizeof(Elf64_Phdr) > len) return -1;
    return 0;
}

Program headers are the load plan

Sections are for linkers and debuggers. Program headers are for loaders. Each PT_LOAD entry says: copy p_filesz bytes from file offset p_offset to virtual address p_vaddr, then zero the remaining p_memsz - p_filesz bytes. That last part is how .bss becomes zero without occupying space in the file.

static int load_segment(address_space_t *as,
                        const unsigned char *image,
                        unsigned long image_len,
                        const Elf64_Phdr *ph) {
    if (ph->p_offset + ph->p_filesz > image_len) return -1;
    if ((ph->p_vaddr & 0xfff) != (ph->p_offset & 0xfff)) return -1;

    uintptr_t start = ph->p_vaddr & ~0xfffUL;
    uintptr_t end = (ph->p_vaddr + ph->p_memsz + 0xfffUL) & ~0xfffUL;
    unsigned flags = user_flags_from_elf(ph->p_flags);

    for (uintptr_t va = start; va < end; va += PAGE_SIZE) {
        void *page = alloc_pages(0, PAGE_USER);
        if (page == 0) return -1;
        map_user_page(as, va, page, flags);
        memset(page, 0, PAGE_SIZE);
    }

    copy_to_user(as, ph->p_vaddr, image + ph->p_offset, ph->p_filesz);
    return 0;
}

This is where Tutorial 13 pays off. The loader is not carving memory by hand; it asks the memory manager for pages and records them as user-owned.

The address space

Every loaded program needs its own translation tables. The kernel part can be shared in the upper half or installed through a common template, but the lower user region belongs to this task. The ELF segments land at their requested virtual addresses, and the stack lands somewhere high enough to grow downward without touching them.

#define USER_STACK_TOP  0x0000007ffff00000UL
#define USER_STACK_SIZE (64 * 1024UL)

static int map_user_stack(address_space_t *as, task_t *task) {
    uintptr_t bottom = USER_STACK_TOP - USER_STACK_SIZE;

    for (uintptr_t va = bottom; va < USER_STACK_TOP; va += PAGE_SIZE) {
        void *page = alloc_pages(0, PAGE_STACK);
        if (page == 0) return -1;
        map_user_page(as, va, page, PTE_USER | PTE_RW);
        memset(page, 0, PAGE_SIZE);
    }

    task->user_sp = USER_STACK_TOP;
    return 0;
}

The user stack is not the same thing as the scheduler's saved context. It is the stack the program uses for local variables, calls, and printf. It belongs to EL0 and is mapped as user memory.

The kernel stack

When EL0 takes an exception into EL1, the CPU must have a safe kernel stack to stand on. Do not handle syscalls on the user stack; user memory is precisely the thing you are not supposed to trust. Each task gets a kernel stack, and the context switch installs it into SP_EL1 before returning to the task.

static int make_kernel_stack(task_t *task) {
    void *page = alloc_pages(1, PAGE_KERNEL);      // 8 KB kernel stack
    if (page == 0) return -1;

    task->kstack_base = (uintptr_t)page;
    task->kstack_top = task->kstack_base + (PAGE_SIZE << 1);
    return 0;
}

void switch_to(task_t *next) {
    write_ttbr0(next->as.ttbr0);
    __asm__ volatile ("msr sp_el1, %0" :: "r"(next->kstack_top) : "memory");
    return_to_user(next);
}

This is one of the quiet lines where an operating system becomes real. User code may break its own stack; the kernel still has somewhere private to save registers, inspect the fault, and decide what to do.

Build the first process

The loader returns an entry point and an address space. The process builder adds stacks, a pid, a saved user context, and an initial register frame. The first time the scheduler picks this task, it does not resume old work; it performs the first eret into the ELF entry point.

int load_embedded_elf(const unsigned char *image, unsigned long len, task_t *task) {
    const Elf64_Ehdr *eh = (const Elf64_Ehdr *)image;
    if (elf_check(eh, len) < 0) return -1;

    address_space_init(&task->as);

    const Elf64_Phdr *ph = (const Elf64_Phdr *)(image + eh->e_phoff);
    for (unsigned i = 0; i < eh->e_phnum; i++) {
        if (ph[i].p_type == PT_LOAD && load_segment(&task->as, image, len, &ph[i]) < 0)
            return -1;
    }

    if (map_user_stack(&task->as, task) < 0) return -1;
    if (make_kernel_stack(task) < 0) return -1;

    task->entry = eh->e_entry;
    task->pid = next_pid++;
    task->state = TASK_READY;
    return 0;
}

The syscall table

Article 06 already opened the svc door. Now the door must serve a real process. The handler saves the user register frame on the kernel stack, reads the syscall number from x8, copies arguments from x0 through x5, dispatches, stores the result back into x0, and returns to the next user instruction.

long syscall_dispatch(task_t *task, long num,
                      long a0, long a1, long a2, long a3, long a4, long a5) {
    (void)a3; (void)a4; (void)a5;

    switch (num) {
        case SYS_WRITE:
            return sys_write(task, (int)a0, (const char *)a1, (unsigned long)a2);
        case SYS_GETPID:
            return task->pid;
        case SYS_YIELD:
            task->state = TASK_READY;
            schedule_now();
            return 0;
        case SYS_UPTIME:
            return system_ticks;
        case SYS_EXIT:
            task->exit_code = (int)a0;
            task->state = TASK_DONE;
            schedule_now();
            return 0;
        default:
            return -38;   // -ENOSYS
    }
}

write is the only syscall that reads user memory, so it is the first place that needs copy discipline. Never pass a user pointer straight to a driver. Check that the range is mapped as user readable, copy it into a kernel buffer in chunks, then write those chunks to the UART.

static long sys_write(task_t *task, int fd, const char *user_buf, unsigned long len) {
    char chunk[128];
    unsigned long done = 0;

    if (fd != 1 && fd != 2) return -9;     // -EBADF

    while (done < len) {
        unsigned long n = len - done;
        if (n > sizeof(chunk)) n = sizeof(chunk);
        if (copy_from_user(task->as, chunk, user_buf + done, n) < 0)
            return -14;                    // -EFAULT
        uart_write(chunk, n);
        done += n;
    }

    return (long)done;
}

What actually happens

The kernel starts with an embedded byte array named something like hello_home_elf. It checks the ELF header, walks the program headers, allocates user pages, maps each segment, copies the file bytes, zeros .bss, maps a user stack, allocates a private kernel stack, assigns a pid, and marks the task ready. The scheduler picks it, installs its address space, installs its kernel stack, and returns to EL0 at the ELF entry point.

The program prints through printf. That formatter calls write, which becomes svc. The CPU enters EL1 on the task's kernel stack. The syscall handler copies text out of user memory, writes it to UART, returns a byte count, and drops back into the formatter. Later the program asks for its pid, asks for uptime, yields a few times, and exits with status 7. Nothing in the kernel called _start as a C function. The kernel loaded a file, built a home, and let the program live there.

[^-^] |=| /_\

Now a program can arrive as bytes and still become a life: code, data, stack, pid, syscalls, a way out. The house is small, but the key fits.

Example package

The accompanying source package lives in the Tutorial 14 example directory. It contains hello_home.c, user.ld, the bare-metal loader kernel, and a Makefile that builds hello_home.elf, generates user_image.c, links that array into kernel8.elf, and produces boot/kernel8.img. The boot config follows the previous examples: UART stays enabled for kernel output, the core clock is fixed for mini UART, the HDMI rainbow splash is disabled, and second-stage firmware logging stays off so the serial log belongs to the tutorial. On hardware the ACT LED is paced by the generic timer while the loaded program yields and keeps blinking after it exits.

Try it

Build the user ELF, regenerate the embedded C array, rebuild the kernel, and boot it. In QEMU's Raspberry Pi 4 model the mini UART is the second serial device, so keep the first one as null and put the second one on stdio.

make
timeout 12s qemu-system-aarch64 -M raspi4b -cpu cortex-a72 -m 2G \
  -nographic -monitor none -serial null -serial stdio \
  -kernel build/kernel8.elf
Home, sweet home kernel
ELF64 AArch64 executable accepted
entry=0x0000000000400380 phnum=2
  PT_LOAD R-X va=0x0000000000400000 filesz=1292 memsz=1292
  PT_LOAD RW- va=0x0000000000401000 filesz=4 memsz=48
user stack 0x00000000004f0000..0x0000000000500000
entering EL0
Home, sweet home
data=42 bss=OK
pid=3 uptime=0 ticks
loop 0, yielding from user space
loop 1, yielding from user space
loop 2, yielding from user space
goodbye from ELF64
process 3 exited with status 7

That is the first full runtime environment: not just EL0, not just a syscall, not just memory pages, but all of them working together around one program image.