- schinit, schinitd and vgpud build every message from pieces, one
write() each, so a job starting on another CPU landed mid-line:
"schinitd: starting vgpud: os.schos.resolvconfno virtio-gpu on this
machine". They now collect a line and write it at the newline;
resolvconf's verbose line is one write with its prefix.
- The ACPI SCI registers IRQ_QUIET. A desktop's SCI never fires, and the
poller took two seconds of silence for a misrouted line ("irq 9:
nothing has arrived on it") and called the handler every tick. A line
is only exempt while every handler on it is quiet.
- The loader claims each segment from where the last one's claim ended,
so two segments sharing a page no longer ask for it twice. When a
claim is still refused, it names the memory-map entries in the way.
- Stale divesymlink() declaration gone; the kernel.md struct table
re-measured (inode, buf and file pools grow from slabs now).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
352 lines
17 KiB
Markdown
352 lines
17 KiB
Markdown
# SchIFT — the kernel
|
||
|
||
Long mode, 4-level paging, preemptive multitasking, and more than one
|
||
processor. What follows is the parts of the kernel that are not a filesystem, a
|
||
driver or a protocol stack: memory, processes, scheduling, traps and signals.
|
||
|
||
---
|
||
|
||
## Address space
|
||
|
||
One page table per process; the kernel is mapped into all of them at
|
||
`KERNBASE = 0xFFFF800000000000`, so a trap does not switch `CR3`.
|
||
|
||
`kvmalloc()` maps physical 0–4 GB as four 1 GB pages, with the top one marked
|
||
uncached — that is where PCI BARs and the framebuffer live. This is why 3 GB is
|
||
the most memory the kernel can treat as RAM, and it is a deliberate simplicity:
|
||
a direct map with a fixed offset makes `P2V`/`V2P` arithmetic rather than a
|
||
lookup.
|
||
|
||
`kalloc()` hands out one 4 KB page at a time from a free list. **There is no
|
||
slab or pool allocator**, and the consequence is visible throughout the tree:
|
||
every subsystem that needs many small objects has invented its own fixed-size
|
||
table — `NPROC` processes, `NSOCK` sockets, `NDEVICE` devices, `NSHM` segments.
|
||
That is why the limits are compile-time constants rather than a design decision
|
||
about each one.
|
||
|
||
The free list carries a canary, checked at the top of both `kalloc()` and
|
||
`kfree()`, so a wild write into it panics at the point it is touched rather than
|
||
handing back a bad pointer that fails somewhere else entirely.
|
||
|
||
### DMA
|
||
|
||
`kernel/core/dma.c` owns an arena carved off the top of physical memory before
|
||
`kalloc` is given the rest. Devices need physically contiguous memory; `kalloc`
|
||
cannot promise it, so the region has to be reserved before there is any
|
||
competition for it.
|
||
|
||
## Per-CPU state
|
||
|
||
The per-CPU block's base is in **both** `FS_BASE` and `GS_BASE`, set by
|
||
`seginit()`:
|
||
|
||
```
|
||
+0 self-pointer required by the x86-64 TLS ABI (%fs:0)
|
||
+8 syscall scratch trapasm.S parks %rsp here across the stack switch
|
||
+16 struct cpu *
|
||
+24 struct proc *
|
||
+32 syscall rax the three registers wrmsr destroys, parked across
|
||
+40 syscall rcx the FS_BASE swap at the top of syscall_entry
|
||
+48 syscall rdx
|
||
```
|
||
|
||
**These offsets are shared with assembly and must not be reordered.** Offset 8
|
||
is not free: `syscall_entry` uses `%fs:(8)`, and because FS and GS base point at
|
||
the same address, that is the same memory as `%gs:8`. A `_Static_assert` in
|
||
`vm.c` pins all seven, because assembly spells them as literals and a slot that
|
||
moves leaves a register quietly holding somebody else's value.
|
||
|
||
Reached through `mycpu()` and `myproc()`, which compile to a single
|
||
`movq %gs:16` / `%gs:24` with no cached base.
|
||
|
||
### The three words at +32, and what they cost before they were here
|
||
|
||
`syscall_entry` has to swap `FS_BASE` from the user's TLS to the kernel's
|
||
before it can touch any `%fs:` data, and `wrmsr` destroys `rax`, `rcx` and
|
||
`rdx`. Those three have to be parked somewhere first — not on the user stack,
|
||
which is untrusted and has not been switched away from yet.
|
||
|
||
They were parked in **three globals**, `_sc_rax`, `_sc_rcx` and `_sc_rdx`,
|
||
under a comment reading:
|
||
|
||
> Single-CPU scratch for syscall_entry FS swap (safe while IF cleared by
|
||
> SYSCALL/SFMASK)
|
||
|
||
`IF` being clear answers for an interrupt arriving on *this* CPU. It says
|
||
nothing about a second CPU executing the same three stores at the same instant,
|
||
and with more than one processor entering syscalls that is not a rare
|
||
interleaving — it is the ordinary one.
|
||
|
||
**`%rcx` is the user return address.** `SYSCALL` puts the user's `rip` there and
|
||
`SYSRET` takes it back from there. So the CPU that lost the race returned its
|
||
process into *another process's* instruction stream, and `%rax` — the syscall
|
||
number on the way in, the return value on the way out — was swapped with it.
|
||
|
||
The symptom was one to four processes per boot dying at `rip 0x0` (an
|
||
instruction fetch at zero, which a process does not branch to; it is handed a
|
||
zero and follows it) or dereferencing `0xffffffffffffffea`, which is `-EINVAL`:
|
||
a syscall return value used as a pointer. It hit shells and `schinitd` alike,
|
||
and it had been written off as a mysterious "zsh sometimes jumps into .data".
|
||
|
||
Measured, at four CPUs, with the same pristine disk image each run:
|
||
|
||
| | boots | faults |
|
||
|---|---|---|
|
||
| three globals | 10 | **10**, in five of the ten boots |
|
||
| three per-CPU words | 18 | **0** |
|
||
|
||
This is the *second* bug of exactly this shape in this file. The first was
|
||
`kernel_fs_base`, a single global that every CPU wrote during its own
|
||
`seginit()`, so the last writer won and every CPU restored one CPU's block.
|
||
`RESTORE_KERNEL_FS` reads `%gs:0` now for that reason. The lesson generalises
|
||
and is worth stating plainly: **on the entry path, a global is a bug.** There is
|
||
one per-CPU block and everything that changes per CPU belongs in it.
|
||
|
||
> **`__thread` is wrong for per-CPU kernel data**, and this is the single most
|
||
> expensive lesson in the tree. `__thread` means per *thread*, and the compiler
|
||
> is entitled to read the thread pointer once and keep it in a register across
|
||
> calls. But `%fs` here is per *CPU*, and a kernel thread changes CPU at every
|
||
> `sched()`. So the assumption is false for exactly the variables that most need
|
||
> to be right, and the failure is a null dereference deep in the scheduler on any
|
||
> machine with more than one core. `include/core/proc.h` carries the disassembly that
|
||
> proves it.
|
||
|
||
## Memory
|
||
|
||
`kalloc()` hands out one 4096-byte page and `kfree()` takes it back. That is the
|
||
whole page allocator: a free list threaded through the free pages themselves,
|
||
no buddy system, no zones, no page-frame database.
|
||
|
||
### Small objects
|
||
|
||
`kernel/core/kmem.c`, `include/core/kmem.h`. A slab is one page from `kalloc()` with
|
||
a header at the front and objects packed after it; free objects hold the free
|
||
list in their own first eight bytes.
|
||
|
||
That last detail is the whole design. **An object needs no header and freeing
|
||
needs no search**: the page base of any object *is* its slab, and the slab
|
||
names its cache. It is also why an object cannot exceed a page.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `kmem_cache_create/alloc/free` | a named cache of one object size, for a subsystem with one kind of thing |
|
||
| `kmalloc` / `kmfree` | power-of-two classes from 16 to 2048, for code with no single type to build a cache around |
|
||
|
||
Each cache keeps `partial` and `full` lists; a slab that empties completely
|
||
goes straight back to `kalloc()` rather than being kept, so `/proc/slab` never
|
||
overstates what the kernel is holding.
|
||
|
||
`make kmem-test` asserts the bookkeeping on the build machine — the same
|
||
arrangement as `schfs-objtest`, and for the same reason: slab lists, free-list
|
||
threading and whether pages actually come back are all things that can be wrong
|
||
without a kernel to be wrong in.
|
||
|
||
### Shared memory objects
|
||
|
||
`shmget`/`shmat`/`shmdt` are System V shared memory: segments of `kalloc`'d
|
||
pages, reference counted, mapped through a VMA with `no_free` set so that
|
||
`freevm()` does not hand another process's pages to `kfree`.
|
||
|
||
`memfd_create(2)` is the same machinery reached by **descriptor** instead of by
|
||
key. That difference is the whole point and the reason it exists on Linux at
|
||
all: two processes share memory by passing the fd over a socket, so neither has
|
||
to agree on a path or race to invent a key. It is what a Wayland buffer crosses
|
||
on, and it is why descriptor passing (`SCM_RIGHTS`) had to be built first.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `memfd_create(name, flags)` | a new segment of size 0, and a descriptor owning one reference |
|
||
| `ftruncate(fd, n)` | gives it a size — growing allocates zeroed pages, shrinking frees from the end |
|
||
| `mmap(…, MAP_SHARED, fd, off)` | maps the segment's existing pages, so both mappers get the same physical memory |
|
||
| `read`/`write`/`lseek`/`fstat` | work, so it is a file and not a token only `mmap` understands |
|
||
|
||
Three things are refused rather than faked, and each refusal is the honest
|
||
version of something this kernel cannot do:
|
||
|
||
- **`MAP_PRIVATE` on a memfd.** Every caller is sharing on purpose; a private
|
||
mapping of an anonymous object is an expensive `MAP_ANONYMOUS`.
|
||
- **Mapping past the end.** Linux lets it through and faults the pages in.
|
||
There is no fault handler here that could, so the range is rejected.
|
||
- **Shrinking while mapped.** Linux truncates the pages out from under the
|
||
mapper. Here a freed page would simply stay mapped to memory somebody else
|
||
now owns.
|
||
|
||
Writable `MAP_SHARED` of a **real file** is still `ENODEV`. That needs a page
|
||
cache, it is a separate project, and nothing in 1.0 asks for it — an anonymous
|
||
object has no such contract to break, which is exactly why `memfd` is the
|
||
narrow answer.
|
||
|
||
### What the fixed tables actually cost
|
||
|
||
The standing claim was that every fixed-size table in the kernel exists to
|
||
avoid asking `kalloc` for something smaller than a page. Measured, that is only
|
||
partly true:
|
||
|
||
| | size | table | |
|
||
|---|---|---|---|
|
||
| `struct proc` | 11744 | `NPROC` 128 | larger than a page; 1 KB of it is FPU state |
|
||
| `struct socket` | 4936 | `NSOCK` 128 | larger than a page |
|
||
| `struct device` | 120 | `NDEVICE` 128 | 15 KB, never hit |
|
||
| `struct buf` | 200 | `NBUF` 256 to start | grows from a slab, data from `kalloc`, up to RAM/32 |
|
||
| `struct inode` | 296 | none | grows from a slab; `NINODE` 200 is only how many are made up front |
|
||
| `struct file` | 64 | none | grows from a slab; `NFILE` 512 is only the static pool |
|
||
|
||
The two largest cannot go in a page-backed slab at all. Freeing them needs a
|
||
contiguous multi-page allocator, which is a separate thing and is not written.
|
||
The buffer header shrank to 200 bytes when its 4 KB of data moved out to a page
|
||
of its own, which is what let the cache grow at all.
|
||
|
||
## Processes
|
||
|
||
`struct proc` in [`include/core/proc.h`](../../include/proc.h). A fixed table of
|
||
`NPROC` entries, allocated by linear scan.
|
||
|
||
**The field order at the top of the struct is fixed by assembly.** `trapasm.S`
|
||
hard-codes `fs_base` at offset `0x18`, and there is a `_Static_assert` in
|
||
`proc.c` that fails the build if it moves. Anything new goes at the end.
|
||
|
||
Each process carries: page directory and size, kernel stack, open file table,
|
||
cwd inode *and* cwd path string, signal handlers and masks, VMA table for
|
||
`mmap`, POSIX identity (pgid, sid, and all six of real/effective/saved uid and
|
||
gid), and the Linux-ABI syscall map.
|
||
|
||
### Slot reuse
|
||
|
||
The table recycles slots, and a recycled slot that inherits the previous
|
||
occupant's state is a whole class of bug that has bitten here twice.
|
||
`allocproc()` therefore explicitly clears the signal fields, the exit status,
|
||
the kill signal, and the identity fields. The failure mode when it did not:
|
||
`deliver_signals()` jumping to a totally unrelated earlier process's handler
|
||
address on this process's very first syscall return.
|
||
|
||
### Kernel threads
|
||
|
||
`kthread_create(fn, name)` makes a process that runs `fn` in kernel mode and
|
||
never returns to userland. It gets a page directory with only the kernel mapped,
|
||
no files, no cwd, no parent — it is not in the process tree, cannot be waited
|
||
for, and must never exit.
|
||
|
||
The point is that it is a *process* and not a timer callback: it shares the
|
||
scheduler's ordinary rotation, so it can hold sleeplocks and block on the disk
|
||
exactly as a syscall would. The network thread is the current user.
|
||
|
||
## Scheduling
|
||
|
||
Round-robin over the process table, one scheduler per CPU, each with its own
|
||
context. `swtch()` saves callee-saved registers and switches stacks; `sched()`
|
||
returns to the scheduler and asserts four invariants on the way — holding
|
||
`ptable.lock`, `ncli == 1`, not `RUNNING`, interrupts off.
|
||
|
||
When a full pass finds nothing runnable the CPU halts rather than spinning.
|
||
|
||
**Locking is a single `ptable.lock` over the whole table.** Not per-process: a
|
||
scheduling operation touches two processes (the one leaving and the one
|
||
arriving) and two-object locking needs an ordering rule that every path obeys.
|
||
The same reasoning produced a single lock over the socket table.
|
||
|
||
### sleep / wakeup
|
||
|
||
xv6's discipline, unchanged because it is correct: `sleep(chan, lk)` atomically
|
||
releases `lk` and blocks; `wakeup(chan)` makes every sleeper on that channel
|
||
runnable. The atomicity is what matters — a sleeper that tests its condition
|
||
under one lock while the waker sets it under another has no interlock and sleeps
|
||
through the wakeup meant for it.
|
||
|
||
## Traps and syscalls
|
||
|
||
Interrupts and exceptions enter through `vectors.S` → `trapasm.S` → `trap()`.
|
||
Syscalls enter through `SYSCALL`/`SYSRET` and `syscall_entry`, which is a
|
||
separate and faster path.
|
||
|
||
Every entry from user mode checks a **canary at the bottom of the kernel stack
|
||
page**. A deep or nested trap that overflows the 4 KB stack clobbers that word
|
||
first, so the failure is reported as `kstack overflow` at the point it happened
|
||
rather than as arbitrary corruption later.
|
||
|
||
One configuration detail with an expensive history: `MSR_STAR`'s `SYSRET`
|
||
selector field must hold the **compat 32-bit** code selector, because `SYSRETQ`
|
||
computes the returned `cs`/`ss` as `STAR[63:48]+16`/`+8`. Putting the real
|
||
64-bit user selector there makes every `sysretq` load an invalid pair — which
|
||
QEMU's TCG does not validate, so it stayed hidden until a hardware interrupt
|
||
forced a strictly-validated `IRETQ`.
|
||
|
||
## Signals
|
||
|
||
Real delivery, not stubs. `sys_rt_sigaction()` records the handler;
|
||
`deliver_signals()` runs in the tail of `syscall()`, just before returning to
|
||
user mode, and if a pending unblocked signal has a handler it rewrites the
|
||
trapframe to enter the handler instead of resuming — stashing the original in
|
||
`proc->saved_sigtf`. `sys_rt_sigreturn()` puts it back.
|
||
|
||
The return address is **not** `sa_restorer`. `SA_RESTORER` is not defined in
|
||
this musl build, so `sigaction()` never fills one in, and pushing 0 as a return
|
||
address crashes with `rip = 0` the moment the handler returns. Instead a 7-byte
|
||
trampoline (`mov eax,15; syscall`) is injected onto the user's own stack below
|
||
`rsp`, clear of the SysV red zone — the standard technique when the C library
|
||
supplies no restorer.
|
||
|
||
Signals without a handler fall back to a default action: termination for the
|
||
fatal set, dropped otherwise.
|
||
|
||
### PID 1 does not die from a signal it never asked to handle
|
||
|
||
`init_ignores()` discards any signal whose disposition is the default action
|
||
when the target is PID 1 — **including `SIGKILL`**. This is what POSIX requires
|
||
and what Linux does in `sig_task_ignored()`, and without it the system has a
|
||
single keystroke that kills it: Ctrl-C signals the foreground process group,
|
||
init is pgid 1, and any moment where the terminal's foreground group is still 1
|
||
lands SIGINT on init. It has no handler, so `exit()` runs, and `exit()` panics
|
||
outright on initproc.
|
||
|
||
Spamming Ctrl-C at a frozen program is exactly the input that finds that window.
|
||
|
||
## Process groups and sessions
|
||
|
||
`setsid`, `setpgid`, `getpgid`, `getsid`, and signal delivery to a whole group.
|
||
Job control is defined entirely in terms of these — a shell puts each pipeline
|
||
in its own process group and signals the group, never individual pids — and
|
||
mksh's job control could not make progress until they existed.
|
||
|
||
## What userspace gets
|
||
|
||
`fork`, `exec`, `wait`/`waitpid`/`wait4` with `WNOHANG` and real pid selection,
|
||
pipes, signals, `mmap` (anonymous and file-backed), POSIX shared memory, Unix
|
||
domain sockets, `poll`/`select`/`epoll`, `eventfd`, `timerfd`, `signalfd`.
|
||
|
||
`mmap` uses a bump allocator (`proc->mmap_next`, starting at `0x40000000`) so
|
||
every mapping gets its own disjoint, exactly-sized range. It previously assigned
|
||
a fixed 8 MB window per VMA slot regardless of the requested length, so a single
|
||
allocation over 8 MB mapped straight into a neighbouring live mapping.
|
||
|
||
---
|
||
|
||
## What it does not do
|
||
|
||
- **`ru_stime` is effectively always zero.** CPU time is sampled at the timer
|
||
tick and split on the interrupted privilege level, but `MSR_SFMASK` includes
|
||
`FL_IF`, so `SYSCALL` clears interrupts and no tick can land while a process
|
||
is inside a syscall. User time is real and discriminates properly — a busy
|
||
loop reports 97%, `sleep 2` reports 0% — but kernel time needs TSC deltas
|
||
closed and reopened around every `sched()`, which is scheduler accounting
|
||
rather than sampling, and is not written.
|
||
|
||
- **No slab allocator.** One page at a time, and every subsystem's fixed-size
|
||
table is a consequence. This is the single change that would most improve the
|
||
rest of the kernel.
|
||
- **No demand paging and no page cache.** `mmap` of a file *reads the whole
|
||
thing in* at map time. Writable `MAP_SHARED` of a file is therefore refused
|
||
outright rather than faked — see [../plan/foundations.md](../plan/foundations.md).
|
||
- **No dynamic linking.** `exec` handles `ET_EXEC` only: no `PT_INTERP`, no
|
||
`ET_DYN`, no shared libraries, no `dlopen`.
|
||
- **No threads.** `clone` exists but does not share an address space;
|
||
`gettid` returns the pid.
|
||
- **Permissions are tracked but not enforced.** uid/gid are maintained honestly
|
||
so `getuid()`/`setuid()` stop lying, and nothing consults them on access.
|
||
- **No swap**, and no memory pressure handling of any kind. `kalloc` failing is
|
||
reported up as `-ENOMEM` and that is the whole policy.
|
||
- **Nested signal delivery is not supported.** `in_signal` guards against
|
||
re-entering a handler while one is running.
|
||
- **Scheduling is fair-share on one global run queue.** Priorities and
|
||
fairness accounting exist now ([scheduler.md](scheduler.md)); per-CPU run
|
||
queues, work stealing and CPU affinity do not, despite `sched_setaffinity`
|
||
existing.
|