SchOS/kernel/dev/dbg.c
Nexoniarz bc39b73969 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
2026-09-14 15:33:53 +02:00

497 lines
14 KiB
C

// SPDX-License-Identifier: Apache-2.0
//
// /dev/dbg -- asking the kernel a question. See include/dbg.h.
//
// The device is a conversation: write a line, read the answer.
//
// # echo dma > /dev/dbg && cat /dev/dbg
//
// 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/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
// the lock is held only while it is being filled, so two programs asking at
// once get one answer each, in some order, and neither gets half of the
// other's. Making it per-open would mean a struct file hook for something that
// has exactly one user.
#include "types.h"
#include "defs.h"
#include "param.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "file.h"
#include "errno.h"
#include "sbuf.h"
#include "memlayout.h"
#include "mmu.h"
#include "dbg.h"
#define NVIEW 24
#define DBGBUF (16 * 1024)
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];
static int nview;
struct claimant {
const char *name;
dbg_whose_fn fn;
};
static struct claimant claimants[NVIEW];
static int nclaim;
static int streq(const char *a, const char *b);
// Static, and 16 KB of it. A view of every process on a 64-slot table is the
// largest thing here and comes to a few kilobytes; the alternative -- kalloc
// on every read -- would put an allocation on the path of the tool you reach
// for when the allocator is what you are investigating.
static char dbgbuf[DBGBUF];
static int dbglen;
static struct spinlock dbglock;
static int dbginit;
static void
dbg_register_at(const char *name, dbg_view_fn fn, const char *what, int rw)
{
if (nview >= NVIEW || !name || !fn)
return;
// Registering the same name twice is a bug in the caller -- it means an init
// that runs once per CPU, which is how `watch` first appeared in the list
// twice. Refusing keeps the list honest whoever makes that mistake next.
for (int i = 0; i < nview; i++)
if (streq(views[i].name, name))
return;
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)
{
if (nclaim >= NVIEW || !name || !fn)
return;
for (int i = 0; i < nclaim; i++)
if (streq(claimants[i].name, name))
return;
claimants[nclaim].name = name;
claimants[nclaim].fn = fn;
nclaim++;
}
static int
streq(const char *a, const char *b)
{
while (*a && *b && *a == *b) { a++; b++; }
return *a == 0 && *b == 0;
}
static void
do_help(struct sbuf *out)
{
sb_puts(out, "views this kernel publishes:\n");
for (int i = 0; i < nview; i++) {
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");
sb_puts(out, " all every view above\n");
sb_puts(out, " help this\n");
}
// "0x3eceb000", or "3eceb000", or a decimal. Returns 0 if there was no number.
static int
parse_addr(const char *s, uint64 *out)
{
uint64 v = 0;
int any = 0, hex = 0;
while (*s == ' ')
s++;
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { hex = 1; s += 2; }
// A bare string of hex digits is taken as hex: every address in this
// kernel's own output is printed without the 0x, and retyping one and
// getting a decimal reading of it would be a trap.
if (!hex) {
const char *p = s;
while (*p) {
if ((*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F')) { hex = 1; break; }
if (!(*p >= '0' && *p <= '9')) break;
p++;
}
if (!hex) {
// All decimal digits, but an address that long is hex in practice.
int n = 0;
const char *q = s;
while (*q >= '0' && *q <= '9') { n++; q++; }
if (n > 10) hex = 1;
}
}
while (*s) {
int d;
if (*s >= '0' && *s <= '9') d = *s - '0';
else if (*s >= 'a' && *s <= 'f') d = *s - 'a' + 10;
else if (*s >= 'A' && *s <= 'F') d = *s - 'A' + 10;
else break;
if (!hex && d > 9)
break;
v = hex ? (v << 4) + d : v * 10 + d;
any = 1;
s++;
}
*out = v;
return any;
}
static void
do_whose(struct sbuf *out, const char *arg)
{
struct whose w;
uint64 addr, kva;
if (!parse_addr(arg, &addr)) {
sb_puts(out, "whose <addr> -- a physical address, or a kernel virtual one\n");
return;
}
// Which kind of address is it? Anything at or above KERNBASE is a kernel
// virtual address and is translated; anything below is taken as physical.
//
// A *user* virtual address cannot be resolved here and is not guessed at:
// it means nothing without saying which address space, and answering for
// whichever one happens to be current would be wrong in a way that looks
// right. `vma <pid>` is the view for that question.
if (addr >= (uint64)KERNBASE) {
kva = addr;
addr = addr - (uint64)KERNBASE;
} else {
kva = addr + (uint64)KERNBASE;
}
w.phys = addr & ~(uint64)(PGSIZE - 1);
w.off = addr & (PGSIZE - 1);
w.owners = 0;
w.free_owners = 0;
w.mappings = 0;
sb_puts(out, "phys 0x");
sb_puthex(out, w.phys, 16);
if (w.off) {
sb_puts(out, " + 0x");
sb_puthex(out, w.off, 3);
}
sb_puts(out, " kernel va 0x");
sb_puthex(out, (kva & ~(uint64)(PGSIZE - 1)), 16);
sb_putc(out, '\n');
if (w.phys >= (uint64)PHYSTOP)
sb_puts(out, " (above PHYSTOP -- this is not RAM this kernel manages)\n");
for (int i = 0; i < nclaim; i++)
claimants[i].fn(out, &w);
// The verdict. Two shapes of wrong are worth naming, because both are
// silent everywhere else and both corrupt memory some distance away.
sb_putc(out, '\n');
if (w.owners == 0 && w.mappings == 0) {
sb_puts(out, " nobody claims this page\n");
} else if (w.owners > 1) {
sb_puts(out, " *** ");
sb_putu(out, w.owners);
sb_puts(out, " owners -- this page is claimed more than once, and whichever\n"
" allocator hands it out second will be handing out memory\n"
" the first one is still using\n");
} else if (w.free_owners > 0 && w.mappings > 0) {
sb_puts(out, " *** its allocator believes this page is free, and ");
sb_putu(out, w.mappings);
sb_puts(out, " page table(s)\n"
" still map it -- it will be handed out from under them\n");
} else if (w.owners == 0) {
// Not a finding. kalloc keeps a free list and no record of what it has
// handed out, so a page in use by the kernel and a page genuinely lost
// look the same from here -- and saying "nobody owns this" about an
// ordinary allocated page would be the query crying wolf.
sb_puts(out, " no allocator has a record of this page, which is what an\n"
" allocated one looks like: kalloc tracks only what is free.\n");
if (w.mappings) {
sb_puts(out, " ");
sb_putu(out, w.mappings);
sb_puts(out, " page table(s) map it, so it is in use.\n");
}
} else {
sb_puts(out, " consistent: ");
sb_putu(out, w.owners);
sb_puts(out, " owner, ");
sb_putu(out, w.mappings);
sb_puts(out, " mapping(s)\n");
}
}
static void
do_all(struct sbuf *out)
{
for (int i = 0; i < nview; i++) {
sb_puts(out, "=== ");
sb_puts(out, views[i].name);
sb_puts(out, " ===\n");
views[i].fn(out, "");
sb_putc(out, '\n');
}
}
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;
while (*line == ' ' || *line == '\t')
line++;
while (*line && *line != '\n' && *line != ' ' && n < (int)sizeof(word) - 1)
word[n++] = *line++;
word[n] = 0;
while (*line == ' ' || *line == '\t')
line++;
n = 0;
while (*line && *line != '\n' && n < (int)sizeof(arg) - 1)
arg[n++] = *line++;
while (n > 0 && (arg[n-1] == ' ' || arg[n-1] == '\r'))
n--;
arg[n] = 0;
if (word[0] == 0 || streq(word, "help")) {
do_help(out);
return out->len;
}
if (streq(word, "all")) {
do_all(out);
return out->len;
}
if (streq(word, "whose")) {
do_whose(out, arg);
return out->len;
}
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;
}
}
sb_puts(out, "no view called '");
sb_puts(out, word);
sb_puts(out, "'. try: help\n");
return out->len;
}
// Fill dbgbuf, and say so if the answer did not fit.
//
// sb_* silently drops what will not fit, which is the right behaviour for a
// builder that must not fail -- but a table that stops in the middle of a row
// 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, int ro)
{
struct sbuf sb;
sb.p = dbgbuf; sb.len = 0; sb.cap = DBGBUF - 64;
if (cmd)
dbg_command_ro(cmd, &sb, ro);
else
do_all(&sb);
if (sb.len >= sb.cap - 1) {
sb.cap = DBGBUF;
sb_puts(&sb, "\n-- truncated: the answer is larger than the buffer. "
"ask for one view at a time.\n");
}
dbgbuf[sb.len] = 0;
return sb.len;
}
// --- the console key --------------------------------------------------------
//
// 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/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
// worth stating because the neighbouring Ctrl-P does exactly that. procdump()
// reads the process table with no lock held, deliberately: a lock is a way to
// hang while reporting a hang. The views cannot follow that rule -- they read
// free lists and socket tables that are meaningless torn -- so they take locks,
// and taking a lock from an interrupt handler deadlocks against whatever was
// interrupted while holding it.
//
// So the key *requests* and a kernel thread *performs*. The cost is that a
// scheduler too wedged to run a thread will not answer; Ctrl-P still does, and
// that division is the honest one.
static int dbg_wanted;
void
dbg_request_dump(void)
{
dbg_wanted = 1;
wakeup(&dbg_wanted);
}
static void
dbg_thread(void)
{
for (;;) {
acquire(&dbglock);
while (!dbg_wanted)
sleep(&dbg_wanted, &dbglock);
dbg_wanted = 0;
release(&dbglock);
cprintf("\n--- debug views, requested from the console ---\n");
dbg_dump_all();
}
}
void
dbg_thread_start(void)
{
if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; }
if (kthread_create(dbg_thread, "dbgd") < 0)
cprintf("dbg: no kernel thread; Ctrl-G will not work\n");
}
void
dbg_dump_all(void)
{
if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; }
acquire(&dbglock);
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.
cprintf("%s", dbgbuf);
release(&dbglock);
}
// --- the device -------------------------------------------------------------
// A command. The answer replaces whatever was there.
//
// **The answer is read from offset zero.** That matters because writing to a
// descriptor advances its offset, so a program that writes and then reads on
// the same descriptor starts reading as many bytes into the answer as its
// command was long -- which is exactly the bug this comment exists to stop
// being written again. `cat` is unaffected because it opens the file fresh;
// cmd/schdbg seeks back to zero. See lseek() there.
int
dbg_write(struct inode *ip, uint64 off, char *src, int n)
{
char line[128];
int i;
(void)off;
if (n <= 0)
return 0;
if (n > (int)sizeof(line) - 1)
n = (int)sizeof(line) - 1;
for (i = 0; i < n; i++)
line[i] = src[i];
line[n] = 0;
if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; }
acquire(&dbglock);
dbglen = fill(line, ip && ip->minor == 1);
release(&dbglock);
return n; // the whole line was consumed either way
}
int
dbg_read(struct inode *ip, uint64 off, char *dst, int n)
{
int i, avail;
(void)ip;
if (!dbginit) { initlock(&dbglock, "dbg"); dbginit = 1; }
acquire(&dbglock);
if (dbglen == 0) {
// Nothing asked yet. Answering with the help text rather than with EOF,
// because `cat /dev/dbg` on its own is what someone does first and an
// empty file teaches them nothing.
struct sbuf sb = { dbgbuf, 0, DBGBUF };
do_help(&sb);
dbglen = sb.len;
}
if (off >= (uint64)dbglen) {
release(&dbglock);
return 0; // EOF
}
avail = dbglen - (int)off;
if (n > avail)
n = avail;
for (i = 0; i < n; i++)
dst[i] = dbgbuf[off + i];
release(&dbglock);
return n;
}