fix(debug): hardware watchpoints, signal stops and breakpoint restore

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-19 23:49:19 +02:00
parent 375182ef1f
commit a8bfd54ed2
26 changed files with 1408 additions and 535 deletions
+8 -1
View File
@@ -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") 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") 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") 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) fs.Parse(args)
// --- Debuggee mode (internal, spawned by the debugger) --- // --- Debuggee mode (internal, spawned by the debugger) ---
@@ -231,6 +231,13 @@ REPL commands:
if sess.Exited() { if sess.Exited() {
break 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() regs, rerr := sess.GetRegs()
if rerr != nil { if rerr != nil {
break break
+99 -44
View File
@@ -9,11 +9,11 @@ import "strings"
import "fmt" import "fmt"
// Breakpoint is one INT3 breakpoint in the debuggee. // Breakpoint is one software breakpoint in the debuggee.
type Breakpoint struct { type Breakpoint struct {
Addr uint64 // absolute address in the debuggee Addr uint64 // absolute address in the debuggee
Label string // source label ("" for raw addresses) 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 Enabled bool
Cond *Condition // optional condition (nil = unconditional) Cond *Condition // optional condition (nil = unconditional)
hits int hits int
@@ -32,8 +32,12 @@ type Condition struct {
MemAddr uint64 // memory address (for register-memory comparison, prefixed with *) MemAddr uint64 // memory address (for register-memory comparison, prefixed with *)
} }
// Eval checks the condition against the current registers. // Eval checks the condition against the current registers. For the
func (c *Condition) Eval(regs *Regs) bool { // 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) actual, ok := regs.RegValue(c.Reg)
if !ok { if !ok {
return true // unknown register, don't block return true // unknown register, don't block
@@ -48,9 +52,16 @@ func (c *Condition) Eval(regs *Regs) bool {
} }
expected = v expected = v
case c.MemAddr != 0: case c.MemAddr != 0:
// Register-memory comparison, requires a Session, not available here. // Register-memory comparison, resolved in the debuggee at
// Fall back to treating as constant (the caller should resolve). // evaluation time.
expected = c.Value if mem == nil {
return true
}
v, ok := mem(c.MemAddr)
if !ok {
return true
}
expected = v
default: default:
expected = c.Value 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. // Breakpoints manages the software breakpoints of one Session.
type Breakpoints struct { type Breakpoints struct {
t tracer t tracer
@@ -83,6 +106,18 @@ func NewBreakpoints(t tracer) *Breakpoints {
return &Breakpoints{t: t, bps: make(map[uint64]*Breakpoint)} 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). // Set installs a breakpoint at addr (replaces any existing one).
func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) { func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) {
return bm.SetWithCond(addr, label, nil) return bm.SetWithCond(addr, label, nil)
@@ -100,13 +135,12 @@ func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
orig := byte(word) orig := make([]byte, len(breakpointInsn))
// Patch with the breakpoint instruction, preserving the rest of the word. for i := range orig {
mask := uint64(0) orig[i] = byte(word >> (8 * i))
for range breakpointInsn {
mask = (mask << 8) | 0xFF
} }
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 { if err := bm.t.Poke(addr, patched); err != nil {
return nil, err return nil, err
} }
@@ -134,26 +168,40 @@ func (bm *Breakpoints) Info() string {
} }
cond := "" cond := ""
if bp.Cond != nil { 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)) result.WriteString(fmt.Sprintf(" %d: %s at %#x [%s, %d hits]%s\n", i, label, bp.Addr, status, bp.hits, cond))
} }
return result.String() 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 { func (bm *Breakpoints) Clear(addr uint64) error {
bp, ok := bm.bps[addr] bp, ok := bm.bps[addr]
if !ok { if !ok {
return fmt.Errorf("debug: no breakpoint at %#x", addr) return fmt.Errorf("debug: no breakpoint at %#x", addr)
} }
word, err := bm.t.Peek(addr) if !bm.restore(addr, bp) {
if err != nil { word, err := bm.t.Peek(addr)
return err if err != nil {
} return err
restored := (word &^ 0xFF) | uint64(bp.Orig) }
if err := bm.t.Poke(addr, restored); err != nil { return fmt.Errorf("debug: restore breakpoint at %#x failed, word is %#x", addr, word)
return err
} }
delete(bm.bps, addr) delete(bm.bps, addr)
return nil return nil
@@ -185,43 +233,54 @@ func (bm *Breakpoints) All() []*Breakpoint {
// HandleTrap is called after the debuggee stops on SIGTRAP. It checks // HandleTrap is called after the debuggee stops on SIGTRAP. It checks
// whether the trap was caused by one of our breakpoints (PC-adjust matches // 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). // returns the breakpoint that was hit (or nil if it was a single-step).
// Hits returns how many times the breakpoint has been hit. // Hits returns how many times the breakpoint has been hit.
func (bp *Breakpoint) Hits() int { return bp.hits } func (bp *Breakpoint) Hits() int { return bp.hits }
func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint { 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) trapAddr := regs.GetPC() - uint64(breakpointPCAdjust)
bp, ok := bm.bps[trapAddr] bp, ok := bm.bps[trapAddr]
if !ok || !bp.Enabled { if !ok || !bp.Enabled {
return nil // single-step trap or unknown return nil // single-step trap or unknown
} }
// Check the condition (if any). // Check the condition (if any).
if bp.Cond != nil && !bp.Cond.Eval(regs) { if bp.Cond != nil && !bp.Cond.Eval(regs, bm.peekValue) {
// Condition not met, restore the byte but do NOT rewind RIP. // Condition not met: step the original instruction and re-arm the
// The process continues from the next instruction (past the INT3). // breakpoint, leaving the debuggee stopped just past it, ready to
word, err := bm.t.Peek(trapAddr) // resume silently. The PC must be rewound first: on architectures
if err == nil { // that report the trap past the instruction (amd64) it would
restored := (word &^ 0xFF) | uint64(bp.Orig) // otherwise sit on the second byte of the replaced instruction.
bm.t.Poke(trapAddr, restored) 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 return nil
} }
bp.hits++ bp.hits++
// Restore the original byte. // Restore the original bytes and rewind PC to re-execute them.
word, err := bm.t.Peek(trapAddr) bm.restore(trapAddr, bp)
if err == nil {
restored := (word &^ 0xFF) | uint64(bp.Orig)
bm.t.Poke(trapAddr, restored)
}
// Rewind PC to re-execute the original instruction.
regs.SetPC(trapAddr) regs.SetPC(trapAddr)
bm.t.SetRegs(regs) bm.t.SetRegs(regs)
return bp 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. // 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 // Called after Step() when we want the breakpoint to fire again on the
// next Continue(). // next Continue().
@@ -234,11 +293,7 @@ func (bm *Breakpoints) Reinsert(addr uint64) error {
if err != nil { if err != nil {
return err return err
} }
mask := uint64(0) patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn)
for range breakpointInsn {
mask = (mask << 8) | 0xFF
}
patched := (word &^ mask) | breakpointWord(breakpointInsn)
return bm.t.Poke(addr, patched) return bm.t.Poke(addr, patched)
} }
+265
View File
@@ -0,0 +1,265 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}
}
}
+38 -188
View File
@@ -6,7 +6,6 @@
package debug package debug
import ( import (
"strings"
"testing" "testing"
) )
@@ -48,7 +47,7 @@ func TestConditionEval(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
got := tt.cond.Eval(regs) got := tt.cond.Eval(regs, nil)
if got != tt.want { if got != tt.want {
t.Errorf("Condition{%q %q %d}.Eval() = %v, want %v", t.Errorf("Condition{%q %q %d}.Eval() = %v, want %v",
tt.cond.Reg, tt.cond.Op, tt.cond.Value, got, tt.want) 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) { // TestConditionEvalMem covers the register-memory form: the value is read
lines := []SourceLine{ // through the supplied reader, and a missing or failing reader must not
{Offset: 0, Line: 5}, // block the breakpoint.
{Offset: 5, Line: 6}, func TestConditionEvalMem(t *testing.T) {
{Offset: 10, Line: 7}, regs := &Regs{RAX: 7}
{Offset: 15, Line: 8}, mem := func(addr uint64) (uint64, bool) {
} if addr == 0x5000 {
return 7, true
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)
} }
return 0, false
} }
// Empty table. eq := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000}
if lineAt(nil, 5) != 0 { if !eq.Eval(regs, mem) {
t.Error("lineAt(nil, 5) should return 0") t.Error("register-memory comparison with matching word should hold")
} }
} ne := Condition{Reg: "rax", Op: "!=", MemAddr: 0x5000}
if ne.Eval(regs, mem) {
func TestOffsetForLine(t *testing.T) { t.Error("register-memory comparison with mismatching word should not hold")
lines := []SourceLine{
{Offset: 0, Line: 5},
{Offset: 5, Line: 6},
{Offset: 10, Line: 7},
} }
bad := Condition{Reg: "rax", Op: "==", MemAddr: 0x6000}
tests := []struct { if !bad.Eval(regs, mem) {
line int t.Error("unreadable memory must not block the breakpoint")
want int
}{
{5, 0},
{6, 5},
{7, 10},
{99, -1}, // not found
{0, -1}, // not found
} }
noReader := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000}
for _, tt := range tests { if !noReader.Eval(regs, nil) {
got := offsetForLine(lines, tt.line) t.Error("missing memory reader must not block the breakpoint")
if got != tt.want {
t.Errorf("offsetForLine(lines, %d) = %d, want %d", tt.line, got, tt.want)
}
} }
} }
@@ -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) { func TestWatchpointSlotTracking(t *testing.T) {
s := &Session{} // per-session slots start free 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) 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)
}
}
+8
View File
@@ -46,3 +46,11 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
} }
return result.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"
}
+13
View File
@@ -7,6 +7,7 @@ package debug
import ( import (
"fmt" "fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm" "sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,15 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
} }
return result 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
}
+14
View File
@@ -7,6 +7,7 @@ package debug
import ( import (
"fmt" "fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm" "sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,16 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
} }
return result 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
}
+15
View File
@@ -7,6 +7,7 @@ package debug
import ( import (
"fmt" "fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm" "sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,17 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
} }
return result 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
}
+22 -2
View File
@@ -73,9 +73,29 @@ func decodeRflags(f uint64) string {
return flags[:len(flags)-1] 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) { 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. // archSPLabel returns the SP register name for display.
+6 -3
View File
@@ -5,7 +5,10 @@
package debug package debug
import "fmt" import (
"encoding/binary"
"fmt"
)
func printRegs(regs *Regs, codeBase, funcOff uint64) { func printRegs(regs *Regs, codeBase, funcOff uint64) {
fmt.Printf(" PC = %#016x (func+%#x)\n", regs.PC, regs.PC-codeBase-funcOff) 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) { func printVectorRegs(v *VectorRegs) {
fmt.Println("\n Vector registers (V0-V31):") fmt.Println("\n Vector registers (V0-V31):")
for i := 0; i < 32; i += 2 { 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, 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, v.V[i+1][8], v.V[i+1][0]) 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]))
} }
} }
+427
View File
@@ -0,0 +1,427 @@
// 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 (
"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(&regs) != 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(&regs); 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][:])
}
}
}
+66 -28
View File
@@ -23,7 +23,13 @@ type Session struct {
stopped bool stopped bool
exited bool exited bool
codeBase uint64 // base address of the JIT code in the debuggee 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 // 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) 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") readyFile := filepath.Join(tmpDir, "ready")
for range 500 { for range 500 {
@@ -125,29 +131,17 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
return s, bufAddrs, nil 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 // waitStopped consumes ptrace-stop events until one the debugger cares
// about arrives: SIGTRAP (a breakpoint or a completed single-step) or the // about arrives: SIGTRAP (a breakpoint or a completed single-step), the
// debuggee's own SIGSTOP. A Go tracee's runtime raises SIGURG for // debuggee's own SIGSTOP, or a genuine signal-delivery-stop. A Go tracee's
// asynchronous preemption, and every signal on a traced thread surfaces as // runtime raises SIGURG for asynchronous preemption, and every signal on a
// a signal-delivery-stop, so those are suppressed and the tracee resumed // traced thread surfaces as a signal-delivery-stop, so SIGURG is suppressed
// without them. Runtime noise is why a single wait can return in the // and the tracee resumed without it. Every other signal (SIGSEGV, SIGBUS,
// middle of runtime code and a resume can then fail: the event stream must // SIGFPE, SIGILL, ...) is returned to the caller: resuming with signal 0
// be drained by the tracer. // 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) { func (s *Session) waitStopped() (syscall.Signal, error) {
for { for {
var ws syscall.WaitStatus var ws syscall.WaitStatus
@@ -165,10 +159,12 @@ func (s *Session) waitStopped() (syscall.Signal, error) {
switch sig := ws.StopSignal(); sig { switch sig := ws.StopSignal(); sig {
case syscall.SIGTRAP, syscall.SIGSTOP: case syscall.SIGTRAP, syscall.SIGSTOP:
s.stopped = true s.stopped = true
s.lastSignal = 0
return sig, nil return sig, nil
default: case syscall.SIGURG:
// Runtime noise (SIGURG preemption and friends): resume the // Go runtime asynchronous preemption: resume the tracee
// tracee without delivering the signal. // without delivering the signal.
s.lastSignal = 0
if _, _, errno := syscall.Syscall6( if _, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE, syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_CONT), uintptr(syscall.PTRACE_CONT),
@@ -177,10 +173,22 @@ func (s *Session) waitStopped() (syscall.Signal, error) {
); errno != 0 { ); errno != 0 {
return 0, fmt.Errorf("debug: PTRACE_CONT: %w", errno) 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. // Peek reads a word (8 bytes) from the debuggee's memory at addr.
func (s *Session) Peek(addr uint64) (uint64, error) { func (s *Session) Peek(addr uint64) (uint64, error) {
mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0) 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. // CodeBase returns the base address of the JIT code in the debuggee.
func (s *Session) CodeBase() uint64 { return s.codeBase } 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() { func (s *Session) Kill() {
if !s.exited { if !s.exited {
syscall.Kill(s.pid, syscall.SIGKILL) syscall.Kill(s.pid, syscall.SIGKILL)
@@ -304,6 +313,35 @@ func (s *Session) Kill() {
if s.cmd != nil && s.cmd.Process != nil { if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait() 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 // findRWXMapping reads /proc/pid/maps and returns the base address of the
+68 -11
View File
@@ -6,6 +6,7 @@
package debug package debug
import ( import (
"encoding/binary"
"fmt" "fmt"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -44,20 +45,24 @@ func (s *Session) SetRegs(regs *Regs) error {
return nil 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 { type FPRegs struct {
FCW uint16 FCW uint16
FSW uint16 FSW uint16
FTW byte FTW uint16
FOP uint16 FOP uint16
FIP uint64 FIP uint64
FCS uint16
FDP uint64 FDP uint64
FDS uint16
MXCSR uint32 MXCSR uint32
MXCSRMask uint32 MXCSRMask uint32
ST [8][16]byte // x87 stack (10 bytes per reg, padded to 16) 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. // 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) 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) { func (s *Session) GetVectorRegs() (VectorRegs, error) {
var v VectorRegs var v VectorRegs
fp, err := s.GetFPRegs() buf := make([]byte, xstateMaxBuffer)
if err != nil { iovec := syscall.Iovec{
return v, err 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 i := range 16 {
for j := range 16 { copy(v.YMM[i][:16], buf[xsaveXMMOffset+16*i:xsaveXMMOffset+16*i+16])
v.YMM[i][j] = fp.XMM[i][j] }
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 return v, nil
+5 -1
View File
@@ -91,5 +91,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction. // breakpointInsn is the software breakpoint instruction.
var breakpointInsn = []byte{0xCC} // INT3 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 const breakpointPCAdjust = 1
+8 -2
View File
@@ -130,5 +130,11 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (BRK #0). // breakpointInsn is the software breakpoint instruction (BRK #0).
var breakpointInsn = []byte{0x00, 0x00, 0x20, 0xD4} // BRK #0 var breakpointInsn = []byte{0x00, 0x00, 0x20, 0xD4} // BRK #0
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. // breakpointPCAdjust is how far PC is past the breakpoint instruction after
const breakpointPCAdjust = 4 // 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
+6 -2
View File
@@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (BRK $0). // breakpointInsn is the software breakpoint instruction (BRK $0).
var breakpointInsn = []byte{0x05, 0x00, 0x2a, 0x00} // break 0 var breakpointInsn = []byte{0x05, 0x00, 0x2a, 0x00} // break 0
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. // breakpointPCAdjust is how far PC is past the breakpoint instruction after
const breakpointPCAdjust = 4 // 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
+6 -2
View File
@@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (EBREAK). // breakpointInsn is the software breakpoint instruction (EBREAK).
var breakpointInsn = []byte{0x73, 0x00, 0x10, 0x00} // ebreak var breakpointInsn = []byte{0x73, 0x00, 0x10, 0x00} // ebreak
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap. // breakpointPCAdjust is how far PC is past the breakpoint instruction after
const breakpointPCAdjust = 4 // 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
+74 -18
View File
@@ -7,9 +7,10 @@ package debug
import ( import (
"bufio" "bufio"
"cmp"
"fmt" "fmt"
"io" "io"
"sort" "slices"
"strconv" "strconv"
"strings" "strings"
) )
@@ -93,7 +94,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
regs, _ := s.GetRegs() regs, _ := s.GetRegs()
pc := regs.GetPC() pc := regs.GetPC()
text, instLen, _ := s.Disassemble(pc) 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) afterAddr := pc + uint64(instLen)
_, err := bm.Set(afterAddr, "(next)") _, err := bm.Set(afterAddr, "(next)")
if err != nil { if err != nil {
@@ -108,6 +109,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
bm.Clear(afterAddr) bm.Clear(afterAddr)
continue 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(&regs) bm.HandleTrap(&regs)
bm.Clear(afterAddr) bm.Clear(afterAddr)
} else { } else {
@@ -143,9 +159,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
bm.Clear(retAddr) bm.Clear(retAddr)
continue continue
} }
if !s.Exited() { if s.Exited() {
bm.HandleTrap(&regs) 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(&regs)
bm.Clear(retAddr) bm.Clear(retAddr)
if s.Exited() { if s.Exited() {
fmt.Println("debuggee exited") fmt.Println("debuggee exited")
@@ -171,6 +199,15 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
fmt.Println("debuggee exited") fmt.Println("debuggee exited")
break 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() reason, wpAddr := s.StopInfo()
if reason == StopWatchpoint { if reason == StopWatchpoint {
fmt.Printf("watchpoint hit at %#x\n", wpAddr) 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]) reg := strings.ToLower(parts[3])
op := parts[4] op := parts[4]
operand := parts[5] operand := parts[5]
if val, err := strconv.ParseUint(operand, 0, 64); err == nil { switch {
cond = &Condition{Reg: reg, Op: op, Value: val} case strings.HasPrefix(operand, "*"):
} else { // Memory operand: compare against the 8-byte word at
cond = &Condition{Reg: reg, Op: op, Reg2: strings.ToLower(operand)} // 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" { } else if len(parts) >= 4 && parts[2] == "if" {
fmt.Println("usage: break <label|addr> if <reg> <op> <value|reg>") fmt.Println("usage: break <label|addr> if <reg> <op> <value|reg|*addr>")
continue continue
} }
bp, err := bm.SetWithCond(addr, label, cond) bp, err := bm.SetWithCond(addr, label, cond)
@@ -237,7 +288,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
} }
condStr := "" condStr := ""
if cond != nil { 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) 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) addr, _ = resolveAddr(parts[1], codeBase, uint64(funcOffset), labels)
} }
if len(parts) > 2 { 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) mem, err := s.ReadMemory(addr, length)
if err != nil { if err != nil {
@@ -336,10 +391,8 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
} }
case "labels", "l": case "labels", "l":
sorted := make([]Label, len(labels)) slices.SortFunc(labels, func(a, b Label) int { return cmp.Compare(a.Offset, b.Offset) })
copy(sorted, labels) for _, l := range labels {
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Offset < sorted[j].Offset })
for _, l := range sorted {
fmt.Printf(" func+%#04x %s\n", l.Offset, l.Name) 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() fmt.Println()
case "help", "h", "?": case "help", "h", "?":
fmt.Printf(` break <label|addr> [if <reg> <op> <val>] set a breakpoint fmt.Printf(` break <label|addr> [if <reg> <op> <val|reg|*addr>]
set a breakpoint, optionally conditional on a
register compared to a constant, a register, or the
8-byte word at *addr
delete <label|addr> remove a breakpoint delete <label|addr> remove a breakpoint
info break list all breakpoints info break list all breakpoints
watch <addr> [r|w] [size] set a hardware watchpoint (write by default) watch <addr> [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": case "unwatch":
if len(parts) >= 2 { if len(parts) >= 2 {
slot, err := strconv.Atoi(parts[1]) slot, err := strconv.Atoi(parts[1])
if err != nil || slot < 0 || slot > 3 { if err != nil || slot < 0 || slot >= maxWatchpoints() {
fmt.Println("usage: unwatch [<slot>]") fmt.Printf("usage: unwatch [<slot 0-%d>]\n", maxWatchpoints()-1)
continue continue
} }
if err := s.ClearWatchpoint(slot); err != nil { if err := s.ClearWatchpoint(slot); err != nil {
+10 -2
View File
@@ -6,6 +6,7 @@
package debug package debug
import ( import (
"encoding/binary"
"syscall" "syscall"
"unsafe" "unsafe"
) )
@@ -61,8 +62,15 @@ func (s *Session) StopInfo() (StopReason, uint64) {
case trapBRKPT: case trapBRKPT:
return StopBreakpoint, 0 return StopBreakpoint, 0
case trapHWBRKPT: case trapHWBRKPT:
addr := *(*uint64)(unsafe.Add(unsafe.Pointer(&info), 16)) // si_addr sits at struct offset 16 (12 bytes of signo/errno/code
return StopWatchpoint, addr // 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: default:
return StopSingleStep, 0 return StopSingleStep, 0
} }
+1 -9
View File
@@ -28,15 +28,7 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error {
return fmt.Errorf("debug target: parse: %v", errs[0]) return fmt.Errorf("debug target: parse: %v", errs[0])
} }
var img *asm.Image img, err := asm.AssembleFileARM64(file)
switch "arm64" {
case "arm64":
img, err = asm.AssembleFileARM64(file)
case "riscv64":
img, err = asm.AssembleFileRISCV(file)
case "loong64":
img, err = asm.AssembleFileLOONG64(file)
}
if err != nil { if err != nil {
return fmt.Errorf("debug target: assemble: %w", err) return fmt.Errorf("debug target: assemble: %w", err)
} }
+1 -9
View File
@@ -28,15 +28,7 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error {
return fmt.Errorf("debug target: parse: %v", errs[0]) return fmt.Errorf("debug target: parse: %v", errs[0])
} }
var img *asm.Image img, err := asm.AssembleFileLOONG64(file)
switch "loong64" {
case "arm64":
img, err = asm.AssembleFileARM64(file)
case "riscv64":
img, err = asm.AssembleFileRISCV(file)
case "loong64":
img, err = asm.AssembleFileLOONG64(file)
}
if err != nil { if err != nil {
return fmt.Errorf("debug target: assemble: %w", err) return fmt.Errorf("debug target: assemble: %w", err)
} }
+8 -2
View File
@@ -12,10 +12,11 @@ type tracer interface {
Peek(addr uint64) (uint64, error) Peek(addr uint64) (uint64, error)
Poke(addr uint64, val uint64) error Poke(addr uint64, val uint64) error
SetRegs(regs *Regs) error SetRegs(regs *Regs) error
Step() error
Pid() int 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 { type mockTracer struct {
mem map[uint64]byte mem map[uint64]byte
peeks []uint64 peeks []uint64
@@ -23,7 +24,8 @@ type mockTracer struct {
addr uint64 addr uint64
val uint64 val uint64
} }
regs *Regs steps int
regs *Regs
} }
func newMockTracer() *mockTracer { func newMockTracer() *mockTracer {
@@ -57,4 +59,8 @@ func (m *mockTracer) SetRegs(regs *Regs) error {
m.regs = regs m.regs = regs
return nil return nil
} }
func (m *mockTracer) Step() error {
m.steps++
return nil
}
func (m *mockTracer) Pid() int { return 42 } func (m *mockTracer) Pid() int { return 42 }
+52 -21
View File
@@ -8,10 +8,48 @@ package debug
import ( import (
"fmt" "fmt"
"syscall" "syscall"
"unsafe"
) )
// Hardware watchpoint support via x86-64 debug registers (DR0-DR3, DR7). // 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<<slot) != 0 {
addr, err := ptracePeekUser(s.pid, drOffset+uintptr(slot*8))
if err == nil && addr != 0 {
return addr
}
}
}
return siAddr
}
// maxWatchpoints reports the number of hardware watchpoint slots the
// architecture provides: four address registers, DR0-DR3.
func maxWatchpoints() int { return 4 }
// WatchpointType selects what triggers the watchpoint. // WatchpointType selects what triggers the watchpoint.
type WatchpointType int type WatchpointType int
@@ -62,23 +100,11 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8")
} }
var drAddr uintptr if err := ptracePokeUser(s.pid, drOffset+uintptr(slot*8), addr); err != nil {
switch slot {
case 0:
drAddr = 0x0
case 1:
drAddr = 0x8
case 2:
drAddr = 0x10
case 3:
drAddr = 0x18
}
if err := ptracePokeUser(s.pid, drAddr, addr); err != nil {
return fmt.Errorf("debug: set DR%d: %w", slot, err) return fmt.Errorf("debug: set DR%d: %w", slot, err)
} }
dr7, err := ptracePeekUser(s.pid, 0x38) dr7, err := ptracePeekUser(s.pid, dr7Off)
if err != nil { if err != nil {
return fmt.Errorf("debug: read DR7: %w", err) return fmt.Errorf("debug: read DR7: %w", err)
} }
@@ -90,7 +116,7 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
mask := ^((uint64(1) << (2 * slot)) | (uint64(3) << (16 + 4*slot)) | (uint64(3) << (18 + 4*slot))) mask := ^((uint64(1) << (2 * slot)) | (uint64(3) << (16 + 4*slot)) | (uint64(3) << (18 + 4*slot)))
dr7 = (dr7 & mask) | enableBit | rwBits | lenField dr7 = (dr7 & mask) | enableBit | rwBits | lenField
if err := ptracePokeUser(s.pid, 0x38, dr7); err != nil { if err := ptracePokeUser(s.pid, dr7Off, dr7); err != nil {
return fmt.Errorf("debug: set DR7: %w", err) return fmt.Errorf("debug: set DR7: %w", err)
} }
s.wpSlots[slot] = true s.wpSlots[slot] = true
@@ -105,12 +131,12 @@ func (s *Session) ClearWatchpoint(slot int) error {
if !s.wpSlots[slot] { if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
} }
dr7, err := ptracePeekUser(s.pid, 0x38) dr7, err := ptracePeekUser(s.pid, dr7Off)
if err != nil { if err != nil {
return err return err
} }
dr7 &^= uint64(1) << (2 * slot) dr7 &^= uint64(1) << (2 * slot)
if err := ptracePokeUser(s.pid, 0x38, dr7); err != nil { if err := ptracePokeUser(s.pid, dr7Off, dr7); err != nil {
return err return err
} }
s.wpSlots[slot] = false s.wpSlots[slot] = false
@@ -119,7 +145,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
// ClearAllWatchpoints removes all hardware watchpoints. // ClearAllWatchpoints removes all hardware watchpoints.
func (s *Session) ClearAllWatchpoints() error { func (s *Session) ClearAllWatchpoints() error {
for slot := range 4 { for slot := range maxWatchpoints() {
if s.wpSlots[slot] { if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil { if err := s.ClearWatchpoint(slot); err != nil {
return err return err
@@ -146,16 +172,21 @@ func ptracePokeUser(pid int, offset uintptr, val uint64) error {
} }
func ptracePeekUser(pid int, offset uintptr) (uint64, error) { func ptracePeekUser(pid int, offset uintptr) (uint64, error) {
// x86 PEEKUSR writes the word to the user-space pointer in data
// (arch/x86/kernel/ptrace.c uses put_user); passing 0 there fails with
// EFAULT, so the word is read through a real address.
const ptracePeekuser = 3 const ptracePeekuser = 3
val, _, errno := syscall.Syscall6( var word uint64
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE, syscall.SYS_PTRACE,
uintptr(ptracePeekuser), uintptr(ptracePeekuser),
uintptr(pid), uintptr(pid),
offset, offset,
0, 0, 0, uintptr(unsafe.Pointer(&word)),
0, 0,
) )
if errno != 0 { if errno != 0 {
return 0, errno return 0, errno
} }
return uint64(val), nil return word, nil
} }
+41 -28
View File
@@ -12,7 +12,7 @@ import (
) )
// Hardware watchpoint support via arm64 debug registers (DBGWVR/DBGWCR). // Hardware watchpoint support via arm64 debug registers (DBGWVR/DBGWCR).
// Accessed via PTRACE_SETREGSET with NT_ARM_HW_BREAK. // Accessed via PTRACE_GETREGSET/SETREGSET with NT_ARM_HW_WATCH.
// WatchpointType selects what triggers the watchpoint. // WatchpointType selects what triggers the watchpoint.
type WatchpointType int type WatchpointType int
@@ -22,26 +22,35 @@ const (
WatchRead WatchpointType = 3 WatchRead WatchpointType = 3
) )
const maxWatchpoints = 16 // maxWatchpoints reports the number of hardware watchpoint slots the
// architecture provides: DBGWVR0-DBGWCR15.
func maxWatchpoints() int { return 16 }
// hwBreakState mirrors the kernel's struct user_hwdebug_state. // hwWatchState mirrors the kernel's struct user_hwdebug_state.
type hwBreakState struct { type hwWatchState struct {
DbgInfo uint32 DbgInfo uint32
_pad [4]byte _pad [4]byte
DbgRegs [16]hwBreakReg DbgRegs [16]hwWatchReg
} }
type hwBreakReg struct { type hwWatchReg struct {
Addr uint64 Addr uint64
Ctrl uint64 Ctrl uint64
} }
const ( // ntArmHWWatch is NT_ARM_HW_WATCH (0x403), the watchpoint regset
ntArmHWBreak = 0x403 // NT_ARM_HW_BREAK // (include/uapi/linux/elf.h; 0x402 is NT_ARM_HW_BREAK). Watchpoints and
) // breakpoints live in different regsets with the same struct shape, so the
// constant is named for what it arms to keep a future edit from arming
// breakpoints instead.
const ntArmHWWatch = 0x403
// archWatchpointAddr resolves the address of the watchpoint that fired:
// the arm64 kernel already reports the watched data address as si_addr.
func archWatchpointAddr(s *Session, siAddr uint64) uint64 { return siAddr }
func (s *Session) FindFreeWatchpointSlot() int { func (s *Session) FindFreeWatchpointSlot() int {
for i := range maxWatchpoints { for i := range maxWatchpoints() {
if !s.wpSlots[i] { if !s.wpSlots[i] {
return i return i
} }
@@ -50,7 +59,7 @@ func (s *Session) FindFreeWatchpointSlot() int {
} }
func (s *Session) IsWatchpointSlotUsed(slot int) bool { func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return false return false
} }
return s.wpSlots[slot] return s.wpSlots[slot]
@@ -58,27 +67,31 @@ func (s *Session) IsWatchpointSlotUsed(slot int) bool {
// SetWatchpoint installs a hardware watchpoint on the given address. // SetWatchpoint installs a hardware watchpoint on the given address.
func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
} }
if s.wpSlots[slot] { if s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d already in use", slot) return fmt.Errorf("debug: watchpoint slot %d already in use", slot)
} }
state, err := s.getHWBreakState() state, err := s.getHWWatchState()
if err != nil { if err != nil {
return fmt.Errorf("debug: read watchpoint state: %w", err) return fmt.Errorf("debug: read watchpoint state: %w", err)
} }
if uint32(slot) >= state.DbgInfo { // MDSCR_EL1 packs (debug_arch << 8) | num_slots into dbg_info, so only
return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo) // 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 state.DbgRegs[slot].Addr = addr
// DBGWCR bits 3-4 select the access type: 01 load, 10 store, 11 either
// (ARM DDI 0487, DBGWCR<n>_EL1 watchpoint type field).
ctrl := uint64(1) // enable ctrl := uint64(1) // enable
switch typ { switch typ {
case WatchWrite: case WatchWrite:
ctrl |= 1 << 3 // store only ctrl |= 2 << 3 // store only
case WatchRead: case WatchRead:
ctrl |= 3 << 3 // load+store ctrl |= 3 << 3 // load+store
} }
@@ -98,7 +111,7 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
ctrl |= bas << 5 ctrl |= bas << 5
state.DbgRegs[slot].Ctrl = ctrl 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) 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 { func (s *Session) ClearWatchpoint(slot int) error {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
} }
if !s.wpSlots[slot] { if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
} }
state, err := s.getHWBreakState() state, err := s.getHWWatchState()
if err != nil { if err != nil {
return err return err
} }
state.DbgRegs[slot].Addr = 0 state.DbgRegs[slot].Addr = 0
state.DbgRegs[slot].Ctrl = 0 state.DbgRegs[slot].Ctrl = 0
if err := s.setHWBreakState(state); err != nil { if err := s.setHWWatchState(state); err != nil {
return err return err
} }
s.wpSlots[slot] = false s.wpSlots[slot] = false
@@ -128,7 +141,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
} }
func (s *Session) ClearAllWatchpoints() error { func (s *Session) ClearAllWatchpoints() error {
for slot := 0; slot < maxWatchpoints; slot++ { for slot := range maxWatchpoints() {
if s.wpSlots[slot] { if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil { if err := s.ClearWatchpoint(slot); err != nil {
return err return err
@@ -138,8 +151,8 @@ func (s *Session) ClearAllWatchpoints() error {
return nil return nil
} }
func (s *Session) getHWBreakState() (*hwBreakState, error) { func (s *Session) getHWWatchState() (*hwWatchState, error) {
var state hwBreakState var state hwWatchState
iovec := syscall.Iovec{ iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(&state)), Base: (*byte)(unsafe.Pointer(&state)),
Len: uint64(unsafe.Sizeof(state)), Len: uint64(unsafe.Sizeof(state)),
@@ -148,7 +161,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) {
syscall.SYS_PTRACE, syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_GETREGSET), uintptr(syscall.PTRACE_GETREGSET),
uintptr(s.pid), uintptr(s.pid),
uintptr(ntArmHWBreak), uintptr(ntArmHWWatch),
uintptr(unsafe.Pointer(&iovec)), uintptr(unsafe.Pointer(&iovec)),
0, 0, 0, 0,
) )
@@ -158,7 +171,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) {
return &state, nil return &state, nil
} }
func (s *Session) setHWBreakState(state *hwBreakState) error { func (s *Session) setHWWatchState(state *hwWatchState) error {
iovec := syscall.Iovec{ iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(state)), Base: (*byte)(unsafe.Pointer(state)),
Len: uint64(unsafe.Sizeof(*state)), Len: uint64(unsafe.Sizeof(*state)),
@@ -167,7 +180,7 @@ func (s *Session) setHWBreakState(state *hwBreakState) error {
syscall.SYS_PTRACE, syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_SETREGSET), uintptr(syscall.PTRACE_SETREGSET),
uintptr(s.pid), uintptr(s.pid),
uintptr(ntArmHWBreak), uintptr(ntArmHWWatch),
uintptr(unsafe.Pointer(&iovec)), uintptr(unsafe.Pointer(&iovec)),
0, 0, 0, 0,
) )
+121 -60
View File
@@ -8,10 +8,47 @@ package debug
import ( import (
"fmt" "fmt"
"syscall" "syscall"
"unsafe"
) )
// Hardware watchpoint support for LoongArch via debug registers. // Hardware watchpoint support via the NT_LOONGARCH_HW_WATCH regset.
// Uses PTRACE_POKEUSER/PEEKUSER to access HW watchpoint registers. //
// 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. // WatchpointType selects what triggers the watchpoint.
type WatchpointType int type WatchpointType int
@@ -21,10 +58,16 @@ const (
WatchRead WatchpointType = 3 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 { func (s *Session) FindFreeWatchpointSlot() int {
for i := range maxWatchpoints { for i := range maxWatchpoints() {
if !s.wpSlots[i] { if !s.wpSlots[i] {
return i return i
} }
@@ -33,53 +76,56 @@ func (s *Session) FindFreeWatchpointSlot() int {
} }
func (s *Session) IsWatchpointSlotUsed(slot int) bool { func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return false return false
} }
return s.wpSlots[slot] 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 { func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
} }
if s.wpSlots[slot] { if s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d already in use", 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") return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8")
} }
// LoongArch debug registers: DBGWVR (watchpoint value) and DBGWCR (watchpoint control). state, err := s.getLoongWatchState()
// Accessed via PTRACE_POKEUSER at architecture-specific offsets. if err != nil {
if err := ptracePokeUser(s.pid, uintptr(0x1000+slot*8), addr); err != nil { return fmt.Errorf("debug: read watchpoint state: %w", err)
return fmt.Errorf("debug: set watchpoint address: %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. state.DbgRegs[slot].Addr = addr
var wcr uint64 = 1 // enable state.DbgRegs[slot].Mask = 0
switch typ { state.DbgRegs[slot].Ctrl = loongCtrlPLV3Enable | ctrlType | lenBits<<loongLenShift
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
if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), wcr); err != nil { if err := s.setLoongWatchState(state); err != nil {
return fmt.Errorf("debug: set watchpoint control: %w", err) return fmt.Errorf("debug: set watchpoint: %w", err)
} }
s.wpSlots[slot] = true s.wpSlots[slot] = true
@@ -87,14 +133,21 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
} }
func (s *Session) ClearWatchpoint(slot int) error { func (s *Session) ClearWatchpoint(slot int) error {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
} }
if !s.wpSlots[slot] { if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", 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 return err
} }
s.wpSlots[slot] = false s.wpSlots[slot] = false
@@ -102,7 +155,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
} }
func (s *Session) ClearAllWatchpoints() error { func (s *Session) ClearAllWatchpoints() error {
for slot := 0; slot < maxWatchpoints; slot++ { for slot := range maxWatchpoints() {
if s.wpSlots[slot] { if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil { if err := s.ClearWatchpoint(slot); err != nil {
return err return err
@@ -112,14 +165,37 @@ func (s *Session) ClearAllWatchpoints() error {
return nil return nil
} }
func ptracePokeUser(pid int, offset uintptr, val uint64) error { func (s *Session) getLoongWatchState() (*loongWatchState, error) {
const ptracePokeuser = 6 var state loongWatchState
iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(&state)),
Len: uint64(unsafe.Sizeof(state)),
}
_, _, errno := syscall.Syscall6( _, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE, syscall.SYS_PTRACE,
uintptr(ptracePokeuser), uintptr(syscall.PTRACE_GETREGSET),
uintptr(pid), uintptr(s.pid),
offset, uintptr(ntLoongHWWatch),
uintptr(val), 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, 0, 0,
) )
if errno != 0 { if errno != 0 {
@@ -127,18 +203,3 @@ func ptracePokeUser(pid int, offset uintptr, val uint64) error {
} }
return nil 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
}
+26 -102
View File
@@ -7,11 +7,16 @@ package debug
import ( import (
"fmt" "fmt"
"syscall"
) )
// Hardware watchpoint support for RISC-V via Sdtrig trigger registers. // Hardware watchpoints are not reachable through the riscv64 kernel ptrace
// Uses PTRACE_POKEUSER/PEEKUSER to access debug registers. // 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. // WatchpointType selects what triggers the watchpoint.
type WatchpointType int type WatchpointType int
@@ -21,10 +26,18 @@ const (
WatchRead WatchpointType = 3 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 { func (s *Session) FindFreeWatchpointSlot() int {
for i := range maxWatchpoints { for i := range maxWatchpoints() {
if !s.wpSlots[i] { if !s.wpSlots[i] {
return i return i
} }
@@ -33,115 +46,26 @@ func (s *Session) FindFreeWatchpointSlot() int {
} }
func (s *Session) IsWatchpointSlotUsed(slot int) bool { func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return false return false
} }
return s.wpSlots[slot] 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 { func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints { return fmt.Errorf("debug: hardware watchpoints are not supported by the riscv64 kernel ptrace interface")
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
} }
// ClearWatchpoint always fails: no watchpoint can ever be armed.
func (s *Session) ClearWatchpoint(slot int) error { func (s *Session) ClearWatchpoint(slot int) error {
if slot < 0 || slot >= maxWatchpoints { if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1) 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)
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
} }
func (s *Session) ClearAllWatchpoints() error { 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 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
}