diff --git a/Makefile b/Makefile index 42efbe3..641e549 100644 --- a/Makefile +++ b/Makefile @@ -108,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 schdbg schmode netecho ldlsd +CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg schdbgd schmode netecho ldlsd CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS)) # ----------------------------------------------------------------------- @@ -623,7 +623,7 @@ qemu-iso: schos.iso # # 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 +# (docs/architecture/debugging.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. # diff --git a/cmd/schdbg/main.c b/cmd/schdbg/main.c index 7c1d116..4cd205f 100644 --- a/cmd/schdbg/main.c +++ b/cmd/schdbg/main.c @@ -2,7 +2,7 @@ // // schdbg -- ask the kernel a question. // -// docs/plan/debugger.md, milestone D2. The kernel half is /dev/dbg: write a +// docs/architecture/debugging.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 diff --git a/cmd/schdbgd/main.c b/cmd/schdbgd/main.c new file mode 100644 index 0000000..47aeca3 --- /dev/null +++ b/cmd/schdbgd/main.c @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// schdbgd -- the debugger, over the network. +// +// docs/architecture/debugging.md, milestone D5, and the last thing on that plan. The +// levels below it are the console key (Ctrl-G), which works before there is a +// filesystem, and /dev/dbg, which works once there is a shell. This is the one +// for when the machine you are debugging is not the machine you are sitting at: +// a target that has locked its display, a board with no keyboard, or a bug that +// only happens under load you cannot generate by hand. +// +// schdbgd & 127.0.0.1:4444 +// schdbgd -p 9000 a different port, still loopback +// schdbgd -a 0.0.0.0 anywhere -- and it will say so, loudly +// +// Then, from anywhere that can reach it: +// +// nc 127.0.0.1 4444 +// dma +// +// **It is a bridge and nothing else.** A line in becomes a line written to +// /dev/dbgro; the answer comes back. There is no protocol here, no framing, no +// state, and no parsing of anything the kernel said -- which is what makes `nc` +// a complete client and keeps this usable on the day the clever client is one +// of the things that is broken. +// +// --- what it deliberately is not ------------------------------------------ +// +// A kernel debugger plus a TCP listener is a remote root hole. That is true of +// every one ever written and it is not a reason not to build one, but it has to +// be decided rather than discovered. Three decisions: +// +// **It does not start by itself.** /etc/schinit/schdbgd.job ships `Disabled`. +// SchOS has no kernel command line, so the boot flag the plan asked for is the +// job manifest, which is the same thing in this system's own idiom: a machine +// that has not been told to run this does not run it. +// +// **It binds loopback unless told otherwise**, and binding anywhere else takes +// an argument that names the address. Reaching it then means `ssh -L`, which is +// somebody's deliberate act rather than a default nobody chose. +// +// **It opens /dev/dbgro, never /dev/dbg.** The read-only minor refuses any view +// that declared itself as changing state -- `watch ` arms a debug +// register, and arming one is a fine thing for a debugger to do and not a thing +// for a stranger on a socket to do. The refusal is in the kernel, driven by the +// view's own registration, so it cannot be got round from here and does not go +// stale when a new view is added. See include/dbg.h. +// +// One connection at a time, because /dev/dbg holds one answer at a time: two +// clients would read each other's. A second caller waits rather than +// interleaving. + +#include "types.h" +#include "user.h" +#include "fcntl.h" +#include "socket.h" + +#define DEFAULT_PORT 4444 + +static void +puts_(const char *s) +{ + int n = 0; + while (s[n]) n++; + write(1, (void *)s, n); +} + +static void +putn(int v) +{ + char b[12]; + int i = 0; + if (v == 0) { write(1, "0", 1); return; } + if (v < 0) { write(1, "-", 1); v = -v; } + while (v > 0 && i < 11) { b[i++] = '0' + (v % 10); v /= 10; } + while (i > 0) { i--; write(1, &b[i], 1); } +} + +static int +atoi_(const char *s) +{ + int v = 0; + while (*s >= '0' && *s <= '9') v = v * 10 + (*s++ - '0'); + return v; +} + +// "10.0.2.15" -> network byte order, or 0 if it is not four numbers. +static unsigned int +parse_ip(const char *s, int *ok) +{ + unsigned int b[4] = { 0, 0, 0, 0 }; + int i; + + *ok = 0; + for (i = 0; i < 4; i++) { + int any = 0; + while (*s >= '0' && *s <= '9') { b[i] = b[i] * 10 + (*s++ - '0'); any = 1; } + if (!any || b[i] > 255) + return 0; + if (i < 3) { + if (*s != '.') return 0; + s++; + } + } + if (*s != 0) + return 0; + *ok = 1; + return b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24); // network order +} + +// One question, one answer. +// +// The descriptor is opened fresh each time and the answer is read from offset +// zero, for the reason written at length in kernel/dev/dbg.c: writing advances +// the offset, so a program that writes and then reads on one descriptor starts +// as many bytes into the answer as its command was long. +static int +ask(int sock, const char *cmd, int len) +{ + char buf[1024]; + int fd, n; + + fd = open("/dev/dbgro", O_RDWR); + if (fd < 0) { + const char *m = "schdbgd: cannot open /dev/dbgro\n"; + write(sock, (void *)m, 32); + return -1; + } + write(fd, (void *)cmd, len); + lseek(fd, 0, 0); + for (;;) { + n = read(fd, buf, sizeof(buf)); + if (n <= 0) + break; + if (write(sock, buf, n) != n) { // the client went away mid-answer + close(fd); + return -1; + } + } + close(fd); + return 0; +} + +// Serve one client until it disconnects. +static void +serve(int sock) +{ + char line[256]; + int n = 0; + + { + const char *hello = + "SchOS debugger. 'help' for the views, 'quit' to go.\n" + "read-only: views that change state are refused here.\n\n"; + int l = 0; while (hello[l]) l++; + write(sock, (void *)hello, l); + } + + for (;;) { + char c; + int r = read(sock, &c, 1); + + if (r <= 0) + return; // closed, or gone + if (c == '\r') + continue; + if (c != '\n') { + // A line longer than the buffer is truncated rather than split. + // Splitting would turn one bad command into two, and the second + // half of a command is a command. + if (n < (int)sizeof(line) - 1) + line[n++] = c; + continue; + } + + line[n] = 0; + if (n == 0) { n = 0; continue; } + + if ((line[0] == 'q' && line[1] == 'u') || + (line[0] == 'e' && line[1] == 'x')) { + const char *bye = "bye\n"; + write(sock, (void *)bye, 4); + return; + } + + if (ask(sock, line, n) < 0) + return; + n = 0; + } +} + +int +main(int argc, char *argv[]) +{ + struct sockaddr_in sa; + unsigned int addr = 0x0100007f; // 127.0.0.1, network order + int port = DEFAULT_PORT, lfd, i, open_to_all = 0; + + for (i = 1; i < argc; i++) { + if (argv[i][0] == '-' && argv[i][1] == 'p' && i + 1 < argc) { + port = atoi_(argv[++i]); + } else if (argv[i][0] == '-' && argv[i][1] == 'a' && i + 1 < argc) { + int ok; + unsigned int a = parse_ip(argv[++i], &ok); + if (!ok) { puts_("schdbgd: -a wants an address like 0.0.0.0\n"); return 1; } + addr = a; + open_to_all = (addr != 0x0100007f); + } else { + puts_("usage: schdbgd [-p port] [-a address]\n" + " loopback and port 4444 unless told otherwise.\n"); + return 1; + } + } + if (port <= 0 || port > 65535) { + puts_("schdbgd: that is not a port\n"); + return 1; + } + + lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) { puts_("schdbgd: no socket\n"); return 1; } + + sa.sin_family = AF_INET; + sa.sin_port = (unsigned short)(((port & 0xff) << 8) | ((port >> 8) & 0xff)); + sa.sin_addr = addr; + for (i = 0; i < 8; i++) sa.sin_zero[i] = 0; + + if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + puts_("schdbgd: cannot bind port "); putn(port); puts_("\n"); + return 1; + } + if (listen(lfd, 1) < 0) { puts_("schdbgd: cannot listen\n"); return 1; } + + puts_("schdbgd: debugger on port "); putn(port); + if (open_to_all) { + // Worth a line of its own. Anyone who can reach this port can read + // this kernel's memory layout, its process table and its open sockets. + puts_("\nschdbgd: *** bound beyond loopback -- anyone who can reach " + "this port can read\n" + "schdbgd: *** this kernel's internal state. That was asked for " + "with -a.\n"); + } else { + puts_(", loopback only\n"); + } + + for (;;) { + int fd = accept(lfd, 0, 0); + if (fd < 0) + continue; + serve(fd); + close(fd); + } +} diff --git a/docs/architecture/debugging.md b/docs/architecture/debugging.md index dd6dfd0..407a148 100644 --- a/docs/architecture/debugging.md +++ b/docs/architecture/debugging.md @@ -204,6 +204,7 @@ fine. | `sysrq g` | `/proc` is mounted | | `/dev/dbg` | `/dev` is mounted | | `schdbg` | there is a shell | +| `schdbgd` over TCP | there is a network, and somebody enabled it | **Ctrl-G is the one that matters for boot problems**, because it is the only one that does not need a filesystem. It sits beside the older Ctrl-P, which @@ -226,6 +227,78 @@ called from `panic()`.** --- +## Over the network + +The fourth way in, and the only one that does not need you to be sitting at the +machine: a target that has locked its display, a board with no keyboard, or a +bug that only appears under load you cannot produce by hand. + +``` +# schdbgd & +schdbgd: debugger on port 4444, loopback only + +$ nc 127.0.0.1 4444 +SchOS debugger. 'help' for the views, 'quit' to go. +read-only: views that change state are refused here. + +dma +arena 0xffff80003da41000-0xffff80003ea41000 + 16384 KB total, 16319 KB free in 1 extent(s), 64 KB out +``` + +`schdbgd` is **a bridge and nothing else**: a line in becomes a line written to +`/dev/dbgro`, and the answer comes back. No framing, no state, no parsing of +anything the kernel said — which is what makes `nc` a complete client, and what +keeps this usable on the day the clever client is one of the broken things. + +It is in a process rather than in the kernel because there is no reason for it +to be in the kernel. A TCP listener inside a kernel debugger is a listener that +has to work when the kernel does not, and this one does not pretend to: if the +scheduler is wedged, `schdbgd` is not running either, and the answer is Ctrl-G. + +### What a listener had to be decided about + +A kernel debugger plus a TCP socket is a remote root hole. That is true of every +one ever written; it is not a reason not to build one, and it is a reason to +decide rather than discover. Three decisions, in increasing order of how much +they actually protect: + +**It does not start by itself.** `/etc/schinit/schdbgd.job` ships `Disabled`. +SchOS has no kernel command line, so the boot flag is the job manifest — the +same thing in this system's own idiom. A machine whose owner never asked for a +debugger is not running one. + +**It binds loopback**, and binding anywhere else takes `-a` naming the address, +and says so in a paragraph rather than a word. Reaching it then means `ssh -L`, +which is somebody's deliberate act. + +**It cannot reach a view that changes state, and that is enforced in the +kernel.** `/dev/dbgro` is `/dev/dbg` on minor 1. The minor is the one piece of +per-open state a character device already sees, so *which node you opened is the +permission*: there is nothing to latch, nothing to remember between the write +and the read, and no way to get from one descriptor to the other. + +Which views are refused comes from the registration — `dbg_register_rw` instead +of `dbg_register` — for exactly the reason the registry exists at all. A list of +dangerous views in one central file is a list that goes stale, and it goes stale +in the direction where a newly added view is quietly treated as safe. + +Today that is one view. `watch ` arms a debug register, which is a +reasonable thing for a debugger to do and not a reasonable thing for a stranger +on a socket to do: + +``` +$ nc 127.0.0.1 4444 +watch 0xffff80003da41000 +'watch' changes state, and this is /dev/dbgro. +use /dev/dbg from the console for that. +``` + +`watch` with no argument is still answered, because that is the listing and it +arms nothing. + +--- + ## Who owns this page The views each speak for one subsystem, which is what keeps them cheap and @@ -360,10 +433,16 @@ something that reports nonsense. `dbg_thread_start()` — which is the last thing `main()` does before entering the scheduler — nothing answers but the log, and the log is enough because it is read on the *next* boot. -- **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. +- **`schdbgd` has no authentication.** Reaching the port *is* the permission, + which is why the port is on loopback and why the daemon is off. Anyone who can + open a TCP connection to it can read this kernel's memory layout, its process + table and its open sockets. Putting a password on it would be worse than + useless — it would make the current arrangement feel safer than it is. +- **One client at a time.** `/dev/dbg` holds one answer at a time, so two + sessions would read each other's; a second caller waits. +- **A disconnected client is noticed only when written to.** A session that + goes away between commands holds the daemon until TCP notices. - **`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 @@ -379,5 +458,7 @@ something that reports nonsense. happened in the two hundred events before this" is not a question this can answer. -What remains — a network transport, kept deliberately outside the kernel — is -in [../plan/debugger.md](../plan/debugger.md). +Nothing remains on the plan this was built from. What is *not* on it, and is the +obvious next thing if this is ever picked up again, is tracing: static +tracepoints and an event ring. That is a different tool that happens to share a +transport. diff --git a/docs/plan/1.0.md b/docs/plan/1.0.md index 2394560..4cd07c7 100644 --- a/docs/plan/1.0.md +++ b/docs/plan/1.0.md @@ -95,9 +95,9 @@ 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`**~~ | **Closed.** All four mechanisms built; H1, H2 and H3 done. H4 — the USB eviction — is *withdrawn* rather than outstanding: USB is the bus peripherals arrive on, not a peripheral, and an eviction that has to leave an identical driver behind has moved nothing. See [hybrid.md](hybrid.md), "Decided: USB stays in the kernel". | -| ~~**`ldls.md`**~~ | **Closed.** L0 through L4 built: the loader handles 2,440 stock modules, `8139too` runs in a SchOS process driving QEMU's rtl8139 through `/dev/pci`, `ping` works over it, and killing the host releases the device and DMA so a replacement takes over. Described in [../architecture/ldls.md](../architecture/ldls.md). One driver, one kernel version, tried once. | +| ~~**`ldls.md`**~~ | **Closed.** L0 through L4 built: the loader handles 2,440 stock modules, and both `8139too` and Linux's `e1000` run in a SchOS process through `/dev/pci` with `ping` working over each. The e1000 run is a handover — the kernel was driving that NIC and gave it up. Described in [../architecture/ldls.md](../architecture/ldls.md). Two drivers, one kernel version, one machine. | | **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 through D4 built and described in [../architecture/debugging.md](../architecture/debugging.md). Only the network transport is open — a userspace daemon bridging TCP to `/dev/dbg`, which is deliberately last. | +| ~~**`debugger.md`**~~ | **Closed.** D0 through D5 built, including the network transport: `schdbgd` bridges TCP to `/dev/dbgro`, ships disabled, binds loopback, and cannot reach a view that changes state. Described in [../architecture/debugging.md](../architecture/debugging.md). | **The row LDLS spent, and gave back.** `hybrid.md` closed — H3 built, H4 withdrawn — and `ldls.md` took the slot it freed. It has since closed too, so @@ -107,13 +107,16 @@ part of 1.0**: nothing in the acceptance test needs a foreign driver. It was taken on because the mechanisms it uses were finished and idle, and because the alternative to it is writing a driver for every device that exists. -**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 +**The row the debugger spent, and gave back.** 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. +and it earned its slot by making every other row cheaper to close. + +It has closed. **Every plan in `docs/plan/` that was open is now closed**, which +means the next thing is Gate 0 itself: the survey, with the instrument built. **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 e7e8c65..f82105d 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -43,13 +43,8 @@ And the one the hybrid mechanisms were built for: | | | |---|---| -| ~~ldls.md~~ | **Built.** The Linux Driver Layer for SchOS: someone else's drivers, in a process, reached through `/dev/pci`. `ping` works over `8139too`. The description moved to [../architecture/ldls.md](../architecture/ldls.md). | - -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. **Built**, except the network transport: symbols, a log that outlives the kernel, `/dev/dbg`, the `whose` ownership query and hardware watchpoints are all described in [../architecture/debugging.md](../architecture/debugging.md). | +| ~~ldls.md~~ | **Built.** The Linux Driver Layer for SchOS: someone else's drivers, in a process, reached through `/dev/pci`. `ping` works over `8139too` and over `e1000`. The description moved to [../architecture/ldls.md](../architecture/ldls.md). | +| ~~debugger.md~~ | **Built**, all of it, including the network transport. A debugger that observes rather than halts and answers in this kernel's own vocabulary: symbols, a log that outlives the kernel, `/dev/dbg`, `whose`, hardware watchpoints, and `schdbgd`. The description moved to [../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 diff --git a/docs/plan/debugger.md b/docs/plan/debugger.md deleted file mode 100644 index 1f9b3b9..0000000 --- a/docs/plan/debugger.md +++ /dev/null @@ -1,243 +0,0 @@ -# A debugger for this kernel - -> **Every milestone on this plan is built.** Symbols, a log that outlives the -> kernel, the introspection registry behind `/dev/dbg`, the `whose` ownership -> query and hardware watchpoints are all described in -> [../architecture/debugging.md](../architecture/debugging.md). -> -> What is left here is **level 2, the network transport** — a userspace daemon -> bridging TCP to `/dev/dbg`, deliberately outside the kernel. Nothing else. - -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. | **done** | -| **D4** | **Watchpoints**, DR0–DR3, break-on-write, reporting rip and call chain and **continuing** rather than stopping. | **done** | -| **D5** | **The network transport**, level 2 above: a userspace daemon bridging TCP to `/dev/dbg`. Off unless a boot flag turns it on, loopback by default. | | - -### 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/etc/schinit/schdbgd.job b/etc/schinit/schdbgd.job new file mode 100644 index 0000000..1e4c6dd --- /dev/null +++ b/etc/schinit/schdbgd.job @@ -0,0 +1,30 @@ +# The debugger, over the network. Off. +# +# docs/architecture/debugging.md milestone D5, and the `Disabled` line below is the +# milestone's own security note being kept. SchOS has no kernel command line, so +# the boot flag the plan asked for is this manifest: a machine that has not been +# told to run this does not run it, and telling it is one line in one file that +# somebody edited on purpose. +# +# schinitd enable os.schos.schdbgd (or: set Disabled = no here) +# +# Off rather than loopback-only-and-on, even though loopback-only is already the +# default bind, because "a listener nobody asked for" is a different kind of +# thing from "a listener bound somewhere narrow". A debugger that can read the +# process table should not be running on a machine whose owner never wanted one. +# +# What it can reach is bounded in the kernel rather than here: schdbgd opens +# /dev/dbgro, which refuses any view that declared itself as changing state. +# See include/dbg.h. + +Label = os.schos.schdbgd +Program = /bin/schdbgd +Args = schdbgd +Disabled = yes +RunAtLoad = yes +KeepAlive = yes + +ThrottleInterval = 3 + +StandardOut = /dev/tty1 +StandardError = /dev/tty1 diff --git a/include/dbg.h b/include/dbg.h index bfcf767..f293df0 100644 --- a/include/dbg.h +++ b/include/dbg.h @@ -6,7 +6,7 @@ struct sbuf; // Asking the kernel a question. // -// docs/plan/debugger.md milestone D2. The thing this exists to end is the +// docs/architecture/debugging.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. @@ -37,6 +37,31 @@ typedef void (*dbg_view_fn)(struct sbuf *out, const char *arg); // and needs no initialisation of its own. void dbg_register(const char *name, dbg_view_fn fn, const char *what); +// The same, for a view that **changes something**. +// +// Nearly every view only reads, and the few that do not are worth separating +// because of where this can now be reached from. `watch 0xffff...` arms a debug +// register; that is a legitimate thing for a debugger to do and an illegitimate +// thing for a stranger on a TCP socket to do, and milestone D5 put a TCP socket +// in front of it. +// +// The flag lives on the registration and not in a list somewhere, for the same +// reason the registration exists at all: a central list of which views are +// dangerous is a list that goes stale, and it goes stale in the direction where +// a new view is quietly treated as safe. +void dbg_register_rw(const char *name, dbg_view_fn fn, const char *what); + +// Answer only the views that read. +// +// `/dev/dbgro` is the same device on minor 1. The minor is the one piece of +// per-open state a character device already sees, so *which node you opened* is +// the permission -- there is nothing to latch, nothing to remember between the +// write and the read, and no way to escalate from one descriptor to the other. +// +// `schdbgd` opens that one and never the other, so nothing reachable over the +// network can arm a watchpoint. See cmd/schdbgd. +int dbg_command_ro(const char *line, struct sbuf *out, int ro); + // --- whose ----------------------------------------------------------- // // The query the views cannot answer between them: *who owns this page?* @@ -67,7 +92,7 @@ void dbg_register_whose(const char *name, dbg_whose_fn fn); // 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 +// The protocol is lines of text -- see docs/architecture/debugging.md for why that and // not a typed binary format. Commands: // // help what this kernel can be asked diff --git a/include/defs.h b/include/defs.h index 3b99db9..c162e55 100644 --- a/include/defs.h +++ b/include/defs.h @@ -365,7 +365,7 @@ void ioapicinit(void); // dma.c — see include/dma.h for the full contract void dma_init(void*, void*); -// watch.c -- hardware watchpoints on DR0-DR3. See docs/plan/debugger.md, D4. +// watch.c -- hardware watchpoints on DR0-DR3. See docs/architecture/debugging.md, D4. struct trapframe; void watch_init(void); void watch_sync(void); @@ -393,7 +393,7 @@ 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. +// ksym.c -- naming a kernel address. See docs/architecture/debugging.md, D0. int ksym_ready(void); const char* ksym_lookup(uint64 addr, uint64 *off); int ksym_fmt(char *buf, int cap, uint64 addr); diff --git a/include/klog.h b/include/klog.h index 52f1c1b..a4c8eb4 100644 --- a/include/klog.h +++ b/include/klog.h @@ -4,7 +4,7 @@ // The kernel log that outlives the kernel. // -// docs/plan/debugger.md milestone D1. Until this existed, `cprintf` wrote to a +// docs/architecture/debugging.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 diff --git a/include/sbuf.h b/include/sbuf.h index fe6d2c9..19229eb 100644 --- a/include/sbuf.h +++ b/include/sbuf.h @@ -8,7 +8,7 @@ // 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. +// second set or to move this one. See docs/architecture/debugging.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 diff --git a/include/socket.h b/include/socket.h index 3de95f7..3b2e6e2 100644 --- a/include/socket.h +++ b/include/socket.h @@ -43,6 +43,7 @@ #define SOL_SOCKET 1 #define IPPROTO_ICMP 1 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 #define SO_REUSEADDR 2 #define SO_TYPE 3 diff --git a/kernel/arch/x86_64/trap.c b/kernel/arch/x86_64/trap.c index 0b2f91f..cfb26cb 100644 --- a/kernel/arch/x86_64/trap.c +++ b/kernel/arch/x86_64/trap.c @@ -130,7 +130,7 @@ trap(struct trapframe *tf) if(myproc() == 0 || (tf->cs&3) == 0){ // The rip is named where it can be. This is a kernel address, so // ksym_lookup() has something to say about it -- and the whole reason - // docs/plan/debugger.md puts D0 first is that "rip ffff8000001294c2" + // docs/architecture/debugging.md puts D0 first is that "rip ffff8000001294c2" // costs an objdump session and "rip in dma_alloc+0x8c" does not. char where[72]; ksym_fmt(where, sizeof(where), tf->rip); diff --git a/kernel/arch/x86_64/watch.c b/kernel/arch/x86_64/watch.c index 54fabd4..99b204a 100644 --- a/kernel/arch/x86_64/watch.c +++ b/kernel/arch/x86_64/watch.c @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// Hardware watchpoints. docs/plan/debugger.md milestone D4. +// Hardware watchpoints. docs/architecture/debugging.md milestone D4. // // The question this answers is the one no amount of printing gets at: // *something wrote to this address -- who?* Adding a print to every writer @@ -411,6 +411,6 @@ watch_view(struct sbuf *out, const char *arg) void watch_init(void) { - dbg_register("watch", watch_view, + dbg_register_rw("watch", watch_view, "hardware watchpoints -- `watch ` to arm one"); } diff --git a/kernel/core/ksym.c b/kernel/core/ksym.c index 0b97522..b6dcc90 100644 --- a/kernel/core/ksym.c +++ b/kernel/core/ksym.c @@ -2,7 +2,7 @@ // // Naming an address. // -// docs/plan/debugger.md milestone D0. It is the least clever thing on that +// docs/architecture/debugging.md milestone D0. It is the least clever thing on that // plan and the one everything else is worth more with: a call chain of six // hex numbers has to be resolved by hand against a disassembly that may not // be the build that was running, and the resolving is done at exactly the diff --git a/kernel/dev/cdev.c b/kernel/dev/cdev.c index d6fbbd4..9108ba8 100644 --- a/kernel/dev/cdev.c +++ b/kernel/dev/cdev.c @@ -119,7 +119,7 @@ struct devsw devsw[] = { // everything else through ioctl; see include/pciuser.h. [9] = { pciuser_read, pciuser_write }, // /dev/dbg (major 10) -- ask the kernel a question. Write a line, read the - // answer; see include/dbg.h and docs/plan/debugger.md. + // answer; see include/dbg.h and docs/architecture/debugging.md. [10] = { dbg_read, dbg_write }, // /dev/netdev (major 11) -- a process, being a network interface. The // reverse of major 9: that hands a device to a process, this lets a process diff --git a/kernel/dev/dbg.c b/kernel/dev/dbg.c index 95bda6c..8274b51 100644 --- a/kernel/dev/dbg.c +++ b/kernel/dev/dbg.c @@ -8,7 +8,7 @@ // // That shape is chosen so the debugger is usable with the tools already on the // machine, before `schdbg` is built and on a system where it will not start. -// docs/plan/debugger.md argues the case at length; the short version is that a +// docs/architecture/debugging.md argues the case at length; the short version is that a // protocol you cannot drive by hand is a protocol you cannot debug. // // One conversation at a time, deliberately. The answer buffer is global and @@ -37,6 +37,7 @@ struct view { const char *name; dbg_view_fn fn; const char *what; + int rw; // changes something; see dbg_register_rw }; static struct view views[NVIEW]; @@ -60,8 +61,8 @@ static int dbglen; static struct spinlock dbglock; static int dbginit; -void -dbg_register(const char *name, dbg_view_fn fn, const char *what) +static void +dbg_register_at(const char *name, dbg_view_fn fn, const char *what, int rw) { if (nview >= NVIEW || !name || !fn) return; @@ -74,9 +75,22 @@ dbg_register(const char *name, dbg_view_fn fn, const char *what) views[nview].name = name; views[nview].fn = fn; views[nview].what = what ? what : ""; + views[nview].rw = rw; nview++; } +void +dbg_register(const char *name, dbg_view_fn fn, const char *what) +{ + dbg_register_at(name, fn, what, 0); +} + +void +dbg_register_rw(const char *name, dbg_view_fn fn, const char *what) +{ + dbg_register_at(name, fn, what, 1); +} + void dbg_register_whose(const char *name, dbg_whose_fn fn) { @@ -105,6 +119,8 @@ do_help(struct sbuf *out) sb_puts(out, " "); sb_pad(out, views[i].name, 10); sb_puts(out, views[i].what); + if (views[i].rw) + sb_puts(out, " [changes state]"); sb_putc(out, '\n'); } sb_puts(out, "\n whose every subsystem's claim on one page\n"); @@ -257,6 +273,18 @@ do_all(struct sbuf *out) int dbg_command(const char *line, struct sbuf *out) +{ + return dbg_command_ro(line, out, 0); +} + +// `ro` refuses any view that declared itself as changing state. +// +// The refusal is driven by the registration rather than by a list here, which +// is the whole reason dbg_register_rw() exists: a list of dangerous views in +// this file would go stale in the direction where a new one is treated as +// safe. +int +dbg_command_ro(const char *line, struct sbuf *out, int ro) { char word[32], arg[64]; int n = 0; @@ -291,6 +319,16 @@ dbg_command(const char *line, struct sbuf *out) for (int i = 0; i < nview; i++) { if (streq(word, views[i].name)) { + // A view that changes state, asked for through /dev/dbgro. Reading it + // with no argument is still allowed, because that is the listing -- + // `watch` on its own prints what is armed and arms nothing. + if (ro && views[i].rw && arg[0] != 0) { + sb_puts(out, "'"); + sb_puts(out, word); + sb_puts(out, "' changes state, and this is /dev/dbgro.\n" + "use /dev/dbg from the console for that.\n"); + return out->len; + } views[i].fn(out, arg); return out->len; } @@ -309,13 +347,13 @@ dbg_command(const char *line, struct sbuf *out) // with no explanation reads as a kernel that is broken rather than as a buffer // that is full. The last 64 bytes are held back to have somewhere to say it. static int -fill(const char *cmd) +fill(const char *cmd, int ro) { struct sbuf sb; sb.p = dbgbuf; sb.len = 0; sb.cap = DBGBUF - 64; if (cmd) - dbg_command(cmd, &sb); + dbg_command_ro(cmd, &sb, ro); else do_all(&sb); @@ -332,7 +370,7 @@ fill(const char *cmd) // // Ctrl-G runs the debugger with no filesystem, no shell and no client program, // which is what makes it available *during boot* rather than once userspace is -// up. docs/plan/debugger.md calls this level 0, and it is the level that +// up. docs/architecture/debugging.md calls this level 0, and it is the level that // matters when the thing you are debugging is the boot. // // It cannot do the work in the interrupt handler, though, and the reason is @@ -384,7 +422,7 @@ dbg_dump_all(void) if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; } acquire(&dbglock); - dbglen = fill(0); + dbglen = fill(0, 0); // Printed inside the lock so a second sysrq cannot interleave with this one. // cprintf takes cons.lock, which is never held by anything that wants // dbglock, so the order is fixed and there is no cycle. @@ -408,7 +446,7 @@ dbg_write(struct inode *ip, uint64 off, char *src, int n) char line[128]; int i; - (void)ip; (void)off; + (void)off; if (n <= 0) return 0; @@ -421,7 +459,7 @@ dbg_write(struct inode *ip, uint64 off, char *src, int n) if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; } acquire(&dbglock); - dbglen = fill(line); + dbglen = fill(line, ip && ip->minor == 1); release(&dbglock); return n; // the whole line was consumed either way diff --git a/kernel/dev/procfs.c b/kernel/dev/procfs.c index dc7e318..1b8ba1f 100644 --- a/kernel/dev/procfs.c +++ b/kernel/dev/procfs.c @@ -42,7 +42,7 @@ // The formatting helpers these files are built out of now live in // include/sbuf.h, because the debugger's introspection registry needs exactly -// the same thing and two copies would have drifted. See docs/plan/debugger.md. +// the same thing and two copies would have drifted. See docs/architecture/debugging.md. // --- the files ------------------------------------------------------------- @@ -451,7 +451,7 @@ proc_sysrq_cmd(char cmd) case 'g': // Every registered view, straight to the console. // - // This is docs/plan/debugger.md's level 0: no process, no filesystem, no + // This is docs/architecture/debugging.md's level 0: no process, no filesystem, no // client program. If /dev/dbg cannot be opened -- because the filesystem // is what is broken -- this still works, and on a machine with only a // serial line it is the whole debugger. diff --git a/kernel/fs/devfs.c b/kernel/fs/devfs.c index dfc2267..dca42e6 100644 --- a/kernel/fs/devfs.c +++ b/kernel/fs/devfs.c @@ -89,6 +89,12 @@ static const struct devnode devnodes[] = { { "fb0", 2, 0, D_ROOT }, { "pci", 9, 0, D_ROOT }, { "dbg", 10, 0, D_ROOT }, + // The same device, answering only the views that read. Minor 1 rather than + // a flag, because the minor is the one piece of per-open state a character + // device already sees -- so which one you opened *is* the permission, and + // nothing has to be remembered between the write and the read. See + // include/dbg.h and cmd/schdbgd. + { "dbgro", 10, 1, D_ROOT }, { "netdev", 11, 0, D_ROOT }, { "mouse", 3, 1, D_ROOT }, diff --git a/kernel/proc/proc.c b/kernel/proc/proc.c index e24d4a0..8b8e509 100644 --- a/kernel/proc/proc.c +++ b/kernel/proc/proc.c @@ -47,7 +47,7 @@ kstack_check_canary(const char *where) // --- what the debugger can be told about processes -------------------------- // -// docs/plan/debugger.md, D2. These are here rather than in kernel/dev/dbg.c +// docs/architecture/debugging.md, D2. These are here rather than in kernel/dev/dbg.c // because this file owns ptable, and a view written anywhere else would need // the lock and the struct exported to get at it. One line at the bottom of the // file that owns the data is the whole discipline. diff --git a/kernel/sys/sys_net.c b/kernel/sys/sys_net.c index 0138655..5b0772b 100644 --- a/kernel/sys/sys_net.c +++ b/kernel/sys/sys_net.c @@ -156,9 +156,19 @@ SYSCALL_DEFINE(socket) // letting an unprivileged process do the one thing raw sockets were used // for. Anything else is still refused rather than quietly given a socket // that speaks a different protocol than the caller asked for. + // + // IPPROTO_TCP with SOCK_STREAM and IPPROTO_UDP with SOCK_DGRAM name the + // default rather than departing from it, and programs do write them out: + // toybox's `nc` passes IPPROTO_TCP, and refusing it meant "Protocol not + // supported" for TCP on a system whose whole network is TCP. if (protocol != 0) { - if (!(domain == AF_INET && type == SOCK_DGRAM && - protocol == IPPROTO_ICMP)) + int redundant = + (domain == AF_INET && type == SOCK_STREAM && protocol == IPPROTO_TCP) || + (domain == AF_INET && type == SOCK_DGRAM && protocol == IPPROTO_UDP); + if (redundant) + protocol = 0; + else if (!(domain == AF_INET && type == SOCK_DGRAM && + protocol == IPPROTO_ICMP)) return -EPROTONOSUPPORT; } diff --git a/tools/ksyms.pl b/tools/ksyms.pl index c68978f..bb56b55 100644 --- a/tools/ksyms.pl +++ b/tools/ksyms.pl @@ -3,7 +3,7 @@ # # Turn `nm -n kernel` into a symbol table the kernel can carry with it. # -# docs/plan/debugger.md milestone D0. Everything the debugger prints is an +# docs/architecture/debugging.md milestone D0. Everything the debugger prints is an # address until this exists, and an address has to be resolved by hand against # a disassembly that may not be the one that was running. # @@ -104,7 +104,7 @@ my $n = scalar @sym; print <<"HDR"; // Generated by tools/ksyms.pl from the first link pass. Do not edit. // -// See docs/plan/debugger.md, milestone D0, and kernel/core/ksym.c for the +// See docs/architecture/debugging.md, milestone D0, and kernel/core/ksym.c for the // lookup that reads this. Everything lands in .ksym, which kernel/kernel.ld // places after .text and .rodata so that carrying the table cannot move the // addresses recorded in it.