diff --git a/Makefile b/Makefile index 65fd31c..8a9d20f 100644 --- a/Makefile +++ b/Makefile @@ -21,11 +21,12 @@ KERN_ARCH = $(addprefix $(BUILD)/kernel/arch/x86_64/, \ KERN_CORE = $(addprefix $(BUILD)/kernel/core/, \ main.o kalloc.o dma.o spinlock.o sleeplock.o string.o console.o \ - panic.o uaccess.o qr.o time.o) + panic.o uaccess.o qr.o time.o ksym.o klog.o sbuf.o) KERN_DEV = $(addprefix $(BUILD)/kernel/dev/, \ bio.o cdev.o device.o disk.o fb.o ide.o input.o irq.o kbd.o mouse.o \ - pci.o pciuser.o text.o ttf.o uart.o gfx.o vt.o ramdisk.o procfs.o) + pci.o pciuser.o text.o ttf.o uart.o gfx.o vt.o ramdisk.o procfs.o \ + dbg.o) # Drivers for hardware that has to be *found* rather than assumed. Anything # discovered by walking a bus belongs here; the fixed-address legacy devices @@ -107,7 +108,7 @@ KERN_NOENTRY = $(filter-out $(BUILD)/kernel/arch/x86_64/entry.o, $(KERNOBJS)) # Third-party software does NOT go here — see ports/. # ----------------------------------------------------------------------- ULIB = $(addprefix $(BUILD)/lib/, start.o glue.o usys.o) -CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud +CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS)) # ----------------------------------------------------------------------- @@ -246,6 +247,7 @@ ifdef USE_CLANG LD = ld.lld OBJCOPY = llvm-objcopy OBJDUMP = llvm-objdump + NM = llvm-nm # ld.lld has no --no-warn-rwx-segments: it does not emit that warning, so # there is nothing to suppress and the flag is a hard error. LDFLAGS = -m elf_x86_64 -nostdlib @@ -280,6 +282,7 @@ else LD = $(TOOLPREFIX)ld OBJCOPY = $(TOOLPREFIX)objcopy OBJDUMP = $(TOOLPREFIX)objdump + NM = $(TOOLPREFIX)nm LDFLAGS = -m elf_x86_64 -nostdlib --no-warn-rwx-segments LD_LOWBASE = PORTCC = $(TOOLPREFIX)gcc @@ -616,11 +619,39 @@ qemu-iso: schos.iso # ----------------------------------------------------------------------- # Kernel binary # ----------------------------------------------------------------------- -$(BUILD)/kernel/kernel: $(KERNOBJS) $(BUILD)/kernel/initcode_obj $(BUILD)/kernel/entryother_obj $(RAMDISK_OBJ) kernel/kernel.ld - @mkdir -p $(BUILD)/kernel - $(LD) $(LDFLAGS) -T kernel/kernel.ld -o $@ \ - $(BUILD)/kernel/arch/x86_64/entry.o $(KERN_NOENTRY) \ +# The kernel is linked twice, and the second pass is the one that ships. +# +# Pass one exists to be read: tools/ksyms.pl turns its symbol table into a C +# file, which pass two compiles in, so the kernel can name its own addresses +# (docs/plan/debugger.md, D0). kernel.ld places .ksym after .text and .rodata +# precisely so that carrying the table cannot move anything the table records +# -- which is what makes two passes sufficient instead of the three Linux needs. +# +# That property is checked, not assumed. If the two passes ever disagree about +# where a function is, the table is silently wrong for every address, and a +# debugger that lies is worse than one that says nothing -- so the build stops. +KERN_LINK = $(BUILD)/kernel/arch/x86_64/entry.o $(KERN_NOENTRY) \ $(BUILD)/kernel/initcode_obj $(BUILD)/kernel/entryother_obj $(RAMDISK_OBJ) + +$(BUILD)/kernel/kernel.pass1: $(KERNOBJS) $(BUILD)/kernel/initcode_obj $(BUILD)/kernel/entryother_obj $(RAMDISK_OBJ) kernel/kernel.ld + @mkdir -p $(BUILD)/kernel + $(LD) $(LDFLAGS) -T kernel/kernel.ld -o $@ $(KERN_LINK) + +$(BUILD)/kernel/ksym.c: $(BUILD)/kernel/kernel.pass1 tools/ksyms.pl + $(NM) -n $< | perl tools/ksyms.pl > $@ + +$(BUILD)/kernel/ksym.o: $(BUILD)/kernel/ksym.c + $(CC) $(CFLAGS) -c -o $@ $< + +$(BUILD)/kernel/kernel: $(BUILD)/kernel/kernel.pass1 $(BUILD)/kernel/ksym.o + $(LD) $(LDFLAGS) -T kernel/kernel.ld -o $@ $(KERN_LINK) $(BUILD)/kernel/ksym.o + @$(NM) -n $(BUILD)/kernel/kernel.pass1 | perl tools/ksyms.pl --list > $(BUILD)/kernel/ksym.pass1.txt + @$(NM) -n $@ | perl tools/ksyms.pl --list > $(BUILD)/kernel/ksym.pass2.txt + @cmp -s $(BUILD)/kernel/ksym.pass1.txt $(BUILD)/kernel/ksym.pass2.txt || \ + { echo "ksyms: the two link passes disagree about where the code is;"; \ + echo "ksyms: the symbol table would name every address wrongly."; \ + diff -u $(BUILD)/kernel/ksym.pass1.txt $(BUILD)/kernel/ksym.pass2.txt | head -20; \ + exit 1; } $(OBJDUMP) -S $@ > $(BUILD)/kernel/kernel.asm # The ramdisk root image, and the object wrapping it for the linker. Built from diff --git a/cmd/schdbg/main.c b/cmd/schdbg/main.c new file mode 100644 index 0000000..7c1d116 --- /dev/null +++ b/cmd/schdbg/main.c @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// schdbg -- ask the kernel a question. +// +// docs/plan/debugger.md, milestone D2. The kernel half is /dev/dbg: write a +// line, read the answer. This is the half that makes that pleasant, and it is +// deliberately almost nothing -- because the protocol is lines of text, and a +// protocol whose client has to be clever is a protocol you cannot use when the +// client will not start. +// +// schdbg the views this kernel publishes +// schdbg dma one of them +// schdbg all all of them +// schdbg -i a prompt, for when you are asking several +// +// Everything here works equally well as: +// +// echo dma > /dev/dbg && cat /dev/dbg +// +// which is the point, and is what you fall back to on a system where this +// program is one of the things that is broken. + +#include "types.h" +#include "user.h" +#include "fcntl.h" + +static void +puts_(const char *s) +{ + int n = 0; + while (s[n]) n++; + write(1, (void *)s, n); +} + +// One question, one answer. +// +// The descriptor is opened fresh for each, because the answer is read from +// offset zero and the kernel replaces it on every write. Reusing one open +// descriptor across two questions would read the second answer from wherever +// the first one left off. +static int +ask(const char *cmd) +{ + char buf[512]; + int fd, n; + + fd = open("/dev/dbg", O_RDWR); + if (fd < 0) { + puts_("schdbg: cannot open /dev/dbg\n"); + return -1; + } + + if (cmd) { + int len = 0; + while (cmd[len]) len++; + if (write(fd, (void *)cmd, len) != len) { + puts_("schdbg: the kernel would not take that command\n"); + close(fd); + return -1; + } + // Back to the start. Writing moved this descriptor's offset forward by + // the length of the command, and the answer begins at zero -- so + // without this the reply arrives with its first few characters missing, + // which is subtle enough to look like a formatting bug in the view. + lseek(fd, 0, 0); /* SEEK_SET */ + } + + while ((n = read(fd, buf, sizeof(buf))) > 0) + write(1, buf, n); + + close(fd); + return 0; +} + +// A line from the terminal, without the newline. Returns 0 at end of input. +static int +readline(char *buf, int cap) +{ + int n = 0; + char c; + + for (;;) { + int r = read(0, &c, 1); + if (r <= 0) + return n > 0; + if (c == '\n') { + buf[n] = 0; + return 1; + } + if (n < cap - 1) + buf[n++] = c; + } +} + +int +main(int argc, char *argv[]) +{ + if (argc > 1 && argv[1][0] == '-' && argv[1][1] == 'i') { + char line[128]; + + puts_("schdbg: ask the kernel something. 'help' lists the views, " + "'quit' leaves.\n"); + for (;;) { + puts_("dbg> "); + if (!readline(line, sizeof(line))) + break; + if (line[0] == 0) + continue; + if (line[0] == 'q' && line[1] == 'u' && line[2] == 'i' && + line[3] == 't' && line[4] == 0) + break; + ask(line); + } + return 0; + } + + // No argument means "what can I ask", which is the question someone has + // the first time and the one an empty response would not answer. + if (argc < 2) + return ask("help") < 0 ? 1 : 0; + + // Everything after the program name, rejoined with single spaces. The + // shell split it and the kernel wants a line; `schdbg vma 4` has to arrive + // as "vma 4" or the argument is silently dropped and the view quietly + // ignores the narrowing it was asked for. + { + char line[128]; + int n = 0; + + for (int i = 1; i < argc; i++) { + if (i > 1 && n < (int)sizeof(line) - 1) + line[n++] = ' '; + for (int j = 0; argv[i][j] && n < (int)sizeof(line) - 1; j++) + line[n++] = argv[i][j]; + } + line[n] = 0; + return ask(line) < 0 ? 1 : 0; + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddb3c95..a98ac3d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -93,6 +93,7 @@ Read in this order if you are new: | [terminals.md](terminals.md) | The six virtual terminals, and the chord that switches them. | | [networking.md](networking.md) | NIC drivers, lwIP, and both socket families. | | [init.md](init.md) | `schinit`, PID 1 as a role, and the job manifests. | +| [debugging.md](debugging.md) | Symbols, a log that outlives the kernel, and asking the kernel a question. | --- diff --git a/docs/architecture/debugging.md b/docs/architecture/debugging.md new file mode 100644 index 0000000..bc99e8e --- /dev/null +++ b/docs/architecture/debugging.md @@ -0,0 +1,237 @@ +# Finding out what the kernel is doing + +Every bug in this project used to be found the same way: add a `cprintf`, +rebuild, boot, read the serial log, guess again. That loop is about ninety +seconds long and it answers exactly one question per turn — the question you +thought to ask *before* you rebuilt. + +What ended it was a real bug. A DMA block was on two allocators' free lists at +once, and finding that took several rounds of that loop, because the kernel +could not be asked whether two allocators owned the same page. It could not be +asked anything. + +> **The kernel had no way to answer a question it was not already compiled to +> answer.** + +Three things close that, and none of them is a debugger in the stepping sense. + +--- + +## The rule the shape follows + +**It observes; it does not control.** No stepping, no software breakpoints, no +stopping the world. + +That is not a scope compromise. Half the bugs this kernel actually produces are +*timing*: an interrupt storm at 77,000 a second, a level-triggered line nobody +acknowledges, a driver that faults only on its second start, a supervisor that +restarts a job fifteen times in forty-five seconds. **Every one of those +disappears when you halt the machine to look at it.** + +And the layering rule, which is the circularity rule the display already has, +pointed somewhere else: + +> A debugger that needs lwIP cannot debug lwIP. A debugger that needs a process +> cannot debug the scheduler. A debugger that needs to allocate cannot debug the +> allocator. + +So the kernel's half is the minimum that works when nothing else does, and +everything convenient is a process. + +--- + +## Naming an address + +`tools/ksyms.pl` turns the kernel's own symbol table into a table the kernel +carries, and `ksym_lookup()` resolves an address through it. A crash chain used +to be nine hex numbers: + +``` + Call chain + #0 ffff80000010a9d9 panic+0x5f + #1 ffff80000011e07f proc_sysrq_cmd+0x99 + #2 ffff80000011e205 proc_write_sysrq+0x6e + #3 ffff800000130159 procfs_write+0x59 + #4 ffff80000012e566 writei+0x45 + #5 ffff800000128a96 filewrite+0x2ec + #6 ffff80000016c0fa sys_writev+0x12b + #7 ffff80000016986e syscall+0xf6 + #8 ffff800000100d2e syscall_trapret +``` + +### Two link passes, and why two is enough + +The kernel is linked twice. Pass one exists to be read; pass two carries the +table built from it. + +The obvious objection is that adding forty kilobytes to the image moves +everything the table describes — which is why Linux's `kallsyms` needs three +passes and a convergence check. **`.ksym` is placed after `.text` and +`.rodata`,** so it cannot move a single address the table records. Two passes, +and the second is stable by construction. + +Not by assumption, though: the build lists the text symbols of both passes and +**fails** if they differ. A symbol table that is subtly wrong for every address +is worse than none, because it produces confident, false answers. + +`ksym.c` reads the table through **weak** symbols, so pass one — which has no +table — still links, and a tree with the generator removed still boots and +simply cannot name anything. + +--- + +## A log that outlives the kernel + +`cprintf` used to go to a serial line and a screen and then be gone. `panic.c` +said so in its own header: *"there is no core dump and no log to go back to."* + +There is now. Every character that passes through `consputc()` — which is every +kernel message, including the whole PSOGM report — is also appended to a byte +ring with a timestamp per line. It is readable as **`/proc/kmsg`**. + +The ring lives in a region carved out before the page allocator is given +anything, and it has a magic header, so **a kernel that reboots finds the +previous kernel's log still there**: + +``` +--- boot 1 --- +[ 0.00] e820: 1018 MB usable in 7 regions, top 1016 MB (19 entries) +... +[ 7.18] PSOGM -- Purple Screen of Guru Meditation +[ 7.18] Call chain +[ 7.18] #0 ffff80000010a9d9 panic+0x5f +... +--- boot 2 --- +[ 0.00] klog: 128 KB at ffff80000053e000, 2162 bytes carried over from the previous boot +``` + +### Where it lives, and why not where you would put it + +The obvious place is the top of RAM, beside the DMA arena. That was the first +attempt, and on the reboot the magic came back as ASCII — because **the top of +usable memory is exactly where the firmware puts its own allocations** before it +hands the machine over. + +It sits immediately above the kernel image instead. `end` is the same address on +every boot of the same build, which is the property a region has to have to be +found again, and the bytes just past the kernel are the ones the loader has +already committed to leaving alone. No hole has to be punched in the allocator: +`kinit1`'s lower bound is a parameter. + +The magic and a header checksum mean a region something else has used is +re-initialised rather than printed as if it were a log. **A reboot that does not +preserve DRAM loses the log, and says so** rather than showing garbage. + +Two properties of the writer are deliberate. It **takes no lock** — it runs +inside `consputc()`, and a lock there would be a way for the logger to deadlock +the thing it exists to report on. And it **buffers before it exists**: the region +is carved after `e820_init()`, so the handful of lines before that go into a +static array and are flushed in, because a log that is blank for the earliest +part of the boot is blank for the part that is hardest to debug any other way. + +--- + +## Asking a question + +`/dev/dbg` is a conversation: write a line, read the answer. + +``` +# schdbg dma +arena 0xffff80003e8ed000-0xffff80003f8ed000 + 16384 KB total, 12295 KB free in 3 extent(s), 4088 KB out + + # base phys bytes + 0 0xffff80003e8f3000 0x000000003e8f3000 4091904 + 1 0xffff80003ecea200 0x000000003ecea200 3584 + 2 0xffff80003f0d3000 0x000000003f0d3000 8495104 +``` + +It is text, not a typed binary protocol, and the reason is that the same bytes +have to work at every level — including the one where nothing can allocate, +frame a record, or wait for a client to acknowledge anything. A protocol you +cannot drive by hand is a protocol you cannot debug. Everything `schdbg` does is +also: + +``` +# echo dma > /dev/dbg && cat /dev/dbg +``` + +### The registry is the point, not the views + +A subsystem publishes its state with **one line at the bottom of the file that +owns the data**: + +```c +dbg_register("dma", dma_dump, "the DMA arena's free extents"); +``` + +Nothing central is edited to add one. That is the difference between something +that stays current and something that does not — `/proc/sysrq` is the version +that does not, a `switch` in one file with a letter per action, and after two +years it holds three self-tests and no state at all. + +| View | | +|---|---| +| `dma` | the arena's free extents, in virtual and physical | +| `mem` | the page allocator's free list, and whether it has a cycle in it | +| `proc` | every process, with state, size and its mmap bump pointer | +| `vma` | every mapping a process holds but does not own the pages of | +| `claims` | devices, DMA blocks and interrupts held by userspace drivers | +| `pci` | the bus, with each device's command register decoded | +| `sock` | the socket table, with buffered bytes and descriptors in flight | + +`all` prints every view; `help` lists them; a view may take an argument, so +`vma 4` narrows to one process. + +Several of those exist because of a specific bug. `vma` is the table that decides +whether `exit()` frees a page or leaves it alone, and the bug that made this +worth building was a DMA block that was never in it. `claims` shows the three +things that go wrong with a userspace driver — a device held by a process that +no longer exists, DMA handed out and never returned, an interrupt line left +masked with nobody reading it. `pci` decodes the command register because +"is bus mastering actually off?" is a question with a wrong answer that looks +fine. + +### Three ways in, and the one that always works + +| | Works when | +|---|---| +| `sysrq g` | always — every view to the console, no process, no filesystem, no client program | +| `/dev/dbg` | there is a scheduler and a filesystem | +| `schdbg` | there is a shell | + +Views run in ordinary process context and may take locks. **They must not be +called from `panic()`.** + +--- + +## What it does not do + +- **It does not step, break, or halt.** If a bug genuinely needs a stepper, this + is the wrong tool, and that is a deliberate trade rather than an omission. +- **No cross-subsystem queries.** *"Who owns physical page `0x3eceb000`?"* — the + question that would have ended the investigation that prompted all this — still + cannot be asked. Each view answers for its own subsystem and nothing + correlates them. That is `whose`, and it is not built. +- **No watchpoints.** DR0–DR3 are untouched and `T_DEBUG` maps to `SIGTRAP`, so + "something zeroed my page table, tell me who" has no answer. +- **No network transport.** The plan puts a TCP bridge in a userspace daemon, + deliberately outside the kernel; it does not exist, so the debugger is local + or serial only. +- **No memory writes**, and no way to poke anything. Reading is what finds bugs. +- **`all` truncates at 16 KB** and says so. On a busy machine, ask for one view + at a time. +- **One conversation at a time.** The answer buffer is global, so two programs + asking at once get one answer each in some order — never half of the other's, + but not two independent sessions either. +- **No userspace symbolization.** The kernel names kernel symbols. A fault in a + process still reports a bare address, and resolving it is a host-side job. +- **The stack walk stops where the frame pointer stops.** `getcallerpcs()` walks + `rbp` chains, so a trap frame in the middle of a chain ends the walk. +- **`sysrq g` costs you log history.** Its output goes through `cprintf`, so + sixteen kilobytes of views push the same amount out of a 128 KB ring. +- **No tracing.** No static tracepoints, no event ring, no counters. "What + happened in the two hundred events before this" is not a question this can + answer. + +What remains is in [../plan/debugger.md](../plan/debugger.md). diff --git a/docs/plan/1.0.md b/docs/plan/1.0.md index d3cc4f1..a711c2d 100644 --- a/docs/plan/1.0.md +++ b/docs/plan/1.0.md @@ -96,6 +96,15 @@ The galling ones. The work exists, is tested, and nothing uses it. | **`foundations.md`** | Item 1 built. Items 2 (a shareable memory object), 3 (`AF_UNIX` in the filesystem namespace), 4 and 5 open. **Wayland stops at item 2.** | | **`hybrid.md`** | All four mechanisms built. H1 and H2 done — the driver can be killed and comes back, which is the milestone the plan calls the one that proves the whole idea. H3 (a modeset API) and H4 (USB) open. | | **Schcom v0** | 715 lines that composite, over a wire format that is superseded. Frozen: it runs, nothing more is added, and it is kept only because it is the one thing in the tree that has ever put a client's pixels on screen. | +| **`debugger.md`** | D0, D1 and D2 built and described in [../architecture/debugging.md](../architecture/debugging.md). D3 (`whose `, the cross-subsystem ownership query) and D4 (hardware watchpoints) open, and the network transport with them. | + +**The row the debugger spent.** Adding one requires closing one, and *A released +PCI device* closed when H2 landed. It is worth saying plainly that the debugger +is not a capability of the OS the way every other row here is: nothing in it +ships to a user or appears in the acceptance test. The case for it is that +**Gate 0 is a list of bugs to find in a kernel that could not be asked +anything** — so it is the instrument the rest of this register is surveyed with, +and it earns its slot by making every other row cheaper to close. **Closed since this register was written.** *A released PCI device* — release is now symmetric with claim (bus mastering cleared before the DMA is freed, a diff --git a/docs/plan/README.md b/docs/plan/README.md index 920c096..cfe5323 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -39,6 +39,12 @@ And the one that says which parts of the above are actually being worked on: |---|---| | **[1.0.md](1.0.md)** | The finish line. Four gates, and everything else explicitly deferred. | +And one that is a tool rather than a capability: + +| | | +|---|---| +| **[debugger.md](debugger.md)** | A debugger for this kernel — one that observes rather than halts, and answers in this kernel's own vocabulary. **Mostly built**: symbols, a log that outlives the kernel, and `/dev/dbg` are done and described in [../architecture/debugging.md](../architecture/debugging.md). | + Two longer-horizon documents sit behind these: [schapi.md](schapi.md), the API layering and the compatibility promise, and [roadmap.md](roadmap.md), which holds everything still open that is not one of the three above. diff --git a/docs/plan/debugger.md b/docs/plan/debugger.md new file mode 100644 index 0000000..983e554 --- /dev/null +++ b/docs/plan/debugger.md @@ -0,0 +1,239 @@ +# A debugger for this kernel + +> **D0, D1 and D2 are built.** Symbols, a log that outlives the kernel, and the +> introspection registry behind `/dev/dbg` are described in +> [../architecture/debugging.md](../architecture/debugging.md). What is left +> here is D3, D4, and the network transport. + +Every bug in this project so far has been found the same way: add a `cprintf`, +rebuild, boot, read the serial log, guess again. That loop is about ninety +seconds long and it answers exactly one question per turn — the question you +thought to ask before you rebuilt. + +The DMA ownership bug in `do_dma()` is what made the case. Finding it took +several rounds of that loop, and the thing that eventually found it was not a +print at all; it was reading `freevm()` and noticing what `PHYSTOP` meant for +the arena. The kernel could not be *asked* whether two allocators owned the same +page, because nothing in it can be asked anything. + +> **The kernel has no way to answer a question it was not already compiled to +> answer.** That is the gap. + +--- + +## What there was, and what was missing + +The state this plan was written against. D0 to D2 have since closed the first +three rows; they are kept because they are the argument. + +| | | +|---|---| +| `cprintf` | goes to UART and console and is then gone. **There is no log.** `panic.c` says so itself: *"there is no core dump and no log to go back to."* | +| PSOGM | collects a great deal at the moment of a panic — CPU, process, call chain, load address — and paints it. It is the only artefact of a crash, and it does not survive being looked away from. | +| `getcallerpcs()` | walks frames and prints **raw addresses**. Turning one into a function name is a manual `objdump` session. | +| `/proc/sysrq` | a hardcoded `switch` with one letter per action. `xhci_dump`, `usb_storage_dump`, `display_selftest` — the right instinct, in the wrong shape: they are self-tests, not state, and adding one means editing a central file. | +| DR0–DR3 | **unused.** `T_DEBUG` maps to `SIGTRAP` and nothing ever arms a hardware watchpoint. | + +--- + +## The two rules + +### It observes; it does not control + +No stepping. No software breakpoints. No stopping the world. + +This is the decision everything else follows from, and it is not a scope +compromise. Half the bugs this project actually hits are *timing*: an interrupt +storm at 77,000 a second, a level-triggered line nobody acknowledges, a driver +that faults only on the second start, a supervisor that restarts a job fifteen +times in forty-five seconds. **Every one of those disappears when you stop the +world to look at it.** A debugger that can only work by halting the machine +cannot see the class of bug this kernel produces. + +So it answers questions, and it watches. Both of those work on a machine that is +still running. + +### It must not depend on what it is debugging + +The same circularity rule the display already has, pointed at a different +subsystem: + +> A debugger that needs lwIP cannot debug lwIP. A debugger that needs a process +> cannot debug the scheduler. A debugger that needs to allocate cannot debug the +> allocator. + +That rule decides the layering below rather than taste deciding it, which is the +only reason to write a rule down. + +--- + +## Why not GDB + +The licence is not the reason — a stub written here from the published protocol +would be our own Apache-2.0 code, the same clean-room argument as +[../architecture/devices.md](../architecture/devices.md) makes for Linux +drivers. Two technical reasons, and they are both about what a *generic* +debugger structurally cannot do. + +**It only knows registers and memory.** GDB can tell you that `0x3eceb000` +contains zeroes. It cannot tell you that the page is in the DMA arena's free +list *and* on `kalloc`'s free list *and* mapped into pid 7 — because those are +this kernel's abstractions, and a debugger that knows about them has to be +written against them. That query is the one that would have ended the session +described above in one line. + +**It stops the world**, per the rule above. + +There is a third, smaller reason: a `gdbstub` is a protocol you implement and +then debug, and its failure mode is an unresponsive stub with no way to ask it +why. The thing below can always be driven by hand over a serial line. + +--- + +## The layers + +Three transports, and the rule decides which gets what. **The kernel's half is +the minimum that works when nothing else does; everything convenient lives in a +process** — which is the same sentence as [hybrid.md](hybrid.md)'s, about a +different thing. + +| | Works when | | +|---|---|---| +| **0. Console and UART** | always, including panic context, with interrupts off, no allocation, no scheduler, no lock taken | `sysrq`-driven. If the machine is dying this is the one you have. | +| **1. `/dev/dbg`** | there is a scheduler and a process | A character device. The local client is an ordinary program — exactly the `/dev/pci` discipline, for exactly the same reason. | +| **2. The network** | there is a network | A **userspace daemon** bridging TCP to `/dev/dbg`. Not in the kernel. lwIP must stay out of the debugger's path, or the debugger cannot be pointed at lwIP. | + +Level 2 is deliberately last, and it is not in the first cut. + +--- + +## The protocol is lines of text + +A request is a line. A response is lines, ending in a blank one. + +``` +> whose 0x3eceb000 +phys 0x3eceb000 + dma arena extent 2, FREE (4096000 bytes @ 0x3eceb000) + kalloc free list, position 41 + pid 7 vgpud va 0x4000d000 rw-u vma[3] no_free + *** three owners -- this page is claimed twice + +> view dma +arena 0x3e000000-0x3f000000 16384 KB, 12284 KB free + free[0] 0x3e0a4000 8192 KB + free[1] 0x3ecec000 4092 KB + in use 4096 KB in 3 blocks +``` + +Text, and not a typed binary protocol, for three reasons that all turn out to be +the same reason: + +- **It works at level 0.** Panic context cannot allocate, cannot frame records, + cannot wait for a client to acknowledge anything. It can put characters on a + serial line. A binary protocol needs a text fallback anyway, and then there are + two protocols. +- **It is usable before a client exists.** `cat`, `echo`, a serial terminal, the + guest's own shell. A protocol that needs its client built first is a protocol + nobody can debug. +- **It survives the thing it is debugging.** When the answer arrives garbled, + you can still read most of it. + +A richer client can be layered on later by parsing this, and if that ever +happens the kernel does not change. + +--- + +## Milestones + +| | | | +|---|---|---| +| **D0** | **Symbols.** A compact sorted table emitted from `nm` at link time and linked into the image; `ksym_lookup(addr, &off)`. | **done** | +| **D1** | **A log that survives.** A ring behind `cprintf`, timestamped, in a region the allocator never sees — so a panic's last words are readable on the next boot. `/proc/kmsg`. | **done** | +| **D2** | **The introspection registry.** `dbg_register("dma", dma_dump)`. Seven views, `/dev/dbg`, `schdbg`, and `sysrq g`. | **done** | +| **D3** | **`whose `** — the cross-subsystem ownership query above. | | +| **D4** | **Watchpoints**, DR0–DR3, break-on-write, reporting rip and call chain and **continuing** rather than stopping. | | + +### Why D0 and D1 come before the interesting parts + +Neither is clever. Both multiply everything after them: a view that prints an +address is worth less than one that prints a name, and a query you can only run +*after* you have noticed something is worth less than a log that was already +running when it happened. + +D1 in particular changes the job rather than improving it. Today a triple fault +leaves nothing at all — the screen is gone, the serial buffer is gone, and the +only evidence is whatever you happened to be watching. A log that outlives the +boot turns that into a file. + +### What D1 turned out to cost + +One measurement changed the design. The log was first placed at the top of RAM +beside the DMA arena, which is the obvious spot for a region nothing should +allocate — and on the first reboot its magic came back as ASCII, because **the +top of usable memory is exactly where the firmware makes its own allocations** +before handing the machine over. It sits above the kernel image instead, which +is both a stable address across boots and memory the loader has already +committed to. Recorded because the reasoning that picked the wrong place was +sound, and only the test was not. + +### What D2 is really for + +Not the views themselves — the *registry*. A subsystem publishes its state with +one line at the bottom of the file that owns the data, and nothing central has to +be edited. That is the only version of this that stays current; the `sysrq` +switch is the version that does not, and it is already three self-tests and no +state. + +--- + +## The security note, for the parts not yet built + +A debugger that reads arbitrary physical memory and a TCP listener are, together, +a remote root hole. That is true of every kernel debugger ever written and it is +not a reason not to build one, but it has to be decided rather than discovered: + +- Level 2 is **off unless a boot flag turns it on**, and binds loopback by + default. +- No memory *writes* in the first cut. Reading is what finds bugs; writing is + what a later milestone can argue for on its own. + +--- + +## What it will still not do + +- **It will not step, break, or halt**, per the rule. If a bug genuinely needs a + stepper, this is the wrong tool and that is a deliberate trade. +- **No stack unwinding through interrupt frames.** `getcallerpcs()` walks `rbp` + chains and stops where the frame pointer stops; a trap frame in the middle of + a chain ends the walk. +- **No userspace symbolization.** The kernel will name kernel symbols. A fault + in a process reports the binary and the offset, and resolving that stays a + host-side job. +- **No profiling.** No sampling, no counters, no flame anything. A timer-driven + sampler is a different tool that happens to share a transport. +- **Watchpoints are limited to four**, by the hardware, and to 1/2/4/8 bytes. + There is no software watchpoint fallback and there should not be one. +- **It is not a tracer.** Static tracepoints and an event ring are the obvious + next thing after D4 and are not on this plan. + +--- + +## On the freeze + +[1.0.md](1.0.md) says no new capability starts until the register is empty, and +the register is not empty. Two things about that. + +The bookkeeping answer is that the rule is *"adding a row requires closing one"*, +and **A released PCI device** closed when H2 landed. There is a slot. + +The better answer is that the register is a list of **capabilities of the OS**, +and this is not one — nothing in it ships to a user or appears in the acceptance +test. It is a tool, and the case for spending time on a tool is that it makes +everything still on the register cheaper to finish. Gate 0 of [1.0.md](1.0.md) +is *"fix what is already wrong"*, assembled from every *What it does not do* +section in [../architecture/](../architecture/). That gate is a list of bugs to +find in a kernel that cannot currently be asked anything. + +**Build the instrument before the survey**, and then the freeze holds for the +rest of it. diff --git a/include/dbg.h b/include/dbg.h new file mode 100644 index 0000000..29143d8 --- /dev/null +++ b/include/dbg.h @@ -0,0 +1,55 @@ +#pragma once + +#include "types.h" + +struct sbuf; + +// Asking the kernel a question. +// +// docs/plan/debugger.md milestone D2. The thing this exists to end is the +// ninety-second loop: add a cprintf, rebuild, boot, read the serial log, and +// learn the answer to exactly the one question you thought of before the +// rebuild. +// +// The interesting part is not the views. It is that a subsystem publishes its +// own state with **one line at the bottom of the file that owns the data**, and +// nothing central has to be edited to add one: +// +// dbg_register("dma", dma_dump, "the DMA arena's free extents"); +// +// That is the difference between something that stays current and something +// that does not. /proc/sysrq is the version that does not -- a switch in one +// file with a letter per action, which is why after two years it holds three +// self-tests and no state at all. +// +// Views run in ordinary process context, from a write to /dev/dbg or from +// sysrq, so they may take locks. **They must not be called from panic().** + +// The argument is whatever followed the view's name on the command line, or +// "" when there was none. Most views ignore it; the ones that can produce a +// hundred lines use it to narrow -- `vma 4` rather than `vma`. It is also the +// shape milestone D3 needs, since `whose ` is nothing but an argument. +typedef void (*dbg_view_fn)(struct sbuf *out, const char *arg); + +// Publish a view. `what` is one line, shown by `help`. +// +// Safe to call from any subsystem's init, in any order: the table is static +// and needs no initialisation of its own. +void dbg_register(const char *name, dbg_view_fn fn, const char *what); + +// Run one command line and render the answer. Returns the number of bytes +// written into `out`. +// +// The protocol is lines of text -- see docs/plan/debugger.md for why that and +// not a typed binary format. Commands: +// +// help what this kernel can be asked +// all every view, in registration order +// [arg] that view, optionally narrowed +int dbg_command(const char *line, struct sbuf *out); + +// Every view, straight to the console. This is what sysrq 'g' runs, and it is +// the level-0 path from the plan: no process, no filesystem, no client. +void dbg_dump_all(void); + +#define DBG_MAJOR 10 diff --git a/include/defs.h b/include/defs.h index f07d7cc..89eb890 100644 --- a/include/defs.h +++ b/include/defs.h @@ -59,6 +59,7 @@ int pciuser_read(struct inode*, uint64, char*, int); int pciuser_write(struct inode*, uint64, char*, int); int pciuser_mmio_ok(uint64 phys, uint64 len); void pciuser_release_all(struct proc*); +int pciuser_owner_pid(int idx); int proc_reincarnate(int status); void proc_kill_init(void); extern uint init_generation; @@ -363,6 +364,23 @@ void ioapicinit(void); // dma.c — see include/dma.h for the full contract void dma_init(void*, void*); + +// dbg.c -- /dev/dbg, the introspection registry. See include/dbg.h. +int dbg_read(struct inode*, uint64, char*, int); +int dbg_write(struct inode*, uint64, char*, int); + +// klog.c -- the kernel log that outlives the kernel. See include/klog.h. +void klog_init(void *base, uint64 bytes); +void klog_putc(int c); +int klog_read(uint64 off, char *dst, int n); +uint64 klog_len(void); +uint64 klog_prev_bytes(void); +int klog_recovered(void); + +// ksym.c -- naming a kernel address. See docs/plan/debugger.md, D0. +int ksym_ready(void); +const char* ksym_lookup(uint64 addr, uint64 *off); +int ksym_fmt(char *buf, int cap, uint64 addr); void *dma_alloc(uint64 size, uint64 align); void dma_free(void*, uint64 size); addr_t dma_phys(const void*); diff --git a/include/klog.h b/include/klog.h new file mode 100644 index 0000000..52f1c1b --- /dev/null +++ b/include/klog.h @@ -0,0 +1,44 @@ +#pragma once + +#include "types.h" + +// The kernel log that outlives the kernel. +// +// docs/plan/debugger.md milestone D1. Until this existed, `cprintf` wrote to a +// serial line and a screen and was then gone: a panic's report could be read +// only by whoever was watching at the time, and a triple fault left nothing at +// all. panic.c said so in its own header comment -- *"there is no core dump and +// no log to go back to"* -- and that sentence is what this deletes. +// +// The buffer is a byte ring in a region carved off physical memory *before* +// kalloc or the DMA arena are given anything, at a fixed distance below +// PHYSTOP, so its address is a property of the machine rather than of this +// boot. Nothing allocates it, nothing frees it, and a reboot that leaves DRAM +// alone leaves it intact -- which is what makes the last words of a crashed +// kernel readable from the one that comes after. + +#define KLOG_BYTES (128 * 1024) // ring plus header +#define KLOG_MAGIC 0x676f4c6bu // "kLog" +#define KLOG_VERSION 1 + +// Kept at the head of the region. Everything in it has to survive a reboot, so +// it is all fixed-width and there are no pointers: the only thing that says +// where the data is, is that it follows. +struct klog_hdr { + uint32 magic; + uint32 version; + uint32 size; // bytes of data[], = KLOG_BYTES - sizeof(struct klog_hdr) + uint32 boot; // how many kernels have written here + uint64 head; // total bytes ever written; the ring is head mod size + uint64 mark; // head as it stood when this boot started + uint32 sum; // over the fields above -- see klog.c + uint32 pad; + char data[]; +}; + +void klog_init(void *base, uint64 bytes); +void klog_putc(int c); +int klog_read(uint64 off, char *dst, int n); +uint64 klog_len(void); +uint64 klog_prev_bytes(void); +int klog_recovered(void); diff --git a/include/param.h b/include/param.h index 81489d1..c63174f 100644 --- a/include/param.h +++ b/include/param.h @@ -29,7 +29,7 @@ #define NOFILE 128 // open files per process #define NFILE 512 // open files per system #define NINODE 200 // maximum number of active i-nodes -#define NDEV 10 // maximum major device number +#define NDEV 11 // maximum major device number #define RAMDISK_DEV 2 // memory-backed block device (kernel/dev/ramdisk.c) #define USBDISK_DEV 3 // USB mass storage (kernel/drivers/usb_storage.c) diff --git a/include/posix.h b/include/posix.h index 0859535..600f60f 100644 --- a/include/posix.h +++ b/include/posix.h @@ -4,10 +4,25 @@ #include "types.h" #include "date.h" +// struct iovec is declared in two headers on purpose, and guarded so that +// including both is not an error. +// +// posix.h has it because readv/writev need it; this file has it because a +// userspace program including for sendmsg must get a complete +// struct msghdr without also having to know about a kernel header. Both +// definitions are identical and both are Linux's layout. +// +// The guard is not cosmetic. C23 permits redeclaring a struct with identical +// members and GCC 15 defaults to C23, so the duplicate was invisible on the +// gcc path -- while clang defaults to gnu17 and rejected it, which broke the +// one toolchain shell.nix exists to provide and which the UEFI loader needs. +#ifndef SCH_IOVEC_DEFINED +#define SCH_IOVEC_DEFINED struct iovec { void *iov_base; uint64 iov_len; }; +#endif // Linux's IOV_MAX. readv/writev used to cap iovcnt at 16 -- which is legal, // since POSIX only requires 16 -- and returned EINVAL above it. But programs diff --git a/include/sbuf.h b/include/sbuf.h new file mode 100644 index 0000000..fe6d2c9 --- /dev/null +++ b/include/sbuf.h @@ -0,0 +1,46 @@ +#pragma once + +#include "types.h" + +// A bounded string builder, because there is no snprintf in this kernel. +// +// These lived inside kernel/dev/procfs.c, where they were written, and were +// static to it. The debugger's introspection registry needs the same thing -- +// a subsystem rendering its own state into a caller's buffer, with no +// allocation and no way to run off the end -- and the choice was to write a +// second set or to move this one. See docs/plan/debugger.md, D2. +// +// Nothing here can overflow: every writer checks cap, and a full buffer +// silently drops the rest. That is the right trade for something whose callers +// are a /proc read and a crash-time dump; a builder that can fail is a builder +// every caller has to check, and none of them would. + +struct sbuf { + char *p; + int len; + int cap; +}; + +void sb_putc(struct sbuf *sb, char c); +void sb_puts(struct sbuf *sb, const char *s); + +// A string in a fixed-width column, space-padded. Over-long values run past +// their column rather than being truncated: a misaligned row is easier to read +// than a mangled name. +void sb_pad(struct sbuf *sb, const char *s, int width); + +// Four lowercase hex digits, zero-padded. PCI and USB identifiers are always +// written this way, and a lookup table keyed on "8086:100e" does not match +// "8086:100E" or "8086:10e". +void sb_puthex4(struct sbuf *sb, uint32 v); + +void sb_putu(struct sbuf *sb, uint64 v); + +// Zero-padded hex of a given width, for addresses. +void sb_puthex(struct sbuf *sb, uint64 v, int digits); + +// A decimal number in a right-aligned column, so tables of sizes line up. +void sb_putu_pad(struct sbuf *sb, uint64 v, int width); + +// "