do not edit — generated by btf.

kaboomkernelfssyscallsuserlandbuild

kernel

this page is the boot-to-running-process story: how a multiboot loader's 32-bit protected-mode handoff turns into a 64-bit kernel that can load and run a real elf binary, plus the drivers that make the machine feel like a machine while that's happening. read syscalls for the actual syscall table -- this page only covers the dispatch mechanism, not the 24 individual calls.

boot: identity paging before anything else

src/boot/boot.s is 32-bit hand-written asm, not nsc, because nothing nsc-compiled can run yet: nscc only emits 64-bit object files, and long mode itself has a hard requirement that paging be turned on before you're allowed into it. get any part of this wrong -- a bad page table entry, cr4/cr3/efer set in the wrong order -- and the cpu triple-faults silently. no error, no message, just an instant reboot. that makes this the single highest-risk file in the whole kernel, and it's tested in isolation before anything else gets built on top of it.

the loader story itself is a real qemu-specific wart, worth knowing about if you ever try to boot this on anything else: qemu's -kernel multiboot1 path hard-refuses any ELFCLASS64 image outright ("Cannot load x86-64 image, give a 32bit one"), and that check runs before qemu even looks at what's inside the load segments. a multiboot1 header alongside a PVH note was tried, and it doesn't work -- qemu detects multiboot1 first and its 32-bit check fires unconditionally, so you get one or the other, never both. PVH (the xen/hvm direct-boot protocol, which qemu also implements) is fine with an ELF64 container; it just needs a note pointing at a 32-bit physical entry address. that's the .note.pvh section at the top of the file. a real multiboot1 header for GRUB or actual hardware would need a genuinely separate 32-bit build, which hasn't happened yet.

_start builds one pml4 entry -> one pdpt entry -> 512 2mib huge pages in pd, identity-mapping the first 1gib (virtual == physical everywhere). that's a lot more than the kernel needs at boot, but it means this table doesn't need revisiting for a good while -- and it turns out that decision pays off directly: paging.nsc (below) only has to retarget one entry this table already built, never allocate a new one. after the fill loop: CR4.PAE, CR3 loaded with the pml4 base, EFER.LME via the MSR, then CR0.PG -- and only after all four of those does the code far-jump into the 64-bit code segment (long_mode_entry). the very first thing that segment does, before calling kmain, is write a raw 'B' directly to COM1 (port 0x3f8) with a bare outb, independent of any nsc code or the io.s helpers being correct -- if that byte never shows up in the serial log, the fault is in this file, not in anything kmain calls. it's a sanity beacon, not a real driver, and it exists specifically because a triple fault here gives you nothing else to go on.

gdt / idt: segments, interrupts, syscalls

gdt.nsc replaces boot.s's own hand-built gdt64 (which only exists to get the cpu into long mode at all) with one the kernel actually owns: null, kernel code, kernel data. the reason to bother once boot.s already has a working gdt: this one has somewhere real to grow -- a tss entry once idt.nsc wants a dedicated interrupt stack, or user-mode segments once elf programs actually run in ring 3, neither of which exists yet. gdt entries can't be an nsc struct, because nsc struct fields are word-addressed rather than byte-packed, so each 8-byte entry gets built by hand (base/limit/access/granularity packed into a u64) and written with a single 8-byte *ptr store. idt.nsc hits the exact same packing constraint for its 16-byte entries.

the idt sets up 32 cpu exception vectors, remaps the pic's 16 irq vectors up to 32-47 (with everything except irq1/keyboard and irq4/com1 masked off -- nothing else has a driver hooked up yet), and one syscall gate at vector 0x80 with dpl=3 so user mode will eventually be able to int $0x80 from ring 3. every vector funnels through one shared C-side dispatcher, isr_dispatch, called by a single shared asm trampoline in idt_asm.s. vector 128 goes straight to syscall_dispatch; vectors under 32 are real cpu exceptions and kaboom has no recovery story for those yet -- it prints the vector/error code to serial and halts forever; anything else is a remapped hardware irq, which always gets an eoi sent back to the pic (or it stops delivering further interrupts entirely -- an easy thing to get wrong, since nothing breaks until the second keypress, not the first) and then gets handed to whichever driver owns it (irq1 -> keyboard, irq4 -> com1/serial).

the syscall convention itself: number in rax (also where the return value goes), args in rdi/rsi/rdx, kept in the same register order/positions as the real x86-64 sysv/syscall convention purely for familiarity -- even though the actual entry mechanism is the classic int-gate here, not the syscall instruction. every syscall that can genuinely fail routes its result through one shared function, sys_doerror, instead of hand-rolling "log it, then set rax" at each call site. sys_doerror takes the caller's own already-correct "did this fail" test (the failure convention genuinely differs per syscall -- some return negative, some a plain 0/1 -- so it never tries to guess that from the result value alone), and if it did fail, writes a kernel log line: "pid <N>: <op>: permission denied" when kaboom_errno says that's really why, or a plainer "<op>: failed" otherwise. kaboom_errno (set in kfs.nsc) has exactly one place that ever sets it to 1 -- kfs_check_perm -- and every other public entry point with its own permission check resets it to 0 at its own start, so a stale value from an earlier, unrelated failure can never leak into a later one's log line. klogs shows these lines like any other kernel log entry. the full syscall table -- all 24 calls, their exact signatures, what each hands back on failure -- lives on syscalls, generated straight from idt.nsc's own numbered doc comment so the two can't quietly drift apart; this page only covers the mechanism.

paging: one retargetable window, nothing more

paging.nsc is not a virtual memory subsystem. it exists to fix exactly one real problem: sh (kaboom's own shebang interpreter) can nest against itself -- a shebang script naming sh as its own interpreter, exec'd from within an already-running sh -- and sh always loads at the same fixed physical/virtual address, 0x400000 (user_shell.ld). loading a fresh copy of sh there while the outer one is still alive on the call stack would corrupt it, with nothing keeping the two apart.

boot.s already built the identity-mapped table this reuses: one pml4 entry, one pdpt entry, 512 2mib pd entries covering the first 1gib. this file's whole job is to make exactly one of those 512 entries -- the one covering 0x400000-0x5fffff, sh's own window -- dynamically retargetable at runtime, so exec.nsc can point it at a genuinely different physical 2mib frame for a nested sh, then point it back once that nested call returns. every other entry, including the one covering 0x600000 (ordinary programs, which never nest against themselves this way), is never touched.

page_current_phys/page_remap_2mb read and rewrite that one pd entry directly, and invlpg flushes the tlb for just that virtual address -- a full cr3 reload would flush the whole table, including mappings nothing here is even changing. paging_alloc_frame bump-allocates a fresh 2mib-aligned physical frame from a scratch pool that starts at the first 2mib boundary at or past heap_arena_limit(), so it never collides with anything the heap or fs arenas could hand out, and it's still comfortably inside the first 1gib boot.s already identity-mapped, so no new pml4/pdpt/pd entries are ever needed. that pool never frees a frame -- the same tradeoff every other allocator in this kernel already makes (kalloc, the fs allocator) -- and that's fine specifically because this only ever gets called once per level of shebang self-nesting, which is already bounded by the shared 64kib call stack running out long before enough 2mib frames could ever accumulate to matter.

worth being explicit about what this deliberately does NOT do: no per-process address spaces, no page permissions (every 2mib entry here still carries the exact same present+writable+huge flags boot.s's own fill loop used -- no nx, no read-only), no more remappable windows than the one that actually needed this, no frame reuse. "extremely simple," fixing the one real problem it was built for -- this replaced an earlier, more fragile assumption that re-executing sh into its own window was safe only because sh happened to have no persistent global state. it doesn't depend on that being true anymore.

exec: loading and running a program

exec.nsc is syscall 7, and it's the thing a shell actually needs: it wraps kfs_dir_find/kfs_read_file/elf_load/elf_call_entry -- four kernel-internal functions kmain's own test code had already used -- behind one call userspace can make. elf_call_entry's own asm does call *rax; ret, so whatever the loaded program leaves in %rax when it returns becomes sys_exec's own return value too: a c-style int main(void) return value doubling as an exit code, since there's no separate exit syscall (see src/user/crt.s's note on why returning already is exiting).

path resolution goes through kfs_resolve, not a bare-name-only lookup, so a real absolute or relative multi-component path (/bin/sh, a/b) resolves correctly -- needed for conventional shebang lines like #!/bin/sh to work at all. if that fails, there's a /bin $PATH fallback: kfs_dir_find_in against kfs_bin_dir_lba (resolved once at mount time), the same way a bare command name typed at the shell finds its binary. that fallback used to be a real, if mostly harmless, bug: the bare name that resolved via the fallback wasn't independently resolvable by anything downstream that didn't also know about kfs_bin_dir_lba -- so a shebang script only reachable through the /bin fallback would get passed to its interpreter as an unresolvable bare name, and the interpreter's own plain sys_open on it would fail with a misleading "script not found", indistinguishable from the script genuinely not existing. the fix builds a real, independently-resolvable /bin/<name> path with kfs_join_path and uses that everywhere downstream instead -- for the interpreter's own argv slot 1 and for the process-table entry both. kfs_join_path itself replaced code that used to hand-spell the five ascii bytes of "/bin/" as individual numbered constants, the one place in this kernel that hardcoded a path as raw bytes instead of a string literal -- not just uglier, but a second, more fragile source of truth for the same "bin" name kfs_mount already had in a normal string. kfs_join_path is now a genuinely reusable primitive, not something specific to this one call site.

permission enforcement happens right after resolution: kfs_check_perm(n, 4) (the execute bit) has to pass before anything gets read or run, and that check applies identically to a shebang interpreter's own re-exec as it does to the original script or binary -- there's no separate, weaker path for "the thing sys_exec found on its own."

the shebang mechanism itself: if the resolved file isn't a valid elf but starts with #!, shebang_parse_interp reads the interpreter path off the first line (no shebang-line arguments, e.g. no #!/bin/sh -x -- "super simple, just commands"), and sys_exec re-execs that interpreter recursively, with argv shifted the same way a real unix execve() does: interpreter, then the script's own path, then whatever args the original call had past its own argv slot 0. one real, deliberate departure from how a real kernel does this: because kaboom's "process" is a call stack, not a real address space, re-executing an interpreter here means genuinely reloading into the same fixed virtual window sh always uses, not replacing a whole address space. since kaboom's only real interpreter is sh itself, and a shebang naming sh gets exec'd from within an already-running sh, this needs a different physical page under that same virtual address for the nested copy -- which is exactly what paging.nsc (above) makes safe: page_current_phys saves the outer mapping, paging_alloc_frame gets a fresh frame, page_remap_2mb points the window at it for the nested call, and the original mapping gets restored once that call returns. there's no cycle detection -- a shebang chain pointing back to itself would recurse until the shared 64kib stack runs out, which is also what bounds how many scratch frames could ever get consumed. that's deliberate, not an oversight: "super simple" on a single-user kernel with no other protection boundaries to defend in the first place.

the process table this all threads through (kaboom_proc_depth, two process slots, not a real array) is the honest answer to "what even is a process here": kaboom has no pcb and no scheduler. the process is the call stack -- sys_exec doesn't return to its own caller until the child returns, and elf_call_entry does a literal call *rax. that's still a real, if shallow, parent/child tree: kmain's own boot-time sys_exec("sh") is the first process, and every command sh's shell loop execs is that process's child. nothing currently running ever calls exec a third time -- only sh does -- so two slots is the whole real tree today, not an arbitrary cap standing in for one. /proc/self and /proc/ps (virtfs.nsc) read this table directly.

elf: what actually gets parsed

elf.nsc is a minimal elf64/x86-64 loader, and it only validates the magic bytes, the ELFCLASS64 class byte, and the EM_X86_64 machine field -- no section/segment count sanity limits, no bounds-checking p_vaddr against real installed memory or the kernel's own image. that's the minimum needed to prove a real, dynamically loaded (not kernel-linked) program can load and run at all; a hardened loader is a real follow-up, not something pretended to exist here.

past validation, it walks every PT_LOAD program header, copies p_filesz bytes from the file to p_vaddr, and zeroes whatever's left up to p_memsz (the segment's own .bss). no relocations, no dynamic linking, no sections -- a plain static non-PIE executable only, which is also all elf_call_entry can actually run anyway: there's no ring3/tss/user segments yet, so a "loaded" program runs in ring0 as an ordinary called function (call *entry, not a real process replacement) and is expected to ret back when it's done, the same simplification as everywhere else in this kernel that doesn't have a scheduler. the byte-level reads (elf_read_u16/u32/u64) exist because elf's layout is dictated by the format spec, not kaboom's own invention (unlike kfs) -- so this needs the same byte-extraction technique as gdt/idt parsing, not kfs's word-aligned shortcuts.

drivers

vga (src/drivers/vga.nsc)

text-mode vga at 0xb8000, 80x25, 2 bytes per cell (char, attribute). every write is a read-modify-write of the containing 8-byte-aligned word, because nsc's *ptr is always a full 8-byte load/store -- there's no byte or halfword-granular deref, and structs can't model a packed hardware layout either since nsc struct fields are word-addressed, not byte-packed. cursor positioning and scrolling are a direct port of tape-kernel's own cm.c/vga.c (cnb/cob/scur/hcur/scrl), not reinvented; the one thing not ported is cnb (reading the cursor position back from the crtc), since vga_row/vga_col already track kaboom's own idea of cursor position and nothing else ever moves the hardware cursor independently.

serial (src/drivers/serial.nsc)

com1, port 0x3f8, a 16550 uart. started life as a debug output channel only -- boot.s's own raw beacon byte proves the pipeline works before kmain even runs -- but it's also a real input channel now, and that mattered for a real reason: under -nographic, com1 is the only input channel there is, since there's no separate graphical window with its own ps/2-backed keyboard focus. that gap went unnoticed for a while because every keystroke this project's own testing had ever used came from qemu's QMP send-key interface, which injects real ps/2 scancodes regardless of display mode -- an actual person typing into a real -nographic session was never exercised until someone tried it and nothing happened at all. serial_handle_irq (irq4) now translates a carriage return (13) to a newline (10) and both real backspace (8) and delete (127) to the same byte, because a raw-mode host terminal does none of the translation a cooked terminal normally would -- and drops anything outside backspace/tab/newline/printable-ascii before it's ever echoed, specifically because echoing a bare esc (27, what both arrow keys and delete start with) back to the user's real terminal let them navigate into and "edit" output that had already scrolled past, purely as a trick of their own terminal's rendering; kaboom's own line buffer was never actually touched by any of it. serial_putc also translates a bare newline to a carriage return followed by a newline on the way out, for the same raw-mode reason in reverse -- found by actually cat-ing a multi-line file over -nographic, not by reading the code, since a captured-to-file serial log (this kernel's only test method for a while) never shows a live terminal's cursor going wrong.

kbd (src/drivers/kbd.nsc)

ps/2 keyboard, scancode set 1, us qwerty, make codes only -- break codes are read (so the controller doesn't stall) and mostly discarded, except shift's own release, which has to be tracked. the scancode-to-ascii table, shift, and caps lock are a direct port of tape-kernel's kb.c (scntasci/gtchr), reshaped into an if-chain instead of a switch since this is irq-driven, not gtchr's own polling loop -- the actual mapping is identical. extended (0xE0-prefixed) scancodes are still just discarded, matching tape-kernel's own "for now." every mapped keypress goes through one shared function, kbd_push, which both echoes it (vga + serial) and buffers it into a 256-byte circular buffer -- the same function serial.nsc's own irq handler calls for a byte that arrived over com1 instead, so neither kbd_getchar nor its readers ever need to know which physical path a byte actually came in on. backspace is deliberately not echoed here -- that's fd_read_stdin's job, once it actually knows whether there's anything on the current line worth erasing, which an irq handler firing on every keystroke has no way to know.

ata (src/drivers/ata.nsc)

ata/ide pio, primary bus, master drive, lba28, polling only -- no dma, no irq-driven i/o. a clean-room rewrite of tape-kernel's own approach, not a port. a 512-byte sector is 64 8-byte words; four consecutive 16-bit port reads from 0x1f0 get packed into one u64 before a single *ptr store (and symmetrically unpacked on write), the same packed-word technique vga uses, since there's still no byte/halfword-granular deref available.

rtc (src/drivers/rtc.nsc)

cmos real-time clock, ports 0x70 (index) and 0x71 (data) -- the same pair every pc-compatible has had since the original at. polling only, no periodic-interrupt mode. rtc_read_datetime checks "update in progress" (status register 0x0a, bit 7) before reading, which avoids the real failure mode -- torn, nonsensical values from reading mid-update -- but there's one race left deliberately unhandled: reading all six fields isn't atomic, so a read that straddles the clock actually ticking over (say, catching 23:59:59 right as it rolls to the next minute, then reading a now-stale day/month/year) can come back very slightly wrong, extremely rarely. that's an accepted imprecision, not worth a read-twice-and-compare loop for what's still a debug-grade clock with nothing syncing to it. year assumes the 2000s, same as every other from-scratch rtc reader that doesn't bother with the century register.

cpu (src/drivers/cpu.nsc)

cpu identification via cpuid, vendor and brand strings only -- no feature-flag decoding (leaf 1's edx/ecx bits, leaf 7's ebx/ecx/edx, none of it), matching this kernel's "ship the real minimum" pattern everywhere else. cpu_vendor reads leaf 0 and has to know its own well-known quirk: the 12-character vendor string comes back in ebx, edx, ecx register order, not the eax/ebx/ecx/edx order every other multi-register cpuid result (including the brand string) actually uses. cpu_brand reads the three extended leaves 0x80000002-0x80000004 in normal register order for the 48-character brand string, and doesn't bother checking leaf 0x80000000's own max-supported-leaf return first -- every cpu qemu emulates supports these, and a real cpu old enough not to would already have failed this kernel's boot for unrelated reasons long before cpu identification became the problem. both are exposed as /int/cpu (virtfs), and info cats it straight through -- confirmed against a real qemu boot to correctly report the actual host cpu qemu is emulating (AuthenticAMD / QEMU Virtual CPU version 2.5+, on the machine this was tested on), not a hardcoded string.

made with nsc powered by kaboom

powered by btf.