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

@ -90,8 +90,24 @@ trap(struct trapframe *tf)
wakeup(&ticks);
release(&tickslock);
}
// Debug registers are per-CPU, and there is no general IPI here, so this
// is how a watchpoint armed on one processor reaches the others: each
// notices the table's generation has moved and reloads. One comparison
// per tick when nothing has changed. See kernel/arch/x86_64/watch.c.
watch_sync();
lapiceoi();
break;
case T_DEBUG:
// A hardware watchpoint fired. It is a *trap*, not a fault -- the store
// has already happened -- so reporting and returning carries on with the
// program none the wiser, which is what lets this be used on a machine
// that is still running.
if(watch_trap(tf))
break;
// Not ours: a single-step, or a #DB from somewhere else. Falls through to
// the unexpected-trap path rather than being swallowed.
goto unexpected;
case T_IRQ0 + IRQ_SPURIOUS:
cprintf("cpu%d: spurious interrupt at %p:%p\n",
cpunum(), tf->cs, tf->rip);
@ -110,6 +126,7 @@ trap(struct trapframe *tf)
break;
}
unexpected:
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

View file

@ -547,6 +547,56 @@ deallocuvm(pml4e_t *pgdir, uint64 oldsz, uint64 newsz)
return newsz;
}
// Walk every page a process has mapped in the low half, calling back for each.
//
// Written for `whose` (docs/architecture/debugging.md): answering "which
// process has this physical page mapped" means a reverse lookup, and there is
// no reverse map in this kernel -- so the only way is forward, over every
// entry. That is O(pages mapped), a few thousand iterations for a real
// process, which is nothing for a command somebody typed and would be
// unacceptable anywhere else.
//
// The low half only. The kernel half is the direct map and is identical in
// every address space, so reporting it per process would be noise: every
// physical page is mapped there, always, and saying so once is the useful
// form of that fact.
void
vm_walk_user(pml4e_t *pml4, void (*fn)(void *ctx, uint64 va, uint64 pa, uint64 pte),
void *ctx)
{
uint i, j, k, l;
pdpe_t *pdp;
pde_t *pd;
pte_t *pt;
if (pml4 == 0)
return;
for (i = 0; i < (512/2); i++) {
if (!(pml4[i] & PTE_P))
continue;
pdp = (pdpe_t*)P2V(PTE_ADDR(pml4[i]));
for (j = 0; j < 512; j++) {
if (!(pdp[j] & PTE_P))
continue;
pd = (pde_t*)P2V(PTE_ADDR(pdp[j]));
for (k = 0; k < 512; k++) {
if (!(pd[k] & PTE_P))
continue;
pt = (pte_t*)P2V(PTE_ADDR(pd[k]));
for (l = 0; l < 512; l++) {
if (!(pt[l] & PTE_P))
continue;
fn(ctx,
((uint64)i << 39) | ((uint64)j << 30) | ((uint64)k << 21) |
((uint64)l << 12),
PTE_ADDR(pt[l]), pt[l]);
}
}
}
}
}
void
freevm(pml4e_t *pml4)
{

416
kernel/arch/x86_64/watch.c Normal file
View file

@ -0,0 +1,416 @@
// SPDX-License-Identifier: Apache-2.0
//
// Hardware watchpoints. docs/plan/debugger.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
// 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, through four debug registers that have been sitting unused in this
// kernel since it was written.
//
// Two decisions worth reading:
//
// **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 write already done. That is
// what makes this usable on a running system, which docs/architecture/
// debugging.md argues is the whole point: the bugs here are timing bugs, and a
// debugger that stops the world cannot see them. The cost is that you cannot
// prevent the write, only learn about it.
//
// **Every CPU watches, without an 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. There is no general IPI in
// this kernel, so instead the table carries a generation number and each CPU
// reloads its registers from the timer interrupt when it notices the number
// has moved. Worst case a watchpoint takes one tick to cover a CPU, and the
// cost when nothing has changed is one comparison per tick.
#include "types.h"
#include "defs.h"
#include "param.h"
#include "memlayout.h"
#include "mmu.h"
#include "x86.h"
#include "proc.h"
#include "traps.h"
#include "sbuf.h"
#include "dbg.h"
#define NWATCH 4 // DR0..DR3, and there are no more
// DR7. The Intel manual's names, because nothing is gained by inventing new
// ones for a register that has had these since 1985.
#define DR7_L(n) (1u << ((n) * 2)) // local enable
#define DR7_G(n) (1u << ((n) * 2 + 1)) // global enable
#define DR7_LE (1u << 8)
#define DR7_GE (1u << 9)
#define DR7_RW_SHIFT(n) (16 + (n) * 4)
#define DR7_LEN_SHIFT(n) (18 + (n) * 4)
#define RW_WRITE 0x1 // break on data write
#define RW_RW 0x3 // break on data read or write
struct watchpoint {
uint64 addr;
int len; // 1, 2, 4 or 8
int rw; // RW_WRITE or RW_RW
int used;
uint64 hits;
};
static struct watchpoint wp[NWATCH];
static volatile uint wp_generation = 1;
static uint wp_seen[NCPU];
// Recursion guard. The handler prints, and printing touches memory; a
// watchpoint armed on something cprintf writes to would otherwise re-enter
// here for ever. Per-CPU because two processors can be in the handler at once
// and one must not silence the other.
static int wp_busy[NCPU];
// --- the registers ----------------------------------------------------------
static inline uint64
read_dr6(void)
{
uint64 v;
asm volatile("mov %%dr6, %0" : "=r"(v));
return v;
}
static inline void
write_dr6(uint64 v)
{
asm volatile("mov %0, %%dr6" : : "r"(v));
}
static inline void
write_dr7(uint64 v)
{
asm volatile("mov %0, %%dr7" : : "r"(v));
}
static void
write_dr(int n, uint64 v)
{
switch (n) {
case 0: asm volatile("mov %0, %%dr0" : : "r"(v)); break;
case 1: asm volatile("mov %0, %%dr1" : : "r"(v)); break;
case 2: asm volatile("mov %0, %%dr2" : : "r"(v)); break;
case 3: asm volatile("mov %0, %%dr3" : : "r"(v)); break;
}
}
// The LEN field is not a byte count. 1 byte is 00, 2 is 01, 8 is 10 and 4 is
// 11 -- the 8 and 4 encodings are the way round nobody expects, and getting
// them backwards produces a watchpoint that silently covers the wrong span.
static uint64
len_bits(int len)
{
switch (len) {
case 1: return 0x0;
case 2: return 0x1;
case 8: return 0x2;
default: return 0x3; // 4
}
}
// Load this CPU's debug registers from the table.
static void
wp_load(void)
{
uint64 dr7 = DR7_LE | DR7_GE;
for (int i = 0; i < NWATCH; i++) {
if (!wp[i].used) {
write_dr(i, 0);
continue;
}
write_dr(i, wp[i].addr);
// Global, not local. The local bits are cleared by a hardware task switch,
// which this kernel never does -- but "watch this everywhere" is what is
// meant, and G is the bit that says so.
dr7 |= DR7_G(i);
dr7 |= (uint64)wp[i].rw << DR7_RW_SHIFT(i);
dr7 |= len_bits(wp[i].len) << DR7_LEN_SHIFT(i);
}
write_dr6(0);
write_dr7(dr7);
}
// Called from the timer interrupt. Cheap when nothing has changed, which is
// every tick except the handful after somebody arms or clears a watchpoint.
void
watch_sync(void)
{
int c = cpunum();
if (c < 0 || c >= NCPU)
return;
if (wp_seen[c] == wp_generation)
return;
wp_seen[c] = wp_generation;
wp_load();
}
// --- the handler ------------------------------------------------------------
// #DB. Say which watchpoint fired, where the write came from, and carry on.
//
// Returns 1 if this was ours, so trap() can tell a watchpoint apart from a
// single-step or any other source of #DB and not swallow the latter.
int
watch_trap(struct trapframe *tf)
{
uint64 dr6 = read_dr6();
int c = cpunum();
int hit = 0;
if (!(dr6 & 0xF))
return 0; // a #DB, but not one of ours
write_dr6(0); // DR6 bits are sticky; nothing else clears them
if (c >= 0 && c < NCPU) {
if (wp_busy[c])
return 1; // already reporting; see the note on recursion
wp_busy[c] = 1;
}
for (int i = 0; i < NWATCH; i++) {
addr_t pcs[10];
char where[72];
if (!(dr6 & (1u << i)))
continue;
hit = 1;
wp[i].hits++;
ksym_fmt(where, sizeof(where), tf->rip);
cprintf("watch %d: 0x%p written, %d byte(s), by %s (rip 0x%p) on cpu %d",
i, wp[i].addr, wp[i].len, where, tf->rip, c);
if (myproc())
cprintf(", pid %d %s", myproc()->pid, myproc()->name);
cprintf("\n");
// The chain matters more than the rip. "memset wrote to it" is not an
// answer; "memset, called from dma_alloc, called from do_dma" is.
getstackpcs((addr_t *)tf->rbp, pcs);
for (int j = 0; j < 6 && pcs[j]; j++) {
ksym_fmt(where, sizeof(where), pcs[j]);
cprintf(" #%d %s\n", j, where);
}
}
if (c >= 0 && c < NCPU)
wp_busy[c] = 0;
return hit;
}
// --- arming and clearing ----------------------------------------------------
static int
watch_set(uint64 addr, int len, int rw)
{
int i;
if (len != 1 && len != 2 && len != 4 && len != 8)
return -1;
// The address must be naturally aligned for the length. The processor
// ignores the low bits otherwise and watches a span you did not ask for,
// which is worse than refusing.
if (addr & (uint64)(len - 1))
return -2;
for (i = 0; i < NWATCH; i++)
if (!wp[i].used)
break;
if (i == NWATCH)
return -3;
wp[i].addr = addr;
wp[i].len = len;
wp[i].rw = rw;
wp[i].hits = 0;
wp[i].used = 1;
wp_generation++;
wp_load(); // this CPU now; the rest at their next tick
return i;
}
static int
watch_clear(int i)
{
if (i < 0 || i >= NWATCH || !wp[i].used)
return -1;
wp[i].used = 0;
wp_generation++;
wp_load();
return 0;
}
// --- the command ------------------------------------------------------------
static int
parse_num(const char **sp, uint64 *out)
{
const char *s = *sp;
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; }
else {
const char *p = s;
while (*p && *p != ' ') {
if ((*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F')) { hex = 1; break; }
p++;
}
}
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++;
}
*sp = s;
*out = v;
return any;
}
static void
watch_list(struct sbuf *out)
{
int any = 0;
sb_puts(out, " # address len on hits\n");
for (int i = 0; i < NWATCH; i++) {
if (!wp[i].used)
continue;
any = 1;
sb_putu_pad(out, i, 3);
sb_puts(out, " 0x");
sb_puthex(out, wp[i].addr, 16);
sb_putu_pad(out, wp[i].len, 5);
sb_puts(out, wp[i].rw == RW_RW ? " rw " : " w ");
sb_putu_pad(out, wp[i].hits, 6);
sb_putc(out, '\n');
}
if (!any)
sb_puts(out, " (none armed)\n");
// Which processors have actually loaded the table.
//
// Reported rather than assumed, because the answer is the difference
// between "this watchpoint is watching" and "this watchpoint is watching
// one quarter of the machine". A CPU that is behind catches up at its next
// timer tick; one that stays behind is a bug and this is where it shows.
sb_puts(out, "\n table generation ");
sb_putu(out, wp_generation);
sb_puts(out, ", loaded by:");
for (int c = 0; c < ncpu && c < NCPU; c++) {
sb_puts(out, " cpu");
sb_putu(out, c);
sb_putc(out, '=');
sb_putu(out, wp_seen[c]);
if (wp_seen[c] != wp_generation)
sb_puts(out, "(behind)");
}
sb_putc(out, '\n');
sb_puts(out, "\n watch <addr> [len] [w|rw] arm one; len is 1, 2, 4 or 8\n"
" watch clear <n> disarm one\n"
" watch clear all disarm every one\n"
"\n Addresses are kernel virtual. `whose` prints the kernel va of a\n"
" physical page, which is how you get from one to the other.\n"
" Hits are reported to the console, not here -- they happen while\n"
" you are not looking, which is the point.\n");
}
static void
watch_view(struct sbuf *out, const char *arg)
{
const char *p = arg;
uint64 addr, len = 8;
int rw = RW_WRITE, r;
while (*p == ' ') p++;
if (*p == 0) {
watch_list(out);
return;
}
if (p[0] == 'c' && p[1] == 'l') { // clear
while (*p && *p != ' ') p++;
while (*p == ' ') p++;
if (p[0] == 'a') { // all
for (int i = 0; i < NWATCH; i++)
if (wp[i].used)
watch_clear(i);
sb_puts(out, "all watchpoints disarmed\n");
return;
}
if (!parse_num(&p, &addr)) {
sb_puts(out, "watch clear <n>, or watch clear all\n");
return;
}
if (watch_clear((int)addr) < 0)
sb_puts(out, "no such watchpoint\n");
else {
sb_puts(out, "watch ");
sb_putu(out, addr);
sb_puts(out, " disarmed\n");
}
return;
}
if (!parse_num(&p, &addr)) {
watch_list(out);
return;
}
parse_num(&p, &len); // optional
while (*p == ' ') p++;
if (p[0] == 'r' && p[1] == 'w')
rw = RW_RW;
// A watchpoint on a user address would fire in whichever address space
// happens to have that linear address mapped, which is every process. Say so
// rather than arming something that reports nonsense.
if (addr < (uint64)KERNBASE)
sb_puts(out, "note: that is below KERNBASE, so it is a *user* linear address\n"
" and will fire in whatever process is current. Kernel\n"
" addresses are what this is for.\n");
r = watch_set(addr, (int)len, rw);
if (r == -1) sb_puts(out, "length must be 1, 2, 4 or 8\n");
else if (r == -2) sb_puts(out, "address must be aligned to its length\n");
else if (r == -3) sb_puts(out, "all four debug registers are in use\n");
else {
sb_puts(out, "watch ");
sb_putu(out, r);
sb_puts(out, " armed on 0x");
sb_puthex(out, addr, 16);
sb_puts(out, ", ");
sb_putu(out, len);
sb_puts(out, rw == RW_RW ? " byte(s), read or write\n" : " byte(s), on write\n");
sb_puts(out, "hits are reported to the console\n");
}
}
void
watch_init(void)
{
dbg_register("watch", watch_view,
"hardware watchpoints -- `watch <addr>` to arm one");
}