Chapter 21: Example — x86_64 Kernel Stub
This chapter puts the previous ones together in one small, complete kernel for x86_64. It boots under qemu-system-x86_64 -kernel, switches the CPU from 32-bit protected mode to 64-bit long mode, prints to the serial console, installs handlers for the timer, page faults and general-protection faults, programs the legacy interrupt controller and timer, and prints a line about once a second. A fault ends in a custom panic handler that reports what happened.
It is a template to read end to end, not a production kernel. The boot path, the port I/O, the interrupt-controller programming and the IDT encoding follow the kernels that the toolchain's own test suite boots under QEMU.
| Feature | Where it is used | Chapter |
|---|---|---|
@entry, @naked, @unsafe, @section |
the boot headers and the entry trampoline | 16, 17, 18 |
asm with operands |
port I/O, lidt, cr2, sti, hlt |
14 |
@interrupt, @interruptSafe |
the timer and fault handlers and everything they call | 16 |
| atomic intrinsics | the tick counter and the fault record | 19 |
@panic_handler with panic = "custom" |
reporting faults | 17 |
buffer[T, N] with bounds = "trap" |
the interrupt descriptor table | 13 |
[linker] script |
placing the boot headers and the stack | 18 |
Project layout
mykernel/
├── package.lppkg
├── scripts/
│ └── linker.ld
└── src/
└── main.lpfree
This is the same layout as the examples/helios-hello-freestanding project in the Loup repository: a manifest at the root, the linker script under scripts/, and the program in src/main.lpfree.
The manifest
name = "mykernel"
version = "0.1.0"
[build]
entry = "src/main.lpfree"
target = "freestanding"
mode = "freestanding"
arch = "x86_64"
panic = "custom" # faults are reported by kernelPanic
[linker]
script = "scripts/linker.ld"
entry = "_start"
[freestanding]
stack_size = 65536
bounds = "trap" # check run-time buffer indexes
Listing 21-1: package.lppkg
panic = "custom" is what makes the kernel's @panic_handler run; without it a panic would halt silently (chapter 17). bounds = "trap" guards every run-time index into a buffer whose size the compiler can see — here, the writes into the IDT.
The linker script
/* Loaded at 1 MiB. .boot holds the Multiboot2 header and the 32-bit entry
* trampoline; .note.Xen carries the PVH entry note, in its own PT_NOTE segment
* so that `qemu-system-x86_64 -kernel` finds it. */
ENTRY(_start)
PHDRS {
text PT_LOAD FLAGS(7); /* RWX */
note PT_NOTE FLAGS(4); /* R */
}
SECTIONS {
. = 0x100000;
.boot : {
KEEP(*(.multiboot))
KEEP(*(.text.boot))
} :text
.note.Xen : { KEEP(*(.note.Xen)) } :text :note
.text : { *(.text .text.*) } :text
.rodata : { *(.rodata .rodata.*) } :text
.data : { *(.data .data.*) } :text
.bss : {
*(.bss .bss.*)
. = ALIGN(16);
. = . + 65536;
__stack_top = .;
} :text
}
Listing 21-2: scripts/linker.ld
The Multiboot2 header must be within the first 32 KiB of the image, so .multiboot is the first thing in .boot. The trampoline below sets its own stack in low memory and does not use __stack_top; the reservation is there for code that wants a stack inside the image.
src/main.lpfree
The rest of the chapter is the source file, in order. Put the Loup listings 21-3 to 21-9 one after another and you have the complete src/main.lpfree.
Boot headers
QEMU's -kernel option loads an x86_64 ELF image only if it can find out how to enter it. The image carries two pieces of data for that: a Multiboot2 header and a Xen PVH ELF note whose value is the 32-bit entry address. Both are plain bytes, emitted by assembler directives from naked functions that are never called (chapter 18):
@mode(freestanding)
# mykernel — a minimal x86_64 kernel for `qemu-system-x86_64 -kernel`.
const ACQUIRE: int = 2
const RELEASE: int = 3
const SEQ_CST: int = 5
# ---- Boot headers -----------------------------------------------------------
# Multiboot2 header: magic, architecture 0 (i386), length, checksum, end tag.
@section(".multiboot")
@naked
@unsafe("naked: data only, never called; the Multiboot2 header")
func multibootHeader(): void {
asm {
".align 8"
"mb2_start:"
".long 0xE85250D6"
".long 0"
".long mb2_end - mb2_start"
".long 0x100000000 - (0xE85250D6 + (mb2_end - mb2_start))"
".short 0"
".short 0"
".long 8"
"mb2_end:"
}
}
# Xen PVH note (type 18, XEN_ELFNOTE_PHYS32_ENTRY): where QEMU enters the image.
@section(".note.Xen")
@naked
@unsafe("naked: data only, never called; the PVH entry note")
func pvhNote(): void {
asm {
".align 4"
".long 4"
".long 4"
".long 18"
".byte 0x58"
".byte 0x65"
".byte 0x6e"
".byte 0x00"
".align 4"
".long _start"
}
}
Listing 21-3: The memory-ordering constants and the boot headers
The entry trampoline
QEMU enters _start in 32-bit protected mode, with paging off and no stack. Loup code is compiled for 64-bit mode, so the entry is a naked function that builds a minimal set of page tables (the first 1 GiB identity-mapped with 2 MiB pages), turns on PAE, long mode and paging, loads a flat GDT, sets a stack, and only then calls kernelMain:
# ---- Entry: 32-bit protected mode to 64-bit long mode ----------------------
# Identity-map the first 1 GiB with 2 MiB pages (tables at 0x70000-0x72FFF),
# enable PAE and long mode, load a flat GDT (0x08 code, 0x10 data), set up a
# stack at 0x90000 and call kernelMain.
@entry
@naked
@unsafe("naked: entered by the PVH loader in 32-bit mode with no stack; builds page tables and enters long mode")
@section(".text.boot")
func _start(): void {
asm {
".code32"
"mov esp, 0x90000"
"mov dword ptr [0x70000], 0x71003"
"mov dword ptr [0x70004], 0"
"mov dword ptr [0x71000], 0x72003"
"mov dword ptr [0x71004], 0"
"mov edi, 0x72000"
"mov eax, 0x83"
"mov ecx, 512"
"fill_pd:"
"mov dword ptr [edi], eax"
"mov dword ptr [edi+4], 0"
"add eax, 0x200000"
"add edi, 8"
"dec ecx"
"jnz fill_pd"
"mov eax, 0x70000"
"mov cr3, eax"
"mov eax, cr4"
"or eax, 0x20"
"mov cr4, eax"
"mov ecx, 0xC0000080"
"rdmsr"
"or eax, 0x100"
"wrmsr"
"mov eax, cr0"
"or eax, 0x80000001"
"mov cr0, eax"
"lgdt [gdt_ptr]"
"jmp 0x08, long_mode"
".code64"
"long_mode:"
"mov ax, 0x10"
"mov ds, ax"
"mov es, ax"
"mov ss, ax"
"mov rsp, 0x90000"
"call kernelMain"
"cli"
"hlt"
".align 8"
"gdt_start:"
".quad 0x0000000000000000"
".quad 0x00209A0000000000"
".quad 0x0000920000000000"
"gdt_ptr:"
".word 23"
".long gdt_start"
clobbers: ["memory"]
}
}
Listing 21-4: The entry trampoline
Step by step: the three tables at 0x70000 (PML4), 0x71000 (PDPT) and 0x72000 (page directory) map virtual address n to physical address n for the first 1 GiB, with flags 0x83 (present, writable, 2 MiB page). cr4 bit 5 enables PAE, bit 8 of the EFER MSR (0xC0000080) enables long mode, and setting cr0's PG and PE bits turns paging on. The far jump through the GDT's 64-bit code segment (0x08) switches the CPU into 64-bit mode at long_mode. The GDT itself is data inside the assembly, after the code. The memory below 0x90000 is free RAM on a PC, which is why the tables and the stack live there.
Because kernelMain is the function the entry calls, the compiler runs the module initializer at its start (chapter 17). This kernel's module variables all have constant initializers, so nothing depends on that.
The serial console
On a PC, COM1 is a 16550 UART in the I/O-port space at 0x3F8, reached with in and out instructions rather than through Volatile (chapter 15). The output functions are @interruptSafe because the fault path uses them; they take no locks and allocate nothing, so the annotation is honest for this kernel:
# ---- Serial console: the 16550 UART at I/O port 0x3F8 ----------------------
const COM1: uint16 = 0x3F8u16
@interruptSafe
func outb(port: uint16, value: uint8): void {
asm { "out dx, al" in: "dx" = port, "al" = value clobbers: [] }
}
@interruptSafe
func inb(port: uint16): uint8 {
var value: uint64 = 0
asm { "in al, dx" out: value = "=a" in: "dx" = port clobbers: [] }
return (value & 0xFFu64) as uint8
}
func serialInit(): void {
outb(COM1 + 1u16, 0x00u8) # no UART interrupts
outb(COM1 + 3u16, 0x80u8) # DLAB on: the next two writes set the divisor
outb(COM1 + 0u16, 0x01u8) # divisor 1: 115200 baud
outb(COM1 + 1u16, 0x00u8)
outb(COM1 + 3u16, 0x03u8) # DLAB off; 8 data bits, no parity, 1 stop bit
outb(COM1 + 2u16, 0xC7u8) # enable and clear the FIFOs
}
@interruptSafe
func putc(c: uint8): void {
while ((inb(COM1 + 5u16) & 0x20u8) == 0u8) { } # wait for "transmit empty"
outb(COM1, c)
}
@interruptSafe
func puts(s: string): void {
for (c in s) {
putc(c as uint8)
}
}
@interruptSafe
func putDec(n: uint64): void {
var divisor: uint64 = 1u64
while (n / divisor >= 10u64) {
divisor = divisor * 10u64
}
var rest = n
while (divisor > 0u64) {
putc((0x30u64 + rest / divisor) as uint8)
rest = rest % divisor
divisor = divisor / 10u64
}
}
@interruptSafe
func putHex(n: uint64): void {
puts("0x")
var shift = 60
while (shift >= 0) {
let nibble = ((n >> (shift as uint64)) & 0xFu64) as uint8
if (nibble < 10u8) {
putc(0x30u8 + nibble)
} else {
putc(0x57u8 + nibble)
}
shift = shift - 4
}
}
Listing 21-5: Serial output over port I/O
The panic handler
The fault handlers record what they saw in module variables and then reach unreachable; with panic = "custom", that calls kernelPanic, which prints the panic message and location and, when a fault was recorded, the fault details:
# ---- Panics -----------------------------------------------------------------
# Filled in by the fault handlers before they stop the kernel.
var faultVector: uint64 = 0
var faultAddress: uint64 = 0
var faultCode: uint64 = 0
var faultRip: uint64 = 0
@panic_handler
func kernelPanic(msg: string, file: string, line: int): noreturn {
asm { "cli" }
puts("\nPANIC: ")
puts(msg)
puts(" at ")
puts(file)
puts(":")
putDec(line as uint64)
puts("\n")
let vector = atomicLoadU64(addressOf(faultVector), ACQUIRE)
if (vector != 0u64) {
puts(" fault vector ")
putDec(vector)
puts(", rip ")
putHex(atomicLoadU64(addressOf(faultRip), ACQUIRE))
puts(", address ")
putHex(atomicLoadU64(addressOf(faultAddress), ACQUIRE))
puts(", error ")
putHex(atomicLoadU64(addressOf(faultCode), ACQUIRE))
puts("\n")
}
while (true) {
asm { "hlt" }
}
}
Listing 21-6: The panic handler
Interrupt handlers
Three handlers: the timer counts ticks and acknowledges the interrupt controller; the page-fault and general-protection handlers record the fault and stop. Everything they call is @interruptSafe (readCr2 here, outb above) or an intrinsic, which the colour rule of chapter 16 checks:
# ---- Interrupts ---------------------------------------------------------------
@repr(C)
struct InterruptFrame {
let rip: uint64;
let cs: uint64;
let rflags: uint64;
let rsp: uint64;
let ss: uint64;
}
var ticks: uint64 = 0
@interrupt(arch: "x86_64", has_error_code: false)
func timerIsr(frame: borrow InterruptFrame): void {
let _old = atomicFetchAddU64(addressOf(ticks), 1u64, SEQ_CST)
outb(0x20u16, 0x20u8) # end of interrupt
}
@interruptSafe
func readCr2(): uint64 {
var a: uint64 = 0
asm { "mov rax, cr2" out: a = "=a" clobbers: [] }
return a
}
@interrupt(arch: "x86_64", has_error_code: true)
func pageFaultIsr(frame: borrow InterruptFrame, error: uint64): void {
atomicStoreU64(addressOf(faultAddress), readCr2(), RELEASE)
atomicStoreU64(addressOf(faultCode), error, RELEASE)
atomicStoreU64(addressOf(faultRip), frame.rip, RELEASE)
atomicStoreU64(addressOf(faultVector), 14u64, RELEASE)
unreachable
}
@interrupt(arch: "x86_64", has_error_code: true)
func generalProtectionIsr(frame: borrow InterruptFrame, error: uint64): void {
atomicStoreU64(addressOf(faultCode), error, RELEASE)
atomicStoreU64(addressOf(faultRip), frame.rip, RELEASE)
atomicStoreU64(addressOf(faultVector), 13u64, RELEASE)
unreachable
}
Listing 21-7: Timer and fault handlers
loup check prints the frame-parameter advisory of chapter 16 for each of the three handlers; it is expected.
The IDT, the PIC and the timer
The IDT is a buffer global filled with gates as in chapter 16. The handler addresses come from lea, because addressOf takes variables only. The two 8259 interrupt controllers are reprogrammed so that their 16 interrupt lines arrive at vectors 0x20–0x2F instead of colliding with the CPU's exception vectors, and only IRQ 0, the timer, is left unmasked. The 8254 timer (PIT) runs at 1 193 182 Hz; a divisor of 11 932 (0x2E9C) gives about 100 interrupts per second:
func timerIsrAddress(): uint64 {
var a: uint64 = 0
asm { "lea rax, [rip + timerIsr]" out: a = "=a" clobbers: [] }
return a
}
func pageFaultIsrAddress(): uint64 {
var a: uint64 = 0
asm { "lea rax, [rip + pageFaultIsr]" out: a = "=a" clobbers: [] }
return a
}
func generalProtectionIsrAddress(): uint64 {
var a: uint64 = 0
asm { "lea rax, [rip + generalProtectionIsr]" out: a = "=a" clobbers: [] }
return a
}
const KERNEL_CS: uint64 = 0x08u64
const GATE_INTERRUPT: uint64 = 0x8Eu64 # present, DPL 0, 64-bit interrupt gate
var idt: buffer[uint64, 512]; # 256 gates x 16 bytes
var idtr: buffer[uint64, 2];
func setGate(vector: int, handler: uint64): void {
let low = (handler & 0xFFFFu64) | (KERNEL_CS << 16) | (GATE_INTERRUPT << 40)
| (((handler >> 16) & 0xFFFFu64) << 48)
idt[vector * 2] = low
idt[vector * 2 + 1] = handler >> 32
}
func loadIdt(): void {
let base = addressOf(idt)
idtr[0] = 4095u64 | (base << 16)
idtr[1] = base >> 48
let p = addressOf(idtr)
asm { "lidt [rax]" in: "rax" = p clobbers: ["memory"] }
}
# Remap the two 8259 PICs to vectors 0x20-0x2F and unmask only IRQ 0.
func remapPic(): void {
outb(0x20u16, 0x11u8)
outb(0xA0u16, 0x11u8)
outb(0x21u16, 0x20u8)
outb(0xA1u16, 0x28u8)
outb(0x21u16, 0x04u8)
outb(0xA1u16, 0x02u8)
outb(0x21u16, 0x01u8)
outb(0xA1u16, 0x01u8)
outb(0x21u16, 0xFEu8)
outb(0xA1u16, 0xFFu8)
}
# PIT channel 0, rate generator, divisor 11932: about 100 Hz.
func startPit(): void {
outb(0x43u16, 0x34u8)
outb(0x40u16, 0x9Cu8)
outb(0x40u16, 0x2Eu8)
}
func interruptsOn(): void {
asm { "sti" clobbers: ["memory"] }
}
Listing 21-8: The descriptor table, the interrupt controller and the timer
The writes idt[vector * 2] use run-time indexes, so bounds = "trap" guards them: a vector above 255 would reach kernelPanic with buffer index out of range instead of overwriting idtr.
kernelMain
# ---- The kernel -------------------------------------------------------------
func kernelMain(): void {
serialInit()
puts("mykernel: booting\n")
setGate(13, generalProtectionIsrAddress())
setGate(14, pageFaultIsrAddress())
setGate(0x20, timerIsrAddress())
loadIdt()
remapPic()
startPit()
puts("mykernel: interrupts on\n")
interruptsOn()
var reported: uint64 = 0
while (true) {
asm { "hlt" } # sleep until the next interrupt
let now = atomicLoadU64(addressOf(ticks), ACQUIRE)
if (now >= reported + 100u64) {
reported = now
puts("tick ")
putDec(now)
puts("\n")
}
}
}
Listing 21-9: The kernel's main function
The main loop sleeps with hlt, wakes on each timer interrupt, and prints from normal context — never from the handler (chapter 16).
Checking, building and running
loup check type-checks the project on any platform (on Linux x86-64, loup-bootstrap check). It reports no errors, only the three frame-parameter advisories mentioned above.
Building the image needs Linux x86-64 with ld.lld (chapter 2). For an x86_64 package with a linker script, loup build uses the llvm lane (clang); --backend llbe selects the clang-free lane instead:
loup build # writes ./mykernel
loup build --backend llbe # the same, without clang
Boot it with the serial port on your terminal:
qemu-system-x86_64 -kernel ./mykernel -serial stdio -display none -no-reboot
When it boots, the console shows the two start-up lines and then a tick line about once a second:
mykernel: booting
mykernel: interrupts on
tick 100
tick 200
tick 300
Stop QEMU with Ctrl-C in the terminal.
Provoking a fault
Only the first 1 GiB is mapped. Add a read of an address above it to kernelMain, just before the while loop:
unsafe("demo: deliberately read an unmapped address") {
let _probe = bufGetU8Vol(0x8000_0000u64, 0)
}
Listing 21-10: A deliberate read of unmapped memory
bufGetU8Vol is a volatile byte read (chapter 13), so the compiler cannot drop it even though the value is unused. The CPU raises a page fault, pageFaultIsr records it and reaches unreachable, and kernelPanic prints PANIC: reached an `unreachable` expression at main.lpfree: followed by the line of that unreachable, then fault vector 14, the address of the faulting instruction, address 0x0000000080000000 (from cr2) and the page-fault error code. With panic = "halt" instead, the same fault would stop the machine without a word — which is why custom is the right setting while you develop.
What the stub leaves out
A real kernel needs much more, and each piece builds on the chapters before:
- Memory management. A physical frame allocator over the boot loader's memory map, page tables of your own instead of the fixed identity map, and an allocator you call directly (chapter 7; in 1.25 class construction does not go through
@allocator). - The local APIC and SMP. The legacy 8259 and PIT are enough on one CPU; multiple CPUs need the local APIC, its timer, and the start-up sequence for secondary CPUs, plus the locks of chapters 19 and 24.
- Exceptions. Only vectors 13, 14 and 0x20 have handlers. Any other exception finds an empty gate and turns into a double fault and then a triple fault, which resets the machine (QEMU exits because of
-no-reboot). Install a handler for each of the 32 exception vectors, and an IST stack for the double fault. - User mode, system calls, a scheduler, drivers.
Summary
- A complete x86_64 kernel is a manifest, a linker script and one Loup file of about 350 lines.
- The image carries a Multiboot2 header and a PVH note (bytes from naked
@sectionfunctions);_startis a naked trampoline that enters long mode and callskernelMain. - COM1 is driven with port I/O from
@interruptSafehelpers; the IDT is abufferfilled with gates whose handler addresses come fromlea. - Handlers count ticks with atomics or record a fault and reach
unreachable;panic = "custom"routes that to a handler that reports it. - Build on Linux x86-64 with
ld.lld; boot withqemu-system-x86_64 -kernel.
Next: testing and deploying firmware.