Bootloader (NASM) loads a C kernel and switches the CPU to 32-bit protected mode. The kernel initializes paging, interrupts and a GDT/TSS, then reads user ELF programs from a FAT12 filesystem on an ATA disk and schedules them at ring 3.
| Tool | Version here | Notes |
|---|---|---|
| Visual Studio 2022 | 17.x, Desktop development with C++ | we use the x86 cl.exe directly — no project/solution needed |
| NASM | 3.02 | winget install -e --id NASM.NASM |
| QEMU | 11.x | C:\Program Files\qemu\qemu-system-i386.exe |
| Python 3 | 3.14 | drives the whole build (build.py + helpers) |
cd D:\Source\Haku
python build.py :: build haku.img only (data image preserved)
python build.py run :: build + boot in a QEMU window
python build.py run --fresh :: recreate the FAT12 data image, then boot
python build.py headless :: fresh data image + boot headless, verify
python build.py x64 :: build haku64.img (64-bit kernel) and boot it
python build.py probe --init=X :: boot an external ELF as /init, check markers
python build.py --memory=1024 :: guest RAM in MiB (default 512, kernel cap 1020)
python build.py inspect ... :: examine a FAT12 image (list/check/extract)The kernel does not assume a memory size: it reads the E820 map, sizes the
supervisor direct map and the frame pool from it, and reports both in the
boot log (MEM-OK: usable=…kB free=…kB direct-map=…MB). --memory= only
changes how much RAM QEMU gives the guest; the kernel uses whatever it finds,
up to the 1020 MiB the 32-bit address space can map without PAE.
python build.py headless verifies ATA detection on both legacy channels
(ATA-SCAN-OK devices=2), the software-reset recovery path
(ATA-RECOVERY-OK), cross-channel sector reads (ATA-MULTI-OK),
FAT12 mounting and
multi-cluster file reads, subdirectory/path resolution (USER-DIR-OK),
ring-3 process entry, per-process kernel stacks,
syscall handling, spawn/waitpid and exit-status reporting, page-protection
faults (USER-PROT-OK), execve image replacement, process exit/reaping,
copy-on-write fork and vfork (USER-COW-OK, USER-VFORK-OK,
VFORK-EXEC-RAN), signal delivery and dispositions (USER-SIGNAL-OK),
the user C library (USER-LIB-OK), and timer IRQs
through the serial log.
build/haku_fs.img is the ATA data disk and is treated as user data: an
existing image is reused untouched, so files created in a previous QEMU
session survive the next build. It is (re)created from kernel/arch/x86/user/user.asm,
kernel/arch/x86/user/exec_target.asm, kernel/arch/x86/user/child.asm,
kernel/arch/x86/user/vfork_target.asm, the
user library in user/lib/, and user/tests/libtest.c only when missing or
with --fresh. If any test-program source is newer than a reused image,
the build warns that its test ELFs on disk may be stale. headless always
starts from a fresh image (its fault-injection tests mutate the disk) and,
after the serial-log check passes, re-verifies the post-run image on the
host side.
python build.py inspect (default: build/haku_fs.img) lists the root
directory and FAT summary without touching the image; --chains also prints
cluster chains, --cat NAME.EXT / --dump NAME.EXT OUT.BIN extract a file,
and --check enforces the kernel's mount invariants (BPB sanity, mirrored
FATs, exact chains, no orphans) with a nonzero exit on any damage.
The build is one Python script on purpose — cmd.exe quoting of spaced
paths (e.g. C:\Program Files\...) is fragile, while subprocess argument
lists just work.
.github/workflows/ci.yml runs on every push to main, every pull request,
and manual dispatch. It uses ubuntu-latest, installs NASM, Clang/LLD, and
QEMU from apt, runs python build.py headless through the
clang-cl/lld-link path in build.py, and uploads haku.img, the data
image, serial/QEMU logs, and any crash screenshots as artifacts even when the
boot check fails. A missing marker, an unexpected *-FAIL marker, or
KERNEL PANIC fails the job.
A second job, musl, builds user/tests/musl_probe.c with musl-gcc -static and boots it as /init with
python build.py probe --init=build/musl_probe --expect=MUSL-PROBE-OK. This
is the only check that runs an outside C library on top of the kernel, so it
is what proves the ABI claims above: musl's own _start, auxiliary-vector
parsing, %gs-based TLS, writev-backed stdio, and file/directory wrappers
all have to work for the probe to print MUSL-PROBE-OK. The probe binary is
built outside Haku's toolchain and packed into the initramfs as /init; the
probe mode boots it without the keyboard injection the headless suite needs
and asserts HAKU-BOOT-OK, INITRAMFS-UNPACK-OK, INIT-READ-OK name=init,
USERMODE-ENTER, and the requested marker.
.github/workflows/release.yml runs for v* tags. It repeats the headless
check, deletes the mutated test image, rebuilds a pristine
build/haku_fs.img, and publishes Haku-<tag>.zip containing haku.img,
haku_fs.img, README.md, LICENSE, and SHA256SUMS.txt:
git tag v0.2.0
git push origin v0.2.0Slow runners can tune headless timing with HAKU_BOOT_DELAY (seconds before
the injected key, default 1) and HAKU_HEADLESS_TIMEOUT (seconds to wait for
guest shutdown, default 30).
BIOS (0x7C00)
└─ kernel/arch/x86/boot/{stage1,stage2}.asm (16-bit): load kernel at 0x10000
→ A20 (Linux-style: test after BIOS/KBC/fast methods) → GDT → CR0.PE=1
└─ far jump to KERNEL_ENTRY (= PE entry point from link.exe)
└─ kernel/arch/x86/kernel/entry.asm (_entry stub) → call kernel_main
└─ kernel/kernel/kernel_main.c: paging/IDT → GDT+TSS → scheduler
└─ timer-preempted ring-3 processes → int 0x80
Memory map:
| Address | Use |
|---|---|
0x7C00 |
boot sector (512 B) |
0x10000–0x80000 |
kernel load window (up to 448 KiB, linked /BASE:0x10000 /ALIGN:16) |
0x7000–0x7240 |
E820 map from stage2 (INT 15h, count at 0x6FFC) |
0x9000–0xC000 |
page directory + initial tables (8 MiB supervisor identity map) |
0x1000–0x4000 |
x86-64 boot: PML4, PDPT and PD (1 GiB identity map, 2 MiB pages) |
0x400000–0x500000 |
direct-map page tables (one per 4 MiB of mapped RAM) |
0xC0000000–0xC0400000 |
supervisor framebuffer window (one page-directory entry) |
0xC0400000–0xFFFFFFFF |
supervisor direct map of physical 0–1020 MiB, sized from E820 at boot |
0x100000–0x400000 |
kernel heap (kmalloc/kfree, 16 B aligned) |
0x90000 |
bootstrap kernel stack top (grows down) |
| low E820 frames (often 4–8 MiB) | per-process 8 KiB kernel stacks, accessed through the direct map |
0x40000000 |
user program code/data virtual base (per-process mapping) |
0x50000000–0x54000000 |
user brk heap region (lazily mapped) |
0x60000000–0x70000000 |
anonymous mmap region (lazily mapped) |
0xBFFFE000–0xC0000000 |
two user stack pages |
0xB8000 |
VGA text buffer |
python build.py x64 produces haku64.img from the same stage1/stage2
floppy, with a 64-bit kernel entry instead of the 32-bit one. Stage 2 still
leaves the CPU in 32-bit protected mode; kernel/arch/x86_64/boot/entry64.asm
takes over from there, enables PAE and EFER.LME, installs a PML4/PDPT/PD
identity map of the first 1 GiB with 2 MiB pages, and far-jumps to a 64-bit
code segment. kernel/arch/x86_64/kernel/boot64.c is then the first C code
that runs in long mode, and it reports HAKU64-BOOT-OK on COM1.
That is the whole of the 64-bit path today. It proves the long-mode
transition, the 64-bit segment setup, the Microsoft x64 calling convention
across the asm/C boundary, and PE32+ handling in tools/pe2bin.py. The heap,
frame allocator, interrupts, scheduler, syscalls, and userland are still the
32-bit implementation, and haku.img remains the default image; ROADMAP.md
(Milestone 8) lists the remaining stages. The 64-bit sources are excluded
from the 32-bit link line, so both targets build from one tree.
kernel/arch/x86/kernel/isr.asm builds a uniform stack frame for all 256
vectors (dummy error code where the CPU pushes none), saves all data segments,
and calls into C. kernel/arch/x86/kernel/idt.c
installs 32 exception gates (0–31), 16 IRQ gates (32–47, used once the
8259 is remapped) and a catch-all for 48–255 that reports instead of
triple-faulting. The syscall gate at vector 128 is DPL 3; all other gates
remain ring-0-only. All are interrupt gates, so handlers run with IF clear.
The boot proves the exception path with an INT3 self-test.
kernel/arch/x86/mm/frame.c now builds its usable-frame bitmap from the 64-bit base and
length fields in the E820 entries. Only complete 4 KiB pages wholly inside
type-1 usable ranges are admitted; invalid entries and overlapping reserved
ranges are excluded. The allocator explicitly reserves low boot/firmware
memory, the kernel's 448 KiB load window, page tables, the boot stack, the
direct-map tables, the ISA hole, and the 1–4 MiB heap. The pool is sized from
the memory map instead of a fixed limit: paging_init reads the highest
usable E820 address, rounds it up to a 4 MiB boundary, and maps exactly that
much, so a 512 MiB guest gets a 512 MiB pool and a 2 GiB guest gets the
1020 MiB the 32-bit address space can address. frame_usable_count() reports
the E820-usable pages inside that window, and every tracked frame is
allocatable: a permanent supervisor direct map at 0xC0400000 addresses
physical 0–1020 MiB, and its PDEs are copied into each user page directory.
The bitmaps, the direct-map page tables, and the per-frame reference counters
are all sized at boot (heap allocations and low RAM), so the kernel image does
not carry a worst-case array. The self-test chain-allocates past the old
8 MiB identity boundary, writes/reads a high frame through PHYS_TO_VIRT,
and probes the last usable frame, which fails if the map stops short of the
installed RAM. Freeing validates alignment, usability, and allocation state
so reserved pages cannot accidentally be released.
kernel/kernel/scheduler.c starts the initial process from HAKU.ELF; a successful
spawn creates a child process from a root-filesystem ELF, fork creates one
as a copy of the caller, and waitpid/wait4 suspends the parent until the
requested child exits. Exit status is returned in the traditional high-byte
wait status format; a successful wait reaps the child record. The current
spawn interface accepts a pathname only and starts the child with empty
argument and environment vectors; the child inherits a copy of the parent's
descriptor table (standard descriptors and any open files, each sharing the
parent's open file description). waitpid accepts a specific child PID or
-1 (any child) and currently supports only options 0; wait4 follows the
Linux call, accepting WNOHANG and writing a zeroed struct rusage (the
per-process accounting described below is not yet wired into it).
exit_group ends the calling process like exit. clone is accepted only
for the SIGCHLD form (fork with no sharing flags) and returns EINVAL for
the thread-creating flags, stack pointer, or parent-tid arguments, so no
half-supported threading model is exposed. A forked child gets a private copy
of the address space (code, data, stacks, heap, and mappings), a copy of the
caller's saved register frame with eax zeroed, and its own descriptor table;
the parent sees the child PID. Pages are shared copy-on-write: each frame
carries a reference count, both address spaces keep the same read-only
mapping, and the first write on either side takes a protection fault that
hands the writer a private copy (the kernel also breaks the share before
writing through the direct map on the caller's behalf). vfork goes further
and shares the address space outright: the child runs in the parent's space
and on its stack while the parent stays suspended, and the parent resumes
with the child PID as soon as the child calls execve or exits. execve
replaces a process image from the root filesystem.
Each process has its own address space and an 8 KiB ring-0 stack; the stack
is allocated as physical frames and addressed through the kernel direct map.
gdt_set_kernel_stack updates TSS.esp0 at every task switch so the next
ring-3 interrupt or syscall enters on the correct stack. The 100 Hz PIT
scheduler saves and restores complete interrupt frames through iret.
User exceptions and exit mark a process for reaping; its address space and
kernel stack are reclaimed after execution has moved to another stack. When
no user process is runnable, the scheduler runs a dedicated kernel idle
context.
kernel/arch/x86/kernel/gdt.c installs flat ring-0/ring-3 code and data descriptors plus a
32-bit TSS. kernel/arch/x86/mm/paging.c gives each process supervisor-only kernel
identity and direct-map entries and maps its user program and two-page user
stack with user permissions.
kernel/arch/x86/user/user.asm is built as an ELF32 image and entered via iret at CPL 3.
It tests spawn (0x1000, Haku-specific), fork (2), wait4 (114), and
waitpid (7), checks that a forked child really shares pages copy-on-write,
runs vfork both to a plain exit and across an execve, as well as
execve (11), which loads another ELF32 image, builds an aligned
initial user stack containing argc, argv, envp, and a terminating
AT_NULL auxiliary-vector entry, and replaces the calling process without
changing its PID or open file descriptors. Each vector is limited to 64
strings; their strings are limited to 4096 combined bytes including NUL
terminators and must fit the two-page initial stack. Larger inputs return
E2BIG, and invalid user pointers return EFAULT. The first process starts
on a stack built the same way, with argv[0] set to the program name, so a
binary written for Linux finds its arguments where its startup code expects
them. The auxiliary vector carries AT_PHDR, AT_PHENT, and AT_PHNUM
(where the program headers are, which a static libc walks to find its own
PT_TLS segment), AT_PAGESZ, AT_CLKTCK, AT_BASE, AT_FLAGS,
AT_ENTRY, the identity words, AT_SECURE, and AT_RANDOM pointing at 16
seed bytes. Haku's own images start their first segment at a page-aligned
file offset, so the header table is not part of any segment; the loader maps
a copy on a page just below the stack and points AT_PHDR there, which is
what keeps the vector meaningful for every image.
The syscall ABI follows
the i386 Linux syscall numbers for a small POSIX-oriented subset: exit (1),
fork (2), read (3), write (4), open (5), close (6), waitpid (7),
unlink (10), execve (11), lseek (19), getpid (20), rename (38),
mkdir (39), rmdir (40), dup (41), brk (45), dup2 (63),
symlink (83), readlink (85),
gettimeofday (78), mmap (90), munmap (91), stat (106), lstat (107),
fstat (108), wait4 (114), clone (120), mprotect (125),
_llseek (140), readv (145), writev (146),
nanosleep (162), mremap (163), vfork (190), mmap2 (192),
stat64 (195), lstat64 (196),
fstat64 (197), madvise (219), exit_group (252), set_thread_area (243),
get_thread_area (244), set_tid_address (258), fstatat64 (300),
uname (122),
getrusage (77), times (43), getppid (64), getuid (24), geteuid (49),
getgid (47), getegid (50), and statx (383), with arguments in
ebx, ecx, edx, esi, and edi and syscall number/result in eax.
_llseek takes and returns a 64-bit offset through a user pointer, and
readv/writev walk at most IOV_MAX (1024) eight-byte i386 iovec
entries; a libc's stdio writes through writev, so a static libc prints
nothing without it.
The signal calls use the same numbers: kill (37), rt_sigreturn (173),
rt_sigaction (174), rt_sigprocmask (175), tkill (238), and tgkill
(270).
The Haku-specific spawn and getusage syscalls use 0x1000 and 0x1001.
The public ABI definitions are in include/haku/abi.h for C and
include/haku/abi.inc for NASM; the build checks that their numeric
constants stay synchronized. They define the syscall numbers (the ones
above plus the Linux numbers the coming milestones add, which answer
ENOSYS until then), errno values, open flags, and the metadata layouts.
haku_stat_t is the 64-byte i386 Linux struct stat returned by stat,
lstat, and fstat; haku_stat64_t is the 96-byte i386 struct stat64
returned by stat64, lstat64, fstat64, and fstatat64 — the layout
glibc and musl use on i386 — and haku_statx_t is the 256-byte struct statx returned by statx, the call musl makes for stat() on i386.
Regular files report mode 0644 (S_IFREG), directories 0755
(S_IFDIR), and the metadata carries the link count, owner, size, 512-byte
block size, and block count.
haku_timeval_t (tv_sec, tv_usec) and haku_timespec_t
(tv_sec, tv_nsec) are the 8-byte user time structures. haku_mmap_arg_t
is the 24-byte i386 old_mmap argument block.
brk grows or shrinks a per-process heap starting at a reserved virtual base
(0x50000000): the break is page-rounded and pages are mapped zero-filled as
it rises; lowering it unmaps the released pages and returns their frames; a
below-base break gives EINVAL and one past the heap limit gives ENOMEM.
mmap takes a pointer to an old_mmap argument block in ebx and
mmap2 (192) takes the same values in registers; both map anonymous pages
only (MAP_ANONYMOUS is required, because Haku has no page cache yet) and
place mappings in a second reserved region (0x60000000) using first-fit over
a per-process table, so earlier releases are reused. MAP_FIXED places a
mapping at the given page-aligned address, which must be a free range inside
the mmap window: the image, the stack, and the heap are never replaced.
MAP_SHARED and MAP_PRIVATE are both accepted; a shared mapping is
indistinguishable from a private one until a second task can map it.
munmap (91) releases a range that lies inside
one tracked anonymous mapping, trims or splits the region, and frees the pages
(and any now-empty page tables). PROT_WRITE selects the PTE R/W bit, so a
PROT_READ mapping is enforced read-only by the MMU, and PROT_NONE keeps
the pages mapped but clears the user bit, so any access faults. Both regions
are reclaimed with the rest of the address space at exit or execve.
mprotect (125) changes the protection of a mapped range: PROT_READ clears
the R/W bit, PROT_WRITE sets it, and PROT_NONE clears the user bit so
the range faults on the next access. A range that is not mapped reports
ENOMEM the way Linux does, while a bad length, a bad prot, or an address
outside the mapping window reports EINVAL. A copy-on-write page keeps its
shared frame protected and remembers that its mapping is writable, so the first write
still produces a private copy instead of writing through to the parent.
mremap (163) grows, shrinks, or moves a mapping: without MREMAP_MAYMOVE a
mapping may only grow in place, and with it the mapping moves to a free range
and keeps its contents (the pages are copied through the direct map).
MADV_DONTNEED (219) releases the pages of a range, so the next access sees
zeroed memory again; the other advice values are hints and change nothing.
set_thread_area (243) points the user FS segment at the thread block the
program passes, which is the thread pointer a libc reads through %fs:0; the
GDT entry carries the base and every task switch restores the base of the
task that is about to run. get_thread_area (244) returns it again. The call
follows the Linux contract so a foreign libc can use it: the caller passes a
16-byte i386 user_desc with entry_number set to 0xFFFFFFFF, the kernel
picks a fixed entry (6, selector 0x33), writes the chosen number back into
the structure, and returns 0; musl then loads entry * 8 + 3 into %gs and
reads its thread pointer from %gs:0. The nonzero return Linux does not
use matters: musl's stub treats any nonzero result as a failure and falls
back to modify_ldt. The base is also mirrored into FS, so Haku's own
library can keep using %fs. The
library wrappers live in user/lib/mman.c behind include/haku/mman.h, and
the ring-3 self-test covers the fixed mapping, the protection changes, the
fault on a read-only page, the move, the advice call, and the thread pointer
(USER-MMAP-OK).
The kernel sets CR0.WP, so ring-0 writes also honour R/W=0: read-only user
pages can only be filled through their physical direct-map alias. ELF segments
are mapped from their PF_W bit (the loader already rejects W|X), and
munmap clears the PTE before returning the frame, so both an RX store and a
store through a released mapping take a page fault. i386 without PAE has no
execute-disable bit, so W^X is enforced by the loader's W|X rejection rather
than by an NX permission.
paging_map_user_page and paging_unmap_user_page maintain per-address-space
counters for present user pages, non-directory user page tables, and a
mapped-page high-water mark. ELF loading, brk, mmap, and munmap all go
through those helpers, so the counters follow every mapping change without a
table walk. The scheduler attributes each 100 Hz timer tick to the interrupted
context by its CS privilege level, counts syscalls at the int 0x80 entry,
and records a voluntary switch when a task blocks, sleeps, or waits and an
involuntary switch when the timer moves a runnable task aside. execve
preserves the process's peak and resource totals while replacing the current
image's memory counters with those of the new address space.
getusage (0x1001, Haku-specific) copies a 64-byte haku_usage_t to a
validated user buffer. It reports current and peak mapped pages, page-table
and kernel-stack pages, current brk and anonymous mmap bytes, and derived
resident/kernel/total byte counts, along with the pid, user/kernel timer
ticks, syscall count, and voluntary/involuntary context switches.
resident_bytes covers mapped user pages; kernel_bytes covers the user page
tables, the page directory, and the 8 KiB kernel stack; total_bytes is their
sum. A NULL or unmapped buffer returns EFAULT. Accounting is per process,
not per thread (the kernel has no threads), there is no demand paging, and
ticks have 10 ms granularity.
read supports stdin from the PS/2 keyboard and regular files; write
supports stdout/stderr to the active display and serial as well as writable
regular files. The standard descriptors 0/1/2 are real per-process
descriptors backed by a registered console device rather than special cases
in the syscall layer: they resolve through the same VFS descriptor table as
file handles, and the device itself only forwards to the terminal layer.
open, close, lseek, unlink, rename, mkdir, rmdir, stat,
fstat, and per-process file descriptors are backed by the VFS/FAT12 layer.
Paths are /-separated 8.3 names: a relative name starts at the process
working directory, an absolute one at the volume root, . keeps the current
directory, and .. climbs to the parent (clamped at the root). stat
and fstat report byte size and mode (S_IFREG | 0644 for files,
S_IFDIR | 0755 for directories) through an 8-byte user buffer. Reads
and writes are bounded to 4096 bytes, and errors return
negative errno values (the user library in user/lib translates these to
errno).
Every FAT12 failure class keeps one distinct errno end to end: missing
files give ENOENT, bad flags/whence/pointers give EINVAL, existing
files give EEXIST, bad descriptors give EBADF, a full volume gives
ENOSPC, a non-directory path component gives ENOTDIR, opening or
unlinking a directory gives EISDIR, removing a non-empty directory gives
ENOTEMPTY, and disk I/O failures give EIO. A root mounted with
VFS_MOUNT_RDONLY rejects create, write, truncate, unlink, rename, mkdir,
and rmdir with EROFS while reads and metadata keep working. After the
user process exits,
the idle task runs an error self-test (FS-ERRORS-OK) including a
root-fill probe that must fail with ENOSPC (FS-ENOSPC-OK). The same
pass also remounts read-only, checks every mutating path returns EROFS,
remounts read-write (FS-EROFS-OK), covers empty files, exact-sector and
boundary-spanning I/O
(FS-FILE-EDGE-OK), injected cut/bad/reserved/cyclic FAT chains (each
restored, reads must give EIO, loops must terminate) plus a crafted
full data area (FS-CORRUPT-OK), and ATA bounds/NULL-transfer rejection
(FS-ATA-OK). The same pass checks the metadata behind stat/fstat:
type bits, size, link count, block size, and block count for a file and a
directory (FS-STAT-OK).
gettimeofday reports whole seconds and microseconds since boot derived
from the 100 Hz PIT tick counter; a NULL timeval is allowed (a no-op query),
an unmapped one gives EFAULT, and the unused timezone argument is ignored.
nanosleep rounds the requested interval up to the next timer tick, marks
the calling task TASK_SLEEPING, switches away, and lets the timer IRQ wake
it when its deadline passes, so it does not busy-wait. A zero interval
succeeds immediately; tv_nsec outside [0, 1e9) gives EINVAL and a NULL
request gives EFAULT. A pending signal interrupts a sleep: the task
wakes with EINTR. The number of sleeping tasks is not counted against idle, so a
sleeping process keeps the idle loop in the scheduler.
Blocking events use wait queues (kernel/kernel/scheduler.h). A queue is a
bitmap over scheduler task slots, so it needs no allocation and cannot fail.
wait_queue_sleep marks the caller TASK_BLOCKED with interrupts still
disabled; the caller then executes sti; hlt, so either the event IRQ returns
straight to the halted task or the timer IRQ saves that frame and switches to
another task. A blocked task is off the run queue, so when nothing else is
runnable the scheduler selects the idle context instead of rescheduling it.
wait_queue_wake_all is called from IRQ context and marks every parked task
runnable; waiters re-check their condition after resuming. Keyboard input is
the first user of this: IRQ1 pushes the character into the terminal's input
ring and wakes its readers, and SCHED-WAITQ-OK proves that a read waiting
for a key left the run queue and let idle run. nanosleep and waitpid keep
their existing TASK_SLEEPING/TASK_WAITING states; the blocked state is
deliberately separate so a child exiting cannot wake a task that is waiting
for console input.
Signals are implemented for the standard range 1..31 (kill, tkill,
tgkill, rt_sigaction, rt_sigprocmask, rt_sigreturn). Each task keeps
a pending set, a blocked set, and one disposition per signal; the sets are
single words, so the mask fits in 32 bits (the real-time range 32..64 is
rejected with EINVAL). rt_sigaction takes the i386 kernel layout
(handler, flags, restorer, mask) and accepts a sigsetsize of 4 or 8, so
musl's eight-byte sigset_t call works unchanged. SA_SIGINFO handlers
receive the siginfo block and the ucontext the kernel saved;
SA_RESTORER uses the program's own restorer, and without one the kernel
installs a stub that calls rt_sigreturn. SIGKILL and SIGSTOP cannot be
caught or ignored (EINVAL).
A queued signal is delivered when the task returns to user mode, which
happens on the next syscall, interrupt, or timer tick. The kernel builds a
frame on the user stack (restorer, three handler arguments, the saved
registers, the siginfo block, and the ucontext), rewrites the return
context to enter the handler, and restores it in rt_sigreturn. The signal
is blocked while its handler runs unless SA_NODEFER says otherwise, and
the blocked set is restored when the handler returns. SIG_IGN discards the
signal, SIG_DFL ends the process for every signal except SIGCHLD,
SIGURG, SIGWINCH, and SIGCONT (which are ignored); a parent then sees
the signal number in the wait status the way Linux reports it. A signal
queued for a task blocked in a syscall wakes it with EINTR (the console
read loop re-checks the input ring instead of returning EINTR). kill
with pid 0 targets the caller and pid -1 targets every other process,
because Haku has no process groups; any other negative pid gives ESRCH.
fork and
vfork reset the dispositions to the default and keep the blocked set;
execve resets the dispositions and keeps both the blocked set and the
pending signals. The C library wrappers live in user/lib/signal.c behind
include/haku/signal.h, and the ring-3 self-test covers delivery, blocking,
SIG_IGN, SA_SIGINFO, SA_RESTORER, the pid/tid forms, the default
fatal disposition, and the EINTR wake-up (USER-SIGNAL-OK). Haku has no
process groups, no job control (the stop signals end the process), no
sigqueue, and one thread per process, so a thread id is a process id.
Writes are not atomic: there is no journal, so a failing multi-sector
write leaves exactly its durable prefix (a short count means those bytes
are on disk and the size covers them), while a total failure restores
the pre-call size and releases clusters the call allocated
(FS-ROLLBACK-OK). Bytes are never attempted past a gap that failed to
zero, so no unwritten holes are ever claimed. If persisting the directory
entry itself fails, the call rolls back to the pre-call metadata rather
than leaking unreachable clusters; a TRUNC whose chain cannot be freed
zeroes the entry best-effort so the file reads back empty instead of
pointing at a broken chain. Anything beyond that best effort (for
example, clusters orphaned by a dying disk) stays leaked: there is no
fsck yet.
Open files survive removal and renaming: unlink deletes the name at
once while open descriptors keep reading, writing, and seeking the data
(clusters are released at the last such close); rename moves the entry
and repoints open descriptors to it, so they keep working under the new
name. Renaming onto an existing name fails with EEXIST, renaming a name
onto itself succeeds as a no-op, and every other missing name gives
ENOENT. The user test program proves all of this from ring 3 in both
processes (USER-FILE-OPS-OK).
dup and dup2 point a second descriptor at the same open file
description: the copy shares the byte offset and access mode, and the file
stays open until the last descriptor closes, so closing the original leaves
the duplicate usable (and an open-on-a-removed-file survives to its final
close). dup returns the lowest free descriptor, dup2 closes and reuses a
free or already-open target and is a no-op when both descriptors match, and
both report EBADF for an invalid source without disturbing the target. The
user program proves the shared offset, the surviving duplicate, and the
error cases in both processes (USER-DUP-OK).
Because 0/1/2 are ordinary descriptors, dup2 can redirect stdout to a
file (a shell-style redirect) and close revokes a standard descriptor so
later I/O returns EBADF until dup2 restores it. spawn copies the
parent's descriptors into the child, and every exit path releases the exiting
process's descriptors, so files a child leaves open are closed automatically
when it terminates. The user program and its child prove the redirect,
revoke, inherited-descriptor, and close-on-exit paths (USER-STDIO-OK,
USER-FD-INHERIT-OK); after both processes exit, the idle self-test asserts
their descriptor tables are empty.
A process also has a working directory. chdir resolves the target, requires
a directory, and stores the result as a canonical root-relative path (no
leading slash, no . or .. components, empty for the root); getcwd
rebuilds / plus that path. Every path syscall resolves relative names
against it, so open("data.txt") and execve("NEXT.ELF", ...) follow the
process, while a leading / still starts at the volume root. The root itself
canonicalizes to . for the VFS, which has no name for it. fork, vfork,
and spawn children inherit the directory, and execve keeps it.
The *at family takes a directory descriptor: openat, unlinkat (with
AT_REMOVEDIR), renameat, mkdirat, faccessat, and the dirfd form of
fstatat64/statx join a relative name to the directory the descriptor was
opened on, and accept AT_FDCWD for the working directory. open with
O_DIRECTORY returns a descriptor for enumeration: getdents64 fills the
user buffer with packed Linux linux_dirent64 records (inode, offset,
record length, type, name) from the backend's opendir/readdir pair, and a
closed directory handle releases a directory object rather than a file one.
Enumeration skips deleted slots, long-name fragments, volume labels, and
./.., so it lists the names a user would see.
pipe/pipe2 create a 512-byte in-memory pipe with two open file
descriptions and separate reader/writer counts: a read returns 0 once
every write end closes, a write to a pipe with no readers raises SIGPIPE
and returns EPIPE, and O_NONBLOCK (set at creation or through
fcntl(F_SETFL)) makes both directions return EAGAIN instead of blocking.
Blocking pipe reads park on a wait queue like the console does. fcntl
answers F_GETFD/F_SETFD, F_GETFL/F_SETFL (O_APPEND, O_NONBLOCK),
and F_DUPFD/F_DUPFD_CLOEXEC; F_GETLK reports "unlocked" and
F_SETLK/F_SETLKW succeed as no-ops, because Haku tracks no record locks.
dup3 is dup2 with an O_CLOEXEC flag check and rejects identical
descriptors with EINVAL.
ioctl on the console answers TCGETS with the terminal's fixed mode
(ICRNL|IXON, OPOST|ONLCR, ISIG|ICANON|ECHO|ECHOE|ECHOK, erase 0x7F,
EOF ^D), accepts TCSETS/TCSETSW/TCSETSF without changing that mode,
reports an 80x25 window for TIOCGWINSZ, and returns ENOTTY for a
descriptor that is not the console. poll reports readiness per descriptor (a
pipe with data is POLLIN, a closed write end adds POLLHUP, a closed read
end makes the write side POLLERR), and it blocks: a zero timeout scans once,
a negative one waits indefinitely, and a positive one is a millisecond
deadline. The wait is driven by the timer rather than by per-descriptor
wakeups — every tick wakes tasks parked on the scheduler's activity queue, so
they re-scan at 100 Hz and read the same tick counter as the clock. That costs
at most one tick of latency and one wake per parked poll per tick, and needs
no console/pipe plumbing. select (both i386 numbers, 142 _newselect and 82,
which musl uses) converts its three sets into the same readiness scan; Haku
has no exceptional conditions, so the except set always comes back empty. The
legacy getdents record format is produced too, by rewriting the getdents64
records in place: each becomes { u32 ino, u32 off, u16 reclen, name } padded
to four bytes, which is shorter than the 64-bit record, so a caller with a
fixed buffer keeps calling until the call returns 0. access checks that the
name resolves and
refuses X_OK because nothing is marked executable; fchmod, fchown,
and their *at forms succeed without changing anything because FAT12 stores
no mode or owner bits. getrlimit/setrlimit/prlimit64 report the fixed
table sizes (8 descriptors, 4 tasks, RLIM_INFINITY elsewhere) and accept a
new value without applying it. getrandom fills a buffer from a xorshift
generator seeded by the tick counter and the pid, sched_yield hands the
CPU to the next runnable task, prctl stores a 15-character process name
and accepts the remaining options as no-ops, and personality always
reports PER_LINUX. The C library covers all of it
(include/haku/dirent.h, poll.h, termios.h, resource.h, and the
*at wrappers in fcntl.h), and the ring-3 self-test exercises the whole
path as USER-FS-ABI-OK.
Not implemented: real record locks (F_GETLK/F_SETLK/F_SETLKW are
accepted without tracking anything) and mremap with MREMAP_DONTUNMAP.
Before copying, the kernel validates every covered page in the current page
tables for presence, user access, and write permission where required. It
rejects unmapped/kernel pointers, wraparound, invalid descriptors and
oversized I/O. The syscall entry enables interrupts while in kernel code so
blocking keyboard reads can make progress; the keyboard wait is atomic
against the interrupt/wakeup race. Ring-3 page faults are contained and
reported rather than halting the kernel: the fault handler prints a crash
report (vector, error code, CR2, control registers, general registers, and
the validated user stack), encodes the error code in the exit status byte
(0x80 | err), and terminates only the faulting process. These syscalls are an
initial POSIX-inspired kernel interface, not full POSIX conformance. Run
python build.py headless to test read/write/open/close/lseek/getpid/exit,
gettimeofday/nanosleep, brk/mmap/munmap, per-process getusage memory and
resource accounting, bad pointers, descriptor errors, input copying,
disk-backed create/write/read across a sector boundary, and continued
timer operation. The protection test spawns one child per fault
(USER-PROT-OK): a store into an RX segment, a store into a PROT_READ
mapping, a read from unmapped memory, and a store through a munmaped page,
each checked against its expected #PF error code (7, 7, 4, 6). The
blocking read is also observed to park the process (SCHED-WAITQ-OK) before
the injected key arrives.
The error matrix (USER-ERRNO-OK) drives each syscall family with a bad
descriptor, an invalid user pointer, a missing path, and a bad argument, and
requires the documented errno instead of a success, a kernel fault, or an
unset return value: EBADF, EFAULT, ENOENT, EEXIST, EISDIR,
ENOTDIR, ENOTEMPTY, EINVAL, ERANGE, ENOTTY, EPIPE, ESRCH,
ECHILD, and ENOMEM. The ELF rejection test (ELF-REJECT-OK) breaks one
header or program-header field at a time — class, byte order, machine, entry,
table geometry, p_offset, p_vaddr, p_filesz against p_memsz, PT_INTERP,
and the writable+executable combination — and requires the loader to reject
every one of them, plus a truncated image. SCHED-CONCURRENT-OK runs two
children that create, verify, and unlink their own files at the same time,
which exercises the sleeping VFS and disk mutexes end to end.
USER-INITRAMFS-OK is the boot-namespace check described under "The boot
namespace" below: it runs as a process started from the unpacked archive, so
a broken mount table or a missing /init fails the build before anything
else can pass.
Haku implements a POSIX-inspired subset aimed at a statically linked musl userland; conformance is not a goal. What exists, by area:
- Process:
exit/exit_group,fork,vfork,clone(fork form),wait4/waitpid,execve,getpid/getppid/getuid/geteuid/getgid/getegid/gettid,kill/tkill/tgkill,rt_sigaction/rt_sigprocmask/rt_sigreturn,sched_yield,prctl(process name),personality,times,getrusage(zeroed),uname,getrlimit/setrlimit/prlimit64(fixed values),getrandom. - Memory:
brk,mmap/mmap2(anonymous only),munmap,mprotect,mremap,madvise,set_thread_area/get_thread_area. - Files:
open/openat,close,read,write,lseek,dup/dup2/dup3,pipe/pipe2,fcntl,ioctl(console),unlink/unlinkat,rename/renameat,mkdir/mkdirat,rmdir,stat/lstat/fstat(32- and 64-bit),fstatat64,statx,symlink/readlink(on the RAM root),truncate,ftruncate,access/faccessat,chdir,getcwd,getdents,getdents64,poll,select, andchmod/chown/fchmod/fchown(accepted, nothing to change). - Time:
gettimeofday,nanosleep. - Haku-specific:
spawn,getusage,futex.
Deliberately absent: process groups and job control, threads (clone
rejects the thread flags), hard links, file-backed mappings,
socket, mount (the boot namespace is fixed at boot), and networking.
Where a call exists but cannot be honoured — permission bits, record locks,
terminal modes — it succeeds
without changing state and the limitation is documented above instead of
returning a surprise error.
python build.py run boots the floppy in a QEMU window with the same disks
as the automated run, so the whole flow can be driven by hand: the injected
key only exists in headless, so press any key yourself at the read prompt.
build/serial.log is only written by headless; in a windowed run the same
text appears on the VGA/framebuffer console.
A data image that a test corrupted can be thrown away safely: delete
build/haku_fs.img or run python build.py --fresh (add headless to also
run the tests), and the image is rebuilt from the test programs. Before
discarding one, python build.py inspect --check says whether it is really
damaged — it verifies the BPB, both FAT mirrors, every cluster chain, and
orphans, and exits nonzero on any inconsistency.
include/haku/ doubles as a freestanding C SDK: <haku/haku.h> includes the
syscall ABI, types.h, errno.h, string.h, stdlib.h, unistd.h,
fcntl.h, stat.h, stdio.h, signal.h, mman.h, futex.h, dirent.h,
poll.h, termios.h, and resource.h.
- Raw syscalls:
haku_syscall0..haku_syscall4inuser/lib/syscall.asm(i386int 0x80, arguments inebx/ecx/edx/esi, result ineax). They return the kernel's-errnounchanged. - POSIX-style wrappers return
-1and seterrno:read,write,close,lseek,dup,dup2,open,stat,lstat,fstat,unlink,rename,mkdir,rmdir,getpid,fork,vfork,waitpid,execve,spawn,getusage,gettimeofday,nanosleep,sleep,brk, andsbrk.vforkis written insyscall.asmrather than C: the child runs on the caller's stack, so the pending return address is parked inedxacross the syscall instead of being left where the child would overwrite it.stat/lstatuse the Linuxfstatat64(300) call withAT_FDCWDandfstatusesfstat64(197), so the library and a musl program read the same 96-bytestruct stat64layout. - Signals:
kill,tkill,tgkill,raise,signal,sigaction,sigprocmask, and thesigsethelpers (sigemptyset,sigfillset,sigaddset,sigdelset,sigismember) inuser/lib/signal.c. The mask is a single word, so only signals 1..31 exist. - Memory mappings:
mmap,mmap2,mprotect,mremap,madvise,munmap,set_thread_area, andget_thread_areainuser/lib/mman.c, with thePROT_*,MAP_*,MREMAP_*, andMADV_*constants ininclude/haku/mman.h. - Strings and memory:
mem*,str*,atoi,malloc/calloc/realloc/free(a first-fit free list overbrk), andprintf/puts/putcharwith a%c/%s/%d/%u/%x/%X/%psubset (no floats, 512-byte output buffer). user/lib/crt0.asmsupplies_start: it callsmain(void)and exits with the returned status.
build.py compiles the library and user/tests/libtest.c with the same
cl.exe (or clang-cl) used for the kernel, links a fixed-base PE32 at
0x40000000 with no CRT, and tools/pe2elf.py repacks its RX/R/RW sections
into ELF32 PT_LOAD segments. The image is installed as LIB.ELF;
NEXT.ELF spawns it, requires a clean exit status, and the run prints
USER-LIB-OK. There is no C runtime, dynamic linker, standard header set,
floating-point formatting, or thread-safe allocator, and data-image filenames
remain 8.3. The user ELFs are regenerated on every build, but an existing
build/haku_fs.img only receives LIB.ELF after --fresh or headless.
kernel/drivers/terminal.c is the text-I/O layer. Output paths (klog, the
exception handlers, and the console device) call terminal_putc /
terminal_write, which render to the active display backend and mirror every
byte to COM1 with CRLF line endings. A group of bytes is written under one
hold of the serial port's IRQ-save lock, and the klog assembles a whole log
line before handing it over, so a kernel log line and a user write never land
inside each other: the markers the headless check looks for stay contiguous
in build/serial.log. The console device writes in 256-byte chunks so the
interrupt-off window stays short (the UART runs at 38400 baud, so a chunk
takes about 70 ms). Input is a 128-byte single-producer /
single-consumer ring owned by the terminal: kbd.c translates set-1 scancodes
and calls terminal_input_push, while terminal_getchar/terminal_read park
on the terminal's wait queue until IRQ1 wakes them. Neither the keyboard
driver nor the syscall layer knows about the other.
Display backends implement the shared display_ops_t table in
kernel/drivers/display.h (name, clear, putc, status). The VGA text
backend is selected at boot and stays the fallback; when the bootloader
hands over a usable VBE mode, framebuffer_init succeeds and the boot path
switches the active backend with display_use(&framebuffer_display). Backend
state is protected with IRQ-save spinlocks because the timer redraws the
status line from interrupt context.
kernel/kernel/klog.c provides five severities (DEBUG, INFO, WARN,
ERROR, CRIT) with an INFO default threshold. klog_log and
klog_begin add the tag and honour klog_set_level; the plain
klog_write/klog_prefix path stays unconditional for boot markers and
test output. klog_self_test proves that DEBUG is suppressed at the
default threshold and appears after lowering it (KLOG-LEVELS-OK).
kernel/kernel/panic.c is the fatal path. It disables interrupts, prints
KERNEL PANIC: <reason>, and calls crash_report (kernel/kernel/crash.c),
which dumps CR0/CR2/CR3, the current pid, the exception frame
(vector, err, eip, cs, eflags), the general registers, segment
registers, and the interrupted stack. Ring-0 stacks are dumped directly;
ring-3 stacks are dumped only after paging_user_range_valid confirms they
are mapped, so a bad user stack cannot fault the reporter. The CPU is then
parked in cli; hlt. Boot self-test failures (PAGING, HEAP, FRAME,
GDT, usermode) and fatal kernel exceptions use the same handler.
Contained ring-3 faults use the same report but terminate only the faulting
process, and the first one logs CRASH-REPORT-OK. QEMU captures the report
in build/serial.log; build.py also passes -d int,cpu_reset and writes
build/qemu.log, and a headless run fails if KERNEL PANIC appears. To
exercise the panic path without changing code, boot with a QEMU monitor and
send nmi while the idle loop is running: the serial log then shows
KERNEL PANIC: NMI followed by the PANIC: register and stack dump.
kernel/drivers/ata.c probes the legacy primary and secondary ATA channels
(0x1F0/0x3F6 and 0x170/0x376) at both master and slave positions,
ignores floating buses and ATAPI devices, and keeps every usable disk in a
device list. Devices are identified with IDENTIFY; LBA28 is used when
available and LBA48 addressing is the fallback. Identify, read, write, and
cache-flush commands use bounded polling PIO: a failed or timed-out command
is retried after a software reset (SRST) up to a fixed number of attempts,
and every write is followed by FLUSH CACHE before it is reported complete
(a drive that rejects the flush keeps the accepted data and only logs a
warning). PIO polling is retained deliberately: the initial FAT mount runs
before the PIC and PIT are initialized, while IRQ completion requires
remapping/unmasking IRQ14/15 and DMA completion adds PCI bus-master
programming and physical-buffer constraints. The synchronous path keeps boot
deterministic and headless-testable; AHCI, PCI native-mode controllers, and
ATAPI are not supported. The boot self-tests prove the recovery path
(ATA-RECOVERY-OK: a software reset followed by an identical sector reread)
and cross-channel I/O (ATA-MULTI-OK: the second device answers a sector
read), and the headless run checks the scan itself (ATA-SCAN-OK devices=2).
kernel/fs/fat12/fat12.c mounts a
FAT12 superfloppy from any detected ATA disk (the first disk that carries a
valid BPB wins, so the volume may live on the secondary channel or on a
slave) and supports creating, reading, writing, truncating and
seeking 8.3-named files anywhere in the tree, plus mkdir/rmdir of
single-cluster subdirectories with real ./.. entries. Paths are
/-separated and resolved from the root; . and .. are handled during
resolution. Cluster allocation, release, mirrored FAT updates and
volume/chain bounds checks are shared by files and directories.
kernel/fs/vfs/vfs.c supplies per-process descriptors and the user open, close,
read, write, and lseek interface through a filesystem-independent
operations vector (vfs_fs_ops: mount/open/close/read/write/seek/unlink/
rename/mkdir/rmdir/stat/lstat/fstat, each returning 1 or a -VFS_E* errno).
VFS never names a concrete filesystem; each backend registers the vector and
the VFS binds it to a directory path in a mount table. Path resolution picks
the longest matching prefix, so /disk/HAKU.ELF reaches the FAT12 backend as
HAKU.ELF while /dev/null reaches the device backend as null; the mount
root itself is ".", the same token the syscall layer hands the VFS after
canonicalizing /. A mount is read-only when it is created with
VFS_MOUNT_RDONLY, and VFS then returns EROFS for every mutating operation
instead of calling the backend, including writes through a descriptor that
was opened before the remount. A mount point cannot be unlinked, renamed, or
removed (EBUSY), a rename that would cross two mounts reports EXDEV, and
a mount point directory must exist in the parent filesystem so it shows up in
a listing. Disk I/O is serialized
against the preemptive scheduler; file contents and directory/FAT metadata
are written through to the ATA disk. The current VFS supports up to five
simultaneously open regular files per process (descriptors 3-7; 0/1/2
are the console) and the O_RDONLY, O_WRONLY, O_RDWR, O_CREAT,
O_EXCL, O_TRUNC, and O_APPEND flags.
FAT12 remains intentionally small as a data-disk format: no partition
tables, long filenames, permissions, journaling, FAT16, or FAT32 (see the
next section for why). Directories are one cluster (16 entries) and do not
grow; moving a directory
across parents with rename leaves its .. entry pointing at the old
parent, so directories should be renamed in place. The userland root is the
RAM filesystem described below, which has none of those limits. The QEMU
headless test creates P1.TXT, exercises DIR1/T.TXT with ./DIR1/../DIR1
paths, and verifies multi-sector writes, reads, seeks, close, and reopen.
Long filenames, partition tables, and FAT16/FAT32 are each a deliberate non-goal for the data disk, not an oversight. The reasoning, recorded so the decision does not have to be re-derived:
- Long filenames (VFAT LFN). LFN entries are a chain of hidden records
with a checksum tying them to the 8.3 name, ordered in reverse and
contiguous; every create, rename, unlink, and directory-growth path would
have to maintain that chain, and a torn write leaves an orphaned fragment.
Haku's userland root is moving to an in-memory filesystem with real long
names and permission bits (see
ROADMAP.mdmilestone 7), which leaves FAT12 as an interchange format for small data disks where 8.3 names are enough. Paying LFN complexity in the interchange format buys nothing the userland filesystem does not already provide. - Partition tables. The kernel owns the whole disk image: the FAT12 volume starts at LBA 0. Adding an MBR would mean parsing a partition table, keeping a per-partition byte offset through every cluster calculation, and choosing a boot partition — for a disk no other operating system shares. If Haku ever has to coexist with another OS on one physical disk, this becomes worth doing, and the offset layer is the natural place to add it.
- FAT16/FAT32. The current images are 1.44 MB, where FAT12 is the correct encoding (a 12-bit entry per cluster). FAT16 becomes relevant only for data disks above roughly 16 MiB at 4 KiB clusters, and FAT32 brings long names, FSInfo, and mirroring rules with it. Both would be new backends with their own on-disk structures rather than a flag on the existing one.
Any of these can be revisited, but only together with interoperability tests:
before such a change lands, mtools/fsck.fat must be able to read, write,
and check the resulting image in CI, and tools/lsfat12.py must agree with
them. A filesystem that only Haku can read is not a filesystem Haku can
export data through, and the roadmap treats that gate as mandatory rather than
optional.
tools/mkfat12.py puts HAKU.ELF, the exec target NEXT.ELF, and the
spawn/wait child CHILD.ELF on a generated 1.44 MB FAT12 image, together with
the initramfs archive INITRD (no extension, because the volume only stores
8.3 names). QEMU
attaches this image as the primary-master IDE disk while the existing boot
floppy still starts the kernel; the build also attaches a generated
1.44 MB spare disk as secondary master so the headless run exercises
device scanning across both legacy channels. The kernel
loads the initial ELF at startup; execve uses VFS to load subsequent ELF
files into a fresh address space before replacing the current image.
The root of the userland namespace is the RAM filesystem, not the FAT12
volume. At boot the kernel mounts ramfs on /, creates the /disk mount
point directory, mounts the FAT12 data disk there, reads disk/INITRD, and
unpacks the cpio archive into the RAM root. /init from that archive runs as
PID 1; the FAT volume stays reachable at /disk, and an image without an
archive still boots the old way with FAT12 as the root.
ramfs(kernel/fs/ramfs) is the writable root: long names (48 bytes per component), permission bits, timestamps from the PIT, nested directories, and symbolic links with a hop limit of 8. Nodes come from a fixed 160-entry table and file data from the kernel heap (256 KiB per file, enough for a static libc binary), so nothing survives a reboot — which is exactly what an initramfs needs. Unlinking a file that is still open keeps the data alive until the last descriptor closes, the same promise FAT12 makes.devfs(kernel/fs/devfs) is a fixed table of/dev/null,/dev/zero,/dev/urandom,/dev/random,/dev/console, and/dev/tty. These are ordinary descriptors on the normal read/write path;seekreportsESPIPEand the entries cannot be unlinked or renamed.procfs(kernel/fs/procfs) generates/proc/version,/proc/uptime, and/proc/meminfowhen the file is opened, so acatsees current values. It is read-only: writes reportEACCES, and the entries cannot be removed.
The archive is cpio "newc" (070701), built by tools/mkcpio.py from the
same ELF images the FAT volume carries. It holds /init, the programs the
exec tests use, and empty dev, proc, and disk directories so the mount
points appear in a listing. INITRAMFS-UNPACK-OK entries=… files=… bytes=…
reports what was unpacked, and the ring-3 self-test proves the rest from user
mode (USER-INITRAMFS-OK): the root listing, a name longer than 8.3, a file
two directories deep, a symlink that lstat reports as a link while stat
and open follow it, /dev/null returning end of file, /dev/zero
returning zeros, /proc/version naming the kernel, and /disk/HAKU.ELF
starting with the ELF magic.
lstat is a real no-follow call: the backend's optional lstat operation
describes the last component itself (ramfs walks with follow_last = 0),
AT_SYMLINK_NOFOLLOW is honored by fstatat64 and statx, and a backend
without links answers exactly like stat. symlink and readlink are
available to user mode; readlink truncates to the caller's buffer instead
of failing, like Linux.
kernel/kernel/sync.h provides four primitives on top of cpu_irq_save/restore:
irq_lock/irq_unlock— a plain IRQ-save pair for short critical sections that never block and never call into a blocking path. Nests correctly.mutex_lock/mutex_unlock— a task-context sleeping mutex. Interrupts stay enabled for the whole critical section, including the I/O inside it, and a waiter parks on the lock's wait queue (TASK_BLOCKED) instead of spinning, so a task queued behind a disk transfer costs no CPU. The test and the park happen in one IRQ-off section, which is what makes a missed wake-up impossible. Boot and idle context has no task to park, so the lock falls back to a bounded spin there; that path is single-threaded by construction. Mutexes are not recursive and are never acquired with interrupts disabled.spin_lock/spin_unlock— a task-context spinlock for very short sections. Waiters busy-wait, so nothing slow may happen while one is held.spin_lock_irqsave/spin_unlock_irqrestore— a spinlock whose holder also disables interrupts, making it non-preemptible and safe from IRQ context.
The locking rules per subsystem:
- Scheduler: every task-array mutation runs with interrupts disabled (in an
IRQ handler or under
irq_lock), so no lock is needed. A task-context lock must never be taken with interrupts disabled — a preempted holder would never run again to release it.scheduler_spawntherefore reserves a task slot, then re-enables interrupts forvfs_inheritbefore publishing the task as runnable. - Allocators (
kernel/arch/x86/mm/frame.c,kernel/kernel/heap.c): IRQ-save spinlocks. The free-list/bitmap updates are short and the holder cannot be preempted, so the allocators are safe from both task and interrupt context. - VFS (
kernel/fs/vfs/vfs.c): one sleeping mutex,vfs_lock, around all descriptor/OFD table changes and file-backend calls. Because the lock sleeps instead of spinning, a second process that opens or reads a file while the first is inside a disk transfer simply blocks and is woken by the unlock; console and pipe I/O drop the lock first because they block for input or buffer space.ofd_releasedoes not lock — callers holdvfs_lock. - Drivers:
kernel/drivers/ata.ctakes the same kind of mutex (ata_lock) around a polling transfer, so the VFS lock and the disk lock are distinct and a task waiting for either one sleeps. The sector cache belowata_lockis the only reader of the drive outside FAT12;kernel/arch/x86/kernel/kbd.conly translates scancodes and forwards them toterminal.c, which owns the input ring (single-producer IRQ1, single-consumer task) and the IRQ-safe wait queue;terminal.cserializes output through the display and UART backends, which use IRQ-save spinlocks becauseklogmay run from interrupt context.
Locks are non-recursive; the only ordering rule is VFS → disk, never the reverse.
kernel/drivers/ata.c keeps a 16-slot direct-mapped read cache (8 KiB) in
front of the drive. FAT12 re-reads the same metadata constantly — both FAT
copies and the directory chain of every path component — so the cache removes
most of the polling latency without changing the filesystem's write-through
model: writes go straight to the platter, a single-sector write refreshes the
cached copy, and a multi-sector write or a failed write invalidates every slot
it may have touched. Selecting another device flushes the cache, because the
sectors belong to whichever drive is active. The boot self-test reads one
sector twice and requires the second read to hit (ATA-CACHE-OK), so a cache
that silently serves stale data fails the headless run.
kernel/arch/x86/elf/elf.c loads static ELF32 little-endian i386 ET_EXEC images read
from FAT12. It validates the ELF header and program-header bounds, accepts
non-overlapping readable PT_LOAD segments, checks address arithmetic and
file ranges, and rejects dynamic/interpreted images and writable-executable
segments. Each segment is mapped according to its ELF permissions; the loader
zeroes p_memsz - p_filesz for BSS and creates a two-page user stack.
Each loaded image receives a fresh page directory containing supervisor-only kernel identity/direct mappings plus only that image's user pages. The scheduler activates each process's CR3 on dispatch and returns to the kernel page directory for its idle context. Exited process mappings and page tables are released after switching away from their kernel stacks. Dynamic linking, relocations, shared libraries, and ELF files larger than the current 256 KiB loader buffer are not supported.
MS link.exe cannot emit flat binaries and raw COFF objects carry
MSVC-specific relocations, so we let the real linker do its job:
entry.obj + arch/x86/kernel/isr + arch/x86/user objects + kernel C objects → fixed-base PE
(/BASE:0x10000 /ENTRY:entry),
then the Python script only copies section bytes to their RVA — zero
relocation math on our side, zero MSVC quirks to chase. The reported
ENTRY= address and exact padded kernel sector count are passed to stage 2.
The stage-2 CHS loop reads track-bounded chunks into the 448 KiB load window,
leaving 64 KiB below it for the bootstrap stack; mkimg.py enforces both
the memory and floppy-image limits. A chunk is also clamped so an INT 13h
transfer never crosses a 64 KiB DMA boundary; when that clamp shortens a
read, the loop advances the sector within the current track instead of
skipping to the next head, which keeps a large kernel image contiguous.
The A20 routine mirrors Linux arch/x86/boot/a20.c (test → BIOS →
test → keyboard controller → long test → fast gate, with the KBC drain
loop), and the COM1 init mirrors Linux early_serial_init.
Haku/
kernel/
arch/x86/
boot/ stage1.asm + stage2.asm: 16-bit boot sector and CHS
kernel loader (up to 448 KiB) → A20 → GDT → protected mode
kernel/ entry.asm + isr.asm: 32-bit entry, interrupt stubs,
GDT/TSS load, ring-3 transition + syscalls
cpu/gdt/idt/pic/pit/kbd: port I/O, descriptor tables,
PIC/PIT, and the PS/2 keyboard driver
mm/ paging.c/.h: i386 page tables (identity + sized direct map + user)
frame.c/.h: E820-backed physical page inventory
elf/ elf.c/.h: validated ELF32 PT_LOAD loader + user spaces
user/ user.asm, child.asm, fault.asm, exec_target.asm,
vfork_target.asm: ELF32 test programs (syscall,
spawn/waitpid, protection faults, execve/vfork)
arch/x86_64/
boot/ entry64.asm: PAE + EFER.LME + PML4/PDPT/PD, far jump
into a 64-bit code segment, then call into C
kernel/ boot64.c: the first 64-bit C code (HAKU64-BOOT-OK)
coverage)
drivers/ ata.c/.h: primary/secondary ATA (master/slave) LBA28/LBA48
polling PIO with reset/retry recovery and cache flush
terminal.c/.h: text-I/O layer (output + input ring)
display.c/.h: shared display backend API
vga_text.c/.h: VGA text backend (fallback)
framebuffer.c/.h: VBE framebuffer text backend
serial.c/.h: COM1 output backend
fs/
fat12/ fat12.c/.h: writable FAT12 files, subdirectories, paths
fat12_vfs.c/.h: FAT12 vfs_fs_ops backend + errno mapping
ramfs/ ramfs.c/.h: in-memory root (long names, modes, links)
devfs/ devfs.c/.h: /dev/null, zero, urandom, console, tty
procfs/ procfs.c/.h: generated /proc/version, uptime, meminfo
initramfs/ cpio.c/.h: cpio "newc" unpacker into the VFS
vfs/ vfs.c/.h: fs-independent descriptors, mount table
test/ fs_test.c/.h: kernel-side filesystem self-tests
kernel/ kernel_main.c: kernel startup
scheduler.c/.h: scheduler + per-process usage counters
sync.c/.h: mutex/spinlock/IRQ-save locks + rules
usermode.c/.h: user mappings, syscall ABI, brk/mmap/munmap
getusage
klog.c/.h: timestamped, leveled terminal logging
panic.c/.h: fatal handler + halted state message
crash.c/.h: register/MMU/stack crash reports
heap.c/.h: kernel kmalloc/kfree allocator
user/
lib/ errno.c, fsabi.c, memory.c, mman.c, signal.c, stdio.c,
string.c, unistd.c, crt0.asm, syscall.asm: user C library
tests/ libtest.c: C library self-test installed as LIB.ELF
musl_probe.c: portable probe built by the CI musl job
include/haku/ abi.h + abi.inc: shared user/kernel ABI constants
types/errno/string/stdlib/unistd/fcntl/stat/stdio/
signal/mman/dirent/poll/termios/resource.h: user
library headers, plus haku.h
tools/pe2bin.py PE image → flat binary (+ reports ENTRY= for the bootloader)
tools/mkimg.py boot floppy size/signature checks
tools/mkfat12.py FAT12 image builder for the user ELF
tools/mkcpio.py cpio "newc" initramfs builder (INITRD on the FAT volume)
tools/pe2elf.py fixed-base PE32 to ELF32 (RX/R/RW) for user C
.github/workflows/ ci.yml: hosted headless build/test + artifacts
release.yml: tag-driven bootable image release
build.py the whole build: cl/link/nasm/qemu orchestration
build/ generated files (stage*.bin, *.obj/pe, haku.img, ...)
haku.img bootable floppy image
build/haku_fs.img 1.44 MB FAT12 ATA disk image with HAKU/NEXT/LIB ELFs
and the INITRD initramfs archive
build/ata_spare.img 1.44 MB blank ATA disk on the secondary channel (scan test)
not a 32-bit image— you used the x64cl.exe; build.py picks…\bin\Hostx64\x86\cl.exeautomatically, don't override it.- unresolved external (e.g.
_memset) — a CRT helper leaked in; add it tokernel.c(or simplify the code that triggered it). - NASM win32 drops all
globalsymbols withoutsection .text— code assembles fine but the .obj has no definitions (found by dumping symbols). Every NASM-for-link file needs an explicit section directive. - MSVC prepends
_, NASM doesn't — write bare names in C (isr0,idt_load) to meet NASM's_isr0,_idt_load. Spelling_isr0in C asks the linker for__isr0. - QEMU triple-faults / reboots — run
python build.py headlessand readbuild/serial.log; noHAKU-BOOT-OKmeans the kernel never started (check disk load / GDT / entry address). AKERNEL PANIC:line is followed by the crash report andbuild/qemu.loghas the exception trace. - NASM not found — install it, then open a fresh terminal (PATH refresh).
- kernel exceeds 448 KiB — the stage-2 loader deliberately stops below the bootstrap stack. Move to a memory-backed or later-stage loading design before increasing this limit.
- Roadmap — current status, milestones, and acceptance criteria.
- Contributing — development setup and pull request guidance.
- Security policy — private vulnerability reporting guidance.
- Support — where to ask questions and report reproducible issues.
- Code of Conduct — expectations for project spaces.
- Agent instructions — repository-wide coding and validation rules.