|
AquilaOS documentation
How it works, and its code
|
When a computer is switched on, its firmware finds a boot loader and runs it. The boot loader loads the kernel into memory, and whatever else the kernel asks for, and jumps to it. From there the kernel is on its own: it has to set up the CPU, find out how much memory there is and take it over, find its devices, mount a root filesystem, and start the first program, init, in user mode. Every other process descends from init.
Booting crosses all three layers (see The kernel and the machine): the platform decides how the kernel is loaded and what it is told, the architecture brings the CPU to a state where C code can run at the kernel's addresses, and the kernel does the rest, the same everywhere. This document starts with what the kernel needs and does, then follows the PC and x86, and then QEMU's virt machine and aarch64, from the boot loader's jump to kmain().
The architecture hands the kernel a struct boot (kernel/include/boot/boot.h), the machine-independent summary of what the boot loader said:
elf_shdr_t is the kernel's own ELF class: 32-bit on i386, 64-bit on x86_64 and aarch64. The kernel never sees the boot loader's own structures: the PC fills struct boot from Multiboot's information, aarch64 from the devicetree. __kboot points to it.
Each step needs the ones before it. The architecture runs the first ones, calling the kernel's functions where the work is generic, and ends by calling kmain():
| Step | Layer | x86 | aarch64 |
|---|---|---|---|
| the CPU at the kernel's addresses | architecture | the boot code | boot/head.S |
| this CPU's own state (curcpu()) | architecture | x86_gdt_setup() | tpidr_el1 |
| the kernel lock, taken until init runs | kernel | kernel_lock() | kernel_lock() |
| the early console: printk() works | platform | earlycon_init() | earlycon_init() |
| traps reported, not fatal | architecture | x86_idt_setup(), x86_isr_setup() | vbar_el1 |
| struct boot filled in | platform | process_multiboot_info() | fdt_boot() |
| physical memory taken over | kernel | mm_setup(), then pmap_init() (architecture) | the same |
| the kernel's allocator | kernel | kvmem_setup() | kvmem_setup() |
| the command line | kernel | kargs_parse() | kargs_parse() |
| the console on the screen | platform | earlycon_reinit() | earlycon_reinit() (nothing to do) |
| interrupt controllers, timers, CPUs | platform | platform_init() | platform_init() |
| devices, filesystems, the root, init | kernel | kmain() | kmain() |
On x86 the architecture's part is x86_cpu_init() (cpu/init.c), on aarch64 aarch64_init() (cpu/init.c); both are described below.
The memory map says which ranges of physical memory are RAM (usable) and which aren't: the firmware's, the devices', the boot loader's tables. mm_setup() (mm/mm.c) takes it over before there is any allocator:
kvmem_setup() then starts the kernel's own allocator, kmalloc(). See Memory.
The command line is parsed (kargs_parse(), core/kargs.c) into key and key=value arguments, which any part of the kernel looks up with kargs_get(). The arguments read at boot:
| Argument | What |
|---|---|
| root= | the root filesystem is this disk partition (/dev/hda2), not the initramfs |
| rootfstype= | the root filesystem's type: ext2 (the default) or minix |
| ip= | eth0's address, prefix length and gateway (10.0.2.15/24:10.0.2.2); see Networking |
| ata.nodma | ATA disks use PIO, not DMA |
| kvmem.test, kvmem.guard | check the kernel's allocator at boot |
| earlycon= | PC: where the kernel's messages go: ttyS0, vga or fb (default: the framebuffer if there is one, else the serial port) |
| apic=off | PC: the 8259s, not the APIC (and one CPU) |
| i8042.nocheck | PC: don't check the keyboard controller's self-test |
| i8254.div= | PC: the PIT's divisor, when it gives the tick |
| smp=off | x86: the boot CPU only |
| clock= | x86: the clock's counter: tsc, hpet or pit (default: the TSC if it is invariant, else the HPET, else the ticks) |
kmain() (core/main.c) is the same on every architecture:
There is no process to fork() the first one from, so it's made by hand. kmain() makes an empty process (proc_new()), makes it the current one, switches to its address space, and loads /init into it (binfmt_load()): an ELF program, whose segments are mapped as for exec. In the initramfs, /init is a link to runit's /sbin/runit-init. The architecture gives its thread a kernel stack and its user-mode registers, the program's entry point and the top of the user stack (arch_proc_init()); then its initial stack is built, with its arguments and environment, as execve() does (init_execve()). init has one argument, the module's command line, and no environment.
sched_init_spawn() (sys/sched.c) then starts the system:
The boot stack is never used again. From here on, the kernel only runs when something enters it: a system call, an interrupt, an exception.
On the PC, AquilaOS is loaded with the Multiboot protocol (version 1), which GRUB, Limine and QEMU's -kernel all speak. A Multiboot kernel is an ELF file with a Multiboot header in its first 8 KiB: a magic number, flags that say what the kernel wants, and a checksum. AquilaOS asks for:
The boot loader loads the kernel at 1 MiB (its physical address), loads the modules (the initial RAM disk), and jumps to the kernel's entry, _start, in 32-bit protected mode with paging off, interrupts off, and flat segments. eax holds Multiboot's magic number, ebx the physical address of the Multiboot information: the kernel's command line, the memory map, the modules, the framebuffer, and the kernel's ELF section headers.
process_multiboot_info() (arch/i386/include/boot/boot.h) turns it into the struct boot. The framebuffer, if the boot loader set a graphics mode, is added to the memory map as reserved, and registered for the early console and for /dev/fb0.
The disk images boot with Limine, which loads the kernel with its multiboot1 protocol. Its configuration is in boot/:
| File | Used by |
|---|---|
| boot/limine.conf | the CD image (make iso): the kernel and initrd.img |
| boot/limine-hdd.conf | the hard disk (make hdd): the root filesystem is root=/dev/hda2, no module |
| boot/limine-live.conf | the live disk (make live, i386 only): the kernel and initrd, gzipped |
Each has an entry with the console on the serial port (earlycon=ttyS0). make run doesn't need a disk: QEMU loads the kernel and the initrd itself (-kernel, -initrd).
Until the terminals are up, printk() writes through the early console (earlycon/): the first serial port from the start, then, once the command line is read, the framebuffer if there is one, else the serial port still; earlycon= chooses one of them, or VGA's text mode.
The kernel is linked to run in the higher half of the address space, where it is the same in every process's address space and out of user space's way, but it is loaded at 1 MiB, before paging is on. The linker scripts make the difference (kernel/arch/i386/kernel.i386.ld, kernel.x86_64.ld):
The VMA() and LMA() macros (kernel/arch/i386/include/mm/mm.h) go from one to the other.
The entry, _start (boot/multiboot.S), saves eax and ebx, loads its own stack (Multiboot gives it none), and calls the next step, which turns paging on with boot page tables that map the kernel twice: where it is, so that the code running at its physical address keeps running, and where it is linked.
x86_bootstrap() (boot/init.c), in C, at its physical address, maps the first 768 MiB of memory (the whole direct map) at 0 and at 0xC0000000 with 4 KiB pages, from a page directory and tables in the boot code's own .bss. It turns paging on, and write protection in the kernel too (CR0.WP: copy on write depends on it), moves the stack pointer to its higher-half address (early_init_fix_stack, in boot/sys.S), and calls x86_cpu_init(), now at its kernel address.
On x86_64 the CPU must also be brought from 32-bit protected mode to long mode, which needs paging. x86_64_bootstrap (boot/x86_64_bootstrap.S), 32-bit code, builds four-level page tables in the kernel's .bss: a page directory of 2 MiB pages mapping the first GiB, reached three times:
| Where | Why |
|---|---|
| 0 | the code that turns paging on runs there |
| 0xFFFF800000000000 | the direct map |
| 0xFFFFFFFF80000000 (−2 GiB) | the kernel |
Then, as the Intel manual says: PAE on (CR4.PAE, and global pages), CR3 loaded, long mode enabled in the EFER MSR, paging and write protection on in CR0. The CPU is then in long mode's compatibility mode; a far jump through a small GDT with a 64-bit code segment brings it to 64-bit mode. A last jump goes to x86_64_start, at the kernel's address, which loads a 64-bit stack and calls x86_cpu_init().
From there both run the same C code.
x86_cpu_init() (cpu/init.c) runs the steps of the table above. The x86-specific ones:
With the APIC, platform_init() starts the other CPUs, the application processors, with x86_smp_init() (cpu/smp.c), unless smp=off. Their local APICs are listed in ACPI's MADT. Each one is sent an INIT IPI and a startup IPI; it starts in 16-bit real mode at the trampoline, a page below 1 MiB (0x8000, cpu/trampoline.S), which brings it through protected mode (and long mode) to the kernel's x86_ap_start(), on its own stack and with its own GDT, TSS and struct cpu. It says it's there and waits, idle, until the scheduler starts. See SMP and the kernel lock.
arch_thread_spawn() (sys/thread.c) loads init's page tables, sets the TSS's stack pointer to the thread's kernel stack (where the CPU will switch on the next interrupt from user mode), and jumps there; x86_jump_user() then irets into user mode, with the user code and stack segments.
On aarch64 the kernel runs on QEMU's virt machine, loaded by QEMU's -kernel (make run with CONFIG=aarch64-virt) as a Linux arm64 Image: the kernel's memory image, flat (kernel-*.img), with Linux's Image header at its start. QEMU loads it at its physical address, RAM's start plus the header's text_offset (0x40080000), loads the initrd, and jumps to it with the MMU off and the physical address of the devicetree in x0.
fdt_boot() (platform/virt/fdt.c) turns the devicetree into the struct boot: the RAM (/memory's reg), the initrd (/chosen's linux,initrd-start and linux,initrd-end, the one module) and the command line (/chosen's bootargs). The early console is the PL011 UART, from the start; earlycon= isn't read.
The kernel is linked at _VMA plus its physical address, with _VMA 0xFFFF000000000000, the start of the direct map: the kernel is where the direct map has its memory (kernel/arch/aarch64/kernel.aarch64.ld).
The entry, _start (boot/head.S), drops from EL2 to EL1 if QEMU started it at EL2, clears the .bss, and builds boot page tables that map the first 4 GiB of physical memory with 1 GiB blocks twice: at the same addresses (TTBR0_EL1, where this code runs) and in the direct map (TTBR1_EL1). The first GiB, the virt machine's devices, is device memory. It turns the MMU on, jumps to its kernel address, loads its stack, and calls aarch64_init().
aarch64_init() (cpu/init.c) runs the steps of the table above:
aarch64 runs on one CPU. arch_thread_spawn() (sys/thread.c) loads init's page tables and moves to its kernel stack; the kernel lock is let go, and aarch64_jump_user() erets into user mode.
What QEMU shows (x86_64, run as make run does but with two CPUs, -smp 2; shortened):
The messages starting x86:, x86_64:, i8259:, apic:, i8254:, cmos:, smp:, hpet: and clock: are the architecture's and platform's; kernel:, buddy:, pci: and kdev: are the kernel's, and e1000: a driver's. The first buddy: set unusable is the kernel itself, from 1 MiB; the last, the SMP trampoline's pages. The command line QEMU passes starts with the kernel's file name, which the kernel ignores. make run itself boots one CPU.
| File | Layer | What |
|---|---|---|
| kernel/include/boot/boot.h | kernel | struct boot |
| kernel/mm/mm.c | kernel | mm_setup(): the memory map, the boot memory |
| kernel/core/kargs.c | kernel | the command line |
| kernel/core/module.c | kernel | the built-in modules |
| kernel/core/main.c | kernel | kmain(): the root filesystem, init |
| kernel/sys/sched.c | kernel | sched_init_spawn() |
| boot/limine*.conf | PC | the boot loader's configurations |
| kernel/arch/i386/boot/multiboot.S | PC | the Multiboot header, the entry |
| kernel/arch/i386/include/boot/boot.h | PC | the Multiboot information to struct boot |
| kernel/arch/i386/earlycon/ | PC | the early console: serial, VGA, framebuffer |
| kernel/arch/i386/platform/pc/init.c | PC | platform_init() |
| kernel/arch/i386/kernel.i386.ld, kernel.x86_64.ld | x86 | where the kernel is linked and loaded |
| kernel/arch/i386/boot/init.c, sys.S | x86 | i386: paging on |
| kernel/arch/i386/boot/x86_64_bootstrap.S | x86 | x86_64: paging and long mode on |
| kernel/arch/i386/cpu/init.c | x86 | x86_cpu_init(): the order of it all |
| kernel/arch/i386/cpu/gdt.c | x86 | the GDT and the TSS |
| kernel/arch/i386/mm/i386.c, x86_64.c | x86 | pmap_init(): the kernel's page tables |
| kernel/arch/i386/cpu/smp.c, trampoline.S | x86 | the other CPUs |
| kernel/arch/i386/sys/thread.c | x86 | into user mode |
| kernel/arch/aarch64/platform/virt/fdt.c | virt | the devicetree to struct boot |
| kernel/arch/aarch64/platform/virt/init.c | virt | platform_init() |
| kernel/arch/aarch64/earlycon/earlycon.c | virt | the early console: the PL011 |
| kernel/arch/aarch64/kernel.aarch64.ld | aarch64 | where the kernel is linked and loaded |
| kernel/arch/aarch64/boot/head.S | aarch64 | the Image header, the entry, the MMU on |
| kernel/arch/aarch64/cpu/init.c | aarch64 | aarch64_init(): the order of it all |
| kernel/arch/aarch64/mm/pmap.c | aarch64 | pmap_init(): the kernel's page tables |
| kernel/arch/aarch64/sys/thread.c | aarch64 | into user mode |