feat(debug): complete the interactive debugger with disassembly, breakpoints, watchpoints and execution control

Assisted-by: DeepSeek V4 Pro
This commit is contained in:
2026-08-01 09:47:00 +02:00
parent c77d68018c
commit d114b3412c
9 changed files with 922 additions and 34 deletions
+8 -6
View File
@@ -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
+32 -1
View File
@@ -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 {
+7 -1
View File
@@ -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
}
+118 -1
View File
@@ -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: <reg> <op> <value>.
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)
+52
View File
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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: <error: %v>\n", pc, err)
break
}
result += fmt.Sprintf(" %#08x: %s\n", pc, text)
if length == 0 {
length = 1
}
pc += uint64(length)
}
return result
}
+28
View File
@@ -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 {
+417 -25
View File
@@ -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 <label|addr> | step [n] | continue | regs | x <addr> [len] | labels | quit")
fmt.Println("commands: break <label|addr> | step [n] | continue | disas [n] | regs | where | x <addr> [len] | w <addr> <val...> | 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(&regs)
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(&regs)
}
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(&regs); 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(&regs); 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 <label|addr>")
fmt.Println("usage: break <label|addr|line> [if <reg> <op> <val>]")
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 <reg> <op> <value>"
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 <label|addr> if <reg> <op> <value>")
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 <addr> <byte|0x...> [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 <reg> <value>")
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 <label|addr> [if <reg> <op> <val>] set a breakpoint
delete <label|addr> remove a breakpoint
info break list all breakpoints
watch <addr> [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 <addr> <val...> 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 <addr> [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
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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(&regs)
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
}