Process semantics
How a process is created, replaced, signalled and reaped here, and the places where matching Linux turned out to mean something other than what the code first assumed.
Two user windows
VibeOS's own programs link at 0x8000000000. Linux programs link at
0x400000, which is inside the kernel's identity map - so the physical range
behind the low window is reserved out of the allocator, and nothing of the
kernel's can live where a process would shadow it. Which addresses a process
may use is decided in one place, hw_user_addr_ok.
This is not a stylistic split. An unmodified static Linux binary has its load address baked in, and refusing it would mean refusing the whole point.
Starting a program
execve builds a System V startup stack: argc, the argv pointers, the
envp pointers, then the auxiliary vector. A C runtime reads more of that
vector than its name suggests it must:
AT_PHDR,AT_PHNUM,AT_PHENT- glibc walks its own program headers.AT_ENTRY,AT_BASE.AT_RANDOM- sixteen bytes used to seed the stack canary. When it was absent, the pointer was null and the process dereferenced it during startup, beforemain.getrandomis deliberately stillENOSYS; that is a separate promise and this one had to be kept first.
The entry rsp must be 16-byte aligned, which is why _start cannot be C.
argv[0] is not the path. BusyBox is twenty commands in one binary and decides
which it is by the name it was invoked under; FAT stores names upper case with
an extension. The shell supplies the name, the path only opens the file.
/proc/self/exe is stored absolute even when the caller used a relative path.
glibc does not merely prefer that - it asserts on it during startup and aborts.
fork
Copy-on-write. The page tables are shared with the writable bit cleared and
PTE_COW (bit 9) set, and a frame refcount table says how many address spaces
hold each frame.
Three places have to agree, and they do not look related:
- The fault handler must accept kernel-mode writes. The kernel writes user
buffers with
CR0.WPset, so aread()into a COW page faults from ring 0. hw_user_range_okmust treat a COW page as writable. It is writable; it just needs a copy first. Rejecting it makes a valid buffer look invalid.- A second fork must preserve the COW bit rather than treating the page as plain read-only, or the third process shares a page nobody will copy.
The refcount table is indexed from the region base. Indexing it from the end of the table instead freed pages that were still in use - a bug that presents as unrelated memory corruption much later.
Signals
Delivered on the return path to user space, never mid-syscall. The frame is
built below the red zone, and return goes through the user library's own
SA_RESTORER trampoline rather than anything the kernel supplies.
Linux sigset_t bits are numbered from zero: bit 0 is signal 1. This
kernel numbers them by signal number. The conversion happens only at the
boundary, in hw_sigset_from_user and hw_sigset_to_user. Doing it anywhere
else means two conventions in the same code path and an off-by-one that only
shows up for whichever signal happens to be tested last.
Exit and wait
A wait status is not an exit code. The exit code lives in the high byte and
the signal number in the low seven bits. 128 + sig is what a shell prints,
not what the kernel stores - copying the shell's arithmetic into the kernel
produces a status that looks right in one test and is wrong everywhere else.
Descriptors are inherited across fork and released on exit, pipe ends
included. Exit that did not release pipe ends left readers waiting on a writer
that no longer existed, which is a hang rather than an error.
Pipes
pipe2, dup, dup2, inheritance across fork, and SIGPIPE for a write
with no reader. The boot self-test runs ls /EFI/BOOT | wc -l in BusyBox's
shell and the gate fails if the pipeline does not complete - a pipeline that
deadlocks and a pipeline that returns the wrong number are both caught, because
the count is asserted and not merely printed.
Scheduling and locks, where processes meet them
Spinlocks mask interrupts, so anything slow under one is slow with the timer
off - and under emulation that is indistinguishable from a hang. execve is
the long one: two megabytes loaded, hundreds of pages mapped, an address space
torn down. It therefore takes g_exec_lock preemptibly, with interrupts
left on, which is safe only because no interrupt handler ever takes that lock.
That choice has a consequence recorded in CLAUDE.md and not yet resolved: a
lock whose holder can be preempted must not be waited on by a core that has
interrupts masked, because such a core never reaches the scheduler and can
never hand the holder a CPU to finish on.
What is verified
The boot gate asserts behaviour rather than log lines: an unmodified static
Linux binary ran, BusyBox dispatched an applet and did file work, the
interactive shell ran, signals were delivered, fork produced a child that was
reaped with the status the parent expected.
Ctrl-C becoming a real SIGINT is implemented but not gated - it is checked
by hand.