diff --git a/cmd/gasm/debug_linux.go b/cmd/gasm/debug_linux.go index 55fb948..afe5a04 100644 --- a/cmd/gasm/debug_linux.go +++ b/cmd/gasm/debug_linux.go @@ -53,7 +53,7 @@ REPL commands: bufSpec := fs.String("buf", "", "buffer specification: name:size:pattern[,name:size:pattern...] where pattern is zero, ones, seq, or hex") script := fs.String("script", "", "run REPL commands from a file (one per line) and exit; '-' reads stdin") cover := fs.Bool("cover", false, "run to completion with a breakpoint on every instruction and report which executed and how often") - timeout := fs.Duration("timeout", 0, "kill the debuggee after this duration (e.g. 30s); for headless --script runs") + timeout := fs.Duration("timeout", 0, "kill the debuggee after this duration (e.g. 30s); for headless --script runs; a timeout exits 3") fs.Parse(args) // --- Debuggee mode (internal, spawned by the debugger) --- @@ -231,6 +231,13 @@ REPL commands: if sess.Exited() { break } + // A genuine signal-delivery-stop (a fault in the kernel): the + // run cannot make progress, because resuming would restart the + // faulting instruction and fault forever. Report and stop. + if sig := sess.LastSignal(); sig != 0 { + fmt.Printf("gasm debug: cover: stopped on signal %v\n", sig) + break + } regs, rerr := sess.GetRegs() if rerr != nil { break diff --git a/debug/breakpoint.go b/debug/breakpoint.go index 985efea..f9e4ec9 100644 --- a/debug/breakpoint.go +++ b/debug/breakpoint.go @@ -9,11 +9,11 @@ import "strings" import "fmt" -// Breakpoint is one INT3 breakpoint in the debuggee. +// Breakpoint is one software breakpoint in the debuggee. type Breakpoint struct { Addr uint64 // absolute address in the debuggee Label string // source label ("" for raw addresses) - Orig byte // original byte at Addr (restored on removal) + Orig []byte // original bytes at Addr (restored on removal) Enabled bool Cond *Condition // optional condition (nil = unconditional) hits int @@ -32,8 +32,12 @@ type Condition struct { MemAddr uint64 // memory address (for register-memory comparison, prefixed with *) } -// Eval checks the condition against the current registers. -func (c *Condition) Eval(regs *Regs) bool { +// Eval checks the condition against the current registers. For the +// register-memory form, mem reads an 8-byte little-endian word from the +// debuggee; it may be nil when no reader is available. Anything that cannot +// be decided (unknown register or operator, unreadable memory) does not +// block the breakpoint. +func (c *Condition) Eval(regs *Regs, mem func(addr uint64) (uint64, bool)) bool { actual, ok := regs.RegValue(c.Reg) if !ok { return true // unknown register, don't block @@ -48,9 +52,16 @@ func (c *Condition) Eval(regs *Regs) bool { } expected = v case c.MemAddr != 0: - // Register-memory comparison, requires a Session, not available here. - // Fall back to treating as constant (the caller should resolve). - expected = c.Value + // Register-memory comparison, resolved in the debuggee at + // evaluation time. + if mem == nil { + return true + } + v, ok := mem(c.MemAddr) + if !ok { + return true + } + expected = v default: expected = c.Value } @@ -72,6 +83,18 @@ func (c *Condition) Eval(regs *Regs) bool { } } +// String renders the condition for display. +func (c *Condition) String() string { + switch { + case c.Reg2 != "": + return fmt.Sprintf("%s %s %s", c.Reg, c.Op, c.Reg2) + case c.MemAddr != 0: + return fmt.Sprintf("%s %s *%#x", c.Reg, c.Op, c.MemAddr) + default: + return fmt.Sprintf("%s %s %#x", c.Reg, c.Op, c.Value) + } +} + // Breakpoints manages the software breakpoints of one Session. type Breakpoints struct { t tracer @@ -83,6 +106,18 @@ func NewBreakpoints(t tracer) *Breakpoints { return &Breakpoints{t: t, bps: make(map[uint64]*Breakpoint)} } +// breakpointMask is the byte mask of the breakpoint instruction inside a +// peeked word: the low len(breakpointInsn) bytes, because every supported +// architecture is little-endian and patches the instruction at the lowest +// address of the word. +func breakpointMask() uint64 { + var mask uint64 + for range breakpointInsn { + mask = (mask << 8) | 0xFF + } + return mask +} + // 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) @@ -100,13 +135,12 @@ func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) ( if err != nil { return nil, err } - orig := byte(word) - // Patch with the breakpoint instruction, preserving the rest of the word. - mask := uint64(0) - for range breakpointInsn { - mask = (mask << 8) | 0xFF + orig := make([]byte, len(breakpointInsn)) + for i := range orig { + orig[i] = byte(word >> (8 * i)) } - patched := (word &^ mask) | breakpointWord(breakpointInsn) + // Patch with the breakpoint instruction, preserving the rest of the word. + patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn) if err := bm.t.Poke(addr, patched); err != nil { return nil, err } @@ -134,26 +168,40 @@ func (bm *Breakpoints) Info() string { } cond := "" if bp.Cond != nil { - cond = fmt.Sprintf(" if %s %s %#x", bp.Cond.Reg, bp.Cond.Op, bp.Cond.Value) + cond = " if " + bp.Cond.String() } result.WriteString(fmt.Sprintf(" %d: %s at %#x [%s, %d hits]%s\n", i, label, bp.Addr, status, bp.hits, cond)) } return result.String() } -// Clear removes the breakpoint at addr, restoring the original byte. +// restore writes the saved original bytes back over the breakpoint +// instruction, preserving the rest of the peeked word. It reports whether +// both the peek and the poke succeeded. +func (bm *Breakpoints) restore(addr uint64, bp *Breakpoint) bool { + word, err := bm.t.Peek(addr) + if err != nil { + return false + } + orig := uint64(0) + for i, b := range bp.Orig { + orig |= uint64(b) << (8 * i) + } + return bm.t.Poke(addr, (word&^breakpointMask())|orig) == nil +} + +// Clear removes the breakpoint at addr, restoring the original bytes. func (bm *Breakpoints) Clear(addr uint64) error { bp, ok := bm.bps[addr] if !ok { return fmt.Errorf("debug: no breakpoint at %#x", addr) } - word, err := bm.t.Peek(addr) - if err != nil { - return err - } - restored := (word &^ 0xFF) | uint64(bp.Orig) - if err := bm.t.Poke(addr, restored); err != nil { - return err + if !bm.restore(addr, bp) { + word, err := bm.t.Peek(addr) + if err != nil { + return err + } + return fmt.Errorf("debug: restore breakpoint at %#x failed, word is %#x", addr, word) } delete(bm.bps, addr) return nil @@ -185,43 +233,54 @@ func (bm *Breakpoints) All() []*Breakpoint { // HandleTrap is called after the debuggee stops on SIGTRAP. It checks // whether the trap was caused by one of our breakpoints (PC-adjust matches -// a breakpoint address), restores the original byte, rewinds PC, and +// a breakpoint address), restores the original bytes, rewinds PC, and // returns the breakpoint that was hit (or nil if it was a single-step). // Hits returns how many times the breakpoint has been hit. func (bp *Breakpoint) Hits() int { return bp.hits } func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint { - // After a breakpoint trap, PC points past the breakpoint instruction. + // On amd64 the kernel reports the trap with RIP past the INT3; on the + // other supported architectures the PC still stands on the trap + // instruction, which breakpointPCAdjust encodes per architecture. trapAddr := regs.GetPC() - uint64(breakpointPCAdjust) bp, ok := bm.bps[trapAddr] 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.t.Peek(trapAddr) - if err == nil { - restored := (word &^ 0xFF) | uint64(bp.Orig) - bm.t.Poke(trapAddr, restored) + if bp.Cond != nil && !bp.Cond.Eval(regs, bm.peekValue) { + // Condition not met: step the original instruction and re-arm the + // breakpoint, leaving the debuggee stopped just past it, ready to + // resume silently. The PC must be rewound first: on architectures + // that report the trap past the instruction (amd64) it would + // otherwise sit on the second byte of the replaced instruction. + if !bm.restore(trapAddr, bp) { + return nil } - // RIP is already past the INT3 (trapAddr + 1). Don't rewind. + regs.SetPC(trapAddr) + if err := bm.t.SetRegs(regs); err != nil { + return nil + } + if err := bm.t.Step(); err != nil { + return nil + } + bm.Reinsert(trapAddr) return nil } bp.hits++ - // Restore the original byte. - word, err := bm.t.Peek(trapAddr) - if err == nil { - restored := (word &^ 0xFF) | uint64(bp.Orig) - bm.t.Poke(trapAddr, restored) - } - // Rewind PC to re-execute the original instruction. + // Restore the original bytes and rewind PC to re-execute them. + bm.restore(trapAddr, bp) regs.SetPC(trapAddr) bm.t.SetRegs(regs) return bp } +// peekValue adapts tracer.Peek to the Condition value reader. +func (bm *Breakpoints) peekValue(addr uint64) (uint64, bool) { + v, err := bm.t.Peek(addr) + return v, err == nil +} + // Reinsert re-inserts the breakpoint at addr after a single-step past it. // Called after Step() when we want the breakpoint to fire again on the // next Continue(). @@ -234,11 +293,7 @@ func (bm *Breakpoints) Reinsert(addr uint64) error { if err != nil { return err } - mask := uint64(0) - for range breakpointInsn { - mask = (mask << 8) | 0xFF - } - patched := (word &^ mask) | breakpointWord(breakpointInsn) + patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn) return bm.t.Poke(addr, patched) } diff --git a/debug/debug_arch_test.go b/debug/debug_arch_test.go new file mode 100644 index 0000000..505bd22 --- /dev/null +++ b/debug/debug_arch_test.go @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux + +package debug + +// Architecture-neutral tests: label and line tables, and the breakpoint +// manager against the mock tracer. These do not launch a debuggee, so they +// build on every supported linux architecture. + +import ( + "strings" + "testing" +) + +func TestLineAt(t *testing.T) { + lines := []SourceLine{ + {Offset: 0, Line: 5}, + {Offset: 5, Line: 6}, + {Offset: 10, Line: 7}, + {Offset: 15, Line: 8}, + } + + tests := []struct { + offset int + want int + }{ + {0, 5}, + {1, 5}, + {4, 5}, + {5, 6}, + {7, 6}, + {10, 7}, + {12, 7}, + {15, 8}, + {20, 8}, + } + + for _, tt := range tests { + got := lineAt(lines, tt.offset) + if got != tt.want { + t.Errorf("lineAt(lines, %d) = %d, want %d", tt.offset, got, tt.want) + } + } + + // Empty table. + if lineAt(nil, 5) != 0 { + t.Error("lineAt(nil, 5) should return 0") + } +} + +func TestOffsetForLine(t *testing.T) { + lines := []SourceLine{ + {Offset: 0, Line: 5}, + {Offset: 5, Line: 6}, + {Offset: 10, Line: 7}, + } + + tests := []struct { + line int + want int + }{ + {5, 0}, + {6, 5}, + {7, 10}, + {99, -1}, // not found + {0, -1}, // not found + } + + for _, tt := range tests { + got := offsetForLine(lines, tt.line) + if got != tt.want { + t.Errorf("offsetForLine(lines, %d) = %d, want %d", tt.line, got, tt.want) + } + } +} + +func TestNearestLabel(t *testing.T) { + labels := []Label{ + {Name: "start", Offset: 0}, + {Name: "loop", Offset: 10}, + {Name: "done", Offset: 20}, + } + + tests := []struct { + offset int + want string + }{ + {0, "start"}, + {5, "start"}, + {10, "loop"}, + {15, "loop"}, + {20, "done"}, + {25, "done"}, + } + + for _, tt := range tests { + got := nearestLabel(labels, tt.offset) + if got != tt.want { + t.Errorf("nearestLabel(labels, %d) = %q, want %q", tt.offset, got, tt.want) + } + } +} + +func TestBreakpointsSetAndClear(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + // Set a breakpoint at address 0x1000. + bp, err := bm.Set(0x1000, "test") + if err != nil { + t.Fatalf("Set: %v", err) + } + if !bp.Enabled { + t.Error("breakpoint not enabled") + } + if bp.Label != "test" { + t.Errorf("label = %q, want test", bp.Label) + } + + // Verify Peek was called. + if len(tr.peeks) != 1 || tr.peeks[0] != 0x1000 { + t.Errorf("peeks = %v, want [0x1000]", tr.peeks) + } + + // Verify Poke wrote the breakpoint instruction's bytes. + if len(tr.pokes) != 1 || tr.pokes[0].addr != 0x1000 { + t.Errorf("pokes = %v", tr.pokes) + } + if got := tr.pokes[0].val & breakpointMask(); got != breakpointWord(breakpointInsn) { + t.Errorf("patched bytes %#x, want %#x", got, breakpointWord(breakpointInsn)) + } + + // At should find it. + if bm.At(0x1000) == nil { + t.Error("At(0x1000) returned nil") + } + + // All should return it. + all := bm.All() + if len(all) != 1 { + t.Errorf("All() = %d breakpoints, want 1", len(all)) + } + + // Clear it. + if err := bm.Clear(0x1000); err != nil { + t.Fatalf("Clear: %v", err) + } + if bm.At(0x1000) != nil { + t.Error("At(0x1000) after Clear should be nil") + } +} + +// TestBreakpointRestoreWidth proves the restore path writes back every +// byte of the breakpoint instruction's width, not just the first byte: on +// arm64, riscv64 and loong64 the instruction is four bytes, and restoring +// one byte would leave three bytes of the trap instruction in place. +func TestBreakpointRestoreWidth(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + tr.mem[0x3000] = 0x11 + tr.mem[0x3001] = 0x22 + tr.mem[0x3002] = 0x33 + tr.mem[0x3003] = 0x44 + + if _, err := bm.Set(0x3000, "width"); err != nil { + t.Fatalf("Set: %v", err) + } + for i, b := range breakpointInsn { + if tr.mem[0x3000+uint64(i)] != b { + t.Fatalf("byte %d after Set = %#x, want the breakpoint byte %#x", i, tr.mem[0x3000+uint64(i)], b) + } + } + if len(bm.At(0x3000).Orig) != len(breakpointInsn) { + t.Fatalf("Orig holds %d bytes, want %d", len(bm.At(0x3000).Orig), len(breakpointInsn)) + } + + if err := bm.Clear(0x3000); err != nil { + t.Fatalf("Clear: %v", err) + } + want := []byte{0x11, 0x22, 0x33, 0x44} + for i, b := range want { + if tr.mem[0x3000+uint64(i)] != b { + t.Errorf("byte %d after Clear = %#x, want %#x (restore must cover the full instruction width)", i, tr.mem[0x3000+uint64(i)], b) + } + } +} + +func TestBreakpointsSetWithCond(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + cond := &Condition{Reg: "rax", Op: "==", Value: 42} + bp, err := bm.SetWithCond(0x2000, "cond_test", cond) + if err != nil { + t.Fatalf("SetWithCond: %v", err) + } + if bp.Cond == nil || bp.Cond.Value != 42 { + t.Error("condition not set") + } + + // Re-setting the same address should update the condition. + cond2 := &Condition{Reg: "rbx", Op: "<", Value: 100} + bp2, err := bm.SetWithCond(0x2000, "cond_test2", cond2) + if err != nil { + t.Fatalf("SetWithCond (update): %v", err) + } + if bp2.Cond.Value != 100 { + t.Error("condition not updated") + } + // Should have only 1 Peek (first Set), second is update (no Peek needed). + if len(tr.peeks) != 1 { + t.Errorf("expected 1 Peek, got %d", len(tr.peeks)) + } +} + +func TestBreakpointsClearAll(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + bm.Set(0x1000, "a") + bm.Set(0x2000, "b") + bm.Set(0x3000, "c") + + if len(bm.All()) != 3 { + t.Fatalf("expected 3 breakpoints, got %d", len(bm.All())) + } + + bm.ClearAll() + if len(bm.All()) != 0 { + t.Errorf("ClearAll: expected 0 breakpoints, got %d", len(bm.All())) + } +} + +func TestBreakpointInfo(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + bm.Set(0x4000, "info_test") + + info := bm.Info() + if info == "" { + t.Error("Info returned empty string") + } + if !strings.Contains(info, "info_test") { + t.Errorf("Info %q does not contain label", info) + } +} + +// TestConditionString covers the display of all three condition forms. +func TestConditionString(t *testing.T) { + tests := []struct { + cond Condition + want string + }{ + {Condition{Reg: "rax", Op: "==", Value: 42}, "rax == 0x2a"}, + {Condition{Reg: "rax", Op: "!=", Reg2: "rbx"}, "rax != rbx"}, + {Condition{Reg: "rax", Op: "<", MemAddr: 0x5000}, "rax < *0x5000"}, + } + for _, tt := range tests { + if got := tt.cond.String(); got != tt.want { + t.Errorf("Condition.String() = %q, want %q", got, tt.want) + } + } +} diff --git a/debug/debug_test.go b/debug/debug_test.go index bb47ed7..09b1516 100644 --- a/debug/debug_test.go +++ b/debug/debug_test.go @@ -6,7 +6,6 @@ package debug import ( - "strings" "testing" ) @@ -48,7 +47,7 @@ func TestConditionEval(t *testing.T) { } for _, tt := range tests { - got := tt.cond.Eval(regs) + got := tt.cond.Eval(regs, nil) if got != tt.want { t.Errorf("Condition{%q %q %d}.Eval() = %v, want %v", tt.cond.Reg, tt.cond.Op, tt.cond.Value, got, tt.want) @@ -56,65 +55,33 @@ func TestConditionEval(t *testing.T) { } } -func TestLineAt(t *testing.T) { - lines := []SourceLine{ - {Offset: 0, Line: 5}, - {Offset: 5, Line: 6}, - {Offset: 10, Line: 7}, - {Offset: 15, Line: 8}, - } - - tests := []struct { - offset int - want int - }{ - {0, 5}, - {1, 5}, - {4, 5}, - {5, 6}, - {7, 6}, - {10, 7}, - {12, 7}, - {15, 8}, - {20, 8}, - } - - for _, tt := range tests { - got := lineAt(lines, tt.offset) - if got != tt.want { - t.Errorf("lineAt(lines, %d) = %d, want %d", tt.offset, got, tt.want) +// TestConditionEvalMem covers the register-memory form: the value is read +// through the supplied reader, and a missing or failing reader must not +// block the breakpoint. +func TestConditionEvalMem(t *testing.T) { + regs := &Regs{RAX: 7} + mem := func(addr uint64) (uint64, bool) { + if addr == 0x5000 { + return 7, true } + return 0, false } - // Empty table. - if lineAt(nil, 5) != 0 { - t.Error("lineAt(nil, 5) should return 0") + eq := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000} + if !eq.Eval(regs, mem) { + t.Error("register-memory comparison with matching word should hold") } -} - -func TestOffsetForLine(t *testing.T) { - lines := []SourceLine{ - {Offset: 0, Line: 5}, - {Offset: 5, Line: 6}, - {Offset: 10, Line: 7}, + ne := Condition{Reg: "rax", Op: "!=", MemAddr: 0x5000} + if ne.Eval(regs, mem) { + t.Error("register-memory comparison with mismatching word should not hold") } - - tests := []struct { - line int - want int - }{ - {5, 0}, - {6, 5}, - {7, 10}, - {99, -1}, // not found - {0, -1}, // not found + bad := Condition{Reg: "rax", Op: "==", MemAddr: 0x6000} + if !bad.Eval(regs, mem) { + t.Error("unreadable memory must not block the breakpoint") } - - for _, tt := range tests { - got := offsetForLine(lines, tt.line) - if got != tt.want { - t.Errorf("offsetForLine(lines, %d) = %d, want %d", tt.line, got, tt.want) - } + noReader := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000} + if !noReader.Eval(regs, nil) { + t.Error("missing memory reader must not block the breakpoint") } } @@ -141,139 +108,6 @@ func TestDecodeRflags(t *testing.T) { } } -func TestNearestLabel(t *testing.T) { - labels := []Label{ - {Name: "start", Offset: 0}, - {Name: "loop", Offset: 10}, - {Name: "done", Offset: 20}, - } - - tests := []struct { - offset int - want string - }{ - {0, "start"}, - {5, "start"}, - {10, "loop"}, - {15, "loop"}, - {20, "done"}, - {25, "done"}, - } - - for _, tt := range tests { - got := nearestLabel(labels, tt.offset) - if got != tt.want { - t.Errorf("nearestLabel(labels, %d) = %q, want %q", tt.offset, got, tt.want) - } - } -} - -func TestBreakpointsSetAndClear(t *testing.T) { - tr := newMockTracer() - bm := NewBreakpoints(tr) - - // Set a breakpoint at address 0x1000. - bp, err := bm.Set(0x1000, "test") - if err != nil { - t.Fatalf("Set: %v", err) - } - if !bp.Enabled { - t.Error("breakpoint not enabled") - } - if bp.Label != "test" { - t.Errorf("label = %q, want test", bp.Label) - } - - // Verify Peek was called. - if len(tr.peeks) != 1 || tr.peeks[0] != 0x1000 { - t.Errorf("peeks = %v, want [0x1000]", tr.peeks) - } - - // Verify Poke wrote INT3. - if len(tr.pokes) != 1 || tr.pokes[0].addr != 0x1000 { - t.Errorf("pokes = %v", tr.pokes) - } - - // At should find it. - if bm.At(0x1000) == nil { - t.Error("At(0x1000) returned nil") - } - - // All should return it. - all := bm.All() - if len(all) != 1 { - t.Errorf("All() = %d breakpoints, want 1", len(all)) - } - - // Clear it. - if err := bm.Clear(0x1000); err != nil { - t.Fatalf("Clear: %v", err) - } - if bm.At(0x1000) != nil { - t.Error("At(0x1000) after Clear should be nil") - } -} - -func TestBreakpointsSetWithCond(t *testing.T) { - tr := newMockTracer() - bm := NewBreakpoints(tr) - - cond := &Condition{Reg: "rax", Op: "==", Value: 42} - bp, err := bm.SetWithCond(0x2000, "cond_test", cond) - if err != nil { - t.Fatalf("SetWithCond: %v", err) - } - if bp.Cond == nil || bp.Cond.Value != 42 { - t.Error("condition not set") - } - - // Re-setting the same address should update the condition. - cond2 := &Condition{Reg: "rbx", Op: "<", Value: 100} - bp2, err := bm.SetWithCond(0x2000, "cond_test2", cond2) - if err != nil { - t.Fatalf("SetWithCond (update): %v", err) - } - if bp2.Cond.Value != 100 { - t.Error("condition not updated") - } - // Should have only 1 Peek (first Set), second is update (no Peek needed). - if len(tr.peeks) != 1 { - t.Errorf("expected 1 Peek, got %d", len(tr.peeks)) - } -} - -func TestBreakpointsClearAll(t *testing.T) { - tr := newMockTracer() - bm := NewBreakpoints(tr) - - bm.Set(0x1000, "a") - bm.Set(0x2000, "b") - bm.Set(0x3000, "c") - - if len(bm.All()) != 3 { - t.Fatalf("expected 3 breakpoints, got %d", len(bm.All())) - } - - bm.ClearAll() - if len(bm.All()) != 0 { - t.Errorf("ClearAll: expected 0 breakpoints, got %d", len(bm.All())) - } -} - -func TestBreakpointInfo(t *testing.T) { - tr := newMockTracer() - bm := NewBreakpoints(tr) - bm.Set(0x4000, "info_test") - - info := bm.Info() - if info == "" { - t.Error("Info returned empty string") - } - if !strings.Contains(info, "info_test") { - t.Errorf("Info %q does not contain label", info) - } -} - func TestWatchpointSlotTracking(t *testing.T) { s := &Session{} // per-session slots start free @@ -323,3 +157,19 @@ func TestWatchpointSlotTracking(t *testing.T) { t.Errorf("FindFreeWatchpointSlot() with all slots used = %d, want -1", got) } } + +// TestUnwatchSlotBound checks the bound the REPL parses against: it must +// cover the architecture's whole slot range, not a hardcoded 0-3. +func TestUnwatchSlotBound(t *testing.T) { + max := maxWatchpoints() + if max < 4 { + t.Fatalf("maxWatchpoints() = %d, want at least 4", max) + } + s := &Session{} + if s.IsWatchpointSlotUsed(max - 1) { + t.Errorf("slot %d should be free initially", max-1) + } + if s.IsWatchpointSlotUsed(max) { + t.Errorf("slot %d must be out of range", max) + } +} diff --git a/debug/disasm_linux_amd64.go b/debug/disasm_linux_amd64.go index 1cd1700..cc2b1c2 100644 --- a/debug/disasm_linux_amd64.go +++ b/debug/disasm_linux_amd64.go @@ -46,3 +46,11 @@ func (s *Session) DisassembleN(addr uint64, n int) string { } return result.String() } + +// isCallInsn reports whether disassembled text (x86asm.IntelSyntax) is a +// call. The first token must match exactly: a prefix test would also catch +// unrelated mnemonics. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + return strings.ToLower(m) == "call" +} diff --git a/debug/disasm_linux_arm64.go b/debug/disasm_linux_arm64.go index fef8162..64d9a84 100644 --- a/debug/disasm_linux_arm64.go +++ b/debug/disasm_linux_arm64.go @@ -7,6 +7,7 @@ package debug import ( "fmt" + "strings" "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/disasm" @@ -44,3 +45,15 @@ func (s *Session) DisassembleN(addr uint64, n int) string { } return result } + +// isCallInsn reports whether disassembled text (arm64asm.GoSyntax) is a +// call. GoSyntax renders bl as CALL; the native mnemonic is accepted too. +// The first token must match exactly so branches never match. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + switch strings.ToLower(m) { + case "call", "bl": + return true + } + return false +} diff --git a/debug/disasm_linux_loong64.go b/debug/disasm_linux_loong64.go index 4dc5ed2..ac4a54a 100644 --- a/debug/disasm_linux_loong64.go +++ b/debug/disasm_linux_loong64.go @@ -7,6 +7,7 @@ package debug import ( "fmt" + "strings" "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/disasm" @@ -44,3 +45,16 @@ func (s *Session) DisassembleN(addr uint64, n int) string { } return result } + +// isCallInsn reports whether disassembled text (loong64asm.GoSyntax) is a +// call. GoSyntax renders bl and jirl calls as CALL (jirl returns print +// RET); the native mnemonics are accepted too. The first token must match +// exactly: a "bl" prefix would catch bltz and other branches. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + switch strings.ToLower(m) { + case "call", "bl", "jirl": + return true + } + return false +} diff --git a/debug/disasm_linux_riscv64.go b/debug/disasm_linux_riscv64.go index 34c6efb..f81c9b7 100644 --- a/debug/disasm_linux_riscv64.go +++ b/debug/disasm_linux_riscv64.go @@ -7,6 +7,7 @@ package debug import ( "fmt" + "strings" "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/disasm" @@ -44,3 +45,17 @@ func (s *Session) DisassembleN(addr uint64, n int) string { } return result } + +// isCallInsn reports whether disassembled text (riscv64asm.GoSyntax) is a +// call. GoSyntax renders jal and jalr calls as CALL; the native mnemonics +// are accepted too. The first token must match exactly: a prefix test on +// "bl" would catch branches on other architectures, and jalr as ret prints +// RET, which must not be stepped over. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + switch strings.ToLower(m) { + case "call", "jal", "jalr": + return true + } + return false +} diff --git a/debug/display_linux_amd64.go b/debug/display_linux_amd64.go index 507f4dc..2acd672 100644 --- a/debug/display_linux_amd64.go +++ b/debug/display_linux_amd64.go @@ -73,9 +73,29 @@ func decodeRflags(f uint64) string { return flags[:len(flags)-1] } -// archReturnAddr reads the return address from the stack (amd64 ABI0 convention). +// archReturnAddr reads the return address of the current frame (amd64 +// ABI0 convention). A function that contains a CALL (or has a frame) is +// assembled with the prologue PUSHQ BP; MOVQ SP, BP, so mid-function the +// word at SP is the saved caller BP, a stack address, and the return +// address sits further up. Walk the stack from SP and take the first word +// that lies in an executable mapping: stack and data words never do, a +// return address always does. func archReturnAddr(s *Session, regs *Regs) (uint64, error) { - return s.Peek(regs.GetSP()) + ranges := execRanges(s.pid) + for off := uint64(0); off < 512; off += 8 { + word, err := s.Peek(regs.RSP + off) + if err != nil { + break + } + for _, r := range ranges { + if word >= r.lo && word < r.hi { + return word, nil + } + } + } + // No mapping available or nothing code-like on the stack: fall back to + // the raw entry convention, [SP] before any push. + return s.Peek(regs.RSP) } // archSPLabel returns the SP register name for display. diff --git a/debug/display_linux_arm64.go b/debug/display_linux_arm64.go index d116cd0..921a4e1 100644 --- a/debug/display_linux_arm64.go +++ b/debug/display_linux_arm64.go @@ -5,7 +5,10 @@ package debug -import "fmt" +import ( + "encoding/binary" + "fmt" +) func printRegs(regs *Regs, codeBase, funcOff uint64) { fmt.Printf(" PC = %#016x (func+%#x)\n", regs.PC, regs.PC-codeBase-funcOff) @@ -31,8 +34,8 @@ func printRegs(regs *Regs, codeBase, funcOff uint64) { func printVectorRegs(v *VectorRegs) { fmt.Println("\n Vector registers (V0-V31):") for i := 0; i < 32; i += 2 { - fmt.Printf(" V%-2d = %016x%016x\n", i, v.V[i][8], v.V[i][0]) - fmt.Printf(" V%-2d = %016x%016x\n", i+1, v.V[i+1][8], v.V[i+1][0]) + fmt.Printf(" V%-2d = %016x%016x\n", i, binary.LittleEndian.Uint64(v.V[i][8:16]), binary.LittleEndian.Uint64(v.V[i][0:8])) + fmt.Printf(" V%-2d = %016x%016x\n", i+1, binary.LittleEndian.Uint64(v.V[i+1][8:16]), binary.LittleEndian.Uint64(v.V[i+1][0:8])) } } diff --git a/debug/ptrace_extra_test.go b/debug/ptrace_extra_test.go new file mode 100644 index 0000000..130a53c --- /dev/null +++ b/debug/ptrace_extra_test.go @@ -0,0 +1,427 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + "unsafe" + + "sourcedock.dev/petrbalvin/gasm-devkit/asm" + "sourcedock.dev/petrbalvin/gasm-devkit/verify" +) + +// Integration tests beyond the basic entry breakpoint: hardware watchpoints, +// conditional breakpoints, next/finish over a CALL, faulting kernels and the +// xstate vector-register readout. All drive a real ptrace session, so they +// run on amd64 hosts only. + +// writeKernel writes an assembly source to a temporary file with the +// architecture suffix the assembler dispatcher expects. +func writeKernel(t *testing.T, src string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "kernel_amd64.s") + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + return path +} + +// launchKernel launches a session for the kernel source and returns the +// session, its breakpoint manager and the function layout. +func launchKernel(t *testing.T, bin, path, funcName string, args []byte) (*Session, *Breakpoints, asm.FuncLayout) { + t.Helper() + k, err := verify.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + fl, err := k.Func(funcName) + if err != nil { + t.Fatalf("Func: %v", err) + } + if len(args) < fl.Args { + padded := make([]byte, fl.Args) + copy(padded, args) + args = padded + } + sess, err := Launch(bin, path, funcName, args) + if err != nil { + t.Fatalf("Launch: %v", err) + } + t.Cleanup(sess.Kill) + bm := NewBreakpoints(sess) + return sess, bm, fl +} + +// runToEntry resumes the freshly launched debuggee until the breakpoint at +// the function entry traps, mirroring the REPL continue loop: the debuggee +// SIGSTOPs twice (launch barrier and entry barrier) before entering the JIT +// call. +func runToEntry(t *testing.T, sess *Session, bm *Breakpoints, entry uint64) { + t.Helper() + for range 50 { + for _, bp := range bm.All() { + bm.Reinsert(bp.Addr) + } + if err := sess.Continue(); err != nil { + t.Fatalf("Continue: %v", err) + } + if sess.Exited() { + t.Fatal("debuggee exited before the entry breakpoint trapped") + } + regs, err := sess.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + if bm.HandleTrap(®s) != nil { + return + } + } + t.Fatal("no entry breakpoint trap after 50 resumes") +} + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns +// what it printed (the REPL writes its reports to stdout). +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stdout + os.Stdout = w + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + defer func() { os.Stdout = old }() + fn() + w.Close() + return <-done +} + +// TestWatchpointArmRunHit proves the debug-register offsets: the watchpoint +// must fire on the store, with si_addr naming the watched address. The +// kernel writes its return value to ret+0(FP), which is the 8-byte word +// right above the stack pointer at entry. +func TestWatchpointArmRunHit(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + + const kernel = `#include "textflag.h" + +// func wpret() int64 +TEXT ·wpret(SB), NOSPLIT, $0-8 + MOVQ $0x5a5a5a5a5a5a5a5a, AX + MOVQ AX, ret+0(FP) + RET +` + path := writeKernel(t, kernel) + sess, bm, fl := launchKernel(t, bin, path, "wpret", nil) + + entry := sess.CodeBase() + uint64(fl.Offset) + if _, err := bm.Set(entry, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + runToEntry(t, sess, bm, entry) + + regs, err := sess.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + watched := regs.RSP + 8 // ret+0(FP): the store target + + slot := sess.FindFreeWatchpointSlot() + if slot < 0 { + t.Fatal("no free watchpoint slot") + } + if err := sess.SetWatchpoint(slot, watched, WatchWrite, 8); err != nil { + t.Fatalf("SetWatchpoint: %v (wrong debug-register offsets?)", err) + } + + if err := sess.Continue(); err != nil { + t.Fatalf("Continue: %v", err) + } + reason, addr := sess.StopInfo() + if reason != StopWatchpoint { + t.Fatalf("stop reason = %v, want StopWatchpoint (DR0-DR3/DR7 offsets are wrong)", reason) + } + if addr != watched { + t.Fatalf("watchpoint address = %#x, want %#x", addr, watched) + } + + // The watched word holds the stored value: x86 data breakpoints are + // reported with the access complete. + if word, err := sess.Peek(watched); err != nil || word != 0x5a5a5a5a5a5a5a5a { + t.Errorf("watched word = %#x (err %v), want 0x5a5a5a5a5a5a5a5a", word, err) + } + if err := sess.ClearWatchpoint(slot); err != nil { + t.Fatalf("ClearWatchpoint: %v", err) + } +} + +// TestConditionalBreakpointFalseThenTrue proves the false-condition path: +// the breakpoint steps over the original instruction, re-arms itself and +// keeps running silently, and the true condition stops exactly once with the +// register in the expected state. +func TestConditionalBreakpointFalseThenTrue(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + + const kernel = `#include "textflag.h" + +// func countdown(n int64) int64 +TEXT ·countdown(SB), NOSPLIT, $0-16 + MOVQ n+0(FP), CX +loop: + DECQ CX + CMPQ CX, $0 + JNE loop + MOVQ CX, ret+8(FP) + RET +` + path := writeKernel(t, kernel) + sess, bm, fl := launchKernel(t, bin, path, "countdown", []byte{8}) + + loopAddr := sess.CodeBase() + uint64(fl.Offset) + uint64(fl.Labels["loop"]) + // The length of the breakpointed instruction, from a disassembly taken + // before the INT3 is patched in. + _, insnLen, err := sess.Disassemble(loopAddr) + if err != nil || insnLen <= 0 { + t.Fatalf("Disassemble at %#x: len=%d err=%v", loopAddr, insnLen, err) + } + cond := &Condition{Reg: "rcx", Op: "==", Value: 1} + bp, err := bm.SetWithCond(loopAddr, "loop", cond) + if err != nil { + t.Fatalf("SetWithCond: %v", err) + } + + hits := 0 + exited := false + for range 200 { + for _, b := range bm.All() { + bm.Reinsert(b.Addr) + } + if err := sess.Continue(); err != nil { + exited = true + break // the debuggee finished + } + if sess.Exited() { + exited = true + break + } + if sig := sess.LastSignal(); sig != 0 { + t.Fatalf("unexpected signal stop %v", sig) + } + regs, err := sess.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + if hit := bm.HandleTrap(®s); hit != nil { + hits++ + if regs.RCX != 1 { + t.Fatalf("hit with RCX=%d, want 1", regs.RCX) + } + // Park after the instruction, as the REPL does. + if err := sess.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + } else { + // A false evaluation must leave the debuggee past the whole + // original instruction: a PC inside it (trapAddr+1 on amd64) + // means the resume happens mid-instruction. + fresh, err := sess.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + if fresh.RIP > loopAddr && fresh.RIP < loopAddr+uint64(insnLen) { + t.Fatalf("false evaluation left the PC at %#x, inside the %d-byte instruction at %#x", + fresh.RIP, insnLen, loopAddr) + } + } + } + if hits != 1 { + t.Fatalf("conditional breakpoint hit %d times, want exactly 1 (false evaluations must run through silently)", hits) + } + if bp.Hits() != 1 { + t.Errorf("bp.Hits() = %d, want 1", bp.Hits()) + } + if !exited || !sess.Exited() { + t.Fatal("debuggee did not run to completion after the conditional hit") + } +} + +// TestNextAndFinishOverCall proves next and finish evaluate the trap with +// registers fetched after the stop: next lands exactly on the instruction +// after the CALL, and finish stops exactly on the return address. +func TestNextAndFinishOverCall(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + + const kernel = `#include "textflag.h" + +// func caller(x int64) int64 +// The argument travels in AX: FP argument slots of CALL-bearing functions +// are an assembler concern outside this test's scope. +TEXT ·caller(SB), NOSPLIT, $0-16 + MOVQ $5, AX + CALL ·bump(SB) +aftercall: + MOVQ AX, ret+8(FP) + RET + +// func bump(x int64) int64 +TEXT ·bump(SB), NOSPLIT, $0-0 + ADDQ $3, AX + RET +` + path := writeKernel(t, kernel) + + // next: step the prologue and the constant load (3 instructions), then + // step over the CALL and check the landing address and RAX. + sess, bm, fl := launchKernel(t, bin, path, "caller", nil) + entry := sess.CodeBase() + uint64(fl.Offset) + if _, err := bm.Set(entry, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + runToEntry(t, sess, bm, entry) + afterOff := uint64(fl.Labels["aftercall"]) + + out := captureStdout(t, func() { + REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, nil, nil, + strings.NewReader("step 3\nnext\nregs\nquit\n")) + }) + if !strings.Contains(out, fmt.Sprintf("func+%#x", afterOff)) { + t.Errorf("next did not land on the instruction after the CALL (func+%#x); output:\n%s", afterOff, out) + } + if !strings.Contains(out, "RAX = 0x0000000000000008") { + t.Errorf("callee did not run exactly once under next (want RAX=8); output:\n%s", out) + } + + // finish: run to the return address read off the stack at entry. + sess2, bm2, fl2 := launchKernel(t, bin, path, "caller", nil) + entry2 := sess2.CodeBase() + uint64(fl2.Offset) + if _, err := bm2.Set(entry2, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + runToEntry(t, sess2, bm2, entry2) + regs, err := sess2.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + retAddr, err := sess2.Peek(regs.RSP) + if err != nil { + t.Fatalf("Peek return address: %v", err) + } + + out2 := captureStdout(t, func() { + REPL(sess2, bm2, sess2.CodeBase(), fl2.Offset, fl2.Size, fl2.Args, nil, nil, + strings.NewReader("step 1\nfinish\nquit\n")) + }) + want := fmt.Sprintf("finished, now at %#x\n", retAddr) + if !strings.Contains(out2, want) { + t.Errorf("finish stopped at the wrong PC; want %q in output:\n%s", want, out2) + } +} + +// TestSignalStopSurfaced proves a faulting kernel surfaces as a reported +// stop instead of an infinite fault loop. A regression here hangs, so a +// watchdog fails the run rather than letting CI stall. +func TestSignalStopSurfaced(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + + const kernel = `#include "textflag.h" + +// func crash() int64 +TEXT ·crash(SB), NOSPLIT, $0-8 + XORQ AX, AX + MOVQ (AX), AX + MOVQ AX, ret+0(FP) + RET +` + path := writeKernel(t, kernel) + sess, bm, _ := launchKernel(t, bin, path, "crash", nil) + + timer := time.AfterFunc(time.Minute, func() { + panic("watchdog: the debugger hung on the faulting kernel instead of reporting the signal stop") + }) + defer timer.Stop() + + out := captureStdout(t, func() { + REPL(sess, bm, sess.CodeBase(), 0, 0, 0, nil, nil, + strings.NewReader("continue\nquit\n")) + }) + if !strings.Contains(out, "stopped on signal") { + t.Errorf("SIGSEGV did not surface as a reported stop; output:\n%s", out) + } + if !sess.Exited() { + t.Error("debuggee should be killed by quit after the signal stop") + } +} + +// TestGetVectorRegsXState proves the NT_X86_XSTATE readout: the request +// succeeds on a normal process and the XMM halves agree with +// PTRACE_GETFPREGS. +func TestGetVectorRegsXState(t *testing.T) { + // The FPRegs layout must mirror the kernel's user_fpregs_struct + // exactly: PTRACE_GETFPREGS fills all 512 bytes, so a short struct + // overflows the caller's memory. + if got := unsafe.Sizeof(FPRegs{}); got != 512 { + t.Fatalf("sizeof(FPRegs) = %d, want 512", got) + } + if got := unsafe.Offsetof(FPRegs{}.XMM); got != 160 { + t.Fatalf("offsetof(FPRegs.XMM) = %d, want 160", got) + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + + const kernel = `#include "textflag.h" + +// func vprobe() int64 +TEXT ·vprobe(SB), NOSPLIT, $0-8 + MOVQ $1, AX + MOVQ AX, ret+0(FP) + RET +` + path := writeKernel(t, kernel) + sess, bm, fl := launchKernel(t, bin, path, "vprobe", nil) + + entry := sess.CodeBase() + uint64(fl.Offset) + if _, err := bm.Set(entry, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + runToEntry(t, sess, bm, entry) + + v, err := sess.GetVectorRegs() + if err != nil { + t.Fatalf("GetVectorRegs: %v", err) + } + fp, err := sess.GetFPRegs() + if err != nil { + t.Fatalf("GetFPRegs: %v", err) + } + for i := range 16 { + if !bytes.Equal(v.YMM[i][:16], fp.XMM[i][:]) { + t.Errorf("YMM%d low half %x, want the FPRegs XMM half %x", i, v.YMM[i][:16], fp.XMM[i][:]) + } + } +} diff --git a/debug/ptrace_linux.go b/debug/ptrace_linux.go index bd5ad95..e7148be 100644 --- a/debug/ptrace_linux.go +++ b/debug/ptrace_linux.go @@ -23,7 +23,13 @@ type Session struct { stopped bool exited bool codeBase uint64 // base address of the JIT code in the debuggee - wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 BADVR0-15) + tmpDir string // scratch directory of the session, removed on Kill + wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 DBGWVR0-15) + // lastSignal holds the signal of the most recent stop when that stop + // was a genuine signal-delivery-stop the caller must see (a fault such + // as SIGSEGV, SIGBUS, SIGFPE or SIGILL); 0 for breakpoint traps, + // single-steps, SIGSTOP and suppressed runtime signals. + lastSignal syscall.Signal } // Launch starts the debuggee subprocess (gasm debug --target ...) and @@ -78,7 +84,7 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s return nil, nil, fmt.Errorf("debug: start debuggee: %w", err) } - s := &Session{pid: cmd.Process.Pid, cmd: cmd} + s := &Session{pid: cmd.Process.Pid, cmd: cmd, tmpDir: tmpDir} readyFile := filepath.Join(tmpDir, "ready") for range 500 { @@ -125,29 +131,17 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s return s, bufAddrs, nil } -// wait waits for the debuggee to stop and returns the wait status. -func (s *Session) wait() error { - var ws syscall.WaitStatus - _, err := syscall.Wait4(s.pid, &ws, 0, nil) - if err != nil { - return err - } - if ws.Exited() { - s.exited = true - return fmt.Errorf("debuggee exited with status %d", ws.ExitStatus()) - } - s.stopped = true - return nil -} - // waitStopped consumes ptrace-stop events until one the debugger cares -// about arrives: SIGTRAP (a breakpoint or a completed single-step) or the -// debuggee's own SIGSTOP. A Go tracee's runtime raises SIGURG for -// asynchronous preemption, and every signal on a traced thread surfaces as -// a signal-delivery-stop, so those are suppressed and the tracee resumed -// without them. Runtime noise is why a single wait can return in the -// middle of runtime code and a resume can then fail: the event stream must -// be drained by the tracer. +// about arrives: SIGTRAP (a breakpoint or a completed single-step), the +// debuggee's own SIGSTOP, or a genuine signal-delivery-stop. A Go tracee's +// runtime raises SIGURG for asynchronous preemption, and every signal on a +// traced thread surfaces as a signal-delivery-stop, so SIGURG is suppressed +// and the tracee resumed without it. Every other signal (SIGSEGV, SIGBUS, +// SIGFPE, SIGILL, ...) is returned to the caller: resuming with signal 0 +// would restart the faulting instruction and fault forever, so a faulting +// kernel must surface as a stop the caller reports. Runtime noise is also +// why a single wait can return in the middle of runtime code and a resume +// can then fail: the event stream must be drained by the tracer. func (s *Session) waitStopped() (syscall.Signal, error) { for { var ws syscall.WaitStatus @@ -165,10 +159,12 @@ func (s *Session) waitStopped() (syscall.Signal, error) { switch sig := ws.StopSignal(); sig { case syscall.SIGTRAP, syscall.SIGSTOP: s.stopped = true + s.lastSignal = 0 return sig, nil - default: - // Runtime noise (SIGURG preemption and friends): resume the - // tracee without delivering the signal. + case syscall.SIGURG: + // Go runtime asynchronous preemption: resume the tracee + // without delivering the signal. + s.lastSignal = 0 if _, _, errno := syscall.Syscall6( syscall.SYS_PTRACE, uintptr(syscall.PTRACE_CONT), @@ -177,10 +173,22 @@ func (s *Session) waitStopped() (syscall.Signal, error) { ); errno != 0 { return 0, fmt.Errorf("debug: PTRACE_CONT: %w", errno) } + default: + // A genuine signal-delivery-stop. Report it; the caller + // decides how to proceed. + s.stopped = true + s.lastSignal = sig + return sig, nil } } } +// LastSignal returns the signal of the most recent stop when that stop was +// a genuine signal-delivery-stop (a fault such as SIGSEGV, SIGFPE, SIGILL +// or SIGBUS), and 0 for breakpoint traps, single-steps, SIGSTOP and +// suppressed runtime signals. +func (s *Session) LastSignal() syscall.Signal { return s.lastSignal } + // Peek reads a word (8 bytes) from the debuggee's memory at addr. func (s *Session) Peek(addr uint64) (uint64, error) { mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0) @@ -294,7 +302,8 @@ func (s *Session) Pid() int { return s.pid } // CodeBase returns the base address of the JIT code in the debuggee. func (s *Session) CodeBase() uint64 { return s.codeBase } -// Kill terminates the debuggee. +// Kill terminates the debuggee and removes the session's scratch +// directory, so a successful session leaves no gasm-debug-* debris behind. func (s *Session) Kill() { if !s.exited { syscall.Kill(s.pid, syscall.SIGKILL) @@ -304,6 +313,35 @@ func (s *Session) Kill() { if s.cmd != nil && s.cmd.Process != nil { s.cmd.Wait() } + if s.tmpDir != "" { + os.RemoveAll(s.tmpDir) + s.tmpDir = "" + } +} + +// execRange is one executable mapping of the debuggee. +type execRange struct { + lo, hi uint64 +} + +// execRanges parses the debuggee's executable mappings from /proc/pid/maps. +func execRanges(pid int) []execRange { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/maps", pid)) + if err != nil { + return nil + } + var out []execRange + for line := range strings.SplitSeq(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || !strings.Contains(fields[1], "x") { + continue + } + var lo, hi uint64 + if _, err := fmt.Sscanf(fields[0], "%x-%x", &lo, &hi); err == nil { + out = append(out, execRange{lo, hi}) + } + } + return out } // findRWXMapping reads /proc/pid/maps and returns the base address of the diff --git a/debug/ptrace_linux_amd64.go b/debug/ptrace_linux_amd64.go index f6277f2..e98b4cc 100644 --- a/debug/ptrace_linux_amd64.go +++ b/debug/ptrace_linux_amd64.go @@ -6,6 +6,7 @@ package debug import ( + "encoding/binary" "fmt" "syscall" "unsafe" @@ -44,20 +45,24 @@ func (s *Session) SetRegs(regs *Regs) error { return nil } -// FPRegs holds the x87 FPU and SSE (XMM) register state from PTRACE_GETFPREGS. +// FPRegs holds the x87 FPU and SSE (XMM) register state from +// PTRACE_GETFPREGS. The layout is the kernel's struct user_fpregs_struct +// (sys/user.h), the FXSAVE image: 512 bytes with XMM0-15 at offset 160. +// The i387 fcs/ds segment fields do not exist in the 64-bit layout. The +// size matters: the copy fills all 512 bytes, so a short or misaligned +// struct makes PTRACE_GETFPREGS overflow the caller's memory. type FPRegs struct { FCW uint16 FSW uint16 - FTW byte + FTW uint16 FOP uint16 FIP uint64 - FCS uint16 FDP uint64 - FDS uint16 MXCSR uint32 MXCSRMask uint32 ST [8][16]byte // x87 stack (10 bytes per reg, padded to 16) - XMM [16][16]byte // XMM0-15 + XMM [16][16]byte // XMM0-15, struct offset 160 + Reserved [96]byte // FXSAVE padding, to the full 512 bytes } // GetFPRegs retrieves the FPU/SSE register state of the stopped debuggee. @@ -82,16 +87,68 @@ type VectorRegs struct { YMM [16][32]byte // YMM0-15 (full 256-bit values) } -// GetVectorRegs retrieves the YMM registers via PTRACE_GETREGSET + XSAVE. +// NT_X86_XSTATE (0x202), the xsave extended-state regset +// (include/uapi/linux/elf.h). +const ntX86XState = 0x202 + +// Layout of the buffer PTRACE_GETREGSET returns for NT_X86_XSTATE: the +// 512-byte legacy fxsave image (x87 state in 0-159, XMM0-15 in 160-511), +// then the 64-byte xsave header whose first 8 bytes are xstate_bv, then one +// component per set feature bit, each 64-byte aligned. The YMM high halves +// are the first extended component, at offset 576; that offset is fixed by +// the ISA on AVX-capable x86-64. XFEATURE_MASK_YMM is bit 2 of xstate_bv +// (arch/x86/include/asm/fpu/types.h); the high halves are zero when the bit +// is clear. +const ( + xsaveXMMOffset = 160 + xsaveXMMSize = 256 + xsaveHeaderOffset = 512 + xsaveBVOffset = xsaveHeaderOffset + ymmOffset = xsaveHeaderOffset + 64 // 576 + ymmSize = 256 // 16 registers, 16 bytes each + xfeatureMaskYMM = 1 << 2 + xstateMaxBuffer = 4096 // CPUID(0xD).xsave_size is far below this +) + +// GetVectorRegs retrieves the YMM registers via PTRACE_GETREGSET on +// NT_X86_XSTATE. The low (XMM) halves always come from the legacy image; +// the high halves are copied only when xstate_bv reports the YMM feature, +// and read as zero otherwise. When the regset request fails the FP image +// still provides correct XMM halves, so that is the fallback. func (s *Session) GetVectorRegs() (VectorRegs, error) { var v VectorRegs - fp, err := s.GetFPRegs() - if err != nil { - return v, err + buf := make([]byte, xstateMaxBuffer) + iovec := syscall.Iovec{ + Base: &buf[0], + Len: uint64(len(buf)), } + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_GETREGSET), + uintptr(s.pid), + uintptr(ntX86XState), + uintptr(unsafe.Pointer(&iovec)), + 0, 0, + ) + if errno != 0 { + fp, err := s.GetFPRegs() + if err != nil { + return v, err + } + for i := range 16 { + copy(v.YMM[i][:16], fp.XMM[i][:]) + } + return v, nil + } + n := int(iovec.Len) for i := range 16 { - for j := range 16 { - v.YMM[i][j] = fp.XMM[i][j] + copy(v.YMM[i][:16], buf[xsaveXMMOffset+16*i:xsaveXMMOffset+16*i+16]) + } + if n >= ymmOffset+ymmSize { + if binary.LittleEndian.Uint64(buf[xsaveBVOffset:xsaveBVOffset+8])&xfeatureMaskYMM != 0 { + for i := range 16 { + copy(v.YMM[i][16:], buf[ymmOffset+16*i:ymmOffset+16*i+16]) + } } } return v, nil diff --git a/debug/regs_linux_amd64.go b/debug/regs_linux_amd64.go index f18c632..ef74887 100644 --- a/debug/regs_linux_amd64.go +++ b/debug/regs_linux_amd64.go @@ -91,5 +91,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) { // breakpointInsn is the software breakpoint instruction. var breakpointInsn = []byte{0xCC} // INT3 -// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. +// breakpointPCAdjust is how far PC is past the breakpoint instruction after +// a trap. x86-64 reports the #DB for INT3 with RIP on the byte after the +// INT3 (Intel SDM vol 3, "Debug Exceptions"), so the trap address is +// PC-1. The other supported architectures leave the PC on the trap +// instruction and use 0 there. const breakpointPCAdjust = 1 diff --git a/debug/regs_linux_arm64.go b/debug/regs_linux_arm64.go index da7b436..2ca2084 100644 --- a/debug/regs_linux_arm64.go +++ b/debug/regs_linux_arm64.go @@ -130,5 +130,11 @@ func (r *Regs) RegValue(name string) (uint64, bool) { // breakpointInsn is the software breakpoint instruction (BRK #0). var breakpointInsn = []byte{0x00, 0x00, 0x20, 0xD4} // BRK #0 -// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. -const breakpointPCAdjust = 4 +// breakpointPCAdjust is how far PC is past the breakpoint instruction after +// a trap: 0, because the arm64 kernel delivers the BRK SIGTRAP with the PC +// still on the BRK. do_el0_brk64 calls send_user_sigtrap, which uses +// instruction_pointer(regs) unmodified (arch/arm64/kernel/debug-monitors.c); +// only the kernel-internal skip paths advance the PC. GDB history agrees: +// decr_pc_after_break on aarch64 Linux is 0 (the +4 variant was a QEMU bug, +// sourceware PR 17280). +const breakpointPCAdjust = 0 diff --git a/debug/regs_linux_loong64.go b/debug/regs_linux_loong64.go index a6e3dd9..2aa68e2 100644 --- a/debug/regs_linux_loong64.go +++ b/debug/regs_linux_loong64.go @@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) { // breakpointInsn is the software breakpoint instruction (BRK $0). var breakpointInsn = []byte{0x05, 0x00, 0x2a, 0x00} // break 0 -// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. -const breakpointPCAdjust = 4 +// breakpointPCAdjust is how far PC is past the breakpoint instruction after +// a trap: 0, because the kernel delivers the break SIGTRAP with csr_era +// still on the break instruction. do_bp passes regs->csr_era straight to +// force_sig_fault(SIGTRAP, TRAP_BRKPT, ...) and never adjusts era on the +// signal path (arch/loongarch/kernel/traps.c). +const breakpointPCAdjust = 0 diff --git a/debug/regs_linux_riscv64.go b/debug/regs_linux_riscv64.go index 8273240..044a760 100644 --- a/debug/regs_linux_riscv64.go +++ b/debug/regs_linux_riscv64.go @@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) { // breakpointInsn is the software breakpoint instruction (EBREAK). var breakpointInsn = []byte{0x73, 0x00, 0x10, 0x00} // ebreak -// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. -const breakpointPCAdjust = 4 +// breakpointPCAdjust is how far PC is past the breakpoint instruction after +// a trap: 0, because the kernel delivers the EBREAK SIGTRAP with sepc still +// on the ebreak. handle_break passes regs->epc straight to +// force_sig_fault(SIGTRAP, TRAP_BRKPT, ...) and only the kernel-internal +// WARN/CFI paths advance epc (arch/riscv/kernel/traps.c). +const breakpointPCAdjust = 0 diff --git a/debug/repl.go b/debug/repl.go index 5f2706f..ad60881 100644 --- a/debug/repl.go +++ b/debug/repl.go @@ -7,9 +7,10 @@ package debug import ( "bufio" + "cmp" "fmt" "io" - "sort" + "slices" "strconv" "strings" ) @@ -93,7 +94,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar regs, _ := s.GetRegs() pc := regs.GetPC() text, instLen, _ := s.Disassemble(pc) - if strings.HasPrefix(strings.ToLower(text), "call") || strings.HasPrefix(strings.ToLower(text), "bl") { + if isCallInsn(text) { afterAddr := pc + uint64(instLen) _, err := bm.Set(afterAddr, "(next)") if err != nil { @@ -108,6 +109,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar bm.Clear(afterAddr) continue } + if s.Exited() { + bm.Clear(afterAddr) + fmt.Println("debuggee exited") + continue + } + if sig := s.LastSignal(); sig != 0 { + bm.Clear(afterAddr) + regs, _ := s.GetRegs() + fmt.Printf("stopped on signal %v at %#x\n", sig, regs.GetPC()) + continue + } + // Fetch the registers after the stop: the trap must be + // evaluated against the real PC, not the pre-Continue + // snapshot, and a stale SetRegs would clobber live state. + regs, _ = s.GetRegs() bm.HandleTrap(®s) bm.Clear(afterAddr) } else { @@ -143,9 +159,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar bm.Clear(retAddr) continue } - if !s.Exited() { - bm.HandleTrap(®s) + if s.Exited() { + bm.Clear(retAddr) + fmt.Println("debuggee exited") + continue } + if sig := s.LastSignal(); sig != 0 { + bm.Clear(retAddr) + regs, _ := s.GetRegs() + fmt.Printf("stopped on signal %v at %#x\n", sig, regs.GetPC()) + continue + } + // Fetch the registers after the stop, as the continue case + // does: HandleTrap must see the PC the trap left behind. + regs, _ = s.GetRegs() + bm.HandleTrap(®s) bm.Clear(retAddr) if s.Exited() { fmt.Println("debuggee exited") @@ -171,6 +199,15 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar fmt.Println("debuggee exited") break } + if sig := s.LastSignal(); sig != 0 { + // A genuine signal-delivery-stop (a fault): report it + // and return to the prompt. Continuing would restart + // the faulting instruction and fault forever. + regs, _ := s.GetRegs() + fmt.Printf("stopped on signal %v at %#x (func+%#x)\n", + sig, regs.GetPC(), regs.GetPC()-codeBase-uint64(funcOffset)) + break + } reason, wpAddr := s.StopInfo() if reason == StopWatchpoint { fmt.Printf("watchpoint hit at %#x\n", wpAddr) @@ -221,13 +258,27 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar reg := strings.ToLower(parts[3]) op := parts[4] operand := parts[5] - if val, err := strconv.ParseUint(operand, 0, 64); err == nil { - cond = &Condition{Reg: reg, Op: op, Value: val} - } else { - cond = &Condition{Reg: reg, Op: op, Reg2: strings.ToLower(operand)} + switch { + case strings.HasPrefix(operand, "*"): + // Memory operand: compare against the 8-byte word at + // the address, resolved in the debuggee when the + // breakpoint is evaluated. + addr, err := strconv.ParseUint(strings.TrimPrefix(operand, "*"), 0, 64) + if err != nil { + fmt.Printf("invalid memory operand: %s\n", operand) + continue + } + cond = &Condition{Reg: reg, Op: op, MemAddr: addr} + default: + val, err := strconv.ParseUint(operand, 0, 64) + if err == nil { + cond = &Condition{Reg: reg, Op: op, Value: val} + } else { + cond = &Condition{Reg: reg, Op: op, Reg2: strings.ToLower(operand)} + } } } else if len(parts) >= 4 && parts[2] == "if" { - fmt.Println("usage: break if ") + fmt.Println("usage: break if ") continue } bp, err := bm.SetWithCond(addr, label, cond) @@ -237,7 +288,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar } condStr := "" if cond != nil { - condStr = fmt.Sprintf(" if %s %s %#x", cond.Reg, cond.Op, cond.Value) + condStr = " if " + cond.String() } fmt.Printf("breakpoint set: %s at %#x (func+%#x)%s\n", bp.Label, bp.Addr, bp.Addr-codeBase-uint64(funcOffset), condStr) @@ -277,7 +328,11 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar addr, _ = resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) } if len(parts) > 2 { - length, _ = strconv.Atoi(parts[2]) + // A malformed or non-positive length would panic + // ReadMemory's make; fall back to the default instead. + if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 { + length = n + } } mem, err := s.ReadMemory(addr, length) if err != nil { @@ -336,10 +391,8 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar } case "labels", "l": - sorted := make([]Label, len(labels)) - copy(sorted, labels) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Offset < sorted[j].Offset }) - for _, l := range sorted { + slices.SortFunc(labels, func(a, b Label) int { return cmp.Compare(a.Offset, b.Offset) }) + for _, l := range labels { fmt.Printf(" func+%#04x %s\n", l.Offset, l.Name) } @@ -369,7 +422,10 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar fmt.Println() case "help", "h", "?": - fmt.Printf(` break [if ] set a breakpoint + fmt.Printf(` break [if ] + set a breakpoint, optionally conditional on a + register compared to a constant, a register, or the + 8-byte word at *addr delete remove a breakpoint info break list all breakpoints watch [r|w] [size] set a hardware watchpoint (write by default) @@ -463,8 +519,8 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar case "unwatch": if len(parts) >= 2 { slot, err := strconv.Atoi(parts[1]) - if err != nil || slot < 0 || slot > 3 { - fmt.Println("usage: unwatch []") + if err != nil || slot < 0 || slot >= maxWatchpoints() { + fmt.Printf("usage: unwatch []\n", maxWatchpoints()-1) continue } if err := s.ClearWatchpoint(slot); err != nil { diff --git a/debug/stopinfo_linux.go b/debug/stopinfo_linux.go index fe19e95..26b6d97 100644 --- a/debug/stopinfo_linux.go +++ b/debug/stopinfo_linux.go @@ -6,6 +6,7 @@ package debug import ( + "encoding/binary" "syscall" "unsafe" ) @@ -61,8 +62,15 @@ func (s *Session) StopInfo() (StopReason, uint64) { case trapBRKPT: return StopBreakpoint, 0 case trapHWBRKPT: - addr := *(*uint64)(unsafe.Add(unsafe.Pointer(&info), 16)) - return StopWatchpoint, addr + // si_addr sits at struct offset 16 (12 bytes of signo/errno/code + // plus 4 bytes of union alignment). The siginfo buffer is only + // 4-byte aligned, so the address is read byte-wise to keep the + // load aligned on riscv64 and loong64. What si_addr names is + // architecture-specific (the data address on arm64, the + // instruction pointer on x86), so the per-architecture + // archWatchpointAddr resolves it to the watched address. + addr := binary.LittleEndian.Uint64(info._pad[4:12]) + return StopWatchpoint, archWatchpointAddr(s, addr) default: return StopSingleStep, 0 } diff --git a/debug/target_linux_arm64.go b/debug/target_linux_arm64.go index d808312..45be1ae 100644 --- a/debug/target_linux_arm64.go +++ b/debug/target_linux_arm64.go @@ -28,15 +28,7 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error { return fmt.Errorf("debug target: parse: %v", errs[0]) } - var img *asm.Image - switch "arm64" { - case "arm64": - img, err = asm.AssembleFileARM64(file) - case "riscv64": - img, err = asm.AssembleFileRISCV(file) - case "loong64": - img, err = asm.AssembleFileLOONG64(file) - } + img, err := asm.AssembleFileARM64(file) if err != nil { return fmt.Errorf("debug target: assemble: %w", err) } diff --git a/debug/target_linux_loong64.go b/debug/target_linux_loong64.go index e3039dd..eeb2278 100644 --- a/debug/target_linux_loong64.go +++ b/debug/target_linux_loong64.go @@ -28,15 +28,7 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error { return fmt.Errorf("debug target: parse: %v", errs[0]) } - var img *asm.Image - switch "loong64" { - case "arm64": - img, err = asm.AssembleFileARM64(file) - case "riscv64": - img, err = asm.AssembleFileRISCV(file) - case "loong64": - img, err = asm.AssembleFileLOONG64(file) - } + img, err := asm.AssembleFileLOONG64(file) if err != nil { return fmt.Errorf("debug target: assemble: %w", err) } diff --git a/debug/tracer.go b/debug/tracer.go index 27c1a04..125e876 100644 --- a/debug/tracer.go +++ b/debug/tracer.go @@ -12,10 +12,11 @@ type tracer interface { Peek(addr uint64) (uint64, error) Poke(addr uint64, val uint64) error SetRegs(regs *Regs) error + Step() error Pid() int } -// mockTracer records Peek/Poke calls and provides fake register state. +// mockTracer records Peek/Poke/Step calls and provides fake register state. type mockTracer struct { mem map[uint64]byte peeks []uint64 @@ -23,7 +24,8 @@ type mockTracer struct { addr uint64 val uint64 } - regs *Regs + steps int + regs *Regs } func newMockTracer() *mockTracer { @@ -57,4 +59,8 @@ func (m *mockTracer) SetRegs(regs *Regs) error { m.regs = regs return nil } +func (m *mockTracer) Step() error { + m.steps++ + return nil +} func (m *mockTracer) Pid() int { return 42 } diff --git a/debug/watchpoint_linux_amd64.go b/debug/watchpoint_linux_amd64.go index 560a98b..d6fb4de 100644 --- a/debug/watchpoint_linux_amd64.go +++ b/debug/watchpoint_linux_amd64.go @@ -8,10 +8,48 @@ package debug import ( "fmt" "syscall" + "unsafe" ) // Hardware watchpoint support via x86-64 debug registers (DR0-DR3, DR7). +// The kernel translates PTRACE_POKEUSER/PEEKUSER offsets inside +// [offsetof(struct user, u_debugreg[0]), u_debugreg[7]] to DR0-DR7 +// (arch/x86/kernel/ptrace.c, arch_ptrace). sys/user.h places u_debugreg at +// 0x350: DR0-DR3 are 0x350/0x358/0x360/0x368, DR6 (status) is 0x380 and +// DR7 (control) is 0x388. Offsets below 0x350 write user_regs_struct +// fields (r15 at 0x0, r10 at 0x38), not debug registers. +const ( + drOffset = 0x350 // offsetof(struct user, u_debugreg[0]), DR0 + dr6Off = 0x380 // offsetof(struct user, u_debugreg[6]), DR6 + dr7Off = 0x388 // offsetof(struct user, u_debugreg[7]), DR7 +) + +// archWatchpointAddr resolves the address of the watchpoint that fired. +// x86 delivers si_addr = the instruction pointer of the trapping access +// (arch/x86/kernel/ptrace.c send_sigtrap passes regs->ip), so the watched +// data address is recovered from DR6's slot bits (B0-B3, positive polarity +// through PEEKUSER) and the matching DR0-DR3. +func archWatchpointAddr(s *Session, siAddr uint64) uint64 { + dr6, err := ptracePeekUser(s.pid, dr6Off) + if err != nil { + return siAddr + } + for slot := range 4 { + if dr6&(1<= maxWatchpoints { + if slot < 0 || slot >= maxWatchpoints() { return false } return s.wpSlots[slot] @@ -58,27 +67,31 @@ func (s *Session) IsWatchpointSlotUsed(slot int) bool { // SetWatchpoint installs a hardware watchpoint on the given address. func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { - if slot < 0 || slot >= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) + if slot < 0 || slot >= maxWatchpoints() { + return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1) } if s.wpSlots[slot] { return fmt.Errorf("debug: watchpoint slot %d already in use", slot) } - state, err := s.getHWBreakState() + state, err := s.getHWWatchState() if err != nil { return fmt.Errorf("debug: read watchpoint state: %w", err) } - if uint32(slot) >= state.DbgInfo { - return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo) + // MDSCR_EL1 packs (debug_arch << 8) | num_slots into dbg_info, so only + // the low byte counts slots. + if uint32(slot) >= state.DbgInfo&0xff { + return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo&0xff) } state.DbgRegs[slot].Addr = addr + // DBGWCR bits 3-4 select the access type: 01 load, 10 store, 11 either + // (ARM DDI 0487, DBGWCR_EL1 watchpoint type field). ctrl := uint64(1) // enable switch typ { case WatchWrite: - ctrl |= 1 << 3 // store only + ctrl |= 2 << 3 // store only case WatchRead: ctrl |= 3 << 3 // load+store } @@ -98,7 +111,7 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size ctrl |= bas << 5 state.DbgRegs[slot].Ctrl = ctrl - if err := s.setHWBreakState(state); err != nil { + if err := s.setHWWatchState(state); err != nil { return fmt.Errorf("debug: set watchpoint: %w", err) } @@ -107,20 +120,20 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size } func (s *Session) ClearWatchpoint(slot int) error { - if slot < 0 || slot >= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) + if slot < 0 || slot >= maxWatchpoints() { + return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1) } if !s.wpSlots[slot] { return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) } - state, err := s.getHWBreakState() + state, err := s.getHWWatchState() if err != nil { return err } state.DbgRegs[slot].Addr = 0 state.DbgRegs[slot].Ctrl = 0 - if err := s.setHWBreakState(state); err != nil { + if err := s.setHWWatchState(state); err != nil { return err } s.wpSlots[slot] = false @@ -128,7 +141,7 @@ func (s *Session) ClearWatchpoint(slot int) error { } func (s *Session) ClearAllWatchpoints() error { - for slot := 0; slot < maxWatchpoints; slot++ { + for slot := range maxWatchpoints() { if s.wpSlots[slot] { if err := s.ClearWatchpoint(slot); err != nil { return err @@ -138,8 +151,8 @@ func (s *Session) ClearAllWatchpoints() error { return nil } -func (s *Session) getHWBreakState() (*hwBreakState, error) { - var state hwBreakState +func (s *Session) getHWWatchState() (*hwWatchState, error) { + var state hwWatchState iovec := syscall.Iovec{ Base: (*byte)(unsafe.Pointer(&state)), Len: uint64(unsafe.Sizeof(state)), @@ -148,7 +161,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) { syscall.SYS_PTRACE, uintptr(syscall.PTRACE_GETREGSET), uintptr(s.pid), - uintptr(ntArmHWBreak), + uintptr(ntArmHWWatch), uintptr(unsafe.Pointer(&iovec)), 0, 0, ) @@ -158,7 +171,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) { return &state, nil } -func (s *Session) setHWBreakState(state *hwBreakState) error { +func (s *Session) setHWWatchState(state *hwWatchState) error { iovec := syscall.Iovec{ Base: (*byte)(unsafe.Pointer(state)), Len: uint64(unsafe.Sizeof(*state)), @@ -167,7 +180,7 @@ func (s *Session) setHWBreakState(state *hwBreakState) error { syscall.SYS_PTRACE, uintptr(syscall.PTRACE_SETREGSET), uintptr(s.pid), - uintptr(ntArmHWBreak), + uintptr(ntArmHWWatch), uintptr(unsafe.Pointer(&iovec)), 0, 0, ) diff --git a/debug/watchpoint_linux_loong64.go b/debug/watchpoint_linux_loong64.go index dd90a85..83d78d7 100644 --- a/debug/watchpoint_linux_loong64.go +++ b/debug/watchpoint_linux_loong64.go @@ -8,10 +8,47 @@ package debug import ( "fmt" "syscall" + "unsafe" ) -// Hardware watchpoint support for LoongArch via debug registers. -// Uses PTRACE_POKEUSER/PEEKUSER to access HW watchpoint registers. +// Hardware watchpoint support via the NT_LOONGARCH_HW_WATCH regset. +// +// The kernel's PTRACE_POKEUSER on loong64 accepts only the user_pt_regs +// indices 0-34 (GPRs, orig_a0, era, badv, per +// arch/loongarch/include/uapi/asm/ptrace.h), so there is no debug-register +// window to poke. The real interface is PTRACE_GETREGSET/SETREGSET on +// NT_LOONGARCH_HW_WATCH (0xa06, include/uapi/linux/elf.h) with struct +// user_watch_state_v2 (arch/loongarch/include/uapi/asm/ptrace.h): a dbg_info +// word followed by 14 slots of {addr u64, mask u64, ctrl u32, pad u32}. +// hw_break_get puts the slot count in the low byte of dbg_info +// (arch/loongarch/kernel/ptrace.c, ptrace_hbp_get_resource_info) and +// hw_break_set ignores dbg_info, reading addr, mask and ctrl per slot. + +const ntLoongHWWatch = 0xa06 + +// loongWatchState mirrors the kernel's struct user_watch_state_v2. +type loongWatchState struct { + DbgInfo uint64 + DbgRegs [14]loongWatchReg +} + +type loongWatchReg struct { + Addr uint64 + Mask uint64 + Ctrl uint32 + Pad uint32 +} + +// Control word bit layout (arch/loongarch/include/asm/hw_breakpoint.h): +// bits 1-4 privilege enables (CTRL_PLV3_ENABLE, 0x10, covers user mode), +// bits 8-9 access type (LOAD 1<<0, STORE 1<<1), bits 10-11 length +// (0=8 bytes, 1=4, 2=2, 3=1, inverted like the hardware FWP cfg). +const ( + loongCtrlPLV3Enable = 0x10 + loongTypeLoad = 1 << 8 + loongTypeStore = 2 << 8 + loongLenShift = 10 +) // WatchpointType selects what triggers the watchpoint. type WatchpointType int @@ -21,10 +58,16 @@ const ( WatchRead WatchpointType = 3 ) -const maxWatchpoints = 4 +// maxWatchpoints reports the slot capacity of the regset struct; the number +// the hardware actually provides is read from dbg_info at arm time. +func maxWatchpoints() int { return len(loongWatchState{}.DbgRegs) } + +// archWatchpointAddr resolves the address of the watchpoint that fired: +// the loongarch kernel already reports the accessed address as si_addr. +func archWatchpointAddr(s *Session, siAddr uint64) uint64 { return siAddr } func (s *Session) FindFreeWatchpointSlot() int { - for i := range maxWatchpoints { + for i := range maxWatchpoints() { if !s.wpSlots[i] { return i } @@ -33,53 +76,56 @@ func (s *Session) FindFreeWatchpointSlot() int { } func (s *Session) IsWatchpointSlotUsed(slot int) bool { - if slot < 0 || slot >= maxWatchpoints { + if slot < 0 || slot >= maxWatchpoints() { return false } return s.wpSlots[slot] } -// SetWatchpoint installs a hardware watchpoint. +// SetWatchpoint installs a hardware watchpoint on the given address. func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { - if slot < 0 || slot >= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) + if slot < 0 || slot >= maxWatchpoints() { + return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1) } if s.wpSlots[slot] { return fmt.Errorf("debug: watchpoint slot %d already in use", slot) } - if size != 1 && size != 2 && size != 4 && size != 8 { + + var ctrlType uint32 + switch typ { + case WatchWrite: + ctrlType = loongTypeStore + case WatchRead: + ctrlType = loongTypeLoad | loongTypeStore + } + var lenBits uint32 + switch size { + case 1: + lenBits = 3 + case 2: + lenBits = 2 + case 4: + lenBits = 1 + case 8: + lenBits = 0 + default: return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") } - // LoongArch debug registers: DBGWVR (watchpoint value) and DBGWCR (watchpoint control). - // Accessed via PTRACE_POKEUSER at architecture-specific offsets. - if err := ptracePokeUser(s.pid, uintptr(0x1000+slot*8), addr); err != nil { - return fmt.Errorf("debug: set watchpoint address: %w", err) + state, err := s.getLoongWatchState() + if err != nil { + return fmt.Errorf("debug: read watchpoint state: %w", err) + } + if uint64(slot) >= state.DbgInfo&0xff { + return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo&0xff) } - // DBGWCR: enable + type + size. - var wcr uint64 = 1 // enable - switch typ { - case WatchWrite: - wcr |= 1 << 3 // store - case WatchRead: - wcr |= 3 << 3 // load+store - } - var sizeBits uint64 - switch size { - case 1: - sizeBits = 0 - case 2: - sizeBits = 1 - case 4: - sizeBits = 2 - case 8: - sizeBits = 3 - } - wcr |= sizeBits << 5 + state.DbgRegs[slot].Addr = addr + state.DbgRegs[slot].Mask = 0 + state.DbgRegs[slot].Ctrl = loongCtrlPLV3Enable | ctrlType | lenBits<= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) + if slot < 0 || slot >= maxWatchpoints() { + return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1) } if !s.wpSlots[slot] { return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) } - if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), 0); err != nil { + state, err := s.getLoongWatchState() + if err != nil { + return err + } + state.DbgRegs[slot].Addr = 0 + state.DbgRegs[slot].Mask = 0 + state.DbgRegs[slot].Ctrl = 0 + if err := s.setLoongWatchState(state); err != nil { return err } s.wpSlots[slot] = false @@ -102,7 +155,7 @@ func (s *Session) ClearWatchpoint(slot int) error { } func (s *Session) ClearAllWatchpoints() error { - for slot := 0; slot < maxWatchpoints; slot++ { + for slot := range maxWatchpoints() { if s.wpSlots[slot] { if err := s.ClearWatchpoint(slot); err != nil { return err @@ -112,14 +165,37 @@ func (s *Session) ClearAllWatchpoints() error { return nil } -func ptracePokeUser(pid int, offset uintptr, val uint64) error { - const ptracePokeuser = 6 +func (s *Session) getLoongWatchState() (*loongWatchState, error) { + var state loongWatchState + iovec := syscall.Iovec{ + Base: (*byte)(unsafe.Pointer(&state)), + Len: uint64(unsafe.Sizeof(state)), + } _, _, errno := syscall.Syscall6( syscall.SYS_PTRACE, - uintptr(ptracePokeuser), - uintptr(pid), - offset, - uintptr(val), + uintptr(syscall.PTRACE_GETREGSET), + uintptr(s.pid), + uintptr(ntLoongHWWatch), + uintptr(unsafe.Pointer(&iovec)), + 0, 0, + ) + if errno != 0 { + return nil, errno + } + return &state, nil +} + +func (s *Session) setLoongWatchState(state *loongWatchState) error { + iovec := syscall.Iovec{ + Base: (*byte)(unsafe.Pointer(state)), + Len: uint64(unsafe.Sizeof(*state)), + } + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_SETREGSET), + uintptr(s.pid), + uintptr(ntLoongHWWatch), + uintptr(unsafe.Pointer(&iovec)), 0, 0, ) if errno != 0 { @@ -127,18 +203,3 @@ func ptracePokeUser(pid int, offset uintptr, val uint64) error { } return nil } - -func ptracePeekUser(pid int, offset uintptr) (uint64, error) { - const ptracePeekuser = 3 - 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 -} diff --git a/debug/watchpoint_linux_riscv64.go b/debug/watchpoint_linux_riscv64.go index c7c8a59..03fb1c3 100644 --- a/debug/watchpoint_linux_riscv64.go +++ b/debug/watchpoint_linux_riscv64.go @@ -7,11 +7,16 @@ package debug import ( "fmt" - "syscall" ) -// Hardware watchpoint support for RISC-V via Sdtrig trigger registers. -// Uses PTRACE_POKEUSER/PEEKUSER to access debug registers. +// Hardware watchpoints are not reachable through the riscv64 kernel ptrace +// interface. arch/riscv/kernel/ptrace.c forwards every POKEUSER/PEEKUSER to +// the generic ptrace_request, and the riscv user_regset view contains only +// the GPR, FP and vector regsets: there is no debug-register or trigger +// regset, and offsets outside the view fail with EIO. The Sdtrig CSRs +// (tselect/tdata1/tdata2) are not exposed to ptrace either. Until the +// kernel grows a trigger regset, SetWatchpoint reports the fact instead of +// poking a window that does not exist. // WatchpointType selects what triggers the watchpoint. type WatchpointType int @@ -21,10 +26,18 @@ const ( WatchRead WatchpointType = 3 ) -const maxWatchpoints = 4 +// maxWatchpoints reports the number of hardware watchpoint slots the +// architecture provides. riscv64 exposes none via ptrace; the bound exists +// so the slot bookkeeping stays consistent. +func maxWatchpoints() int { return 4 } + +// archWatchpointAddr resolves the address of the watchpoint that fired. +// Unreachable in practice (watchpoints cannot be armed), but si_addr names +// the accessed address where the kernel does report one. +func archWatchpointAddr(s *Session, siAddr uint64) uint64 { return siAddr } func (s *Session) FindFreeWatchpointSlot() int { - for i := range maxWatchpoints { + for i := range maxWatchpoints() { if !s.wpSlots[i] { return i } @@ -33,115 +46,26 @@ func (s *Session) FindFreeWatchpointSlot() int { } func (s *Session) IsWatchpointSlotUsed(slot int) bool { - if slot < 0 || slot >= maxWatchpoints { + if slot < 0 || slot >= maxWatchpoints() { return false } return s.wpSlots[slot] } -// SetWatchpoint installs a hardware watchpoint. +// SetWatchpoint always fails: the riscv64 kernel ptrace interface has no +// hardware-watchpoint access. func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { - if slot < 0 || slot >= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) - } - if s.wpSlots[slot] { - return fmt.Errorf("debug: watchpoint slot %d already in use", slot) - } - if size != 1 && size != 2 && size != 4 && size != 8 { - return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") - } - - // RISC-V trigger registers: tdata1 encodes type/control, tdata2 holds address. - // The exact encoding depends on the trigger implementation (Sdtrig). - // Use PTRACE_POKEUSER to write to the trigger CSRs via the kernel's - // debug register interface. - if err := ptracePokeUser(s.pid, uintptr(0x1000+slot*8), addr); err != nil { - return fmt.Errorf("debug: set watchpoint address: %w", err) - } - - // tdata1: set match control. Mode=2 (data match), select=0, action=1 (debug exception). - var tdata1 uint64 = 2 << 60 // type = match (2) - tdata1 |= 1 << 0 // action = enter debug mode - tdata1 |= 1 << 7 // store (write) trigger - if typ == WatchRead { - tdata1 |= 1 << 6 // load trigger - } - // Size encoding: 0=1byte, 1=2byte, 2=4byte, 3=8byte. - var sizeBits uint64 - switch size { - case 1: - sizeBits = 0 - case 2: - sizeBits = 1 - case 4: - sizeBits = 2 - case 8: - sizeBits = 3 - } - tdata1 |= sizeBits << 16 // size field - - if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), tdata1); err != nil { - return fmt.Errorf("debug: set watchpoint control: %w", err) - } - - s.wpSlots[slot] = true - return nil + return fmt.Errorf("debug: hardware watchpoints are not supported by the riscv64 kernel ptrace interface") } +// ClearWatchpoint always fails: no watchpoint can ever be armed. func (s *Session) ClearWatchpoint(slot int) error { - if slot < 0 || slot >= maxWatchpoints { - return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) + if slot < 0 || slot >= maxWatchpoints() { + return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1) } - if !s.wpSlots[slot] { - return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) - } - - // Disable by clearing tdata1. - if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), 0); err != nil { - return err - } - s.wpSlots[slot] = false - return nil + return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) } func (s *Session) ClearAllWatchpoints() error { - for slot := 0; slot < maxWatchpoints; slot++ { - if s.wpSlots[slot] { - if err := s.ClearWatchpoint(slot); err != nil { - return err - } - } - } return nil } - -func ptracePokeUser(pid int, offset uintptr, val uint64) error { - const ptracePokeuser = 6 - _, _, errno := syscall.Syscall6( - syscall.SYS_PTRACE, - uintptr(ptracePokeuser), - uintptr(pid), - offset, - uintptr(val), - 0, 0, - ) - if errno != 0 { - return errno - } - return nil -} - -func ptracePeekUser(pid int, offset uintptr) (uint64, error) { - const ptracePeekuser = 3 - 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 -}