// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause //go:build linux package debug import "strings" import "fmt" // 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 bytes at Addr (restored on removal) Enabled bool Cond *Condition // optional condition (nil = unconditional) hits int } // Condition is a register-comparison condition evaluated when a breakpoint // is hit. Supports three forms: // - register vs constant: // - register vs register: // - register vs memory: * type Condition struct { Reg string // register name (rax, rbx, rip, rsp, ...) Op string // comparison operator: ==, !=, <, >, <=, >= Value uint64 // constant value (when Reg2 == "" and MemAddr == 0) Reg2 string // second register name (for register-register comparison) MemAddr uint64 // memory address (for register-memory comparison, prefixed with *) } // 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 } var expected uint64 switch { case c.Reg2 != "": // Register-register comparison. v, ok := regs.RegValue(c.Reg2) if !ok { return true } expected = v case c.MemAddr != 0: // 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 } switch c.Op { case "==", "=": return actual == expected case "!=": return actual != expected case "<": return actual < expected case ">": return actual > expected case "<=": return actual <= expected case ">=": return actual >= expected default: return true } } // 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 bps map[uint64]*Breakpoint } // NewBreakpoints creates a new breakpoint manager. 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) } // SetWithCond installs a breakpoint with an optional condition. func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) (*Breakpoint, error) { if bp, ok := bm.bps[addr]; ok { bp.Enabled = true bp.Cond = cond return bp, nil } // Read the original bytes. word, err := bm.t.Peek(addr) if err != nil { return nil, err } orig := make([]byte, len(breakpointInsn)) for i := range orig { orig[i] = byte(word >> (8 * i)) } // 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 } bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true, Cond: cond} bm.bps[addr] = bp return bp, nil } // Info returns a formatted list of all breakpoints. func (bm *Breakpoints) Info() string { if len(bm.bps) == 0 { return "no breakpoints set\n" } var result strings.Builder i := 0 for _, bp := range bm.bps { i++ status := "enabled" if !bp.Enabled { status = "disabled" } label := bp.Label if label == "" { label = fmt.Sprintf("%#x", bp.Addr) } cond := "" if bp.Cond != nil { cond = " 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() } // 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) } 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 } // ClearAll removes all breakpoints. func (bm *Breakpoints) ClearAll() error { for addr := range bm.bps { if err := bm.Clear(addr); err != nil { return err } } return nil } // At returns the breakpoint at addr, if any. func (bm *Breakpoints) At(addr uint64) *Breakpoint { return bm.bps[addr] } // All returns all breakpoints. func (bm *Breakpoints) All() []*Breakpoint { out := make([]*Breakpoint, 0, len(bm.bps)) for _, bp := range bm.bps { out = append(out, bp) } return out } // 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 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 { // 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, 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 } 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 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(). func (bm *Breakpoints) Reinsert(addr uint64) error { bp, ok := bm.bps[addr] if !ok || !bp.Enabled { return nil } word, err := bm.t.Peek(addr) if err != nil { return err } patched := (word &^ breakpointMask()) | breakpointWord(breakpointInsn) return bm.t.Poke(addr, patched) } // breakpointWord converts the breakpoint instruction bytes to a uint64. func breakpointWord(insn []byte) uint64 { var w uint64 for i, b := range insn { w |= uint64(b) << (i * 8) } return w }