do not edit — generated by btf.

kaboomkernelfssyscallsuserlandbuild

userland

this page is everything that runs on top of exec.nsc handing control to a loaded elf: a from-scratch userspace libc, every coreutil built on it, and sh -- the shell, the shebang interpreter, and the thing that turns a chmod-7'd text file into a runnable script. read syscalls for the exact calls all of this rests on, and kernel for how a program actually gets loaded and re-exec'd; this page only covers what happens once one is running.

a from-scratch libc, opt-in per program

lib.nsc (ptr_byte_at, argv_get, u64_to_dec) predates everything else here and is linked into every program unconditionally, always has been -- it's the one thing every userspace program needs regardless of what else it does: reading a byte through a raw ptr (nsc's *ptr is always a full 8-byte load/store, never byte-granular), pulling a slot out of argv (itself a raw ptr to an array of 8-byte pointer slots, so reading one is a plain aligned load, no packed-word trick needed the way byte-at-a-time access requires), and printing an unsigned decimal number. it also still keeps its own my_strlen, but only stdio.nsc's own fputs calls it now -- every real program call site has since moved onto string.nsc's strlen instead (below). my_strlen stays for that one caller rather than pulling string.nsc/mem.nsc into every program that only wants buffered stdio and nothing else -- the same "opt-in, no forced dead weight" reasoning the whole module-linking scheme already runs on.

past that, growing kaboom's own capability while staying 100% nsc -- no fork/exec/wait/pipes/signals, ever, so a real csh-style port was never in scope -- meant writing an actual libc from scratch rather than porting one:

mem.nsc: memcpy/memset/memmove/memcmp, plus mem_byte_set, the write-side counterpart lib.nsc never needed (nothing there ever wrote through a raw ptr). memmove handles overlap by picking a copy direction based on which end overlaps -- the one thing a plain memcpy is allowed to get wrong.

string.nsc: strlen/strcpy/strncpy/strcat/strcmp/strncmp/strchr/strrchr. strlen used to be a real duplicate of lib.nsc's older my_strlen -- every real call site (every coreutil, sh) has since been migrated onto this one instead. str_eq, the other old duplicate, had zero real callers left anywhere and was deleted outright rather than migrated. strncpy keeps real strncpy's actual (mis)behavior -- pads with nulls if src is shorter than n, does NOT null-terminate at all if src is n bytes or longer -- kept exactly that surprising because anything porting real code expects it. strrchr matches ch equal to the terminator byte itself by scanning through the terminator's own position instead of stopping before it, which gets real strrchr's "ch is the terminator" case for free instead of needing a special case.

ctype.nsc: is_digit/is_upper/is_lower/is_alpha/is_alnum/is_space/to_upper/to_lower. plain ascii range checks, no locale, no unicode -- nothing in this kernel or userspace has ever needed either -- and no lookup table, since nsc has no static const array initializers to build one from anyway.

malloc.nsc: a real explicit-free-list-plus-boundary-tags allocator (first-fit search, splitting on allocation, bidirectional coalescing on free -- the standard textbook design), sitting on top of sys_alloc/sys_free. sys_free really is a no-op kernel-side (see alloc.nsc's own note), so this file is what makes free() actually mean something for a userspace program without changing anything kernel-side. every chunk pulled from sys_alloc is bracketed with permanently-allocated, zero-payload prologue and epilogue sentinels, so coalesce() never needs to know a chunk's real bounds -- a sentinel's alloc bit is always set, which stops merging exactly at the edge on its own, and lets multiple chunks (heap_extend can be called more than once) not need to be contiguous.

stdio.nsc: buffered putchar/puts (one sys_write per flush instead of one per line, flushing on a newline as well as a full 256-byte buffer, not just the buffer filling), unbuffered fputs for stderr or anything that doesn't want stdout's buffering, print_err (below), and i64_to_dec/u64_to_hex/put_udec/put_idec for number formatting straight to stdout. there's deliberately no printf: nsc has no varargs at all, no va_list, no ... parameters anywhere in the language, so a real variadic printf isn't something this toolchain can express.

print_err(fd, prefix, generic_reason) is the userspace half of the kernel's own kaboom_errno/sys_doerror error-logging path: it calls sys_errno() (syscall 23, returns kaboom_errno directly) and prints "prefix: permission denied" if that's really why the most recent syscall failed, or "prefix: generic_reason" otherwise -- call it right after the failing syscall, before making another one, since sys_errno only ever reflects the most recent call. every genuine syscall-failure message across cat/mv/ls/mkdir/touch/rm/rmdir/ps/chmod/cp/sh/ed is migrated onto it now; input-validation errors that never reach a syscall at all (chmod's own "perm must be 0-7, or rwx letters") are deliberately not, and neither is sh's "command not found" (below) -- there's a real, documented reason that one stays separate.

the opt-in linking scheme is mk/bu.sh's own decision, explained in its header comment: lib.o is linked into every program unconditionally, always has been, but the newer modules (mem/string/ctype/malloc/stdio) are opt-in PER PROGRAM based on which of their own .nsh headers a program actually includes. linking all of them into everything unconditionally was tried first and measured to add roughly 19kb of dead code to every single binary -- which pushed ed over disk.pl's binary size cap for zero benefit to programs that never call any of it. mk/bu.sh greps each program's own source for "mod.nsh" to decide what it wants, plus one level of dependency resolution on top: string.nsc and stdio.nsc both call mem.nsc's functions internally, so their object needs mem.o on the link line even for a program that never includes mem.nsh directly itself.

that migration also caught a few real bugs along the way: info.nsc's print_stat had all four of its labels declared one byte too long (a hand-counted sys_write length, now gone since fputs computes it), ed.nsc's own lines_putc was a byte-for-byte duplicate of mem_byte_set (now gone, replaced by the shared one), and ls.nsc's hand-rolled 24-byte-capped strlen is gone too -- puts's own byte-at-a-time scan is safe on a kfs dirent name without it, since kfs_name_matches kernel-side already refuses any name over 23 bytes, guaranteeing the null terminator sits well inside the 24-byte field.

coreutils

most of these are deliberately small -- one argument, no flags, "ship the real minimum" -- so a few genuinely are just a hardcoded read of a virtual file dressed up as a command:

cat takes one filename (bare, relative, or absolute -- kfs_resolve, by way of fd_open kernel-side, handles all three identically) and reads it whole into a sys_alloc'd buffer, capped at 63488 bytes, in one sys_read. the buffer is heap-allocated rather than a stack local or a global array on purpose: kaboom's single shared 64kib stack (see kernel) has no room for a buffer this size in any one frame, and a fixed global array that size would sit in every cat process's own .bss whether or not it's ever actually used -- exactly the case the arena allocator exists to avoid.

ls with no argument lists the cwd, same as it always has. with one argument it resolves that argument as a real path (sys_stat/sys_listdir_path, both routed through kfs_resolve, so a bare name, a relative path, and an absolute one all work) and either prints the name back (if it's a file, matching real ls's own behavior on a file argument) or lists it (if it's a directory) -- "ls doc" on a subdirectory of cwd used to silently do nothing useful at all, since that argument handling never existed before. the local names buffer is sized for 64 entries at 24 bytes each, not the older 16 -- directories can now chain past 15 entries into a second block (see fs), so 16 stopped being a real ceiling the moment /bin itself grew past it.

cp src dst reads src whole and writes it to dst via sys_writefile, which creates dst if it doesn't exist. mv src dst is genuinely copy-then-delete under the hood, not a real rename: kfs has no rename-in-place primitive at the directory-entry level, and no directory-move primitive either, so mv reads src fully, sys_writefiles it to dst, then sys_rms the old name. functionally a move -- src is gone afterward, dst has its content -- just not atomic and not O(1) the way a real rename would be. good enough until something actually needs better.

mkdir name takes no -p, no mode argument, and always creates with rwx (7) -- chmod after the fact if something more restrictive is actually wanted. touch name creates the file if it doesn't exist and is a no-op, not an error, if it does; kfs has no mtime field on an inode at all yet, so unlike real touch this can't update an existing file's timestamp -- "make sure this file exists" is the whole feature for now. rm name only removes files (kfs_rm verifies the inode's own type is a file); rmdir name is the directory counterpart, and relies on kfs_rmdir already refusing a non-empty directory rather than checking that itself.

chmod and perms are a matched read/write pair for the same permission bits. kaboom's permission model is one bitmask digit, 0 through 7 (r=1 w=2 x=4) -- not unix's three-digit owner/group/other octal, since kaboom has no user accounts to have separate owner/group/other permissions for in the first place. chmod perm file takes that digit directly ("chmod 3 file"), or the same bitmask spelled out as an rwx letter triplet with a dash for an unset bit ("chmod rw- file"), auto-detected by argument length. perms file is the read-back half: before it existed, nothing could ever read a permission bit back out to userspace at all -- kfs_check_perm only ever checked one internally, and kfs_chmod could only write one -- so there was no way to see what a previous chmod actually left a file at, short of a later operation failing or succeeding. perms prints both forms at once ("bin/sh: 7 rwx"), which means whatever perms shows you is also valid chmod input, unchanged either way. building this caught a real, live bug: mixing buffered putchar (only flushes on a newline, a full 256-byte buffer, or an explicit stdio_flush) with unbuffered fputs/sys_write on the same output line reorders the output, since an immediate write can leapfrog a still-buffered byte sitting ahead of it in program order -- perms's first attempt printed "qtest: rw-3" instead of "qtest: 3 rw-" because the buffered digit got pushed all the way to the end. fixed by keeping the whole line on one discipline throughout (fputs/sys_write only, no putchar at all) -- worth knowing for anything else that ever builds a line out of more than one write: pick buffered-throughout or unbuffered-throughout, never interleave the two on one line.

ps is "literal ls /proc, essentially" -- more literal than that description even suggests: /proc/ps is a real virtual file, regenerated fresh on every read straight from exec.nsc's own process table, and ps.nsc is nothing but a hardcoded open-read-write of it. pwd is a two-line wrapper around sys_pwd. date prints "YYYY-MM-DD HH:MM:SS" straight off the cmos rtc (sys_date, syscall 22, fills a 6-u64 buffer with second/minute/hour/day/month/year) -- one fixed, sortable format, no formatting options and no timezone handling at all, since the rtc itself is usually just whatever the host gave it (utc under qemu, most of the time). info dumps kfs stats (blocks and inodes, total and used) via sys_info, then cats /int/mem and /int/cpu straight through -- again, showing memory and cpu info is nothing more than reading two virtual files that already format themselves as plain text, the same reuse ps already relies on. klogs is dmesg-style: dumps the entire kernel log in one shot, no follow mode. clear wraps sys_vga_clear (syscall 16) -- that syscall existed before clear did, because ed needed it first to redraw its own screen, but had no standalone command wrapping it until asked for directly. echo joins its arguments with single spaces and a trailing newline; no -n flag.

ed is a real line editor, a genuine port -- not a reinvention -- of tape-kernel's own line editor (its src/usr/editor.c). the real logic is unchanged: a fixed 32-line by 80-column buffer, e re-enters every line fresh (an empty line stops early), w joins the buffer back with newlines and saves it, q quits. kaboom has no arrays-of-arrays (array elements have to be a scalar type), so the 32x80 buffer tape-kernel declared as a real two-dimensional char array is one flat i8 buffer here, sized 32 times 81, indexed by hand as line-times-81-plus-column -- same data, same layout, just addressed the way every other flat table in this kernel already is. two small, real additions past the original port: a appends lines after whatever's already there instead of always starting over, and the display now shows each line's own number -- both asked for directly, not part of the port. one honest adaptation, not a logic change: tape-kernel's version repaints the whole screen at fixed row/column positions every loop and reads single keys with a raw, no-echo keyboard read. kaboom's vga driver only exposes sequential, scrolling output plus a hardware clear -- there's no absolute-position write or a real cursor api yet -- so ed prints the same information (filename, hotkeys, current lines) sequentially after a clear instead of redrawing it in place; a single-byte sys_read already blocks for exactly one keystroke, which is exactly what the original's own read gave it. building the append feature surfaced a real bug: an earlier version used the loop index hitting 32 as its own stop signal and then returned that same index as the new line count, so stopping normally via an empty line -- not by filling all 32 slots -- always reported a line count of 32 instead of how many lines were actually entered. every save afterward appended one extra newline per phantom line past the real content, and it was found by actually cat-ing a saved file and seeing roughly 29 trailing blank lines, not by reading the code. every working buffer of real size here (the line table, the load buffer, the save buffer) is sys_alloc'd now, not a stack local or a fixed global array -- an earlier version did exactly that anyway, before the allocator was actually wired up to userspace.

sh: builtins, exec, and shebang scripts

sh is "minimalized csh+ash": one builtin set (cd/pwd/exit), everything else exec'd through kfs's own namespace and the cwd model kfs_cd/kfs_pwd already track kernel-side. no quoting, no escaping, no pipes or redirection, no job control -- splitting a line on bare spaces only is genuinely the whole parser, same "ship the real minimum" pattern as every coreutil above.

run_line is the one function both the interactive prompt and script mode actually dispatch through, so a script behaves identically to typing the same lines by hand -- there's no separate "script dialect" anywhere in this kernel. it splits a null-terminated line into up to 16 words in place, then checks the first word against exit (returns a stop signal to the caller), cd (sys_cd with no argument goes to /, with one it goes there or prints "no such directory"), and pwd (calls sys_pwd, writes the result, flushes) -- anything else falls through to sys_exec.

that three-way builtin check is a hardcoded strcmp if/else chain, and this was checked directly, not assumed to be fine: could it be a real dispatch table instead? no -- nscc, the compiler, has no function pointers at all. every call site has to name a real function symbol at compile time; there's no way to take a function's address, store it in a variable or a struct field, or call through one. nsc also has no arrays of structs, only arrays of a single scalar or pointer type, so a name-to-function table iterated in a loop isn't buildable in this language as it stands -- the same finding fs's own virtfs dispatch coverage already documents. a real jump table here would need an actual nscc compiler change (indirect-call codegen), a much bigger undertaking than de-hardcoding one three-branch dispatch, and arguably works against the same "small, simple" design direction that would motivate wanting a table in the first place. an if/else chain calling a named function per case is already the most direct, minimal shape this language can express for either dispatch -- not laziness, the correct choice given what nsc actually is.

when none of the three builtins match, run_line calls sys_exec with the first word, its length, the word count, and the word array itself. if that comes back -1, sh deliberately does NOT route it through print_err/sys_errno the way every other failure message in this kernel now does -- per sh.nsc's own comment, exec's -1 return is already documented as ambiguous (syscalls's own entry for syscall 7 says the same thing): a real exec failure and a child program that legitimately returned -1 as its own exit code are indistinguishable from this return value alone, so sys_errno's value at that point can't be trusted to mean "why sys_exec itself failed" either -- a child that ran fine and made its own syscalls would have left sys_errno at whatever ITS last syscall set it to, not at whatever sys_exec itself hit. so sh just prints a flat "sh: command not found" instead, regardless of which of the two actually happened.

run_script is what a shebang script -- or "sh scriptname" typed directly -- actually runs under: read the whole file into a sys_alloc'd buffer in one sys_read, then walk it one line at a time, skipping blank lines and anything starting with a hash (including the shebang line itself, if present), running everything else through run_line exactly as if it had been typed interactively. a trailing line with no final newline still runs. no pipes, no redirection, no control flow at all -- "super simple, just commands" is genuinely the entire feature, not a summary of a bigger one.

there are two distinct ways a script actually reaches run_script. the first: the kernel's own sys_exec (exec.nsc, see kernel) notices a resolved file isn't a valid elf but starts with a hash-bang, reads the interpreter path off the first line -- no shebang-line arguments are supported, so something like a real /bin/sh -x would just fail, "super simple, just commands" again -- and re-execs that interpreter (in practice, always sh, kaboom's only one) with the word array shifted the same way a real unix execve does: the interpreter first, then the script's own path, then whatever arguments the original call had past its own first argument slot. that's exactly the argc-two-or-more case main checks for, and it calls run_script directly. the second way: typing "sh scriptname" at the prompt reaches the identical main code path a completely different way -- sh is a perfectly ordinary elf, so sys_exec loads and runs it completely normally, and sh itself sees the same argc-two-or-more case once it starts running. a conventional shebang line like "#!/bin/sh" -- an absolute, multi-component path -- resolves correctly because sys_exec's path resolution goes through kfs_resolve, not a bare-name-only lookup; when that shebang interpreter is sh itself, re-executing it into its own fixed load window while the outer sh is still alive on the call stack is exactly the case kernel's paging coverage exists for.

made with nsc powered by kaboom

powered by btf.