debugger: D5, the network transport -- and the plan closes

schdbgd bridges TCP to the debugger, which is the one way in that does not
need you to be sitting at the machine. It is a bridge and nothing else: a line
in becomes a line written to /dev/dbgro and the answer comes back, so `nc` is a
complete client and there is no clever client to be broken on the day you need
this.

    # schdbgd &
    schdbgd: debugger on port 4444, loopback only
    $ nc 127.0.0.1 4444
    dma
    arena  0xffff80003da41000-0xffff80003ea41000  16384 KB total, ...

A kernel debugger plus a TCP socket is a remote root hole, so three things were
decided rather than discovered:

  - It does not start by itself. 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.
  - It binds loopback; -a names an address and says so in a paragraph.
  - It cannot reach a view that changes state, enforced in the kernel.

That last one is /dev/dbgro: /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 -- nothing to latch, nothing to remember between the write and the
read, no way across. Which views are refused comes from dbg_register_rw() on
the registration itself, because a central list of dangerous views goes stale
in the direction where a new view is quietly treated as safe. Today that is
`watch <addr>`; `watch` with no argument is still the listing and still
answered.

Also: socket() now accepts IPPROTO_TCP with SOCK_STREAM and IPPROTO_UDP with
SOCK_DGRAM. They name the default rather than departing from it, and toybox's
nc writes them out -- so TCP was "Protocol not supported" on a system whose
whole network is TCP.

docs/plan/debugger.md retires into docs/architecture/debugging.md; 16 files
repointed. Every plan in docs/plan/ that was open is now closed, so the next
thing is Gate 0 itself: the survey, with the instrument built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 15:33:53 +02:00
commit bc39b73969
23 changed files with 491 additions and 293 deletions

View file

@ -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);

View file

@ -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 <addr>` to arm one");
}

View file

@ -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

View file

@ -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

View file

@ -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 <addr> 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

View file

@ -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.

View file

@ -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 },

View file

@ -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.

View file

@ -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;
}