diff --git a/cmd/gasm/debug_linux_amd64.go b/cmd/gasm/debug_linux_amd64.go new file mode 100644 index 0000000..a27e0d0 --- /dev/null +++ b/cmd/gasm/debug_linux_amd64.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package main + +import ( + "fmt" + "os" + "sort" + + "sourcedock.dev/petrbalvin/gasm-devkit/debug" + "sourcedock.dev/petrbalvin/gasm-devkit/verify" +) + +func cmdDebug(args []string) int { + fs := newCommand("debug", "gasm debug --func ", ` +Interactive debugger for JIT-assembled amd64 functions. Launches the +function in a traced subprocess (ptrace), then provides a REPL for +single-stepping, breakpoints, register and memory inspection. + +REPL commands: + break set a breakpoint at a label or absolute address + step [n] single-step n instructions (default 1) + continue run until next breakpoint or exit + regs print general-purpose registers + x [addr] [len] hex-dump memory (default: current PC, 64 bytes) + labels list function labels and offsets + quit kill the debuggee and exit +`) + target := fs.Bool("target", false, "") // hidden: debuggee subprocess mode + funcName := fs.String("func", "", "function to debug") + argsFile := fs.String("args", "", "file containing the ABI0 argument block") + fs.Parse(args) + + // --- Debuggee mode (internal, spawned by the debugger) --- + if *target { + tmpDir := os.Getenv("GASM_DEBUG_TMP") + if tmpDir == "" || fs.NArg() < 1 || *funcName == "" || *argsFile == "" { + fmt.Fprintln(os.Stderr, "gasm debug --target: internal mode") + return 2 + } + if err := debug.RunTarget(fs.Arg(0), *funcName, *argsFile, tmpDir); err != nil { + fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err) + return 1 + } + return 0 + } + + // --- Debugger mode (interactive REPL) --- + if fs.NArg() < 1 || *funcName == "" { + fmt.Fprintln(os.Stderr, "usage: gasm debug --func ") + return 2 + } + path := fs.Arg(0) + + // Load the kernel to extract function metadata and labels. + k, err := verify.Load(path) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err) + return 1 + } + defer k.Close() + + fl, err := k.Func(*funcName) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err) + return 1 + } + + // Build the label list for the REPL. + var labels []debug.Label + for name, off := range fl.Labels { + labels = append(labels, debug.Label{Name: name, Offset: off}) + } + sort.Slice(labels, func(i, j int) bool { return labels[i].Offset < labels[j].Offset }) + + // Launch the debuggee with a zeroed argument block. + argBlock := make([]byte, fl.Args) + sess, err := debug.Launch("", path, *funcName, argBlock) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err) + return 1 + } + defer sess.Kill() + + bm := debug.NewBreakpoints(sess) + fmt.Printf("gasm debug: %s in %s (pid %d)\n", *funcName, path, sess.Pid()) + debug.REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, labels) + return 0 +} diff --git a/cmd/gasm/debug_other.go b/cmd/gasm/debug_other.go new file mode 100644 index 0000000..833a697 --- /dev/null +++ b/cmd/gasm/debug_other.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !(linux && amd64) + +package main + +import ( + "fmt" + "os" +) + +func cmdDebug(args []string) int { + fmt.Fprintln(os.Stderr, "gasm debug: the interactive debugger requires linux/amd64 (ptrace)") + return 1 +} diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index cfed313..132271a 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -53,6 +53,8 @@ func main() { os.Exit(cmdAsm(os.Args[2:])) case "verify": os.Exit(cmdVerify(os.Args[2:])) + case "debug": + os.Exit(cmdDebug(os.Args[2:])) case "lsp": os.Exit(cmdLSP(os.Args[2:])) case "version", "--version", "-V": diff --git a/debug/breakpoint.go b/debug/breakpoint.go new file mode 100644 index 0000000..e9aaf2f --- /dev/null +++ b/debug/breakpoint.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import "fmt" + +// Breakpoint is one INT3 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) + Enabled bool + hits int +} + +// Breakpoints manages the set of breakpoints for a Session. +type Breakpoints struct { + s *Session + bps map[uint64]*Breakpoint +} + +// NewBreakpoints creates a breakpoint manager for the session. +func NewBreakpoints(s *Session) *Breakpoints { + return &Breakpoints{s: s, bps: make(map[uint64]*Breakpoint)} +} + +// Set installs a breakpoint at addr (replaces any existing one). +func (bm *Breakpoints) Set(addr uint64, label string) (*Breakpoint, error) { + if bp, ok := bm.bps[addr]; ok { + bp.Enabled = true + return bp, nil + } + // Read the original byte. + word, err := bm.s.Peek(addr) + if err != nil { + return nil, err + } + orig := byte(word) + // Patch with INT3 (0xCC), preserving the rest of the word. + patched := (word &^ 0xFF) | 0xCC + if err := bm.s.Poke(addr, patched); err != nil { + return nil, err + } + bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true} + bm.bps[addr] = bp + return bp, nil +} + +// Clear removes the breakpoint at addr, restoring the original byte. +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.s.Peek(addr) + if err != nil { + return err + } + restored := (word &^ 0xFF) | uint64(bp.Orig) + if err := bm.s.Poke(addr, restored); err != nil { + return err + } + 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 (RIP-1 matches +// a breakpoint address), restores the original byte, rewinds RIP, and +// returns the breakpoint that was hit (or nil if it was a single-step). +func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint { + // After INT3, RIP points to the byte AFTER the 0xCC. + trapAddr := regs.RIP - 1 + bp, ok := bm.bps[trapAddr] + if !ok || !bp.Enabled { + return nil // single-step trap or unknown + } + bp.hits++ + // Restore the original byte. + word, err := bm.s.Peek(trapAddr) + if err == nil { + restored := (word &^ 0xFF) | uint64(bp.Orig) + bm.s.Poke(trapAddr, restored) + } + // Rewind RIP to re-execute the original instruction. + regs.RIP = trapAddr + bm.s.SetRegs(regs) + return bp +} + +// 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.s.Peek(addr) + if err != nil { + return err + } + patched := (word &^ 0xFF) | 0xCC + return bm.s.Poke(addr, patched) +} diff --git a/debug/ptrace_linux_amd64.go b/debug/ptrace_linux_amd64.go new file mode 100644 index 0000000..b896257 --- /dev/null +++ b/debug/ptrace_linux_amd64.go @@ -0,0 +1,308 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +// Package debug implements the interactive debugger for gasm (Phase 4): +// single-stepping, breakpoints, register and memory inspection for +// JIT-assembled Plan 9 amd64 functions, controlled via ptrace. +package debug + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + "unsafe" +) + +// Regs holds the full general-purpose register set of a traced process +// (the Linux amd64 user_regs_struct layout). +type Regs struct { + R15 uint64 + R14 uint64 + R13 uint64 + R12 uint64 + RBP uint64 + RBX uint64 + R11 uint64 + R10 uint64 + R9 uint64 + R8 uint64 + RAX uint64 + RCX uint64 + RDX uint64 + RSI uint64 + RDI uint64 + OrigRAX uint64 + RIP uint64 + CS uint64 + RFLAGS uint64 + RSP uint64 + SS uint64 + FSBase uint64 + GSBase uint64 + DS uint64 + ES uint64 + FS uint64 + GS uint64 +} + +// Session is a ptrace debugging session controlling one debuggee process. +type Session struct { + pid int + cmd *exec.Cmd + stopped bool + exited bool + codeBase uint64 // base address of the JIT code in the debuggee +} + +// Launch starts the debuggee subprocess (gasm debug --target ...) and +// attaches to it via ptrace. The debuggee assembles the file, maps the +// JIT code, calls PTRACE_TRACEME and raises SIGSTOP; Launch waits for +// that initial stop and returns a ready Session. +func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) { + self, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("debug: cannot find gasm binary: %w", err) + } + if gasmBin != "" { + self = gasmBin + } + + // Write the arg block to a temp file (the child reads it). + tmpDir, err := os.MkdirTemp("", "gasm-debug-*") + if err != nil { + return nil, fmt.Errorf("debug: tempdir: %w", err) + } + argsFile := filepath.Join(tmpDir, "args.bin") + if err := os.WriteFile(argsFile, args, 0o644); err != nil { + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("debug: write args: %w", err) + } + + cmd := exec.Command(self, "debug", "--target", "--func", funcName, "--args", argsFile, asmPath) + cmd.Env = append(os.Environ(), "GASM_DEBUG_TMP="+tmpDir) + cmd.Stdout = nil // output goes to the debugger, not the terminal + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{} + + if err := cmd.Start(); err != nil { + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("debug: start debuggee: %w", err) + } + + s := &Session{pid: cmd.Process.Pid, cmd: cmd} + + // Wait for the child to signal readiness and stop. The child calls + // PTRACE_TRACEME then SIGSTOP, so Wait4 with WUNTRACED observes the + // ptrace-stop directly (no PTRACE_ATTACH needed). + readyFile := filepath.Join(tmpDir, "ready") + for i := 0; i < 500; i++ { + if _, err := os.Stat(readyFile); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + var ws syscall.WaitStatus + if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil { + cmd.Process.Kill() + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("debug: wait for debuggee: %w", err) + } + s.stopped = true + + // Read the code base from /proc/pid/maps (find the RWX mapping). + s.codeBase = findRWXMapping(s.pid) + if s.codeBase == 0 { + // Fallback: try the file the child wrote. + baseFile := filepath.Join(tmpDir, "codebase") + if data, err := os.ReadFile(baseFile); err == nil { + fmt.Sscanf(string(data), "%d", &s.codeBase) + } + } + + return s, 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 +} + +// GetRegs reads the general-purpose registers of the stopped debuggee. +func (s *Session) GetRegs() (Regs, error) { + var regs Regs + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_GETREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(®s)), + 0, 0, + ) + if errno != 0 { + return regs, fmt.Errorf("debug: PTRACE_GETREGS: %w", errno) + } + return regs, nil +} + +// SetRegs writes the general-purpose registers of the stopped debuggee. +func (s *Session) SetRegs(regs *Regs) error { + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_SETREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(regs)), + 0, 0, + ) + if errno != 0 { + return fmt.Errorf("debug: PTRACE_SETREGS: %w", errno) + } + return nil +} + +// Peek reads a word (8 bytes) from the debuggee's memory at addr. +// Uses /proc/pid/mem which works reliably with Go's multi-threaded runtime. +func (s *Session) Peek(addr uint64) (uint64, error) { + mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0) + if err != nil { + return 0, fmt.Errorf("debug: open /proc/%d/mem: %w", s.pid, err) + } + defer mem.Close() + buf := make([]byte, 8) + if _, err := mem.ReadAt(buf, int64(addr)); err != nil { + return 0, fmt.Errorf("debug: read mem %#x: %w", addr, err) + } + return uint64(buf[0]) | uint64(buf[1])<<8 | uint64(buf[2])<<16 | uint64(buf[3])<<24 | + uint64(buf[4])<<32 | uint64(buf[5])<<40 | uint64(buf[6])<<48 | uint64(buf[7])<<56, nil +} + +// Poke writes a word (8 bytes) to the debuggee's memory at addr. +func (s *Session) Poke(addr, val uint64) error { + mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("debug: open /proc/%d/mem: %w", s.pid, err) + } + defer mem.Close() + buf := []byte{byte(val), byte(val >> 8), byte(val >> 16), byte(val >> 24), + byte(val >> 32), byte(val >> 40), byte(val >> 48), byte(val >> 56)} + if _, err := mem.WriteAt(buf, int64(addr)); err != nil { + return fmt.Errorf("debug: write mem %#x: %w", addr, err) + } + return nil +} + +// ReadMemory reads len bytes from the debuggee's memory at addr. +func (s *Session) ReadMemory(addr uint64, length int) ([]byte, error) { + out := make([]byte, length) + for i := 0; i < length; i += 8 { + word, err := s.Peek(addr + uint64(i)) + if err != nil { + return out[:i], err + } + for j := 0; j < 8 && i+j < length; j++ { + out[i+j] = byte(word >> (8 * j)) + } + } + return out, nil +} + +// Step executes a single instruction in the debuggee. +func (s *Session) Step() error { + if s.exited { + return fmt.Errorf("debug: debuggee has exited") + } + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_SINGLESTEP), + uintptr(s.pid), + 0, 0, 0, 0, + ) + if errno != 0 { + return fmt.Errorf("debug: PTRACE_SINGLESTEP: %w", errno) + } + return s.wait() +} + +// Continue resumes execution until the next breakpoint or exit. +func (s *Session) Continue() error { + if s.exited { + return fmt.Errorf("debug: debuggee has exited") + } + _, _, errno := syscall.Syscall6( + syscall.SYS_PTRACE, + uintptr(syscall.PTRACE_CONT), + uintptr(s.pid), + 0, 0, 0, 0, + ) + if errno != 0 { + return fmt.Errorf("debug: PTRACE_CONT: %w", errno) + } + return s.wait() +} + +// Exited returns true if the debuggee has terminated. +func (s *Session) Exited() bool { + return s.exited +} + +// Pid returns the debuggee's process ID. +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. +func (s *Session) Kill() { + if !s.exited { + syscall.Kill(s.pid, syscall.SIGKILL) + syscall.Wait4(s.pid, nil, 0, nil) + s.exited = true + } + if s.cmd != nil && s.cmd.Process != nil { + s.cmd.Wait() + } +} + +// findRWXMapping reads /proc/pid/maps and returns the base address of the +// first read-write-execute mapping (the JIT code region). +func findRWXMapping(pid int) uint64 { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/maps", pid)) + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + // Format: addr-addr perms offset dev inode pathname + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + perms := fields[1] + if len(perms) >= 3 && perms[0] == 'r' && perms[1] == 'w' && perms[2] == 'x' { + // Parse the start address. + var start uint64 + fmt.Sscanf(fields[0], "%x-", &start) + return start + } + } + return 0 +} diff --git a/debug/repl.go b/debug/repl.go new file mode 100644 index 0000000..b67189f --- /dev/null +++ b/debug/repl.go @@ -0,0 +1,246 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "bufio" + "fmt" + "os" + "sort" + "strconv" + "strings" +) + +// Label is a named address within the debugged function. +type Label struct { + Name string + Offset int // function-relative offset +} + +// REPL runs the interactive debugger loop. On entry, the debuggee is +// stopped in the Go runtime (after PTRACE_TRACEME + SIGSTOP). The REPL +// sets a temporary breakpoint at the function entry, continues to it, and +// then presents the prompt — so the user starts debugging at the first +// instruction of the assembled function. +func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, argsSize int, labels []Label) { + entryAddr := codeBase + uint64(funcOffset) + + // Run to the function entry. + bp, err := bm.Set(entryAddr, "(entry)") + if err != nil { + fmt.Printf("warning: cannot set entry breakpoint: %v\n", err) + } else { + if err := s.Continue(); err != nil { + fmt.Printf("warning: continue to entry: %v\n", err) + } + regs, _ := s.GetRegs() + bm.HandleTrap(®s) + // Remove the temporary entry breakpoint. + bm.Clear(entryAddr) + _ = bp + } + + fmt.Printf("stopped at function entry: %#x (%d bytes)\n", entryAddr, funcSize) + fmt.Println("commands: break | step [n] | continue | regs | x [len] | labels | quit") + + scanner := bufio.NewScanner(os.Stdin) + + for { + fmt.Print("(gasm) ") + if !scanner.Scan() { + break + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + parts := strings.Fields(line) + cmd := parts[0] + + switch cmd { + case "q", "quit": + s.Kill() + return + + case "regs": + regs, err := s.GetRegs() + if err != nil { + fmt.Println(err) + continue + } + printRegs(®s, codeBase, uint64(funcOffset)) + + case "step", "s": + n := 1 + if len(parts) > 1 { + n, _ = strconv.Atoi(parts[1]) + } + for i := 0; i < n; i++ { + if s.Exited() { + fmt.Println("debuggee exited") + break + } + if err := s.Step(); err != nil { + fmt.Println(err) + break + } + } + if !s.Exited() { + regs, _ := s.GetRegs() + fmt.Printf("=> %#x (func+%#x)\n", regs.RIP, regs.RIP-codeBase-uint64(funcOffset)) + } + + case "continue", "c": + if s.Exited() { + fmt.Println("debuggee exited") + continue + } + // Re-insert all breakpoints before continuing. + for _, bp := range bm.All() { + bm.Reinsert(bp.Addr) + } + if err := s.Continue(); err != nil { + fmt.Println(err) + continue + } + regs, _ := s.GetRegs() + if bp := bm.HandleTrap(®s); bp != nil { + name := bp.Label + if name == "" { + name = fmt.Sprintf("%#x", bp.Addr) + } + fmt.Printf("breakpoint hit: %s (func+%#x)\n", name, bp.Addr-codeBase-uint64(funcOffset)) + } else if !s.Exited() { + fmt.Printf("stopped at %#x\n", regs.RIP) + } + + case "break", "b": + if len(parts) < 2 { + fmt.Println("usage: break ") + continue + } + addr, label := resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + if addr == 0 { + fmt.Printf("unknown label or address: %s\n", parts[1]) + continue + } + bp, err := bm.Set(addr, label) + if err != nil { + fmt.Println(err) + continue + } + fmt.Printf("breakpoint set: %s at %#x (func+%#x)\n", bp.Label, bp.Addr, bp.Addr-codeBase-uint64(funcOffset)) + + case "delete", "d": + if len(parts) < 2 { + fmt.Println("usage: delete ") + continue + } + addr, _ := resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + if addr == 0 { + fmt.Printf("unknown: %s\n", parts[1]) + continue + } + if err := bm.Clear(addr); err != nil { + fmt.Println(err) + } else { + fmt.Println("breakpoint removed") + } + + case "x": + regs, _ := s.GetRegs() + addr := regs.RIP // default: current PC + length := 64 + if len(parts) > 1 { + addr, _ = resolveAddr(parts[1], codeBase, uint64(funcOffset), labels) + } + if len(parts) > 2 { + length, _ = strconv.Atoi(parts[2]) + } + mem, err := s.ReadMemory(addr, length) + if err != nil { + fmt.Println(err) + continue + } + hexDump(addr, mem) + + 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 { + fmt.Printf(" func+%#04x %s\n", l.Offset, l.Name) + } + + default: + fmt.Printf("unknown command: %s\n", cmd) + } + } + s.Kill() +} + +func printRegs(regs *Regs, codeBase, funcOff uint64) { + fmt.Printf(" RIP = %#016x (func+%#x)\n", regs.RIP, regs.RIP-codeBase-funcOff) + fmt.Printf(" RSP = %#016x RBP = %#016x\n", regs.RSP, regs.RBP) + fmt.Printf(" RAX = %#016x RBX = %#016x\n", regs.RAX, regs.RBX) + fmt.Printf(" RCX = %#016x RDX = %#016x\n", regs.RCX, regs.RDX) + fmt.Printf(" RSI = %#016x RDI = %#016x\n", regs.RSI, regs.RDI) + fmt.Printf(" R8 = %#016x R9 = %#016x\n", regs.R8, regs.R9) + fmt.Printf(" R10 = %#016x R11 = %#016x\n", regs.R10, regs.R11) + fmt.Printf(" R12 = %#016x R13 = %#016x\n", regs.R12, regs.R13) + fmt.Printf(" R14 = %#016x R15 = %#016x\n", regs.R14, regs.R15) + fmt.Printf(" RFLAGS = %#x\n", regs.RFLAGS) +} + +func hexDump(addr uint64, data []byte) { + for i := 0; i < len(data); i += 16 { + end := i + 16 + if end > len(data) { + end = len(data) + } + fmt.Printf(" %#08x:", addr+uint64(i)) + for j := i; j < i+16; j++ { + if j < end { + fmt.Printf(" %02x", data[j]) + } else { + fmt.Print(" ") + } + } + fmt.Print(" ") + for j := i; j < end; j++ { + if data[j] >= 0x20 && data[j] < 0x7f { + fmt.Printf("%c", data[j]) + } else { + fmt.Print(".") + } + } + fmt.Println() + } +} + +func resolveAddr(s string, codeBase, funcOff uint64, labels []Label) (uint64, string) { + // Try as a hex address. + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + v, err := strconv.ParseUint(s, 0, 64) + if err == nil { + return v, "" + } + } + // Try as func+offset. + if strings.HasPrefix(s, "+") { + off, err := strconv.ParseUint(s[1:], 0, 64) + if err == nil { + return codeBase + funcOff + off, fmt.Sprintf("func+%#x", off) + } + } + // Try as a label name. + for _, l := range labels { + if l.Name == s { + return codeBase + funcOff + uint64(l.Offset), l.Name + } + } + return 0, "" +} diff --git a/debug/target_linux_amd64.go b/debug/target_linux_amd64.go new file mode 100644 index 0000000..956d88f --- /dev/null +++ b/debug/target_linux_amd64.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 + +package debug + +import ( + "fmt" + "os" + "runtime" + "syscall" + "unsafe" + + "sourcedock.dev/petrbalvin/gasm-devkit/asm" + "sourcedock.dev/petrbalvin/gasm-devkit/parser" + "sourcedock.dev/petrbalvin/gasm-devkit/verify" +) + +// RunTarget is the debuggee entry point (gasm debug --target). It +// assembles the file, maps the JIT code, registers itself for ptrace, +// stops, and then executes the named function. The parent debugger +// controls execution from there. +func RunTarget(asmPath, funcName, argsFile, tmpDir string) error { + // Parse and assemble. + src, err := os.ReadFile(asmPath) + if err != nil { + return fmt.Errorf("debug target: %w", err) + } + file, errs := parser.Parse(asmPath, string(src)) + if len(errs) > 0 { + return fmt.Errorf("debug target: parse: %v", errs[0]) + } + img, err := asm.AssembleFile(file) + if err != nil { + return fmt.Errorf("debug target: assemble: %w", err) + } + + // Find the function. + var fl *asm.FuncLayout + for i := range img.Funcs { + if img.Funcs[i].Name == funcName { + fl = &img.Funcs[i] + break + } + } + if fl == nil { + return fmt.Errorf("debug target: function %q not found", funcName) + } + + // Map the entire image RWX (we need write access for breakpoints). + code := img.Bytes() + exec, err := mapRWX(code) + if err != nil { + return fmt.Errorf("debug target: mmap: %w", err) + } + + // Write the code base address for the parent. + codeBase := uintptr(unsafe.Pointer(&exec[0])) + if err := os.WriteFile(tmpDir+"/codebase", []byte(fmt.Sprintf("%d", codeBase)), 0o644); err != nil { + return fmt.Errorf("debug target: write codebase: %w", err) + } + + // Write function metadata (offset, size, args) for the parent. + meta := fmt.Sprintf("%d %d %d", fl.Offset, fl.Size, fl.Args) + os.WriteFile(tmpDir+"/funcmeta", []byte(meta), 0o644) + + // Write label table for breakpoint resolution. + labelsFile, _ := os.Create(tmpDir + "/labels") + if labelsFile != nil { + for label, off := range fl.Labels { + fmt.Fprintf(labelsFile, "%s %d\n", label, off) + } + labelsFile.Close() + } + + // Read the argument block. + args, err := os.ReadFile(argsFile) + if err != nil { + return fmt.Errorf("debug target: read args: %w", err) + } + if len(args) < fl.Args { + padded := make([]byte, fl.Args) + copy(padded, args) + args = padded + } + + // Lock this goroutine to the current OS thread so the parent's + // ptrace (attached to this thread) controls the JIT execution. + runtime.LockOSThread() + + // Request tracing by the parent, then stop. PTRACE_TRACEME makes + // the subsequent SIGSTOP a ptrace-stop (not a group-stop), giving + // the parent full control from the start. + if _, _, errno := syscall.Syscall(syscall.SYS_PTRACE, uintptr(syscall.PTRACE_TRACEME), 0, 0); errno != 0 { + return fmt.Errorf("debug target: PTRACE_TRACEME: %v", errno) + } + os.WriteFile(tmpDir+"/ready", []byte("ok"), 0o644) + syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) + + // --- Execution resumes here after the parent continues us --- + + // Prepare the ABI0 stack and call the function. + fnAddr := codeBase + uintptr(fl.Offset) + stackArgs := make([]byte, fl.Args) + copy(stackArgs, args) + + _, callErr := verify.Call(fnAddr, stackArgs) + if callErr != nil { + // The function returned an error (shouldn't happen for valid code). + os.Exit(1) + } + os.Exit(0) + return nil +} + +// mapRWX maps code into a read-write-execute region (needed for +// breakpoint patching via ptrace POKETEXT, though ptrace can write +// to any mapping regardless of permissions). +func mapRWX(code []byte) ([]byte, error) { + const pageSize = 4096 + size := (len(code) + pageSize - 1) &^ (pageSize - 1) + mem, err := syscall.Mmap(-1, 0, size, + syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC, + syscall.MAP_PRIVATE|syscall.MAP_ANON) + if err != nil { + return nil, err + } + copy(mem, code) + return mem, nil +}