debugger: who owns this page, and who just wrote to it

docs/plan/debugger.md D3 and D4, which finish the plan apart from the
network transport.

D3 -- `whose <addr>`. The views each speak for one subsystem, which is what
keeps them cheap and current, and it is exactly why none of them can see
the bug that matters: a page on the DMA arena's free list looks fine from
dma.c, the same page on kalloc's free list looks fine from kalloc.c, and
that it is on both is a fact no single subsystem holds.

    # schdbg whose 0x3e239000
    phys 0x000000003e239000   kernel va 0xffff80003e239000
      dma     arena, handed out
      pcidma  slot 2, 0 KB into a 4000 KB block given to pid 4 (vgpud)
      mapped  pid 4 (vgpud) at 0x000000004000d000  rwu  vma[5] no_free

      consistent: 1 owner, 1 mapping(s)

Claimants register the way views do -- one line in the file that owns the
data -- so the kernel image, the log region, the arena, the page allocator,
the pciuser DMA blocks and every page table each answer for themselves.
`mapped` is the one no allocator can answer: there is no reverse map, so
vm_walk_user() goes forward over every mapped page of every live process.

Two verdicts are shouted and everything else is reported as fact. More than
one owner means the second allocator to hand the page out is handing out
memory the first is still using -- the H2 bug, which would have printed as
three lines. Free-but-still-mapped means a page will be given away from
under the page tables that point at it. Deliberately *not* a finding: no
owner at all, because kalloc keeps a free list and no record of what it has
handed out, so an ordinary allocated page has nobody to speak for it and
saying otherwise would be the query crying wolf.

D4 -- watchpoints, on the four debug registers that had been sitting unused
since this kernel was written. Armed against the same framebuffer page and
then killing the driver:

    watch 0: 0xffff80003e239000 written, 8 byte(s), by stosq+0x1f, pid 12 vgpud
             #0  memset+0xba
             #1  dma_alloc+0x2e2
             #2  do_dma+0x22a
             #3  pciuser_ioctl+0x13b
             #4  sys_ioctl+0x3c7
             #5  syscall+0xf6

The chain is the answer, not the rip. It reports and continues -- a data
watchpoint is a trap, delivered after the store completes, so returning
carries on with the program none the wiser, which is what lets this be used
on a machine that is still running.

Debug registers are per-CPU and there is no general IPI here, so the table
carries a generation number and each CPU reloads from its timer interrupt
when it notices the number has moved: one comparison per tick, one tick of
latency at worst. `watch` prints which CPUs have caught up, because "this
watchpoint is watching" and "this watchpoint is watching one CPU" must not
look the same.

Also fixed while testing: watch_init() was called from mpmain(), which runs
on every processor, so the command appeared in the list once per CPU.
dbg_register() now refuses a duplicate name outright, so the next init that
runs per-CPU fails loudly in the list rather than quietly doubling it.

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 01:17:57 +02:00
commit 113595f80b
17 changed files with 1067 additions and 19 deletions

View file

@ -13,7 +13,10 @@ 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.
Five things close that, and none of them is a debugger in the stepping sense:
names for addresses, a log that survives, a way to ask each subsystem what it
knows, a way to ask all of them about one page, and the processor's own
watchpoints.
---
@ -179,6 +182,7 @@ years it holds three self-tests and no state at all.
| `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 |
| `watch` | the hardware watchpoints, and which CPUs have loaded them |
`all` prints every view; `help` lists them; a view may take an argument, so
`vma 4` narrows to one process.
@ -200,21 +204,144 @@ fine.
| `/dev/dbg` | there is a scheduler and a filesystem |
| `schdbg` | there is a shell |
A watchpoint's *hits* go to the console rather than to any of these, because
they happen while you are not looking, which is the point of having them.
Views run in ordinary process context and may take locks. **They must not be
called from `panic()`.**
---
## Who owns this page
The views each speak for one subsystem, which is what keeps them cheap and
current — and it is exactly why none of them can see the bug that matters. A
page on the DMA arena's free list looks fine from `dma.c`. The same page on
`kalloc`'s free list looks fine from `kalloc.c`. That it is on *both* is a fact
no single subsystem holds, and it is the bug that cost this project a day.
`whose` asks every subsystem about one page and counts the answers:
```
# schdbg whose 0x3e239000
phys 0x000000003e239000 kernel va 0xffff80003e239000
dma arena, handed out
pcidma slot 2, 0 KB into a 4000 KB block given to pid 4 (vgpud)
mapped pid 4 (vgpud) at 0x000000004000d000 rwu vma[5] no_free
consistent: 1 owner, 1 mapping(s)
```
That is the complete life of one page in three lines: the arena gave it out,
`/dev/pci` handed it to a driver, and the driver has it mapped under a VMA
marked `no_free` so `exit()` will not free it twice.
The claimants are registered exactly as views are, for the same reason — adding
one is a line in the file that owns the data:
| | |
|---|---|
| `kernel` | inside the kernel image, below `end` |
| `klog` | inside the log region |
| `dma` | the arena owns it, and whether the arena thinks it is free |
| `mem` | on `kalloc`'s free list, and where |
| `pcidma` | inside a block handed to a userspace driver, and which |
| `mapped` | every page table that maps it, at what address, under which VMA |
`mapped` is the one no allocator can answer. There is no reverse map in this
kernel, so it walks every mapped page of every live process — a few thousand
iterations, which is nothing for a command somebody typed and would be
unacceptable anywhere else.
### The two verdicts that matter
**More than one owner** means whichever allocator hands the page out second
will be handing out memory the first one is still using. That is the H2 bug,
and it would have printed as three claimant lines instead of taking a day.
**Free, but still mapped** means an allocator believes a page is spare while
page tables still point at it — it will be given away from under them.
Anything else is reported as fact, not verdict. In particular *no owner* is
normal: `kalloc` keeps a free list and no record of what it has handed out, so
an ordinary allocated page has nobody to speak for it, and saying "nobody owns
this" about one would be the query crying wolf.
An address at or above `KERNBASE` is taken as a kernel virtual address and
translated; anything below is taken as physical. A *user* virtual address is
refused rather than guessed at — it means nothing without saying which address
space, and answering for whichever one happened to be current would be wrong in
a way that looks right.
---
## Watching an address
The question no amount of printing gets at: *something wrote here — who?*
Adding a print to every writer only works when you already know who the writers
are, and the bug is invariably a writer you have not thought of.
The processor will simply tell you. Four debug registers had been sitting unused
in this kernel since it was written.
```
# schdbg watch 0xffff80003e239000 8 w
watch 0 armed on 0xffff80003e239000, 8 byte(s), on write
watch 0: 0xffff80003e239000 written, 8 byte(s), by stosq+0x1f on cpu 0, pid 12 vgpud
#0 memset+0xba
#1 dma_alloc+0x2e2
#2 do_dma+0x22a
#3 pciuser_ioctl+0x13b
#4 sys_ioctl+0x3c7
#5 syscall+0xf6
```
The chain is the answer, not the rip: "`memset` wrote to it" is not useful;
"`memset`, from `dma_alloc`, from `do_dma`" is.
**It reports and continues.** A data watchpoint on x86 is a *trap*, not a fault
— it is delivered after the storing instruction has completed — so returning
from the handler carries on with the program none the wiser. That is what makes
it usable on a machine that is still running, which is the whole stance of this
document. The cost is that you cannot prevent the write, only learn about it.
**Every CPU watches, and there is no IPI.** Debug registers are per-CPU, so
arming DR0 on the processor that ran the command would watch one quarter of a
four-way machine and silently miss the rest. The table carries a generation
number instead, and each CPU reloads from its timer interrupt when it notices
the number has moved: one comparison per tick, and at worst one tick of latency.
`watch` reports which CPUs have caught up, because "this watchpoint is watching"
and "this watchpoint is watching one CPU" must not look the same:
```
table generation 2, loaded by: cpu0=2 cpu1=2
```
Addresses must be naturally aligned for their length — the processor quietly
ignores the low bits otherwise and watches a span you did not ask for, so it is
refused instead. A watchpoint below `KERNBASE` is a *user* linear address and
will fire in whatever process is current; `watch` says so rather than arming
something that reports nonsense.
---
## 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.
- **`whose` cannot take a user virtual address**, by refusal rather than
oversight. `vma <pid>` is the view for that question.
- **`whose` is a snapshot with no lock held across it.** Each claimant takes its
own subsystem's lock and releases it, so a page that changes hands while the
query runs can be reported inconsistently. Taking every allocator's lock at
once to fix that would deadlock against the allocators themselves.
- **Four watchpoints, and no more.** That is the hardware. There is no software
watchpoint fallback and there should not be one.
- **Watchpoints cannot stop a write**, only report it — see above. And a
watchpoint on something `cprintf` itself writes is silently dropped rather
than looping; the handler guards against re-entering itself per CPU.
- **A watchpoint takes up to one timer tick** to reach the other CPUs.
- **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.
@ -234,4 +361,5 @@ called from `panic()`.**
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).
What remains — a network transport, kept deliberately outside the kernel — is
in [../plan/debugger.md](../plan/debugger.md).