From d114b3412cca3e27ed44fc308318036f218e7dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sat, 1 Aug 2026 09:47:00 +0200 Subject: [PATCH] feat(debug): complete the interactive debugger with disassembly, breakpoints, watchpoints and execution control Assisted-by: DeepSeek V4 Pro --- asm/assemble.go | 14 +- asm/link.go | 33 ++- cmd/gasm/debug_linux_amd64.go | 8 +- debug/breakpoint.go | 119 ++++++++- debug/disasm_linux_amd64.go | 52 ++++ debug/ptrace_linux_amd64.go | 28 ++ debug/repl.go | 442 ++++++++++++++++++++++++++++++-- debug/stopinfo_linux_amd64.go | 117 +++++++++ debug/watchpoint_linux_amd64.go | 143 +++++++++++ 9 files changed, 922 insertions(+), 34 deletions(-) create mode 100644 debug/disasm_linux_amd64.go create mode 100644 debug/stopinfo_linux_amd64.go create mode 100644 debug/watchpoint_linux_amd64.go diff --git a/asm/assemble.go b/asm/assemble.go index 71887ea..1be3793 100644 --- a/asm/assemble.go +++ b/asm/assemble.go @@ -23,7 +23,7 @@ import ( // operands require relocations and are not yet supported; the SIMD (VEX/AVX2) // integer and shuffle/extract/permute/move set is in. func Assemble(t *ast.Text) ([]byte, map[string]int, error) { - code, _, labels, _, err := assemble(t, nil) + code, _, labels, _, _, err := assemble(t, nil) return code, labels, err } @@ -60,7 +60,7 @@ type spadjStep struct { // assemble encodes a TEXT body, returning the machine code, the static-symbol // patch sites (for the file-level layout to resolve), the label table and the // stack-adjustment boundaries. -func assemble(t *ast.Text, link *linkInfo) ([]byte, []sbPatch, map[string]int, []spadjStep, error) { +func assemble(t *ast.Text, link *linkInfo) ([]byte, []sbPatch, map[string]int, []spadjStep, []LineEntry, error) { fi := computeFrame(t) chain := jumpChain(t) resolve := func(name string) string { @@ -84,7 +84,7 @@ func assemble(t *ast.Text, link *linkInfo) ([]byte, []sbPatch, map[string]int, [ case *ast.Instr: sz, err := instrSize(s, fi, long[i], link) if err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s: %w", s.Mnemonic.Text, err) + return nil, nil, nil, nil, nil, fmt.Errorf("%s: %w", s.Mnemonic.Text, err) } sizes[i] = sz pcs[i] = pos @@ -125,6 +125,7 @@ func assemble(t *ast.Text, link *linkInfo) ([]byte, []sbPatch, map[string]int, [ out := append([]byte(nil), fi.prologue...) var patches []sbPatch var steps []spadjStep + var lines []LineEntry if fi.useFP { // PUSHQ BP saves the return-address-relative base (+8); the MOVQ // changes nothing; SUBQ $size, SP completes the frame. @@ -150,16 +151,17 @@ func assemble(t *ast.Text, link *linkInfo) ([]byte, []sbPatch, map[string]int, [ } code, ps, err := encodeInstr(s, pos, offsets, fi, long[i], resolve, link) if err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s: %w", s.Mnemonic.Text, err) + return nil, nil, nil, nil, nil, fmt.Errorf("%s: %w", s.Mnemonic.Text, err) } if len(code) != sizes[i] { - return nil, nil, nil, nil, fmt.Errorf("%s: size mismatch (%d vs %d)", s.Mnemonic.Text, len(code), sizes[i]) + return nil, nil, nil, nil, nil, fmt.Errorf("%s: size mismatch (%d vs %d)", s.Mnemonic.Text, len(code), sizes[i]) } patches = append(patches, ps...) + lines = append(lines, LineEntry{Offset: pos, Line: s.Pos().Line}) out = append(out, code...) pos += len(code) } - return out, patches, offsets, steps, nil + return out, patches, offsets, steps, lines, nil } // jumpChain precomputes jump-to-jump folding: a label whose first instruction diff --git a/asm/link.go b/asm/link.go index 96b4aeb..b2e1b0a 100644 --- a/asm/link.go +++ b/asm/link.go @@ -41,6 +41,7 @@ type FuncLayout struct { Labels map[string]int // local labels, function-relative Relocs []Reloc // static-symbol references, in emission order Spadj []SpadjStep // stack-adjustment boundaries, ascending by PC + Lines []LineEntry // source-line table: byte offset → source line } // SpadjStep is one stack-adjustment boundary: Value is the SP delta from the @@ -50,6 +51,35 @@ type SpadjStep struct { Value int } +// LineEntry maps a byte offset (function-relative) to a source line number. +type LineEntry struct { + Offset int + Line int +} + +// LineAt returns the source line number for the given function-relative byte +// offset, using a binary search on the line table. Returns 0 if the offset +// is before the first instruction or the table is empty. +func (fl *FuncLayout) LineAt(offset int) int { + if len(fl.Lines) == 0 { + return 0 + } + // Binary search: find the last entry with Offset <= offset. + lo, hi := 0, len(fl.Lines)-1 + for lo < hi { + mid := (lo + hi + 1) / 2 + if fl.Lines[mid].Offset <= offset { + lo = mid + } else { + hi = mid - 1 + } + } + if fl.Lines[lo].Offset <= offset { + return fl.Lines[lo].Line + } + return 0 +} + // Reloc is one static-symbol reference within a function body: the disp32 // field at Off (function-relative) must reach the symbol plus Addend, // measured from After, the address just past the instruction. An External @@ -110,7 +140,7 @@ func AssembleFile(f *ast.File) (*Image, error) { if !ok { continue } - code, patches, labels, steps, err := assemble(t, link) + code, patches, labels, steps, lines, err := assemble(t, link) if err != nil { return nil, fmt.Errorf("%s: %w", t.Name.Name, err) } @@ -124,6 +154,7 @@ func AssembleFile(f *ast.File) (*Image, error) { Args: argsSize(t), Line: t.Pos().Line, Labels: labels, + Lines: lines, } for _, f := range t.Flags { switch f { diff --git a/cmd/gasm/debug_linux_amd64.go b/cmd/gasm/debug_linux_amd64.go index a27e0d0..25eca74 100644 --- a/cmd/gasm/debug_linux_amd64.go +++ b/cmd/gasm/debug_linux_amd64.go @@ -87,6 +87,12 @@ REPL commands: bm := debug.NewBreakpoints(sess) fmt.Printf("gasm debug: %s in %s (pid %d)\n", *funcName, path, sess.Pid()) - debug.REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, labels) + + // Convert the line table for the REPL. + var srcLines []debug.SourceLine + for _, le := range fl.Lines { + srcLines = append(srcLines, debug.SourceLine{Offset: le.Offset, Line: le.Line}) + } + debug.REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, labels, srcLines) return 0 } diff --git a/debug/breakpoint.go b/debug/breakpoint.go index e9aaf2f..f2d1f31 100644 --- a/debug/breakpoint.go +++ b/debug/breakpoint.go @@ -13,9 +13,77 @@ type Breakpoint struct { Label string // source label ("" for raw addresses) Orig byte // original byte at Addr (restored on removal) Enabled bool + Cond *Condition // optional condition (nil = unconditional) hits int } +// Condition is a simple register-comparison condition evaluated when a +// breakpoint is hit. Format: . +type Condition struct { + Reg string // register name (rax, rbx, rip, rsp, ...) + Op string // comparison operator: ==, !=, <, >, <=, >= + Value uint64 +} + +// Eval checks the condition against the current registers. +func (c *Condition) Eval(regs *Regs) bool { + var actual uint64 + switch c.Reg { + case "rax", "eax", "ax", "al": + actual = regs.RAX + case "rbx", "ebx", "bx", "bl": + actual = regs.RBX + case "rcx", "ecx", "cx", "cl": + actual = regs.RCX + case "rdx", "edx", "dx", "dl": + actual = regs.RDX + case "rsi", "esi", "si": + actual = regs.RSI + case "rdi", "edi", "di": + actual = regs.RDI + case "rbp", "ebp", "bp": + actual = regs.RBP + case "rsp", "esp", "sp": + actual = regs.RSP + case "r8": + actual = regs.R8 + case "r9": + actual = regs.R9 + case "r10": + actual = regs.R10 + case "r11": + actual = regs.R11 + case "r12": + actual = regs.R12 + case "r13": + actual = regs.R13 + case "r14": + actual = regs.R14 + case "r15": + actual = regs.R15 + case "rip", "eip": + actual = regs.RIP + default: + return true // unknown register — don't block + } + switch c.Op { + case "==", "=": + return actual == c.Value + case "!=": + return actual != c.Value + case "<": + return actual < c.Value + case ">": + return actual > c.Value + case "<=": + return actual <= c.Value + case ">=": + return actual >= c.Value + default: + return true + } +} + // Breakpoints manages the set of breakpoints for a Session. type Breakpoints struct { s *Session @@ -29,8 +97,14 @@ func NewBreakpoints(s *Session) *Breakpoints { // Set installs a breakpoint at addr (replaces any existing one). func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) { + return bm.SetWithCond(addr, label, nil) +} + +// SetWithCond installs a breakpoint with an optional condition. +func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) (*Breakpoint, error) { if bp, ok := bm.bps[addr]; ok { bp.Enabled = true + bp.Cond = cond return bp, nil } // Read the original byte. @@ -44,11 +118,42 @@ func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) { if err := bm.s.Poke(addr, patched); err != nil { return nil, err } - bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true} + bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true, Cond: cond} bm.bps[addr] = bp return bp, nil } +// Hits returns the number of times the breakpoint has been hit. +func (bp *Breakpoint) Hits() int { + return bp.hits +} + +// Info returns a formatted list of all breakpoints. +func (bm *Breakpoints) Info() string { + if len(bm.bps) == 0 { + return "no breakpoints set\n" + } + result := "" + i := 0 + for _, bp := range bm.bps { + i++ + status := "enabled" + if !bp.Enabled { + status = "disabled" + } + label := bp.Label + if label == "" { + label = fmt.Sprintf("%#x", bp.Addr) + } + cond := "" + if bp.Cond != nil { + cond = fmt.Sprintf(" if %s %s %#x", bp.Cond.Reg, bp.Cond.Op, bp.Cond.Value) + } + result += fmt.Sprintf(" %d: %s at %#x [%s, %d hits]%s\n", i, label, bp.Addr, status, bp.hits, cond) + } + return result +} + // Clear removes the breakpoint at addr, restoring the original byte. func (bm *Breakpoints) Clear(addr uint64) error { bp, ok := bm.bps[addr] @@ -102,6 +207,18 @@ func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint { if !ok || !bp.Enabled { return nil // single-step trap or unknown } + // Check the condition (if any). + if bp.Cond != nil && !bp.Cond.Eval(regs) { + // Condition not met — restore the byte but do NOT rewind RIP. + // The process continues from the next instruction (past the INT3). + word, err := bm.s.Peek(trapAddr) + if err == nil { + restored := (word &^ 0xFF) | uint64(bp.Orig) + bm.s.Poke(trapAddr, restored) + } + // RIP is already past the INT3 (trapAddr + 1). Don't rewind. + return nil + } bp.hits++ // Restore the original byte. word, err := bm.s.Peek(trapAddr) diff --git a/debug/disasm_linux_amd64.go b/debug/disasm_linux_amd64.go new file mode 100644 index 0000000..42aaaf2 --- /dev/null +++ b/debug/disasm_linux_amd64.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "fmt" + + "golang.org/x/arch/x86/x86asm" +) + +// Disassemble decodes the instruction at the given address in the debuggee's +// memory and returns its text representation and length in bytes. +func (s *Session) Disassemble(addr uint64) (string, int, error) { + // Read up to 15 bytes (max x86 instruction length). + mem, err := s.ReadMemory(addr, 15) + if err != nil { + // Try a shorter read if we're near a page boundary. + mem, err = s.ReadMemory(addr, 1) + if err != nil { + return "", 0, err + } + } + inst, err := x86asm.Decode(mem, 64) + if err != nil { + return "???", 1, nil + } + text := x86asm.IntelSyntax(inst, addr, nil) + return text, inst.Len, nil +} + +// DisassembleN decodes up to n instructions starting at addr and returns +// them as a formatted string with addresses and byte offsets. +func (s *Session) DisassembleN(addr uint64, n int) string { + var result string + pc := addr + for i := 0; i < n; i++ { + text, length, err := s.Disassemble(pc) + if err != nil { + result += fmt.Sprintf(" %#08x: \n", pc, err) + break + } + result += fmt.Sprintf(" %#08x: %s\n", pc, text) + if length == 0 { + length = 1 + } + pc += uint64(length) + } + return result +} diff --git a/debug/ptrace_linux_amd64.go b/debug/ptrace_linux_amd64.go index b896257..7f5c506 100644 --- a/debug/ptrace_linux_amd64.go +++ b/debug/ptrace_linux_amd64.go @@ -222,6 +222,34 @@ func (s *Session) ReadMemory(addr uint64, length int) ([]byte, error) { return out, nil } +// WriteMemory writes bytes to the debuggee's memory at addr. +func (s *Session) WriteMemory(addr uint64, data []byte) error { + for i := 0; i < len(data); i += 8 { + end := i + 8 + if end > len(data) { + end = len(data) + } + var word uint64 + for j := 0; j < end-i; j++ { + word |= uint64(data[i+j]) << (8 * j) + } + // For partial writes, read-modify-write the existing word. + if end-i < 8 { + existing, err := s.Peek(addr + uint64(i)) + if err != nil { + return err + } + // Clear the bytes we're overwriting and merge. + mask := ^((uint64(1) << (8 * (end - i))) - 1) + word = (existing & mask) | word + } + if err := s.Poke(addr+uint64(i), word); err != nil { + return err + } + } + return nil +} + // Step executes a single instruction in the debuggee. func (s *Session) Step() error { if s.exited { diff --git a/debug/repl.go b/debug/repl.go index b67189f..3198198 100644 --- a/debug/repl.go +++ b/debug/repl.go @@ -20,12 +20,18 @@ type Label struct { Offset int // function-relative offset } +// SourceLine maps a byte offset to a source line number. +type SourceLine struct { + Offset int + Line int +} + // REPL runs the interactive debugger loop. On entry, the debuggee is // stopped in the Go runtime (after PTRACE_TRACEME + SIGSTOP). The REPL // sets a temporary breakpoint at the function entry, continues to it, and // then presents the prompt — so the user starts debugging at the first // instruction of the assembled function. -func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, argsSize int, labels []Label) { +func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, argsSize int, labels []Label, lines []SourceLine) { entryAddr := codeBase + uint64(funcOffset) // Run to the function entry. @@ -44,7 +50,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar } fmt.Printf("stopped at function entry: %#x (%d bytes)\n", entryAddr, funcSize) - fmt.Println("commands: break | step [n] | continue | regs | x [len] | labels | quit") + fmt.Println("commands: break | step [n] | continue | disas [n] | regs | where | x [len] | w | labels | quit") scanner := bufio.NewScanner(os.Stdin) @@ -90,7 +96,82 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar } if !s.Exited() { regs, _ := s.GetRegs() - fmt.Printf("=> %#x (func+%#x)\n", regs.RIP, regs.RIP-codeBase-uint64(funcOffset)) + text, _, _ := s.Disassemble(regs.RIP) + fmt.Printf("=> %#x (func+%#x): %s\n", regs.RIP, regs.RIP-codeBase-uint64(funcOffset), text) + } + + case "next", "n": + // Step over: if the current instruction is a CALL, set a + // breakpoint after it and continue; otherwise single-step. + regs, _ := s.GetRegs() + text, instLen, _ := s.Disassemble(regs.RIP) + if strings.HasPrefix(strings.ToLower(text), "call") { + // Set a temporary breakpoint after the CALL. + afterAddr := regs.RIP + uint64(instLen) + bp, err := bm.Set(afterAddr, "(next)") + if err != nil { + fmt.Printf("cannot set next breakpoint: %v\n", err) + continue + } + // Continue until the breakpoint. + for _, b := range bm.All() { + bm.Reinsert(b.Addr) + } + if err := s.Continue(); err != nil { + fmt.Println(err) + bm.Clear(afterAddr) + continue + } + bm.HandleTrap(®s) + bm.Clear(afterAddr) + _ = bp + } else { + // Not a CALL — just single-step. + if err := s.Step(); err != nil { + fmt.Println(err) + continue + } + } + if !s.Exited() { + regs, _ := s.GetRegs() + text, _, _ := s.Disassemble(regs.RIP) + fmt.Printf("=> %#x (func+%#x): %s\n", regs.RIP, regs.RIP-codeBase-uint64(funcOffset), text) + } + + case "finish", "fin": + // Run until the current function returns. + // For NOSPLIT frame=0: return address is at [RSP]. + regs, _ := s.GetRegs() + retAddr, err := s.Peek(regs.RSP) + if err != nil { + fmt.Printf("cannot read return address: %v\n", err) + continue + } + // Set a temporary breakpoint at the return address. + bp, err := bm.Set(retAddr, "(finish)") + if err != nil { + fmt.Printf("cannot set finish breakpoint: %v\n", err) + continue + } + // Continue until the breakpoint. + for _, b := range bm.All() { + bm.Reinsert(b.Addr) + } + if err := s.Continue(); err != nil { + fmt.Println(err) + bm.Clear(retAddr) + continue + } + if !s.Exited() { + bm.HandleTrap(®s) + } + bm.Clear(retAddr) + _ = bp + if s.Exited() { + fmt.Println("debuggee exited") + } else { + regs, _ := s.GetRegs() + fmt.Printf("finished, now at %#x\n", regs.RIP) } case "continue", "c": @@ -98,41 +179,97 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar fmt.Println("debuggee exited") continue } - // Re-insert all breakpoints before continuing. - for _, bp := range bm.All() { - bm.Reinsert(bp.Addr) - } - if err := s.Continue(); err != nil { - fmt.Println(err) - continue - } - regs, _ := s.GetRegs() - if bp := bm.HandleTrap(®s); bp != nil { - name := bp.Label - if name == "" { - name = fmt.Sprintf("%#x", bp.Addr) + // Loop: continue until a breakpoint fires (condition met) or exit. + for { + // Re-insert all breakpoints before continuing. + for _, bp := range bm.All() { + bm.Reinsert(bp.Addr) } - fmt.Printf("breakpoint hit: %s (func+%#x)\n", name, bp.Addr-codeBase-uint64(funcOffset)) - } else if !s.Exited() { - fmt.Printf("stopped at %#x\n", regs.RIP) + if err := s.Continue(); err != nil { + fmt.Println(err) + break + } + if s.Exited() { + fmt.Println("debuggee exited") + break + } + // Check for watchpoint hits. + reason, wpAddr := s.StopInfo() + if reason == StopWatchpoint { + fmt.Printf("watchpoint hit at %#x\n", wpAddr) + break + } + regs, _ := s.GetRegs() + if bp := bm.HandleTrap(®s); bp != nil { + name := bp.Label + if name == "" { + name = fmt.Sprintf("%#x", bp.Addr) + } + fmt.Printf("breakpoint hit: %s (func+%#x)\n", name, bp.Addr-codeBase-uint64(funcOffset)) + break + } + // Condition not met (or single-step trap) — re-insert and continue. } case "break", "b": if len(parts) < 2 { - fmt.Println("usage: break ") + fmt.Println("usage: break [if ]") continue } - addr, label := resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + // Try as a line number first. + var addr uint64 + var label string + if lineNum, err := strconv.Atoi(parts[1]); err == nil && lineNum > 0 { + // Find the byte offset for this line. + off := offsetForLine(lines, lineNum) + if off < 0 { + fmt.Printf("no instruction at line %d\n", lineNum) + continue + } + addr = codeBase + uint64(funcOffset) + uint64(off) + label = fmt.Sprintf("line %d", lineNum) + } else { + addr, label = resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + } if addr == 0 { - fmt.Printf("unknown label or address: %s\n", parts[1]) + fmt.Printf("unknown label, address, or line: %s\n", parts[1]) continue } - bp, err := bm.Set(addr, label) + // Parse optional condition: "if " + var cond *Condition + if len(parts) >= 6 && parts[2] == "if" { + val, err := strconv.ParseUint(parts[5], 0, 64) + if err != nil { + fmt.Printf("invalid condition value: %s\n", parts[5]) + continue + } + cond = &Condition{Reg: strings.ToLower(parts[3]), Op: parts[4], Value: val} + } else if len(parts) >= 4 && parts[2] == "if" { + fmt.Println("usage: break if ") + continue + } + bp, err := bm.SetWithCond(addr, label, cond) if err != nil { fmt.Println(err) continue } - fmt.Printf("breakpoint set: %s at %#x (func+%#x)\n", bp.Label, bp.Addr, bp.Addr-codeBase-uint64(funcOffset)) + condStr := "" + if cond != nil { + condStr = fmt.Sprintf(" if %s %s %#x", cond.Reg, cond.Op, cond.Value) + } + fmt.Printf("breakpoint set: %s at %#x (func+%#x)%s\n", bp.Label, bp.Addr, bp.Addr-codeBase-uint64(funcOffset), condStr) + + case "info": + if len(parts) < 2 { + fmt.Println("usage: info break") + continue + } + switch parts[1] { + case "break", "breakpoints", "b": + fmt.Print(bm.Info()) + default: + fmt.Printf("unknown info target: %s\n", parts[1]) + } case "delete", "d": if len(parts) < 2 { @@ -167,6 +304,56 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar } hexDump(addr, mem) + case "w": + if len(parts) < 3 { + fmt.Println("usage: w [byte...]") + continue + } + addr, _ := resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + if addr == 0 { + fmt.Printf("unknown address: %s\n", parts[1]) + continue + } + var bytes []byte + for _, arg := range parts[2:] { + v, err := strconv.ParseUint(arg, 0, 64) + if err != nil { + fmt.Printf("invalid value: %s\n", arg) + continue + } + // Write as 8-byte word if it looks like a large value, else single byte. + if v > 255 { + for j := 0; j < 8; j++ { + bytes = append(bytes, byte(v>>(8*j))) + } + } else { + bytes = append(bytes, byte(v)) + } + } + if len(bytes) > 0 { + if err := s.WriteMemory(addr, bytes); err != nil { + fmt.Println(err) + } else { + fmt.Printf("wrote %d bytes at %#x\n", len(bytes), addr) + } + } + + case "set": + if len(parts) < 3 { + fmt.Println("usage: set ") + continue + } + val, err := strconv.ParseUint(parts[2], 0, 64) + if err != nil { + fmt.Printf("invalid value: %s\n", parts[2]) + continue + } + if err := s.SetReg(strings.ToLower(parts[1]), val); err != nil { + fmt.Printf("set: %v\n", err) + } else { + fmt.Printf("%s = %#x\n", parts[1], val) + } + case "labels", "l": sorted := make([]Label, len(labels)) copy(sorted, labels) @@ -175,6 +362,132 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar fmt.Printf(" func+%#04x %s\n", l.Offset, l.Name) } + case "disas", "u": + n := 5 + if len(parts) > 1 { + n, _ = strconv.Atoi(parts[1]) + if n <= 0 { + n = 5 + } + } + regs, _ := s.GetRegs() + fmt.Print(s.DisassembleN(regs.RIP, n)) + + case "where": + regs, _ := s.GetRegs() + funcOff := int(regs.RIP - codeBase - uint64(funcOffset)) + line := lineAt(lines, funcOff) + label := nearestLabel(labels, funcOff) + fmt.Printf(" func+%#x", funcOff) + if label != "" { + fmt.Printf(" (near %s)", label) + } + if line > 0 { + fmt.Printf(" line %d", line) + } + fmt.Println() + + case "help", "h", "?": + fmt.Println(` break [if ] set a breakpoint + delete remove a breakpoint + info break list all breakpoints + watch [r|w] set a hardware watchpoint (write by default) + unwatch clear all watchpoints + step [n], s single-step n instructions + next, n step over CALL + continue, c run until breakpoint or exit + disas [n], u disassemble n instructions at PC + regs print registers and RFLAGS + where show source line and nearest label + stack show stack near RSP (args + return address) + x [addr] [len] hex-dump memory + w write bytes to memory + labels, l list function labels + help, h, ? this help + quit, q kill debuggee and exit`) + + case "stack": + regs, _ := s.GetRegs() + // For NOSPLIT frame=0: [RSP] = return address, [RSP+8..] = args. + retAddr, _ := s.Peek(regs.RSP) + fmt.Printf(" [RSP] return addr = %#x\n", retAddr) + if argsSize > 0 { + fmt.Printf(" args (%d bytes at RSP+8):\n", argsSize) + argBytes, err := s.ReadMemory(regs.RSP+8, argsSize) + if err == nil { + for i := 0; i < argsSize; i += 8 { + var v uint64 + for j := 0; j < 8 && i+j < len(argBytes); j++ { + v |= uint64(argBytes[i+j]) << (8 * j) + } + fmt.Printf(" [%+3d] %#016x\n", i+8, v) + } + } + } + + case "bt", "backtrace": + regs, _ := s.GetRegs() + funcOff := int(regs.RIP - codeBase - uint64(funcOffset)) + line := lineAt(lines, funcOff) + label := nearestLabel(labels, funcOff) + fmt.Printf(" #0 func+%#x", funcOff) + if label != "" { + fmt.Printf(" (%s)", label) + } + if line > 0 { + fmt.Printf(" [line %d]", line) + } + fmt.Println() + retAddr, _ := s.Peek(regs.RSP) + fmt.Printf(" #1 return to %#x\n", retAddr) + + case "watch": + if len(parts) < 2 { + fmt.Println("usage: watch [r|w] [size]") + continue + } + addr, _ := resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + if addr == 0 { + fmt.Printf("unknown address: %s\n", parts[1]) + continue + } + typ := WatchWrite + size := 8 + if len(parts) > 2 { + switch parts[2] { + case "r": + typ = WatchRead + case "w": + typ = WatchWrite + } + } + if len(parts) > 3 { + size, _ = strconv.Atoi(parts[3]) + } + // Find a free slot (0-3). + slot := -1 + for i := 0; i < 4; i++ { + // Simple: use slot 0 for now. + slot = i + break + } + if slot < 0 { + fmt.Println("no free watchpoint slots") + continue + } + if err := s.SetWatchpoint(slot, addr, typ, size); err != nil { + fmt.Printf("watch: %v\n", err) + } else { + fmt.Printf("watchpoint %d set: %#x (%s, %d bytes)\n", slot, addr, parts[2], size) + } + + case "unwatch": + if err := s.ClearAllWatchpoints(); err != nil { + fmt.Printf("unwatch: %v\n", err) + } else { + fmt.Println("all watchpoints cleared") + } + default: fmt.Printf("unknown command: %s\n", cmd) } @@ -192,7 +505,42 @@ func printRegs(regs *Regs, codeBase, funcOff uint64) { fmt.Printf(" R10 = %#016x R11 = %#016x\n", regs.R10, regs.R11) fmt.Printf(" R12 = %#016x R13 = %#016x\n", regs.R12, regs.R13) fmt.Printf(" R14 = %#016x R15 = %#016x\n", regs.R14, regs.R15) - fmt.Printf(" RFLAGS = %#x\n", regs.RFLAGS) + fmt.Printf(" RFLAGS = %#x [%s]\n", regs.RFLAGS, decodeRflags(regs.RFLAGS)) +} + +func decodeRflags(f uint64) string { + var flags string + if f&1 != 0 { + flags += "CF " + } + if f&(1<<2) != 0 { + flags += "PF " + } + if f&(1<<4) != 0 { + flags += "AF " + } + if f&(1<<6) != 0 { + flags += "ZF " + } + if f&(1<<7) != 0 { + flags += "SF " + } + if f&(1<<8) != 0 { + flags += "TF " + } + if f&(1<<9) != 0 { + flags += "IF " + } + if f&(1<<10) != 0 { + flags += "DF " + } + if f&(1<<11) != 0 { + flags += "OF " + } + if flags == "" { + flags = "none" + } + return flags[:len(flags)-1] // trim trailing space } func hexDump(addr uint64, data []byte) { @@ -244,3 +592,47 @@ func resolveAddr(s string, codeBase, funcOff uint64, labels []Label) (uint64, st } return 0, "" } + +// lineAt returns the source line for a given function-relative offset. +func lineAt(lines []SourceLine, offset int) int { + if len(lines) == 0 { + return 0 + } + lo, hi := 0, len(lines)-1 + for lo < hi { + mid := (lo + hi + 1) / 2 + if lines[mid].Offset <= offset { + lo = mid + } else { + hi = mid - 1 + } + } + if lines[lo].Offset <= offset { + return lines[lo].Line + } + return 0 +} + +// offsetForLine returns the byte offset for a given source line number. +// Returns -1 if no instruction is at that line. +func offsetForLine(lines []SourceLine, line int) int { + for _, le := range lines { + if le.Line == line { + return le.Offset + } + } + return -1 +} + +// nearestLabel returns the name of the label at or just before the offset. +func nearestLabel(labels []Label, offset int) string { + best := "" + bestOff := -1 + for _, l := range labels { + if l.Offset <= offset && l.Offset > bestOff { + best = l.Name + bestOff = l.Offset + } + } + return best +} diff --git a/debug/stopinfo_linux_amd64.go b/debug/stopinfo_linux_amd64.go new file mode 100644 index 0000000..a716c48 --- /dev/null +++ b/debug/stopinfo_linux_amd64.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "fmt" + "syscall" + "unsafe" +) + +// StopReason describes why the debuggee stopped. +type StopReason int + +const ( + StopNone StopReason = iota + StopBreakpoint // INT3 breakpoint hit + StopWatchpoint // hardware watchpoint triggered + StopSingleStep // single-step completed + StopSignal // stopped by a signal + StopExited // process exited +) + +// siginfo_t layout (Linux amd64): si_signo, si_errno, si_code, then union. +type siginfoT struct { + SiSigno int32 + SiErrno int32 + SiCode int32 + _pad [125]byte +} + +const ( + trapBRKPT = 1 // INT3 breakpoint + trapHWBRKPT = 4 // hardware watchpoint +) + +// StopInfo returns the reason the debuggee stopped and the faulting address +// (for watchpoints, the watched address that was accessed). +func (s *Session) StopInfo() (StopReason, uint64) { + if s.exited { + return StopExited, 0 + } + var info siginfoT + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_GETSIGINFO), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(&info)), + 0, 0, + ) + if errno != 0 { + return StopNone, 0 + } + if info.SiSigno != int32(syscall.SIGTRAP) { + return StopSignal, uint64(info.SiCode) + } + switch info.SiCode { + case trapBRKPT: + return StopBreakpoint, 0 + case trapHWBRKPT: + // The faulting address is in si_addr (offset 16 in siginfo_t on amd64). + addr := *(*uint64)(unsafe.Pointer(uintptr(unsafe.Pointer(&info)) + 16)) + return StopWatchpoint, addr + default: + return StopSingleStep, 0 + } +} + +// SetReg modifies a register value in the debuggee. +func (s *Session) SetReg(name string, value uint64) error { + regs, err := s.GetRegs() + if err != nil { + return err + } + switch name { + case "rax", "eax", "ax", "al": + regs.RAX = value + case "rbx", "ebx", "bx", "bl": + regs.RBX = value + case "rcx", "ecx", "cx", "cl": + regs.RCX = value + case "rdx", "edx", "dx", "dl": + regs.RDX = value + case "rsi", "esi", "si": + regs.RSI = value + case "rdi", "edi", "di": + regs.RDI = value + case "rbp", "ebp", "bp": + regs.RBP = value + case "rsp", "esp", "sp": + regs.RSP = value + case "r8": + regs.R8 = value + case "r9": + regs.R9 = value + case "r10": + regs.R10 = value + case "r11": + regs.R11 = value + case "r12": + regs.R12 = value + case "r13": + regs.R13 = value + case "r14": + regs.R14 = value + case "r15": + regs.R15 = value + case "rip", "eip": + regs.RIP = value + default: + return fmt.Errorf("debug: unknown register %q", name) + } + return s.SetRegs(®s) +} diff --git a/debug/watchpoint_linux_amd64.go b/debug/watchpoint_linux_amd64.go new file mode 100644 index 0000000..609db70 --- /dev/null +++ b/debug/watchpoint_linux_amd64.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "fmt" + "syscall" +) + +// Hardware watchpoint support via x86-64 debug registers (DR0-DR3, DR7). +// +// DR0-DR3 hold the watched addresses. DR7 is the control register: +// bits 0,2,4,6: local enable for DR0-DR3 +// bits 16-17,20-21,24-25,28-29: R/W type (00=exec, 01=write, 11=read/write) +// bits 18-19,22-23,26-27,30-31: length (00=1, 01=2, 10=8, 11=4) + +// WatchpointType selects what triggers the watchpoint. +type WatchpointType int + +const ( + WatchWrite WatchpointType = 1 // trigger on write + WatchRead WatchpointType = 3 // trigger on read or write +) + +// SetWatchpoint installs a hardware watchpoint on the given address. +// slot is 0-3 (four hardware watchpoints available). +func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { + if slot < 0 || slot > 3 { + return fmt.Errorf("debug: watchpoint slot must be 0-3") + } + + // Determine the length encoding. + var lenBits uint64 + switch size { + case 1: + lenBits = 0 + case 2: + lenBits = 1 + case 4: + lenBits = 3 + case 8: + lenBits = 2 + default: + return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") + } + + // Write the watched address to DR0-DR3. + var drAddr uintptr + switch slot { + case 0: + drAddr = 0x0 // DR0 offset in user_regs_struct + case 1: + drAddr = 0x8 // DR1 + case 2: + drAddr = 0x10 // DR2 + case 3: + drAddr = 0x18 // DR3 + } + + // PTRACE_POKEUSER writes to the debuggee's user area (includes debug regs). + if err := ptracePokeUser(s.pid, drAddr, addr); err != nil { + return fmt.Errorf("debug: set DR%d: %w", slot, err) + } + + // Read the current DR7, set the enable and type bits, write it back. + dr7, err := ptracePeekUser(s.pid, 0x38) // DR7 offset + if err != nil { + return fmt.Errorf("debug: read DR7: %w", err) + } + + enableBit := uint64(1) << (2 * slot) // local enable + rwBits := uint64(typ) << (16 + 4*slot) // R/W type + lenField := lenBits << (18 + 4*slot) // length + + // Clear the existing bits for this slot, then set the new ones. + mask := ^((uint64(1) << (2 * slot)) | (uint64(3) << (16 + 4*slot)) | (uint64(3) << (18 + 4*slot))) + dr7 = (dr7 & mask) | enableBit | rwBits | lenField + + if err := ptracePokeUser(s.pid, 0x38, dr7); err != nil { + return fmt.Errorf("debug: set DR7: %w", err) + } + return nil +} + +// ClearWatchpoint removes a hardware watchpoint. +func (s *Session) ClearWatchpoint(slot int) error { + if slot < 0 || slot > 3 { + return fmt.Errorf("debug: watchpoint slot must be 0-3") + } + // Read DR7, clear the enable bit for this slot. + dr7, err := ptracePeekUser(s.pid, 0x38) + if err != nil { + return err + } + dr7 &^= uint64(1) << (2 * slot) // disable + return ptracePokeUser(s.pid, 0x38, dr7) +} + +// ClearAllWatchpoints removes all hardware watchpoints. +func (s *Session) ClearAllWatchpoints() error { + for slot := 0; slot < 4; slot++ { + if err := s.ClearWatchpoint(slot); err != nil { + return err + } + } + return nil +} + +// ptracePokeUser writes a value to the debuggee's user area at the given offset. +func ptracePokeUser(pid int, offset uintptr, val uint64) error { + const ptracePokeuser = 6 // PTRACE_POKEUSER + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(ptracePokeuser), + uintptr(pid), + offset, + uintptr(val), + 0, 0, + ) + if errno != 0 { + return errno + } + return nil +} + +// ptracePeekUser reads a value from the debuggee's user area at the given offset. +func ptracePeekUser(pid int, offset uintptr) (uint64, error) { + const ptracePeekuser = 3 // PTRACE_PEEKUSER + val, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(ptracePeekuser), + uintptr(pid), + offset, + 0, 0, 0, + ) + if errno != 0 { + return 0, errno + } + return uint64(val), nil +}