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
+99 -44
View File
@@ -9,11 +9,11 @@ import "strings"
import "fmt"
// Breakpoint is one INT3 breakpoint in the debuggee.
// Breakpoint is one software breakpoint in the debuggee.
type Breakpoint struct {
Addr uint64 // absolute address in the debuggee
Label string // source label ("" for raw addresses)
Orig byte // original byte at Addr (restored on removal)
Orig []byte // original bytes at Addr (restored on removal)
Enabled bool
Cond *Condition // optional condition (nil = unconditional)
hits int
@@ -32,8 +32,12 @@ type Condition struct {
MemAddr uint64 // memory address (for register-memory comparison, prefixed with *)
}
// Eval checks the condition against the current registers.
func (c *Condition) Eval(regs *Regs) bool {
// Eval checks the condition against the current registers. For the
// register-memory form, mem reads an 8-byte little-endian word from the
// debuggee; it may be nil when no reader is available. Anything that cannot
// be decided (unknown register or operator, unreadable memory) does not
// block the breakpoint.
func (c *Condition) Eval(regs *Regs, mem func(addr uint64) (uint64, bool)) bool {
actual, ok := regs.RegValue(c.Reg)
if !ok {
return true // unknown register, don't block
@@ -48,9 +52,16 @@ func (c *Condition) Eval(regs *Regs) bool {
}
expected = v
case c.MemAddr != 0:
// Register-memory comparison, requires a Session, not available here.
// Fall back to treating as constant (the caller should resolve).
expected = c.Value
// Register-memory comparison, resolved in the debuggee at
// evaluation time.
if mem == nil {
return true
}
v, ok := mem(c.MemAddr)
if !ok {
return true
}
expected = v
default:
expected = c.Value
}
@@ -72,6 +83,18 @@ func (c *Condition) Eval(regs *Regs) bool {
}
}
// String renders the condition for display.
func (c *Condition) String() string {
switch {
case c.Reg2 != "":
return fmt.Sprintf("%s %s %s", c.Reg, c.Op, c.Reg2)
case c.MemAddr != 0:
return fmt.Sprintf("%s %s *%#x", c.Reg, c.Op, c.MemAddr)
default:
return fmt.Sprintf("%s %s %#x", c.Reg, c.Op, c.Value)
}
}
// Breakpoints manages the software breakpoints of one Session.
type Breakpoints struct {
t tracer
@@ -83,6 +106,18 @@ func NewBreakpoints(t tracer) *Breakpoints {
return &Breakpoints{t: t, bps: make(map[uint64]*Breakpoint)}
}
// breakpointMask is the byte mask of the breakpoint instruction inside a
// peeked word: the low len(breakpointInsn) bytes, because every supported
// architecture is little-endian and patches the instruction at the lowest
// address of the word.
func breakpointMask() uint64 {
var mask uint64
for range breakpointInsn {
mask = (mask << 8) | 0xFF
}
return mask
}
// Set installs a breakpoint at addr (replaces any existing one).
func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) {
return bm.SetWithCond(addr, label, nil)
@@ -100,13 +135,12 @@ func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) (
if err != nil {
return nil, err
}
orig := byte(word)
// Patch with the breakpoint instruction, preserving the rest of the word.
mask := uint64(0)
for range breakpointInsn {
mask = (mask << 8) | 0xFF
orig := make([]byte, len(breakpointInsn))
for i := range orig {
orig[i] = byte(word >> (8 * i))
}
patched := (word &^ mask) | breakpointWord(breakpointInsn)
// Patch with the breakpoint instruction, preserving the rest of the word.
patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn)
if err := bm.t.Poke(addr, patched); err != nil {
return nil, err
}
@@ -134,26 +168,40 @@ func (bm *Breakpoints) Info() string {
}
cond := ""
if bp.Cond != nil {
cond = fmt.Sprintf(" if %s %s %#x", bp.Cond.Reg, bp.Cond.Op, bp.Cond.Value)
cond = " if " + bp.Cond.String()
}
result.WriteString(fmt.Sprintf(" %d: %s at %#x [%s, %d hits]%s\n", i, label, bp.Addr, status, bp.hits, cond))
}
return result.String()
}
// Clear removes the breakpoint at addr, restoring the original byte.
// restore writes the saved original bytes back over the breakpoint
// instruction, preserving the rest of the peeked word. It reports whether
// both the peek and the poke succeeded.
func (bm *Breakpoints) restore(addr uint64, bp *Breakpoint) bool {
word, err := bm.t.Peek(addr)
if err != nil {
return false
}
orig := uint64(0)
for i, b := range bp.Orig {
orig |= uint64(b) << (8 * i)
}
return bm.t.Poke(addr, (word&^breakpointMask())|orig) == nil
}
// Clear removes the breakpoint at addr, restoring the original bytes.
func (bm *Breakpoints) Clear(addr uint64) error {
bp, ok := bm.bps[addr]
if !ok {
return fmt.Errorf("debug: no breakpoint at %#x", addr)
}
word, err := bm.t.Peek(addr)
if err != nil {
return err
}
restored := (word &^ 0xFF) | uint64(bp.Orig)
if err := bm.t.Poke(addr, restored); err != nil {
return err
if !bm.restore(addr, bp) {
word, err := bm.t.Peek(addr)
if err != nil {
return err
}
return fmt.Errorf("debug: restore breakpoint at %#x failed, word is %#x", addr, word)
}
delete(bm.bps, addr)
return nil
@@ -185,43 +233,54 @@ func (bm *Breakpoints) All() []*Breakpoint {
// HandleTrap is called after the debuggee stops on SIGTRAP. It checks
// whether the trap was caused by one of our breakpoints (PC-adjust matches
// a breakpoint address), restores the original byte, rewinds PC, and
// a breakpoint address), restores the original bytes, rewinds PC, and
// returns the breakpoint that was hit (or nil if it was a single-step).
// Hits returns how many times the breakpoint has been hit.
func (bp *Breakpoint) Hits() int { return bp.hits }
func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint {
// After a breakpoint trap, PC points past the breakpoint instruction.
// On amd64 the kernel reports the trap with RIP past the INT3; on the
// other supported architectures the PC still stands on the trap
// instruction, which breakpointPCAdjust encodes per architecture.
trapAddr := regs.GetPC() - uint64(breakpointPCAdjust)
bp, ok := bm.bps[trapAddr]
if !ok || !bp.Enabled {
return nil // single-step trap or unknown
}
// Check the condition (if any).
if bp.Cond != nil && !bp.Cond.Eval(regs) {
// Condition not met, restore the byte but do NOT rewind RIP.
// The process continues from the next instruction (past the INT3).
word, err := bm.t.Peek(trapAddr)
if err == nil {
restored := (word &^ 0xFF) | uint64(bp.Orig)
bm.t.Poke(trapAddr, restored)
if bp.Cond != nil && !bp.Cond.Eval(regs, bm.peekValue) {
// Condition not met: step the original instruction and re-arm the
// breakpoint, leaving the debuggee stopped just past it, ready to
// resume silently. The PC must be rewound first: on architectures
// that report the trap past the instruction (amd64) it would
// otherwise sit on the second byte of the replaced instruction.
if !bm.restore(trapAddr, bp) {
return nil
}
// RIP is already past the INT3 (trapAddr + 1). Don't rewind.
regs.SetPC(trapAddr)
if err := bm.t.SetRegs(regs); err != nil {
return nil
}
if err := bm.t.Step(); err != nil {
return nil
}
bm.Reinsert(trapAddr)
return nil
}
bp.hits++
// Restore the original byte.
word, err := bm.t.Peek(trapAddr)
if err == nil {
restored := (word &^ 0xFF) | uint64(bp.Orig)
bm.t.Poke(trapAddr, restored)
}
// Rewind PC to re-execute the original instruction.
// Restore the original bytes and rewind PC to re-execute them.
bm.restore(trapAddr, bp)
regs.SetPC(trapAddr)
bm.t.SetRegs(regs)
return bp
}
// peekValue adapts tracer.Peek to the Condition value reader.
func (bm *Breakpoints) peekValue(addr uint64) (uint64, bool) {
v, err := bm.t.Peek(addr)
return v, err == nil
}
// Reinsert re-inserts the breakpoint at addr after a single-step past it.
// Called after Step() when we want the breakpoint to fire again on the
// next Continue().
@@ -234,11 +293,7 @@ func (bm *Breakpoints) Reinsert(addr uint64) error {
if err != nil {
return err
}
mask := uint64(0)
for range breakpointInsn {
mask = (mask << 8) | 0xFF
}
patched := (word &^ mask) | breakpointWord(breakpointInsn)
patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn)
return bm.t.Poke(addr, patched)
}
+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
import (
"strings"
"testing"
)
@@ -48,7 +47,7 @@ func TestConditionEval(t *testing.T) {
}
for _, tt := range tests {
got := tt.cond.Eval(regs)
got := tt.cond.Eval(regs, nil)
if got != tt.want {
t.Errorf("Condition{%q %q %d}.Eval() = %v, want %v",
tt.cond.Reg, tt.cond.Op, tt.cond.Value, got, tt.want)
@@ -56,65 +55,33 @@ func TestConditionEval(t *testing.T) {
}
}
func TestLineAt(t *testing.T) {
lines := []SourceLine{
{Offset: 0, Line: 5},
{Offset: 5, Line: 6},
{Offset: 10, Line: 7},
{Offset: 15, Line: 8},
}
tests := []struct {
offset int
want int
}{
{0, 5},
{1, 5},
{4, 5},
{5, 6},
{7, 6},
{10, 7},
{12, 7},
{15, 8},
{20, 8},
}
for _, tt := range tests {
got := lineAt(lines, tt.offset)
if got != tt.want {
t.Errorf("lineAt(lines, %d) = %d, want %d", tt.offset, got, tt.want)
// TestConditionEvalMem covers the register-memory form: the value is read
// through the supplied reader, and a missing or failing reader must not
// block the breakpoint.
func TestConditionEvalMem(t *testing.T) {
regs := &Regs{RAX: 7}
mem := func(addr uint64) (uint64, bool) {
if addr == 0x5000 {
return 7, true
}
return 0, false
}
// Empty table.
if lineAt(nil, 5) != 0 {
t.Error("lineAt(nil, 5) should return 0")
eq := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000}
if !eq.Eval(regs, mem) {
t.Error("register-memory comparison with matching word should hold")
}
}
func TestOffsetForLine(t *testing.T) {
lines := []SourceLine{
{Offset: 0, Line: 5},
{Offset: 5, Line: 6},
{Offset: 10, Line: 7},
ne := Condition{Reg: "rax", Op: "!=", MemAddr: 0x5000}
if ne.Eval(regs, mem) {
t.Error("register-memory comparison with mismatching word should not hold")
}
tests := []struct {
line int
want int
}{
{5, 0},
{6, 5},
{7, 10},
{99, -1}, // not found
{0, -1}, // not found
bad := Condition{Reg: "rax", Op: "==", MemAddr: 0x6000}
if !bad.Eval(regs, mem) {
t.Error("unreadable memory must not block the breakpoint")
}
for _, tt := range tests {
got := offsetForLine(lines, tt.line)
if got != tt.want {
t.Errorf("offsetForLine(lines, %d) = %d, want %d", tt.line, got, tt.want)
}
noReader := Condition{Reg: "rax", Op: "==", MemAddr: 0x5000}
if !noReader.Eval(regs, nil) {
t.Error("missing memory reader must not block the breakpoint")
}
}
@@ -141,139 +108,6 @@ func TestDecodeRflags(t *testing.T) {
}
}
func TestNearestLabel(t *testing.T) {
labels := []Label{
{Name: "start", Offset: 0},
{Name: "loop", Offset: 10},
{Name: "done", Offset: 20},
}
tests := []struct {
offset int
want string
}{
{0, "start"},
{5, "start"},
{10, "loop"},
{15, "loop"},
{20, "done"},
{25, "done"},
}
for _, tt := range tests {
got := nearestLabel(labels, tt.offset)
if got != tt.want {
t.Errorf("nearestLabel(labels, %d) = %q, want %q", tt.offset, got, tt.want)
}
}
}
func TestBreakpointsSetAndClear(t *testing.T) {
tr := newMockTracer()
bm := NewBreakpoints(tr)
// Set a breakpoint at address 0x1000.
bp, err := bm.Set(0x1000, "test")
if err != nil {
t.Fatalf("Set: %v", err)
}
if !bp.Enabled {
t.Error("breakpoint not enabled")
}
if bp.Label != "test" {
t.Errorf("label = %q, want test", bp.Label)
}
// Verify Peek was called.
if len(tr.peeks) != 1 || tr.peeks[0] != 0x1000 {
t.Errorf("peeks = %v, want [0x1000]", tr.peeks)
}
// Verify Poke wrote INT3.
if len(tr.pokes) != 1 || tr.pokes[0].addr != 0x1000 {
t.Errorf("pokes = %v", tr.pokes)
}
// At should find it.
if bm.At(0x1000) == nil {
t.Error("At(0x1000) returned nil")
}
// All should return it.
all := bm.All()
if len(all) != 1 {
t.Errorf("All() = %d breakpoints, want 1", len(all))
}
// Clear it.
if err := bm.Clear(0x1000); err != nil {
t.Fatalf("Clear: %v", err)
}
if bm.At(0x1000) != nil {
t.Error("At(0x1000) after Clear should be nil")
}
}
func TestBreakpointsSetWithCond(t *testing.T) {
tr := newMockTracer()
bm := NewBreakpoints(tr)
cond := &Condition{Reg: "rax", Op: "==", Value: 42}
bp, err := bm.SetWithCond(0x2000, "cond_test", cond)
if err != nil {
t.Fatalf("SetWithCond: %v", err)
}
if bp.Cond == nil || bp.Cond.Value != 42 {
t.Error("condition not set")
}
// Re-setting the same address should update the condition.
cond2 := &Condition{Reg: "rbx", Op: "<", Value: 100}
bp2, err := bm.SetWithCond(0x2000, "cond_test2", cond2)
if err != nil {
t.Fatalf("SetWithCond (update): %v", err)
}
if bp2.Cond.Value != 100 {
t.Error("condition not updated")
}
// Should have only 1 Peek (first Set), second is update (no Peek needed).
if len(tr.peeks) != 1 {
t.Errorf("expected 1 Peek, got %d", len(tr.peeks))
}
}
func TestBreakpointsClearAll(t *testing.T) {
tr := newMockTracer()
bm := NewBreakpoints(tr)
bm.Set(0x1000, "a")
bm.Set(0x2000, "b")
bm.Set(0x3000, "c")
if len(bm.All()) != 3 {
t.Fatalf("expected 3 breakpoints, got %d", len(bm.All()))
}
bm.ClearAll()
if len(bm.All()) != 0 {
t.Errorf("ClearAll: expected 0 breakpoints, got %d", len(bm.All()))
}
}
func TestBreakpointInfo(t *testing.T) {
tr := newMockTracer()
bm := NewBreakpoints(tr)
bm.Set(0x4000, "info_test")
info := bm.Info()
if info == "" {
t.Error("Info returned empty string")
}
if !strings.Contains(info, "info_test") {
t.Errorf("Info %q does not contain label", info)
}
}
func TestWatchpointSlotTracking(t *testing.T) {
s := &Session{} // per-session slots start free
@@ -323,3 +157,19 @@ func TestWatchpointSlotTracking(t *testing.T) {
t.Errorf("FindFreeWatchpointSlot() with all slots used = %d, want -1", got)
}
}
// TestUnwatchSlotBound checks the bound the REPL parses against: it must
// cover the architecture's whole slot range, not a hardcoded 0-3.
func TestUnwatchSlotBound(t *testing.T) {
max := maxWatchpoints()
if max < 4 {
t.Fatalf("maxWatchpoints() = %d, want at least 4", max)
}
s := &Session{}
if s.IsWatchpointSlotUsed(max - 1) {
t.Errorf("slot %d should be free initially", max-1)
}
if s.IsWatchpointSlotUsed(max) {
t.Errorf("slot %d must be out of range", max)
}
}
+8
View File
@@ -46,3 +46,11 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
}
return result.String()
}
// isCallInsn reports whether disassembled text (x86asm.IntelSyntax) is a
// call. The first token must match exactly: a prefix test would also catch
// unrelated mnemonics.
func isCallInsn(text string) bool {
m, _, _ := strings.Cut(text, " ")
return strings.ToLower(m) == "call"
}
+13
View File
@@ -7,6 +7,7 @@ package debug
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,15 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
}
return result
}
// isCallInsn reports whether disassembled text (arm64asm.GoSyntax) is a
// call. GoSyntax renders bl as CALL; the native mnemonic is accepted too.
// The first token must match exactly so branches never match.
func isCallInsn(text string) bool {
m, _, _ := strings.Cut(text, " ")
switch strings.ToLower(m) {
case "call", "bl":
return true
}
return false
}
+14
View File
@@ -7,6 +7,7 @@ package debug
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,16 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
}
return result
}
// isCallInsn reports whether disassembled text (loong64asm.GoSyntax) is a
// call. GoSyntax renders bl and jirl calls as CALL (jirl returns print
// RET); the native mnemonics are accepted too. The first token must match
// exactly: a "bl" prefix would catch bltz and other branches.
func isCallInsn(text string) bool {
m, _, _ := strings.Cut(text, " ")
switch strings.ToLower(m) {
case "call", "bl", "jirl":
return true
}
return false
}
+15
View File
@@ -7,6 +7,7 @@ package debug
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
@@ -44,3 +45,17 @@ func (s *Session) DisassembleN(addr uint64, n int) string {
}
return result
}
// isCallInsn reports whether disassembled text (riscv64asm.GoSyntax) is a
// call. GoSyntax renders jal and jalr calls as CALL; the native mnemonics
// are accepted too. The first token must match exactly: a prefix test on
// "bl" would catch branches on other architectures, and jalr as ret prints
// RET, which must not be stepped over.
func isCallInsn(text string) bool {
m, _, _ := strings.Cut(text, " ")
switch strings.ToLower(m) {
case "call", "jal", "jalr":
return true
}
return false
}
+22 -2
View File
@@ -73,9 +73,29 @@ func decodeRflags(f uint64) string {
return flags[:len(flags)-1]
}
// archReturnAddr reads the return address from the stack (amd64 ABI0 convention).
// archReturnAddr reads the return address of the current frame (amd64
// ABI0 convention). A function that contains a CALL (or has a frame) is
// assembled with the prologue PUSHQ BP; MOVQ SP, BP, so mid-function the
// word at SP is the saved caller BP, a stack address, and the return
// address sits further up. Walk the stack from SP and take the first word
// that lies in an executable mapping: stack and data words never do, a
// return address always does.
func archReturnAddr(s *Session, regs *Regs) (uint64, error) {
return s.Peek(regs.GetSP())
ranges := execRanges(s.pid)
for off := uint64(0); off < 512; off += 8 {
word, err := s.Peek(regs.RSP + off)
if err != nil {
break
}
for _, r := range ranges {
if word >= r.lo && word < r.hi {
return word, nil
}
}
}
// No mapping available or nothing code-like on the stack: fall back to
// the raw entry convention, [SP] before any push.
return s.Peek(regs.RSP)
}
// archSPLabel returns the SP register name for display.
+6 -3
View File
@@ -5,7 +5,10 @@
package debug
import "fmt"
import (
"encoding/binary"
"fmt"
)
func printRegs(regs *Regs, codeBase, funcOff uint64) {
fmt.Printf(" PC = %#016x (func+%#x)\n", regs.PC, regs.PC-codeBase-funcOff)
@@ -31,8 +34,8 @@ func printRegs(regs *Regs, codeBase, funcOff uint64) {
func printVectorRegs(v *VectorRegs) {
fmt.Println("\n Vector registers (V0-V31):")
for i := 0; i < 32; i += 2 {
fmt.Printf(" V%-2d = %016x%016x\n", i, v.V[i][8], v.V[i][0])
fmt.Printf(" V%-2d = %016x%016x\n", i+1, v.V[i+1][8], v.V[i+1][0])
fmt.Printf(" V%-2d = %016x%016x\n", i, binary.LittleEndian.Uint64(v.V[i][8:16]), binary.LittleEndian.Uint64(v.V[i][0:8]))
fmt.Printf(" V%-2d = %016x%016x\n", i+1, binary.LittleEndian.Uint64(v.V[i+1][8:16]), binary.LittleEndian.Uint64(v.V[i+1][0:8]))
}
}
+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
exited bool
codeBase uint64 // base address of the JIT code in the debuggee
wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 BADVR0-15)
tmpDir string // scratch directory of the session, removed on Kill
wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 DBGWVR0-15)
// lastSignal holds the signal of the most recent stop when that stop
// was a genuine signal-delivery-stop the caller must see (a fault such
// as SIGSEGV, SIGBUS, SIGFPE or SIGILL); 0 for breakpoint traps,
// single-steps, SIGSTOP and suppressed runtime signals.
lastSignal syscall.Signal
}
// Launch starts the debuggee subprocess (gasm debug --target ...) and
@@ -78,7 +84,7 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
return nil, nil, fmt.Errorf("debug: start debuggee: %w", err)
}
s := &Session{pid: cmd.Process.Pid, cmd: cmd}
s := &Session{pid: cmd.Process.Pid, cmd: cmd, tmpDir: tmpDir}
readyFile := filepath.Join(tmpDir, "ready")
for range 500 {
@@ -125,29 +131,17 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
return s, bufAddrs, nil
}
// wait waits for the debuggee to stop and returns the wait status.
func (s *Session) wait() error {
var ws syscall.WaitStatus
_, err := syscall.Wait4(s.pid, &ws, 0, nil)
if err != nil {
return err
}
if ws.Exited() {
s.exited = true
return fmt.Errorf("debuggee exited with status %d", ws.ExitStatus())
}
s.stopped = true
return nil
}
// waitStopped consumes ptrace-stop events until one the debugger cares
// about arrives: SIGTRAP (a breakpoint or a completed single-step) or the
// debuggee's own SIGSTOP. A Go tracee's runtime raises SIGURG for
// asynchronous preemption, and every signal on a traced thread surfaces as
// a signal-delivery-stop, so those are suppressed and the tracee resumed
// without them. Runtime noise is why a single wait can return in the
// middle of runtime code and a resume can then fail: the event stream must
// be drained by the tracer.
// about arrives: SIGTRAP (a breakpoint or a completed single-step), the
// debuggee's own SIGSTOP, or a genuine signal-delivery-stop. A Go tracee's
// runtime raises SIGURG for asynchronous preemption, and every signal on a
// traced thread surfaces as a signal-delivery-stop, so SIGURG is suppressed
// and the tracee resumed without it. Every other signal (SIGSEGV, SIGBUS,
// SIGFPE, SIGILL, ...) is returned to the caller: resuming with signal 0
// would restart the faulting instruction and fault forever, so a faulting
// kernel must surface as a stop the caller reports. Runtime noise is also
// why a single wait can return in the middle of runtime code and a resume
// can then fail: the event stream must be drained by the tracer.
func (s *Session) waitStopped() (syscall.Signal, error) {
for {
var ws syscall.WaitStatus
@@ -165,10 +159,12 @@ func (s *Session) waitStopped() (syscall.Signal, error) {
switch sig := ws.StopSignal(); sig {
case syscall.SIGTRAP, syscall.SIGSTOP:
s.stopped = true
s.lastSignal = 0
return sig, nil
default:
// Runtime noise (SIGURG preemption and friends): resume the
// tracee without delivering the signal.
case syscall.SIGURG:
// Go runtime asynchronous preemption: resume the tracee
// without delivering the signal.
s.lastSignal = 0
if _, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_CONT),
@@ -177,10 +173,22 @@ func (s *Session) waitStopped() (syscall.Signal, error) {
); errno != 0 {
return 0, fmt.Errorf("debug: PTRACE_CONT: %w", errno)
}
default:
// A genuine signal-delivery-stop. Report it; the caller
// decides how to proceed.
s.stopped = true
s.lastSignal = sig
return sig, nil
}
}
}
// LastSignal returns the signal of the most recent stop when that stop was
// a genuine signal-delivery-stop (a fault such as SIGSEGV, SIGFPE, SIGILL
// or SIGBUS), and 0 for breakpoint traps, single-steps, SIGSTOP and
// suppressed runtime signals.
func (s *Session) LastSignal() syscall.Signal { return s.lastSignal }
// Peek reads a word (8 bytes) from the debuggee's memory at addr.
func (s *Session) Peek(addr uint64) (uint64, error) {
mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0)
@@ -294,7 +302,8 @@ func (s *Session) Pid() int { return s.pid }
// CodeBase returns the base address of the JIT code in the debuggee.
func (s *Session) CodeBase() uint64 { return s.codeBase }
// Kill terminates the debuggee.
// Kill terminates the debuggee and removes the session's scratch
// directory, so a successful session leaves no gasm-debug-* debris behind.
func (s *Session) Kill() {
if !s.exited {
syscall.Kill(s.pid, syscall.SIGKILL)
@@ -304,6 +313,35 @@ func (s *Session) Kill() {
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait()
}
if s.tmpDir != "" {
os.RemoveAll(s.tmpDir)
s.tmpDir = ""
}
}
// execRange is one executable mapping of the debuggee.
type execRange struct {
lo, hi uint64
}
// execRanges parses the debuggee's executable mappings from /proc/pid/maps.
func execRanges(pid int) []execRange {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/maps", pid))
if err != nil {
return nil
}
var out []execRange
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || !strings.Contains(fields[1], "x") {
continue
}
var lo, hi uint64
if _, err := fmt.Sscanf(fields[0], "%x-%x", &lo, &hi); err == nil {
out = append(out, execRange{lo, hi})
}
}
return out
}
// findRWXMapping reads /proc/pid/maps and returns the base address of the
+68 -11
View File
@@ -6,6 +6,7 @@
package debug
import (
"encoding/binary"
"fmt"
"syscall"
"unsafe"
@@ -44,20 +45,24 @@ func (s *Session) SetRegs(regs *Regs) error {
return nil
}
// FPRegs holds the x87 FPU and SSE (XMM) register state from PTRACE_GETFPREGS.
// FPRegs holds the x87 FPU and SSE (XMM) register state from
// PTRACE_GETFPREGS. The layout is the kernel's struct user_fpregs_struct
// (sys/user.h), the FXSAVE image: 512 bytes with XMM0-15 at offset 160.
// The i387 fcs/ds segment fields do not exist in the 64-bit layout. The
// size matters: the copy fills all 512 bytes, so a short or misaligned
// struct makes PTRACE_GETFPREGS overflow the caller's memory.
type FPRegs struct {
FCW uint16
FSW uint16
FTW byte
FTW uint16
FOP uint16
FIP uint64
FCS uint16
FDP uint64
FDS uint16
MXCSR uint32
MXCSRMask uint32
ST [8][16]byte // x87 stack (10 bytes per reg, padded to 16)
XMM [16][16]byte // XMM0-15
XMM [16][16]byte // XMM0-15, struct offset 160
Reserved [96]byte // FXSAVE padding, to the full 512 bytes
}
// GetFPRegs retrieves the FPU/SSE register state of the stopped debuggee.
@@ -82,16 +87,68 @@ type VectorRegs struct {
YMM [16][32]byte // YMM0-15 (full 256-bit values)
}
// GetVectorRegs retrieves the YMM registers via PTRACE_GETREGSET + XSAVE.
// NT_X86_XSTATE (0x202), the xsave extended-state regset
// (include/uapi/linux/elf.h).
const ntX86XState = 0x202
// Layout of the buffer PTRACE_GETREGSET returns for NT_X86_XSTATE: the
// 512-byte legacy fxsave image (x87 state in 0-159, XMM0-15 in 160-511),
// then the 64-byte xsave header whose first 8 bytes are xstate_bv, then one
// component per set feature bit, each 64-byte aligned. The YMM high halves
// are the first extended component, at offset 576; that offset is fixed by
// the ISA on AVX-capable x86-64. XFEATURE_MASK_YMM is bit 2 of xstate_bv
// (arch/x86/include/asm/fpu/types.h); the high halves are zero when the bit
// is clear.
const (
xsaveXMMOffset = 160
xsaveXMMSize = 256
xsaveHeaderOffset = 512
xsaveBVOffset = xsaveHeaderOffset
ymmOffset = xsaveHeaderOffset + 64 // 576
ymmSize = 256 // 16 registers, 16 bytes each
xfeatureMaskYMM = 1 << 2
xstateMaxBuffer = 4096 // CPUID(0xD).xsave_size is far below this
)
// GetVectorRegs retrieves the YMM registers via PTRACE_GETREGSET on
// NT_X86_XSTATE. The low (XMM) halves always come from the legacy image;
// the high halves are copied only when xstate_bv reports the YMM feature,
// and read as zero otherwise. When the regset request fails the FP image
// still provides correct XMM halves, so that is the fallback.
func (s *Session) GetVectorRegs() (VectorRegs, error) {
var v VectorRegs
fp, err := s.GetFPRegs()
if err != nil {
return v, err
buf := make([]byte, xstateMaxBuffer)
iovec := syscall.Iovec{
Base: &buf[0],
Len: uint64(len(buf)),
}
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_GETREGSET),
uintptr(s.pid),
uintptr(ntX86XState),
uintptr(unsafe.Pointer(&iovec)),
0, 0,
)
if errno != 0 {
fp, err := s.GetFPRegs()
if err != nil {
return v, err
}
for i := range 16 {
copy(v.YMM[i][:16], fp.XMM[i][:])
}
return v, nil
}
n := int(iovec.Len)
for i := range 16 {
for j := range 16 {
v.YMM[i][j] = fp.XMM[i][j]
copy(v.YMM[i][:16], buf[xsaveXMMOffset+16*i:xsaveXMMOffset+16*i+16])
}
if n >= ymmOffset+ymmSize {
if binary.LittleEndian.Uint64(buf[xsaveBVOffset:xsaveBVOffset+8])&xfeatureMaskYMM != 0 {
for i := range 16 {
copy(v.YMM[i][16:], buf[ymmOffset+16*i:ymmOffset+16*i+16])
}
}
}
return v, nil
+5 -1
View File
@@ -91,5 +91,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction.
var breakpointInsn = []byte{0xCC} // INT3
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap.
// breakpointPCAdjust is how far PC is past the breakpoint instruction after
// a trap. x86-64 reports the #DB for INT3 with RIP on the byte after the
// INT3 (Intel SDM vol 3, "Debug Exceptions"), so the trap address is
// PC-1. The other supported architectures leave the PC on the trap
// instruction and use 0 there.
const breakpointPCAdjust = 1
+8 -2
View File
@@ -130,5 +130,11 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (BRK #0).
var breakpointInsn = []byte{0x00, 0x00, 0x20, 0xD4} // BRK #0
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap.
const breakpointPCAdjust = 4
// breakpointPCAdjust is how far PC is past the breakpoint instruction after
// a trap: 0, because the arm64 kernel delivers the BRK SIGTRAP with the PC
// still on the BRK. do_el0_brk64 calls send_user_sigtrap, which uses
// instruction_pointer(regs) unmodified (arch/arm64/kernel/debug-monitors.c);
// only the kernel-internal skip paths advance the PC. GDB history agrees:
// decr_pc_after_break on aarch64 Linux is 0 (the +4 variant was a QEMU bug,
// sourceware PR 17280).
const breakpointPCAdjust = 0
+6 -2
View File
@@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (BRK $0).
var breakpointInsn = []byte{0x05, 0x00, 0x2a, 0x00} // break 0
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap.
const breakpointPCAdjust = 4
// breakpointPCAdjust is how far PC is past the breakpoint instruction after
// a trap: 0, because the kernel delivers the break SIGTRAP with csr_era
// still on the break instruction. do_bp passes regs->csr_era straight to
// force_sig_fault(SIGTRAP, TRAP_BRKPT, ...) and never adjusts era on the
// signal path (arch/loongarch/kernel/traps.c).
const breakpointPCAdjust = 0
+6 -2
View File
@@ -126,5 +126,9 @@ func (r *Regs) RegValue(name string) (uint64, bool) {
// breakpointInsn is the software breakpoint instruction (EBREAK).
var breakpointInsn = []byte{0x73, 0x00, 0x10, 0x00} // ebreak
// breakpointPCAdjust is how far PC is past the breakpoint instruction after a trap.
const breakpointPCAdjust = 4
// breakpointPCAdjust is how far PC is past the breakpoint instruction after
// a trap: 0, because the kernel delivers the EBREAK SIGTRAP with sepc still
// on the ebreak. handle_break passes regs->epc straight to
// force_sig_fault(SIGTRAP, TRAP_BRKPT, ...) and only the kernel-internal
// WARN/CFI paths advance epc (arch/riscv/kernel/traps.c).
const breakpointPCAdjust = 0
+74 -18
View File
@@ -7,9 +7,10 @@ package debug
import (
"bufio"
"cmp"
"fmt"
"io"
"sort"
"slices"
"strconv"
"strings"
)
@@ -93,7 +94,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
regs, _ := s.GetRegs()
pc := regs.GetPC()
text, instLen, _ := s.Disassemble(pc)
if strings.HasPrefix(strings.ToLower(text), "call") || strings.HasPrefix(strings.ToLower(text), "bl") {
if isCallInsn(text) {
afterAddr := pc + uint64(instLen)
_, err := bm.Set(afterAddr, "(next)")
if err != nil {
@@ -108,6 +109,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
bm.Clear(afterAddr)
continue
}
if s.Exited() {
bm.Clear(afterAddr)
fmt.Println("debuggee exited")
continue
}
if sig := s.LastSignal(); sig != 0 {
bm.Clear(afterAddr)
regs, _ := s.GetRegs()
fmt.Printf("stopped on signal %v at %#x\n", sig, regs.GetPC())
continue
}
// Fetch the registers after the stop: the trap must be
// evaluated against the real PC, not the pre-Continue
// snapshot, and a stale SetRegs would clobber live state.
regs, _ = s.GetRegs()
bm.HandleTrap(&regs)
bm.Clear(afterAddr)
} else {
@@ -143,9 +159,21 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
bm.Clear(retAddr)
continue
}
if !s.Exited() {
bm.HandleTrap(&regs)
if s.Exited() {
bm.Clear(retAddr)
fmt.Println("debuggee exited")
continue
}
if sig := s.LastSignal(); sig != 0 {
bm.Clear(retAddr)
regs, _ := s.GetRegs()
fmt.Printf("stopped on signal %v at %#x\n", sig, regs.GetPC())
continue
}
// Fetch the registers after the stop, as the continue case
// does: HandleTrap must see the PC the trap left behind.
regs, _ = s.GetRegs()
bm.HandleTrap(&regs)
bm.Clear(retAddr)
if s.Exited() {
fmt.Println("debuggee exited")
@@ -171,6 +199,15 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
fmt.Println("debuggee exited")
break
}
if sig := s.LastSignal(); sig != 0 {
// A genuine signal-delivery-stop (a fault): report it
// and return to the prompt. Continuing would restart
// the faulting instruction and fault forever.
regs, _ := s.GetRegs()
fmt.Printf("stopped on signal %v at %#x (func+%#x)\n",
sig, regs.GetPC(), regs.GetPC()-codeBase-uint64(funcOffset))
break
}
reason, wpAddr := s.StopInfo()
if reason == StopWatchpoint {
fmt.Printf("watchpoint hit at %#x\n", wpAddr)
@@ -221,13 +258,27 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
reg := strings.ToLower(parts[3])
op := parts[4]
operand := parts[5]
if val, err := strconv.ParseUint(operand, 0, 64); err == nil {
cond = &Condition{Reg: reg, Op: op, Value: val}
} else {
cond = &Condition{Reg: reg, Op: op, Reg2: strings.ToLower(operand)}
switch {
case strings.HasPrefix(operand, "*"):
// Memory operand: compare against the 8-byte word at
// the address, resolved in the debuggee when the
// breakpoint is evaluated.
addr, err := strconv.ParseUint(strings.TrimPrefix(operand, "*"), 0, 64)
if err != nil {
fmt.Printf("invalid memory operand: %s\n", operand)
continue
}
cond = &Condition{Reg: reg, Op: op, MemAddr: addr}
default:
val, err := strconv.ParseUint(operand, 0, 64)
if err == nil {
cond = &Condition{Reg: reg, Op: op, Value: val}
} else {
cond = &Condition{Reg: reg, Op: op, Reg2: strings.ToLower(operand)}
}
}
} else if len(parts) >= 4 && parts[2] == "if" {
fmt.Println("usage: break <label|addr> if <reg> <op> <value|reg>")
fmt.Println("usage: break <label|addr> if <reg> <op> <value|reg|*addr>")
continue
}
bp, err := bm.SetWithCond(addr, label, cond)
@@ -237,7 +288,7 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
}
condStr := ""
if cond != nil {
condStr = fmt.Sprintf(" if %s %s %#x", cond.Reg, cond.Op, cond.Value)
condStr = " if " + cond.String()
}
fmt.Printf("breakpoint set: %s at %#x (func+%#x)%s\n", bp.Label, bp.Addr, bp.Addr-codeBase-uint64(funcOffset), condStr)
@@ -277,7 +328,11 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
addr, _ = resolveAddr(parts[1], codeBase, uint64(funcOffset), labels)
}
if len(parts) > 2 {
length, _ = strconv.Atoi(parts[2])
// A malformed or non-positive length would panic
// ReadMemory's make; fall back to the default instead.
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
length = n
}
}
mem, err := s.ReadMemory(addr, length)
if err != nil {
@@ -336,10 +391,8 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
}
case "labels", "l":
sorted := make([]Label, len(labels))
copy(sorted, labels)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Offset < sorted[j].Offset })
for _, l := range sorted {
slices.SortFunc(labels, func(a, b Label) int { return cmp.Compare(a.Offset, b.Offset) })
for _, l := range labels {
fmt.Printf(" func+%#04x %s\n", l.Offset, l.Name)
}
@@ -369,7 +422,10 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
fmt.Println()
case "help", "h", "?":
fmt.Printf(` break <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
info break list all breakpoints
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":
if len(parts) >= 2 {
slot, err := strconv.Atoi(parts[1])
if err != nil || slot < 0 || slot > 3 {
fmt.Println("usage: unwatch [<slot>]")
if err != nil || slot < 0 || slot >= maxWatchpoints() {
fmt.Printf("usage: unwatch [<slot 0-%d>]\n", maxWatchpoints()-1)
continue
}
if err := s.ClearWatchpoint(slot); err != nil {
+10 -2
View File
@@ -6,6 +6,7 @@
package debug
import (
"encoding/binary"
"syscall"
"unsafe"
)
@@ -61,8 +62,15 @@ func (s *Session) StopInfo() (StopReason, uint64) {
case trapBRKPT:
return StopBreakpoint, 0
case trapHWBRKPT:
addr := *(*uint64)(unsafe.Add(unsafe.Pointer(&info), 16))
return StopWatchpoint, addr
// si_addr sits at struct offset 16 (12 bytes of signo/errno/code
// plus 4 bytes of union alignment). The siginfo buffer is only
// 4-byte aligned, so the address is read byte-wise to keep the
// load aligned on riscv64 and loong64. What si_addr names is
// architecture-specific (the data address on arm64, the
// instruction pointer on x86), so the per-architecture
// archWatchpointAddr resolves it to the watched address.
addr := binary.LittleEndian.Uint64(info._pad[4:12])
return StopWatchpoint, archWatchpointAddr(s, addr)
default:
return StopSingleStep, 0
}
+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])
}
var img *asm.Image
switch "arm64" {
case "arm64":
img, err = asm.AssembleFileARM64(file)
case "riscv64":
img, err = asm.AssembleFileRISCV(file)
case "loong64":
img, err = asm.AssembleFileLOONG64(file)
}
img, err := asm.AssembleFileARM64(file)
if err != nil {
return fmt.Errorf("debug target: assemble: %w", err)
}
+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])
}
var img *asm.Image
switch "loong64" {
case "arm64":
img, err = asm.AssembleFileARM64(file)
case "riscv64":
img, err = asm.AssembleFileRISCV(file)
case "loong64":
img, err = asm.AssembleFileLOONG64(file)
}
img, err := asm.AssembleFileLOONG64(file)
if err != nil {
return fmt.Errorf("debug target: assemble: %w", err)
}
+8 -2
View File
@@ -12,10 +12,11 @@ type tracer interface {
Peek(addr uint64) (uint64, error)
Poke(addr uint64, val uint64) error
SetRegs(regs *Regs) error
Step() error
Pid() int
}
// mockTracer records Peek/Poke calls and provides fake register state.
// mockTracer records Peek/Poke/Step calls and provides fake register state.
type mockTracer struct {
mem map[uint64]byte
peeks []uint64
@@ -23,7 +24,8 @@ type mockTracer struct {
addr uint64
val uint64
}
regs *Regs
steps int
regs *Regs
}
func newMockTracer() *mockTracer {
@@ -57,4 +59,8 @@ func (m *mockTracer) SetRegs(regs *Regs) error {
m.regs = regs
return nil
}
func (m *mockTracer) Step() error {
m.steps++
return nil
}
func (m *mockTracer) Pid() int { return 42 }
+52 -21
View File
@@ -8,10 +8,48 @@ package debug
import (
"fmt"
"syscall"
"unsafe"
)
// Hardware watchpoint support via x86-64 debug registers (DR0-DR3, DR7).
// The kernel translates PTRACE_POKEUSER/PEEKUSER offsets inside
// [offsetof(struct user, u_debugreg[0]), u_debugreg[7]] to DR0-DR7
// (arch/x86/kernel/ptrace.c, arch_ptrace). sys/user.h places u_debugreg at
// 0x350: DR0-DR3 are 0x350/0x358/0x360/0x368, DR6 (status) is 0x380 and
// DR7 (control) is 0x388. Offsets below 0x350 write user_regs_struct
// fields (r15 at 0x0, r10 at 0x38), not debug registers.
const (
drOffset = 0x350 // offsetof(struct user, u_debugreg[0]), DR0
dr6Off = 0x380 // offsetof(struct user, u_debugreg[6]), DR6
dr7Off = 0x388 // offsetof(struct user, u_debugreg[7]), DR7
)
// archWatchpointAddr resolves the address of the watchpoint that fired.
// x86 delivers si_addr = the instruction pointer of the trapping access
// (arch/x86/kernel/ptrace.c send_sigtrap passes regs->ip), so the watched
// data address is recovered from DR6's slot bits (B0-B3, positive polarity
// through PEEKUSER) and the matching DR0-DR3.
func archWatchpointAddr(s *Session, siAddr uint64) uint64 {
dr6, err := ptracePeekUser(s.pid, dr6Off)
if err != nil {
return siAddr
}
for slot := range 4 {
if dr6&(1<<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.
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")
}
var drAddr uintptr
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 {
if err := ptracePokeUser(s.pid, drOffset+uintptr(slot*8), addr); err != nil {
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 {
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)))
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)
}
s.wpSlots[slot] = true
@@ -105,12 +131,12 @@ func (s *Session) ClearWatchpoint(slot int) error {
if !s.wpSlots[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 {
return err
}
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
}
s.wpSlots[slot] = false
@@ -119,7 +145,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
// ClearAllWatchpoints removes all hardware watchpoints.
func (s *Session) ClearAllWatchpoints() error {
for slot := range 4 {
for slot := range maxWatchpoints() {
if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil {
return err
@@ -146,16 +172,21 @@ func ptracePokeUser(pid int, offset uintptr, val 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
val, _, errno := syscall.Syscall6(
var word uint64
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(ptracePeekuser),
uintptr(pid),
offset,
0, 0, 0,
uintptr(unsafe.Pointer(&word)),
0, 0,
)
if errno != 0 {
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).
// 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.
type WatchpointType int
@@ -22,26 +22,35 @@ const (
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.
type hwBreakState struct {
// hwWatchState mirrors the kernel's struct user_hwdebug_state.
type hwWatchState struct {
DbgInfo uint32
_pad [4]byte
DbgRegs [16]hwBreakReg
DbgRegs [16]hwWatchReg
}
type hwBreakReg struct {
type hwWatchReg struct {
Addr uint64
Ctrl uint64
}
const (
ntArmHWBreak = 0x403 // NT_ARM_HW_BREAK
)
// ntArmHWWatch is NT_ARM_HW_WATCH (0x403), the watchpoint regset
// (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 {
for i := range maxWatchpoints {
for i := range maxWatchpoints() {
if !s.wpSlots[i] {
return i
}
@@ -50,7 +59,7 @@ func (s *Session) FindFreeWatchpointSlot() int {
}
func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints {
if slot < 0 || slot >= maxWatchpoints() {
return false
}
return s.wpSlots[slot]
@@ -58,27 +67,31 @@ func (s *Session) IsWatchpointSlotUsed(slot int) bool {
// SetWatchpoint installs a hardware watchpoint on the given address.
func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
}
if s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d already in use", slot)
}
state, err := s.getHWBreakState()
state, err := s.getHWWatchState()
if err != nil {
return fmt.Errorf("debug: read watchpoint state: %w", err)
}
if uint32(slot) >= state.DbgInfo {
return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo)
// MDSCR_EL1 packs (debug_arch << 8) | num_slots into dbg_info, so only
// the low byte counts slots.
if uint32(slot) >= state.DbgInfo&0xff {
return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo&0xff)
}
state.DbgRegs[slot].Addr = addr
// DBGWCR bits 3-4 select the access type: 01 load, 10 store, 11 either
// (ARM DDI 0487, DBGWCR<n>_EL1 watchpoint type field).
ctrl := uint64(1) // enable
switch typ {
case WatchWrite:
ctrl |= 1 << 3 // store only
ctrl |= 2 << 3 // store only
case WatchRead:
ctrl |= 3 << 3 // load+store
}
@@ -98,7 +111,7 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
ctrl |= bas << 5
state.DbgRegs[slot].Ctrl = ctrl
if err := s.setHWBreakState(state); err != nil {
if err := s.setHWWatchState(state); err != nil {
return fmt.Errorf("debug: set watchpoint: %w", err)
}
@@ -107,20 +120,20 @@ func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size
}
func (s *Session) ClearWatchpoint(slot int) error {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
}
if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
}
state, err := s.getHWBreakState()
state, err := s.getHWWatchState()
if err != nil {
return err
}
state.DbgRegs[slot].Addr = 0
state.DbgRegs[slot].Ctrl = 0
if err := s.setHWBreakState(state); err != nil {
if err := s.setHWWatchState(state); err != nil {
return err
}
s.wpSlots[slot] = false
@@ -128,7 +141,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
}
func (s *Session) ClearAllWatchpoints() error {
for slot := 0; slot < maxWatchpoints; slot++ {
for slot := range maxWatchpoints() {
if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil {
return err
@@ -138,8 +151,8 @@ func (s *Session) ClearAllWatchpoints() error {
return nil
}
func (s *Session) getHWBreakState() (*hwBreakState, error) {
var state hwBreakState
func (s *Session) getHWWatchState() (*hwWatchState, error) {
var state hwWatchState
iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(&state)),
Len: uint64(unsafe.Sizeof(state)),
@@ -148,7 +161,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) {
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_GETREGSET),
uintptr(s.pid),
uintptr(ntArmHWBreak),
uintptr(ntArmHWWatch),
uintptr(unsafe.Pointer(&iovec)),
0, 0,
)
@@ -158,7 +171,7 @@ func (s *Session) getHWBreakState() (*hwBreakState, error) {
return &state, nil
}
func (s *Session) setHWBreakState(state *hwBreakState) error {
func (s *Session) setHWWatchState(state *hwWatchState) error {
iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(state)),
Len: uint64(unsafe.Sizeof(*state)),
@@ -167,7 +180,7 @@ func (s *Session) setHWBreakState(state *hwBreakState) error {
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_SETREGSET),
uintptr(s.pid),
uintptr(ntArmHWBreak),
uintptr(ntArmHWWatch),
uintptr(unsafe.Pointer(&iovec)),
0, 0,
)
+121 -60
View File
@@ -8,10 +8,47 @@ package debug
import (
"fmt"
"syscall"
"unsafe"
)
// Hardware watchpoint support for LoongArch via debug registers.
// Uses PTRACE_POKEUSER/PEEKUSER to access HW watchpoint registers.
// Hardware watchpoint support via the NT_LOONGARCH_HW_WATCH regset.
//
// The kernel's PTRACE_POKEUSER on loong64 accepts only the user_pt_regs
// indices 0-34 (GPRs, orig_a0, era, badv, per
// arch/loongarch/include/uapi/asm/ptrace.h), so there is no debug-register
// window to poke. The real interface is PTRACE_GETREGSET/SETREGSET on
// NT_LOONGARCH_HW_WATCH (0xa06, include/uapi/linux/elf.h) with struct
// user_watch_state_v2 (arch/loongarch/include/uapi/asm/ptrace.h): a dbg_info
// word followed by 14 slots of {addr u64, mask u64, ctrl u32, pad u32}.
// hw_break_get puts the slot count in the low byte of dbg_info
// (arch/loongarch/kernel/ptrace.c, ptrace_hbp_get_resource_info) and
// hw_break_set ignores dbg_info, reading addr, mask and ctrl per slot.
const ntLoongHWWatch = 0xa06
// loongWatchState mirrors the kernel's struct user_watch_state_v2.
type loongWatchState struct {
DbgInfo uint64
DbgRegs [14]loongWatchReg
}
type loongWatchReg struct {
Addr uint64
Mask uint64
Ctrl uint32
Pad uint32
}
// Control word bit layout (arch/loongarch/include/asm/hw_breakpoint.h):
// bits 1-4 privilege enables (CTRL_PLV3_ENABLE, 0x10, covers user mode),
// bits 8-9 access type (LOAD 1<<0, STORE 1<<1), bits 10-11 length
// (0=8 bytes, 1=4, 2=2, 3=1, inverted like the hardware FWP cfg).
const (
loongCtrlPLV3Enable = 0x10
loongTypeLoad = 1 << 8
loongTypeStore = 2 << 8
loongLenShift = 10
)
// WatchpointType selects what triggers the watchpoint.
type WatchpointType int
@@ -21,10 +58,16 @@ const (
WatchRead WatchpointType = 3
)
const maxWatchpoints = 4
// maxWatchpoints reports the slot capacity of the regset struct; the number
// the hardware actually provides is read from dbg_info at arm time.
func maxWatchpoints() int { return len(loongWatchState{}.DbgRegs) }
// archWatchpointAddr resolves the address of the watchpoint that fired:
// the loongarch kernel already reports the accessed address as si_addr.
func archWatchpointAddr(s *Session, siAddr uint64) uint64 { return siAddr }
func (s *Session) FindFreeWatchpointSlot() int {
for i := range maxWatchpoints {
for i := range maxWatchpoints() {
if !s.wpSlots[i] {
return i
}
@@ -33,53 +76,56 @@ func (s *Session) FindFreeWatchpointSlot() int {
}
func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints {
if slot < 0 || slot >= maxWatchpoints() {
return false
}
return s.wpSlots[slot]
}
// SetWatchpoint installs a hardware watchpoint.
// SetWatchpoint installs a hardware watchpoint on the given address.
func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
}
if s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d already in use", slot)
}
if size != 1 && size != 2 && size != 4 && size != 8 {
var ctrlType uint32
switch typ {
case WatchWrite:
ctrlType = loongTypeStore
case WatchRead:
ctrlType = loongTypeLoad | loongTypeStore
}
var lenBits uint32
switch size {
case 1:
lenBits = 3
case 2:
lenBits = 2
case 4:
lenBits = 1
case 8:
lenBits = 0
default:
return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8")
}
// LoongArch debug registers: DBGWVR (watchpoint value) and DBGWCR (watchpoint control).
// Accessed via PTRACE_POKEUSER at architecture-specific offsets.
if err := ptracePokeUser(s.pid, uintptr(0x1000+slot*8), addr); err != nil {
return fmt.Errorf("debug: set watchpoint address: %w", err)
state, err := s.getLoongWatchState()
if err != nil {
return fmt.Errorf("debug: read watchpoint state: %w", err)
}
if uint64(slot) >= state.DbgInfo&0xff {
return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, state.DbgInfo&0xff)
}
// DBGWCR: enable + type + size.
var wcr uint64 = 1 // enable
switch typ {
case WatchWrite:
wcr |= 1 << 3 // store
case WatchRead:
wcr |= 3 << 3 // load+store
}
var sizeBits uint64
switch size {
case 1:
sizeBits = 0
case 2:
sizeBits = 1
case 4:
sizeBits = 2
case 8:
sizeBits = 3
}
wcr |= sizeBits << 5
state.DbgRegs[slot].Addr = addr
state.DbgRegs[slot].Mask = 0
state.DbgRegs[slot].Ctrl = loongCtrlPLV3Enable | ctrlType | lenBits<<loongLenShift
if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), wcr); err != nil {
return fmt.Errorf("debug: set watchpoint control: %w", err)
if err := s.setLoongWatchState(state); err != nil {
return fmt.Errorf("debug: set watchpoint: %w", err)
}
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 {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
}
if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
}
if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), 0); err != nil {
state, err := s.getLoongWatchState()
if err != nil {
return err
}
state.DbgRegs[slot].Addr = 0
state.DbgRegs[slot].Mask = 0
state.DbgRegs[slot].Ctrl = 0
if err := s.setLoongWatchState(state); err != nil {
return err
}
s.wpSlots[slot] = false
@@ -102,7 +155,7 @@ func (s *Session) ClearWatchpoint(slot int) error {
}
func (s *Session) ClearAllWatchpoints() error {
for slot := 0; slot < maxWatchpoints; slot++ {
for slot := range maxWatchpoints() {
if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil {
return err
@@ -112,14 +165,37 @@ func (s *Session) ClearAllWatchpoints() error {
return nil
}
func ptracePokeUser(pid int, offset uintptr, val uint64) error {
const ptracePokeuser = 6
func (s *Session) getLoongWatchState() (*loongWatchState, error) {
var state loongWatchState
iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(&state)),
Len: uint64(unsafe.Sizeof(state)),
}
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(ptracePokeuser),
uintptr(pid),
offset,
uintptr(val),
uintptr(syscall.PTRACE_GETREGSET),
uintptr(s.pid),
uintptr(ntLoongHWWatch),
uintptr(unsafe.Pointer(&iovec)),
0, 0,
)
if errno != 0 {
return nil, errno
}
return &state, nil
}
func (s *Session) setLoongWatchState(state *loongWatchState) error {
iovec := syscall.Iovec{
Base: (*byte)(unsafe.Pointer(state)),
Len: uint64(unsafe.Sizeof(*state)),
}
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_SETREGSET),
uintptr(s.pid),
uintptr(ntLoongHWWatch),
uintptr(unsafe.Pointer(&iovec)),
0, 0,
)
if errno != 0 {
@@ -127,18 +203,3 @@ func ptracePokeUser(pid int, offset uintptr, val uint64) error {
}
return nil
}
func ptracePeekUser(pid int, offset uintptr) (uint64, error) {
const ptracePeekuser = 3
val, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(ptracePeekuser),
uintptr(pid),
offset,
0, 0, 0,
)
if errno != 0 {
return 0, errno
}
return uint64(val), nil
}
+26 -102
View File
@@ -7,11 +7,16 @@ package debug
import (
"fmt"
"syscall"
)
// Hardware watchpoint support for RISC-V via Sdtrig trigger registers.
// Uses PTRACE_POKEUSER/PEEKUSER to access debug registers.
// Hardware watchpoints are not reachable through the riscv64 kernel ptrace
// interface. arch/riscv/kernel/ptrace.c forwards every POKEUSER/PEEKUSER to
// the generic ptrace_request, and the riscv user_regset view contains only
// the GPR, FP and vector regsets: there is no debug-register or trigger
// regset, and offsets outside the view fail with EIO. The Sdtrig CSRs
// (tselect/tdata1/tdata2) are not exposed to ptrace either. Until the
// kernel grows a trigger regset, SetWatchpoint reports the fact instead of
// poking a window that does not exist.
// WatchpointType selects what triggers the watchpoint.
type WatchpointType int
@@ -21,10 +26,18 @@ const (
WatchRead WatchpointType = 3
)
const maxWatchpoints = 4
// maxWatchpoints reports the number of hardware watchpoint slots the
// architecture provides. riscv64 exposes none via ptrace; the bound exists
// so the slot bookkeeping stays consistent.
func maxWatchpoints() int { return 4 }
// archWatchpointAddr resolves the address of the watchpoint that fired.
// Unreachable in practice (watchpoints cannot be armed), but si_addr names
// the accessed address where the kernel does report one.
func archWatchpointAddr(s *Session, siAddr uint64) uint64 { return siAddr }
func (s *Session) FindFreeWatchpointSlot() int {
for i := range maxWatchpoints {
for i := range maxWatchpoints() {
if !s.wpSlots[i] {
return i
}
@@ -33,115 +46,26 @@ func (s *Session) FindFreeWatchpointSlot() int {
}
func (s *Session) IsWatchpointSlotUsed(slot int) bool {
if slot < 0 || slot >= maxWatchpoints {
if slot < 0 || slot >= maxWatchpoints() {
return false
}
return s.wpSlots[slot]
}
// SetWatchpoint installs a hardware watchpoint.
// SetWatchpoint always fails: the riscv64 kernel ptrace interface has no
// hardware-watchpoint access.
func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
}
if s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d already in use", slot)
}
if size != 1 && size != 2 && size != 4 && size != 8 {
return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8")
}
// RISC-V trigger registers: tdata1 encodes type/control, tdata2 holds address.
// The exact encoding depends on the trigger implementation (Sdtrig).
// Use PTRACE_POKEUSER to write to the trigger CSRs via the kernel's
// debug register interface.
if err := ptracePokeUser(s.pid, uintptr(0x1000+slot*8), addr); err != nil {
return fmt.Errorf("debug: set watchpoint address: %w", err)
}
// tdata1: set match control. Mode=2 (data match), select=0, action=1 (debug exception).
var tdata1 uint64 = 2 << 60 // type = match (2)
tdata1 |= 1 << 0 // action = enter debug mode
tdata1 |= 1 << 7 // store (write) trigger
if typ == WatchRead {
tdata1 |= 1 << 6 // load trigger
}
// Size encoding: 0=1byte, 1=2byte, 2=4byte, 3=8byte.
var sizeBits uint64
switch size {
case 1:
sizeBits = 0
case 2:
sizeBits = 1
case 4:
sizeBits = 2
case 8:
sizeBits = 3
}
tdata1 |= sizeBits << 16 // size field
if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), tdata1); err != nil {
return fmt.Errorf("debug: set watchpoint control: %w", err)
}
s.wpSlots[slot] = true
return nil
return fmt.Errorf("debug: hardware watchpoints are not supported by the riscv64 kernel ptrace interface")
}
// ClearWatchpoint always fails: no watchpoint can ever be armed.
func (s *Session) ClearWatchpoint(slot int) error {
if slot < 0 || slot >= maxWatchpoints {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints-1)
if slot < 0 || slot >= maxWatchpoints() {
return fmt.Errorf("debug: watchpoint slot must be 0-%d", maxWatchpoints()-1)
}
if !s.wpSlots[slot] {
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
}
// Disable by clearing tdata1.
if err := ptracePokeUser(s.pid, uintptr(0x1001+slot*8), 0); err != nil {
return err
}
s.wpSlots[slot] = false
return nil
return fmt.Errorf("debug: watchpoint slot %d is not in use", slot)
}
func (s *Session) ClearAllWatchpoints() error {
for slot := 0; slot < maxWatchpoints; slot++ {
if s.wpSlots[slot] {
if err := s.ClearWatchpoint(slot); err != nil {
return err
}
}
}
return nil
}
func ptracePokeUser(pid int, offset uintptr, val uint64) error {
const ptracePokeuser = 6
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(ptracePokeuser),
uintptr(pid),
offset,
uintptr(val),
0, 0,
)
if errno != 0 {
return errno
}
return nil
}
func ptracePeekUser(pid int, offset uintptr) (uint64, error) {
const ptracePeekuser = 3
val, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(ptracePeekuser),
uintptr(pid),
offset,
0, 0, 0,
)
if errno != 0 {
return 0, errno
}
return uint64(val), nil
}