frostbyte
A small hobby operating system kernel for x86_64, written in C and GAS assembly. It boots via Multiboot2/GRUB, switches from 32-bit protected mode into 64-bit long mode with its own page tables, runs a preemptive round-robin scheduler, and loads static ELF programs from a ramdisk into ring 3 that talk back through system calls.
Everything below is verifiable headlessly in QEMU.
What it does
- Boots as a Multiboot2 kernel loaded by GRUB at 1 MiB, then builds its own paging
structures and jumps into a higher-half 64-bit kernel at
0xFFFFFFFF80000000. - Talks over serial COM1 and the VGA text buffer through a shared
kprintf. - Installs a full IDT (32 CPU exception handlers, 16 remapped hardware IRQs, and a
ring-3-callable
int 0x80syscall gate). - Drives the PIT at 100 Hz and preempts between kernel and user threads on every tick.
- Reads the PS/2 keyboard and feeds an in-kernel shell.
- Manages physical RAM with a bitmap frame allocator built from the Multiboot2
memory map, maps pages on demand, and serves a real free-list heap with
kfreeand coalescing. - Loads a Multiboot2 module ramdisk in USTAR (tar) format, parses static x86_64 ELF executables from it, maps their segments as user pages, and runs them in ring 3 with their own kernel and user stacks via a TSS.
- Mounts a VFS: the USTAR ramdisk read-only at
/(directories and files) and a writable in-RAM tmpfs at/tmp, with per-process file descriptor tables. - Serves system calls:
write,read,exit,getpid,yield,sbrk,sleep, plusopen,close,lseek,stat,getdents,mkdir,unlink.
Layout
src/
boot.S multiboot2 header, 32-bit entry, long mode + paging bring-up
isrStubs.S per-vector interrupt stubs and the common save/restore path
kmain.c bring-up sequence and the demo threads
serial.c COM1 driver
vga.c 80x25 VGA text driver with scrolling and hardware cursor
console.c kprintf over serial + VGA
string.c freestanding memset/memcpy/memmove/strcmp/...
gdt.c flat 64-bit GDT
idt.c IDT setup
isr.c exception panic + IRQ dispatch
pic.c 8259 PIC remap, EOI, masking
pit.c programmable interval timer
keyboard.c PS/2 scancode set 1 to ASCII, ring buffer
pmm.c bitmap physical frame allocator
paging.c 4-level page table walk, on-demand tables, user-range mapping
heap.c free-list heap allocator: kmalloc / kfree / krealloc, coalescing
sched.c preemptive scheduler, kernel + ring-3 threads, sleep/exit/sbrk
gdt.c kernel + user segments and the 64-bit TSS descriptor
tss.c task state segment, per-thread rsp0 for ring 3 -> ring 0
multiboot.c Multiboot2 module (ramdisk) discovery
initrd.c USTAR (tar) ramdisk header/content/directory reader
vfs.c path resolution, per-process descriptors, tmpfs, fd syscalls
elf.c static ELF64 loader (maps PT_LOAD segments as user pages)
syscall.c int 0x80 dispatch and the system calls
shell.c help / echo / ls / cat / mkdir / cd / pwd / mem / uptime / ps / clear
include/ headers (shared syscall.h is included by the kernel and userland)
user/
crt.S _start: call main, then exit
ulib.c userland syscall wrappers and print helpers
hello.c writes from ring 3, yields, exits
counter.c counts with sleep between steps
sbrktest.c grows its heap with sbrk and verifies the page
fileread.c reads a ramdisk file and round-trips a tmpfs file over syscalls
user.ld links each program above 1 GiB with headers in the first segment
fsroot/ sample text files packed into the ramdisk (etc/, home/)
tools/mktar.c host tool that packs the user ELFs and text files into a USTAR image
tools/ci-test.sh headless QEMU boot gate: greps serial for the filesystem proof
linker.ld higher-half layout, low boot segment
Makefile build + run + test targets
iso/boot/grub/grub.cfg
Requirements
System GCC 13 with freestanding flags (no cross compiler needed), GNU ld, plus
grub-mkrescue, xorriso, mtools and qemu-system-x86_64.
Build
make
This compiles every src/*.c and src/*.S and links the kernel with linker.ld
into build/frostbyte.elf. It also builds the userland programs under user/
as standalone static ELF64 executables, packs them together with the fsroot/
text files into a USTAR build/initrd.img with the host mktar tool, and wraps
kernel plus ramdisk into a bootable os.iso with grub-mkrescue. GRUB hands the
ramdisk to the kernel as a Multiboot2 module.
make test boots the ISO headless in QEMU and greps the serial log for the proof
that a ring-3 program read a ramdisk file and round-tripped a tmpfs file.
Run
Windowed (see the VGA text screen and type into the shell):
make run
Headless with the kernel log on your terminal over serial:
make runserial
To capture the serial log to a file:
qemu-system-x86_64 -cdrom os.iso -serial file:boot.log -display none -m 128M -no-reboot
Proof
docs/boot-serial.log is a captured headless boot. Abridged:
========================================
frostbyte kernel x86_64 long mode
multiboot2 / grub / freestanding gcc
========================================
[boot] multiboot2 magic verified (36d76289)
[gdt] loaded flat 64-bit segments
[idt] 256 vectors installed (32 exceptions, 16 irqs)
[pic] remapped to 0x20-0x2f, irq0 and irq1 unmasked
[pmm] 130559 KiB usable, 32736 frames, 32463 free after kernel
[heap] bump allocator at 0x0000000000111000, 8192 KiB
[paging] mapped 0x0000000040000000 -> phys 911000, readback OK
[pit] timer at 100 Hz
[kbd] ps/2 keyboard driver ready
[sched] 4 threads: idle, shell, workerA, workerB
[boot] enabling interrupts, starting preemptive scheduler
frostbyte> [workerA] tick=50 iterations=8650613
[workerB] tick=91 iterations=15943273
[workerA] tick=150 iterations=29911570
[workerB] tick=191 iterations=34964279
workerA and workerB never yield; they run tight loops on separate kernel
stacks. Both advancing while the timer ticks climb is the proof that the timer
interrupt preempts and the scheduler round-robins between them.
docs/userspace-serial.log is a captured headless boot of the userspace layer.
The kernel mounts the ramdisk, loads three ELF programs into ring 3, and they
answer over the syscall interface. Abridged:
[heap] coalesce PASS, 8191 KiB free of 8192 KiB, blocks=1
[initrd] fbar mounted, 3 files
[user] loaded 'hello' pid=4 entry=0x00000000400000b7 stackTop=0x0000000048000000
frostbyte> hello: greetings from ring 3, pid=4
counter: pid=5 step=0
sbrktest: pid=6 requesting 8192 bytes via sbrk
hello: resumed after yield, exiting cleanly
[user] pid=4 'hello' exited with code 7
sbrktest: heap page write and read-back verified OK
counter: finished counting
[user] pid=5 'counter' exited with code 0
The hello: line only reaches the serial port through the write syscall from
ring 3, so its presence is the proof that the ring 0 -> ring 3 transition, the
int 0x80 gate, and the dispatcher all work. As a counterfactual, routing int 0x80 to a vector the dispatcher never sees erases every user line and leaves a
ring-3 page fault (the fault frame reports cs=1b, confirming the code really ran
unprivileged).
docs/boot-serial.log and docs/shell-serial.log show the v1 kernel path: the
two never-yielding worker threads advancing on separate stacks (scheduler proof)
and the shell answering keystrokes injected over the QEMU monitor.
docs/screenshot.png is a QEMU framebuffer dump, showing the VGA text mode output
that the serial log cannot display.
Design notes
- Higher-half kernel: the boot segment is identity mapped low so GRUB can enter
it in 32-bit, and the rest of the kernel is linked at
-2 GiB(-mcmodel=kernel).boot.Smaps the first 1 GiB both identity and at the higher half, then far-jumps into 64-bit code. - Context switching happens inside the timer IRQ. The common interrupt stub saves
all registers to the interrupted thread's stack, hands the stack pointer to the
scheduler, and reloads the stack pointer the scheduler returns. A fresh thread
starts from a hand-crafted interrupt frame, so
iretqdrops straight into its entry point with interrupts enabled. - Userspace runs in the kernel's address space but under ring 3. Each user program
is linked at its own base above 1 GiB (so it sits outside the identity-mapped
low region) and its segments are mapped as user pages. The
int 0x80frame is a normal interrupt frame, so the same save/restore stub serves both IRQs and syscalls;iretqreturns to ring 3 because the savedcshas RPL 3. - Every thread that can enter ring 3 owns a separate kernel stack. The scheduler
writes that stack's top into the TSS
rsp0on each switch, so a trap taken in ring 3 lands on the right kernel stack. - The heap is an address-ordered free list with boundary coalescing and block
splitting, which is what makes
kfreeand reuse under fragmentation work.
Not in this version
Per-process address spaces (all user programs currently share one page table and
are separated by link base), a persistent on-disk filesystem, networking, and SMP
are the next steps. The current VFS reads a USTAR ramdisk and a RAM-only tmpfs;
fork/exec, an ATA/AHCI block driver behind the VFS, and a TCP/IP stack come
after that.