diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca1868..a15f408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The FreeBSD port of the debugger.** `gasm debug` runs on FreeBSD on + amd64, arm64 and riscv64: the same interactive surface as on Linux — + breakpoints, hardware watchpoints (x86 debug registers, the arm64 debug + register file), single-stepping, register and memory access — behind the + kernel's own ptrace requests, with tracee memory through `PT_IO` and + stop reports through `PT_LWPINFO`. The JIT substrate maps executable + memory through `golang.org/x/sys/unix`, so `verify` builds on FreeBSD + too. The pipeline compile-gates all three architectures; live + validation awaits a FreeBSD machine. - **Workspace-wide navigation in the language server.** `gasm lsp` indexes the `.s` files under the workspace root beyond the documents the editor has open, so go-to-definition, find references and workspace symbol search diff --git a/README.md b/README.md index 625ea30..e00468e 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ to give that syntax the tooling it deserves. - **Debugger.** `gasm debug` is a source-level ptrace debugger with breakpoints (optionally conditional), hardware watchpoints, register and memory inspection, and headless script runs that report instruction and - label coverage. + label coverage; it runs on Linux (all four architectures) and FreeBSD + (amd64, arm64, riscv64). - **Language server.** `gasm lsp` serves completion, hover, document symbols, push and pull diagnostics, semantic-token highlighting, go-to-definition, find references, rename, formatting, inlay hints, code actions, signature @@ -150,7 +151,7 @@ actually been executed. |---|---|---| | Encoding: byte-for-byte against `go tool asm` | native hardware | native hardware (the toolchain cross-assembles any GOARCH on any host) | | Execution: JIT calls, ABI checks, differential fuzzing | native hardware | qemu-user emulation | -| Debugger: ptrace tracing, breakpoints, watchpoints, coverage | native hardware | emulation cannot run ptrace; the layer compiles and its architecture-neutral units run under `go test ./...`, nothing more | +| Debugger: ptrace tracing, breakpoints, watchpoints, coverage | native hardware | emulation cannot run ptrace; the layer compiles and its architecture-neutral units run under `go test ./...`, nothing more. FreeBSD (amd64, arm64, riscv64) is in the same position: the port compiles behind the cross-build gate and its integration test is ready, but no FreeBSD machine has executed it | Consequences, stated plainly. An emulator is a model of a CPU, not the CPU: instruction semantics are implemented in software and can differ @@ -220,9 +221,11 @@ The plan, in the order it is being worked: toolchain itself does not support; through ELF, Plan 9 assembly becomes usable outside Go entirely. - **Platforms: Linux and FreeBSD.** Linux is supported today on all four - architectures and is where the binary builds. FreeBSD follows: the - JIT's executable-memory mapping and the ptrace debugger layer are the - two pieces of porting work. Other unix systems may follow those two. + architectures and is where the binary builds. FreeBSD follows on amd64, + arm64 and riscv64: the JIT's executable-memory mapping and the ptrace + debugger layer are ported (the debugger's live validation awaits a + FreeBSD machine, as the validation status states). Other unix systems + may follow those two. - **Four architectures, no more.** amd64, arm64, riscv64 and loong64. No others are planned. diff --git a/cmd/gasm/debug_other.go b/cmd/gasm/debug_other.go index 825f9f9..b9ae0fe 100644 --- a/cmd/gasm/debug_other.go +++ b/cmd/gasm/debug_other.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build !linux +//go:build !(linux || (freebsd && (amd64 || arm64 || riscv64))) package main @@ -11,6 +11,6 @@ import ( ) func cmdDebug(args []string) int { - fmt.Fprintln(os.Stderr, "gasm debug: the interactive debugger requires Linux (ptrace)") + fmt.Fprintln(os.Stderr, "gasm debug: the interactive debugger requires Linux or FreeBSD (ptrace)") return 1 } diff --git a/cmd/gasm/debug_linux.go b/cmd/gasm/debug_ptrace.go similarity index 99% rename from cmd/gasm/debug_linux.go rename to cmd/gasm/debug_ptrace.go index 06ca91e..0c2eca3 100644 --- a/cmd/gasm/debug_linux.go +++ b/cmd/gasm/debug_ptrace.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux +//go:build linux || (freebsd && (amd64 || arm64 || riscv64)) package main diff --git a/debug/breakpoint.go b/debug/breakpoint.go index f9e4ec9..a1f3cf1 100644 --- a/debug/breakpoint.go +++ b/debug/breakpoint.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux +//go:build linux || (freebsd && (amd64 || arm64 || riscv64)) package debug diff --git a/debug/disasm_freebsd_amd64.go b/debug/disasm_freebsd_amd64.go new file mode 100644 index 0000000..d9922c5 --- /dev/null +++ b/debug/disasm_freebsd_amd64.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +import ( + "fmt" + "strings" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/disasm" +) + +// Disassemble decodes the instruction at the given address in the debuggee's +// memory and returns its text representation and length in bytes. +func (s *Session) Disassemble(addr uint64) (string, int, error) { + mem, err := s.ReadMemory(addr, 15) + if err != nil { + return "", 0, err + } + ins, err := disasm.Decode(arch.AMD64, mem, addr) + if err != nil { + return "", 0, err + } + return ins.Text, ins.Len, nil +} + +// DisassembleN decodes up to n instructions starting at addr and returns +// them as a formatted string with addresses and byte offsets. +func (s *Session) DisassembleN(addr uint64, n int) string { + var result strings.Builder + pc := addr + for range n { + text, length, err := s.Disassemble(pc) + if err != nil { + result.WriteString(fmt.Sprintf(" %#08x: \n", pc, err)) + break + } + result.WriteString(fmt.Sprintf(" %#08x: %s\n", pc, text)) + if length == 0 { + length = 1 + } + pc += uint64(length) + } + return result.String() +} + +// isCallInsn reports whether disassembled text (x86asm.IntelSyntax) is a +// call. The first token must match exactly: a prefix test would also catch +// unrelated mnemonics. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + return strings.ToLower(m) == "call" +} diff --git a/debug/disasm_freebsd_arm64.go b/debug/disasm_freebsd_arm64.go new file mode 100644 index 0000000..07ad7f5 --- /dev/null +++ b/debug/disasm_freebsd_arm64.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 + +package debug + +import ( + "fmt" + "strings" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/disasm" +) + +// Disassemble decodes the instruction at the given address in the debuggee's +// memory and returns its text representation and length in bytes. +func (s *Session) Disassemble(addr uint64) (string, int, error) { + mem, err := s.ReadMemory(addr, 4) + if err != nil { + return "", 0, err + } + ins, err := disasm.Decode(arch.ARM64, mem, addr) + if err != nil { + return "", 0, err + } + return ins.Text, ins.Len, nil +} + +// DisassembleN decodes up to n instructions starting at addr and returns +// them as a formatted string with addresses and byte offsets. +func (s *Session) DisassembleN(addr uint64, n int) string { + var result strings.Builder + pc := addr + for range n { + text, length, err := s.Disassemble(pc) + if err != nil { + result.WriteString(fmt.Sprintf(" %#08x: \n", pc, err)) + break + } + result.WriteString(fmt.Sprintf(" %#08x: %s\n", pc, text)) + if length == 0 { + length = 1 + } + pc += uint64(length) + } + return result.String() +} + +// isCallInsn reports whether disassembled text (arm64asm.GoSyntax) is a +// call. GoSyntax renders bl as CALL; the native mnemonic is accepted too. +// The first token must match exactly so branches never match. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + switch strings.ToLower(m) { + case "call", "bl": + return true + } + return false +} diff --git a/debug/disasm_freebsd_riscv64.go b/debug/disasm_freebsd_riscv64.go new file mode 100644 index 0000000..310f5d9 --- /dev/null +++ b/debug/disasm_freebsd_riscv64.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && riscv64 + +package debug + +import ( + "fmt" + "strings" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/disasm" +) + +// Disassemble decodes the instruction at the given address in the debuggee's +// memory and returns its text representation and length in bytes. +func (s *Session) Disassemble(addr uint64) (string, int, error) { + mem, err := s.ReadMemory(addr, 4) + if err != nil { + return "", 0, err + } + ins, err := disasm.Decode(arch.RISCV, mem, addr) + if err != nil { + return "", 0, err + } + return ins.Text, ins.Len, nil +} + +// DisassembleN decodes up to n instructions starting at addr and returns +// them as a formatted string with addresses and byte offsets. +func (s *Session) DisassembleN(addr uint64, n int) string { + var result strings.Builder + pc := addr + for range n { + text, length, err := s.Disassemble(pc) + if err != nil { + result.WriteString(fmt.Sprintf(" %#08x: \n", pc, err)) + break + } + result.WriteString(fmt.Sprintf(" %#08x: %s\n", pc, text)) + if length == 0 { + length = 1 + } + pc += uint64(length) + } + return result.String() +} + +// isCallInsn reports whether disassembled text (riscv64asm.GoSyntax) is a +// call. GoSyntax renders jal and jalr calls as CALL; the native mnemonics +// are accepted too. The first token must match exactly: a prefix test on +// "bl" would catch branches on other architectures, and jalr as ret prints +// RET, which must not be stepped over. +func isCallInsn(text string) bool { + m, _, _ := strings.Cut(text, " ") + switch strings.ToLower(m) { + case "call", "jal", "jalr": + return true + } + return false +} diff --git a/debug/display_freebsd_amd64.go b/debug/display_freebsd_amd64.go new file mode 100644 index 0000000..6b75d73 --- /dev/null +++ b/debug/display_freebsd_amd64.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +import "fmt" + +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 [%s]\n", regs.RFLAGS, decodeRflags(regs.RFLAGS)) +} + +func printVectorRegs(v *VectorRegs) { + fmt.Println("\n Vector registers (YMM):") + for i := 0; i < 16; i += 2 { + fmt.Printf(" YMM%-2d = ", i) + printYMM(v.YMM[i][:]) + fmt.Printf(" YMM%-2d = ", i+1) + printYMM(v.YMM[i+1][:]) + fmt.Println() + } +} + +func printYMM(b []byte) { + for j := 0; j < 32; j += 4 { + v := uint32(b[j]) | uint32(b[j+1])<<8 | uint32(b[j+2])<<16 | uint32(b[j+3])<<24 + fmt.Printf("%08x ", v) + } +} + +func decodeRflags(f uint64) string { + var flags string + if f&1 != 0 { + flags += "CF " + } + if f&(1<<2) != 0 { + flags += "PF " + } + if f&(1<<4) != 0 { + flags += "AF " + } + if f&(1<<6) != 0 { + flags += "ZF " + } + if f&(1<<7) != 0 { + flags += "SF " + } + if f&(1<<8) != 0 { + flags += "TF " + } + if f&(1<<9) != 0 { + flags += "IF " + } + if f&(1<<10) != 0 { + flags += "DF " + } + if f&(1<<11) != 0 { + flags += "OF " + } + if flags == "" { + return "none" + } + return flags[:len(flags)-1] +} + +// SetReg modifies a register value in the debuggee. +func (s *Session) SetReg(name string, value uint64) error { + regs, err := s.GetRegs() + if err != nil { + return err + } + switch name { + case "rax", "eax", "ax", "al": + regs.RAX = value + case "rbx", "ebx", "bx", "bl": + regs.RBX = value + case "rcx", "ecx", "cx", "cl": + regs.RCX = value + case "rdx", "edx", "dx", "dl": + regs.RDX = value + case "rsi", "esi", "si": + regs.RSI = value + case "rdi", "edi", "di": + regs.RDI = value + case "rbp", "ebp", "bp": + regs.RBP = value + case "rsp", "esp", "sp": + regs.RSP = value + case "r8": + regs.R8 = value + case "r9": + regs.R9 = value + case "r10": + regs.R10 = value + case "r11": + regs.R11 = value + case "r12": + regs.R12 = value + case "r13": + regs.R13 = value + case "r14": + regs.R14 = value + case "r15": + regs.R15 = value + case "rip", "eip": + regs.RIP = value + default: + return fmt.Errorf("debug: unknown register %q", name) + } + return s.SetRegs(®s) +} + +// 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. FreeBSD exposes no mapping list, so the +// walk degenerates to the raw entry convention, [SP] before any push. +func archReturnAddr(s *Session, regs *Regs) (uint64, error) { + return s.Peek(regs.RSP) +} + +// archSPLabel returns the SP register name for display. +func archSPLabel() string { return "RSP" } diff --git a/debug/display_freebsd_arm64.go b/debug/display_freebsd_arm64.go new file mode 100644 index 0000000..aa6d749 --- /dev/null +++ b/debug/display_freebsd_arm64.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 + +package debug + +import ( + "encoding/binary" + "fmt" +) + +func printRegs(regs *Regs, codeBase, funcOff uint64) { + fmt.Printf(" PC = %#016x (func+%#x)\n", regs.PC, regs.PC-codeBase-funcOff) + fmt.Printf(" SP = %#016x FP = %#016x\n", regs.SP, regs.X29) + fmt.Printf(" LR = %#016x\n", regs.X30) + fmt.Printf(" X0 = %#016x X1 = %#016x\n", regs.X0, regs.X1) + fmt.Printf(" X2 = %#016x X3 = %#016x\n", regs.X2, regs.X3) + fmt.Printf(" X4 = %#016x X5 = %#016x\n", regs.X4, regs.X5) + fmt.Printf(" X6 = %#016x X7 = %#016x\n", regs.X6, regs.X7) + fmt.Printf(" X8 = %#016x X9 = %#016x\n", regs.X8, regs.X9) + fmt.Printf(" X10 = %#016x X11 = %#016x\n", regs.X10, regs.X11) + fmt.Printf(" X12 = %#016x X13 = %#016x\n", regs.X12, regs.X13) + fmt.Printf(" X14 = %#016x X15 = %#016x\n", regs.X14, regs.X15) + fmt.Printf(" X16 = %#016x X17 = %#016x\n", regs.X16, regs.X17) + fmt.Printf(" X18 = %#016x X19 = %#016x\n", regs.X18, regs.X19) + fmt.Printf(" X20 = %#016x X21 = %#016x\n", regs.X20, regs.X21) + fmt.Printf(" X22 = %#016x X23 = %#016x\n", regs.X22, regs.X23) + fmt.Printf(" X24 = %#016x X25 = %#016x\n", regs.X24, regs.X25) + fmt.Printf(" X26 = %#016x X27 = %#016x\n", regs.X26, regs.X27) + fmt.Printf(" X28 = %#016x PSTATE = %#x\n", regs.X28, regs.PSTATE) +} + +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, 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])) + } +} + +// SetReg modifies a register value in the debuggee. +func (s *Session) SetReg(name string, value uint64) error { + regs, err := s.GetRegs() + if err != nil { + return err + } + switch name { + case "x0": + regs.X0 = value + case "x1": + regs.X1 = value + case "x2": + regs.X2 = value + case "x3": + regs.X3 = value + case "x4": + regs.X4 = value + case "x5": + regs.X5 = value + case "x6": + regs.X6 = value + case "x7": + regs.X7 = value + case "x8": + regs.X8 = value + case "x9": + regs.X9 = value + case "x10": + regs.X10 = value + case "x11": + regs.X11 = value + case "x12": + regs.X12 = value + case "x13": + regs.X13 = value + case "x14": + regs.X14 = value + case "x15": + regs.X15 = value + case "x16": + regs.X16 = value + case "x17": + regs.X17 = value + case "x18": + regs.X18 = value + case "x19": + regs.X19 = value + case "x20": + regs.X20 = value + case "x21": + regs.X21 = value + case "x22": + regs.X22 = value + case "x23": + regs.X23 = value + case "x24": + regs.X24 = value + case "x25": + regs.X25 = value + case "x26": + regs.X26 = value + case "x27": + regs.X27 = value + case "x28": + regs.X28 = value + case "x29", "fp": + regs.X29 = value + case "x30", "lr": + regs.X30 = value + case "sp": + regs.SP = value + case "pc": + regs.PC = value + default: + return fmt.Errorf("debug: unknown register %q", name) + } + return s.SetRegs(®s) +} + +// archReturnAddr reads the return address from LR (arm64 convention). +func archReturnAddr(s *Session, regs *Regs) (uint64, error) { + return regs.X30, nil +} + +// archSPLabel returns the SP register name for display. +func archSPLabel() string { return "SP" } diff --git a/debug/display_freebsd_riscv64.go b/debug/display_freebsd_riscv64.go new file mode 100644 index 0000000..56381d0 --- /dev/null +++ b/debug/display_freebsd_riscv64.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && riscv64 + +package debug + +import "fmt" + +func printRegs(regs *Regs, codeBase, funcOff uint64) { + fmt.Printf(" PC = %#016x (func+%#x)\n", regs.PC, regs.PC-codeBase-funcOff) + fmt.Printf(" SP = %#016x FP = %#016x\n", regs.Sp, regs.S0) + fmt.Printf(" RA = %#016x\n", regs.Ra) + fmt.Printf(" A0 = %#016x A1 = %#016x\n", regs.A0, regs.A1) + fmt.Printf(" A2 = %#016x A3 = %#016x\n", regs.A2, regs.A3) + fmt.Printf(" A4 = %#016x A5 = %#016x\n", regs.A4, regs.A5) + fmt.Printf(" A6 = %#016x A7 = %#016x\n", regs.A6, regs.A7) + fmt.Printf(" T0 = %#016x T1 = %#016x\n", regs.T0, regs.T1) + fmt.Printf(" T2 = %#016x T3 = %#016x\n", regs.T2, regs.T3) + fmt.Printf(" T4 = %#016x T5 = %#016x\n", regs.T4, regs.T5) + fmt.Printf(" T6 = %#016x\n", regs.T6) + fmt.Printf(" S1 = %#016x S2 = %#016x\n", regs.S1, regs.S2) + fmt.Printf(" S3 = %#016x S4 = %#016x\n", regs.S3, regs.S4) + fmt.Printf(" S5 = %#016x S6 = %#016x\n", regs.S5, regs.S6) + fmt.Printf(" S7 = %#016x S8 = %#016x\n", regs.S7, regs.S8) + fmt.Printf(" S9 = %#016x S10 = %#016x\n", regs.S9, regs.S10) + fmt.Printf(" S11 = %#016x\n", regs.S11) +} + +func printVectorRegs(v *VectorRegs) { + fmt.Println("\n FP registers (F0-F31):") + for i := 0; i < 32; i += 2 { + fmt.Printf(" F%-2d = %#018x F%-2d = %#018x\n", i, v.F[i], i+1, v.F[i+1]) + } + fmt.Printf(" FCSR = %#x\n", v.FCSR) +} + +// SetReg modifies a register value in the debuggee. +func (s *Session) SetReg(name string, value uint64) error { + regs, err := s.GetRegs() + if err != nil { + return err + } + switch name { + case "pc": + regs.PC = value + case "ra", "x1": + regs.Ra = value + case "sp", "x2": + regs.Sp = value + case "gp", "x3": + regs.Gp = value + case "tp", "x4": + regs.Tp = value + case "t0", "x5": + regs.T0 = value + case "t1", "x6": + regs.T1 = value + case "t2", "x7": + regs.T2 = value + case "s0", "fp", "x8": + regs.S0 = value + case "s1", "x9": + regs.S1 = value + case "a0", "x10": + regs.A0 = value + case "a1", "x11": + regs.A1 = value + case "a2", "x12": + regs.A2 = value + case "a3", "x13": + regs.A3 = value + case "a4", "x14": + regs.A4 = value + case "a5", "x15": + regs.A5 = value + case "a6", "x16": + regs.A6 = value + case "a7", "x17": + regs.A7 = value + case "s2", "x18": + regs.S2 = value + case "s3", "x19": + regs.S3 = value + case "s4", "x20": + regs.S4 = value + case "s5", "x21": + regs.S5 = value + case "s6", "x22": + regs.S6 = value + case "s7", "x23": + regs.S7 = value + case "s8", "x24": + regs.S8 = value + case "s9", "x25": + regs.S9 = value + case "s10", "x26": + regs.S10 = value + case "s11", "x27": + regs.S11 = value + case "t3", "x28": + regs.T3 = value + case "t4", "x29": + regs.T4 = value + case "t5", "x30": + regs.T5 = value + case "t6", "x31": + regs.T6 = value + default: + return fmt.Errorf("debug: unknown register %q", name) + } + return s.SetRegs(®s) +} + +// archReturnAddr reads the return address from RA (riscv64 convention). +func archReturnAddr(s *Session, regs *Regs) (uint64, error) { + return regs.Ra, nil +} + +// archSPLabel returns the SP register name for display. +func archSPLabel() string { return "SP" } diff --git a/debug/ptrace_freebsd.go b/debug/ptrace_freebsd.go new file mode 100644 index 0000000..cd7dc93 --- /dev/null +++ b/debug/ptrace_freebsd.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && (amd64 || arm64 || riscv64) + +package debug + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// Session is a ptrace debugging session controlling one debuggee process. +// The FreeBSD implementation sits behind the same surface as the Linux one: +// PT_TRACE_ME from the debuggee, PT_CONTINUE/PT_STEP from the tracer, and +// tracee memory through PT_IO (FreeBSD has no /proc/pid/mem to fall back +// on, so PT_IO is the only supported route). +type Session struct { + pid int + cmd *exec.Cmd + stopped bool + exited bool + codeBase uint64 // base address of the JIT code in the debuggee + tmpDir string // scratch directory of the session, removed on Kill + wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 dbw 0-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 +// attaches to it via ptrace. +func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) { + sess, _, err := LaunchWithBuffers(gasmBin, asmPath, funcName, args, "") + return sess, err +} + +// LaunchWithBuffers is like Launch but also allocates buffers in the debuggee. +// +// It pins the calling goroutine to its OS thread and leaves it pinned: the +// debuggee's PT_TRACE_ME binds the tracer relation to the forking thread, +// and every ptrace request on the session must come from that same thread. +// All Session methods must therefore be called from the goroutine that +// launched the session (the REPL and coverage loops do exactly that). +func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec string) (*Session, []uint64, error) { + runtime.LockOSThread() // ptrace requests must stay on the forking thread + self, err := os.Executable() + if err != nil { + return nil, nil, fmt.Errorf("debug: cannot find gasm binary: %w", err) + } + if gasmBin != "" { + self = gasmBin + } + + tmpDir, err := os.MkdirTemp("", "gasm-debug-*") + if err != nil { + return nil, 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, nil, fmt.Errorf("debug: write args: %w", err) + } + + if bufSpec != "" { + if err := os.WriteFile(filepath.Join(tmpDir, "bufspec"), []byte(bufSpec), 0o644); err != nil { + os.RemoveAll(tmpDir) + return nil, nil, fmt.Errorf("debug: write bufspec: %w", err) + } + } + + cmd := exec.Command(self, "debug", "--func", funcName, "--args", argsFile, asmPath) + cmd.Env = append(os.Environ(), "GASM_DEBUG_TARGET=1", "GASM_DEBUG_TMP="+tmpDir) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{} + + if err := cmd.Start(); err != nil { + os.RemoveAll(tmpDir) + return nil, nil, fmt.Errorf("debug: start debuggee: %w", err) + } + + s := &Session{pid: cmd.Process.Pid, cmd: cmd, tmpDir: tmpDir} + + readyFile := filepath.Join(tmpDir, "ready") + for range 500 { + if _, err := os.Stat(readyFile); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + + // The debuggee parks itself with SIGSTOP once the JIT code is mapped. + // A Go tracee also reports SIGURG preemption as signal-delivery-stops, + // so the wait loops until a stop the debugger cares about instead of + // assuming the first event is the SIGSTOP. + if _, err := s.waitStopped(); err != nil { + cmd.Process.Kill() + os.RemoveAll(tmpDir) + return nil, nil, fmt.Errorf("debug: wait for debuggee: %w", err) + } + s.stopped = true + + // The debuggee reports its JIT mapping in the codebase file; that is + // the supported path on FreeBSD, where no /proc/pid/maps exists to + // scan for the RWX region as a fallback. + if data, err := os.ReadFile(filepath.Join(tmpDir, "codebase")); err == nil { + fmt.Sscanf(string(data), "%d", &s.codeBase) + } + + var bufAddrs []uint64 + if bufSpec != "" { + addrFile := filepath.Join(tmpDir, "bufaddrs") + if data, err := os.ReadFile(addrFile); err == nil { + for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { + var addr uint64 + if _, err := fmt.Sscanf(line, "%d", &addr); err == nil { + bufAddrs = append(bufAddrs, addr) + } + } + } + } + + return s, bufAddrs, nil +} + +// waitStopped consumes ptrace-stop events until one the debugger cares +// 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. +func (s *Session) waitStopped() (syscall.Signal, error) { + for { + var ws syscall.WaitStatus + if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil { + return 0, err + } + if ws.Exited() { + s.exited = true + return 0, fmt.Errorf("debuggee exited with status %d", ws.ExitStatus()) + } + if ws.Signaled() { + s.exited = true + return 0, fmt.Errorf("debuggee killed by signal %v", ws.Signal()) + } + switch sig := ws.StopSignal(); sig { + case syscall.SIGTRAP, syscall.SIGSTOP: + s.stopped = true + s.lastSignal = 0 + return sig, nil + case syscall.SIGURG: + // Go runtime asynchronous preemption: resume the tracee + // without delivering the signal. + s.lastSignal = 0 + if err := unix.PtraceCont(s.pid, 0); err != nil { + return 0, fmt.Errorf("debug: PT_CONTINUE: %w", err) + } + 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, through +// PT_IO with PIOD_READ_D. +func (s *Session) Peek(addr uint64) (uint64, error) { + var buf [8]byte + if _, err := unix.PtraceIO(unix.PIOD_READ_D, s.pid, uintptr(addr), buf[:], len(buf)); 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, through +// PT_IO with PIOD_WRITE_D. +func (s *Session) Poke(addr, val uint64) error { + 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 := unix.PtraceIO(unix.PIOD_WRITE_D, s.pid, uintptr(addr), buf, len(buf)); 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 in one +// PT_IO request, the shape the request is built for. +func (s *Session) ReadMemory(addr uint64, length int) ([]byte, error) { + out := make([]byte, length) + n, err := unix.PtraceIO(unix.PIOD_READ_D, s.pid, uintptr(addr), out, length) + return out[:n], err +} + +// WriteMemory writes bytes to the debuggee's memory at addr in one PT_IO +// request. +func (s *Session) WriteMemory(addr uint64, data []byte) error { + _, err := unix.PtraceIO(unix.PIOD_WRITE_D, s.pid, uintptr(addr), data, len(data)) + return err +} + +// Step executes a single instruction in the debuggee. +func (s *Session) Step() error { + if s.exited { + return fmt.Errorf("debug: debuggee has exited") + } + if err := unix.PtraceSingleStep(s.pid); err != nil { + return fmt.Errorf("debug: PT_STEP: %w", err) + } + _, err := s.waitStopped() + return err +} + +// Continue resumes execution until the next breakpoint or exit. +func (s *Session) Continue() error { + if s.exited { + return fmt.Errorf("debug: debuggee has exited") + } + if err := unix.PtraceCont(s.pid, 0); err != nil { + return fmt.Errorf("debug: PT_CONTINUE: %w", err) + } + _, err := s.waitStopped() + return err +} + +// 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 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) + syscall.Wait4(s.pid, nil, 0, nil) + s.exited = true + } + 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 is a stub on FreeBSD: there is no /proc/pid/maps to parse, +// and procfs(5) is not guaranteed to be mounted. The callers degrade +// gracefully: archReturnAddr falls back to the raw stack convention and +// the mapping scan is skipped. +func execRanges(pid int) []execRange { return nil } + +// findRWXMapping is a stub on FreeBSD for the same reason: the codebase +// handshake file is the supported way the JIT region is located. +func findRWXMapping(pid int) uint64 { return 0 } diff --git a/debug/ptrace_freebsd_amd64.go b/debug/ptrace_freebsd_amd64.go new file mode 100644 index 0000000..1370705 --- /dev/null +++ b/debug/ptrace_freebsd_amd64.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +import ( + "encoding/binary" + "fmt" + "unsafe" + + "golang.org/x/sys/unix" +) + +// GetRegs reads the general-purpose registers of the stopped debuggee and +// converts the FreeBSD struct reg into the portable layout. +func (s *Session) GetRegs() (Regs, error) { + var ur unix.Reg + if err := unix.PtraceGetRegs(s.pid, &ur); err != nil { + return Regs{}, fmt.Errorf("debug: PT_GETREGS: %w", err) + } + return Regs{ + R15: uint64(ur.R15), + R14: uint64(ur.R14), + R13: uint64(ur.R13), + R12: uint64(ur.R12), + R11: uint64(ur.R11), + R10: uint64(ur.R10), + R9: uint64(ur.R9), + R8: uint64(ur.R8), + RDI: uint64(ur.Rdi), + RSI: uint64(ur.Rsi), + RBP: uint64(ur.Rbp), + RBX: uint64(ur.Rbx), + RDX: uint64(ur.Rdx), + RCX: uint64(ur.Rcx), + RAX: uint64(ur.Rax), + RIP: uint64(ur.Rip), + CS: uint64(ur.Cs), + RFLAGS: uint64(ur.Rflags), + RSP: uint64(ur.Rsp), + SS: uint64(ur.Ss), + FS: uint64(ur.Fs), + GS: uint64(ur.Gs), + DS: uint64(ur.Ds), + ES: uint64(ur.Es), + }, nil +} + +// SetRegs writes the general-purpose registers of the stopped debuggee. +func (s *Session) SetRegs(regs *Regs) error { + // Read-modify-write keeps the fields FreeBSD owns (trapno, err) intact. + var ur unix.Reg + if err := unix.PtraceGetRegs(s.pid, &ur); err != nil { + return fmt.Errorf("debug: PT_GETREGS: %w", err) + } + ur.R15 = int64(regs.R15) + ur.R14 = int64(regs.R14) + ur.R13 = int64(regs.R13) + ur.R12 = int64(regs.R12) + ur.R11 = int64(regs.R11) + ur.R10 = int64(regs.R10) + ur.R9 = int64(regs.R9) + ur.R8 = int64(regs.R8) + ur.Rdi = int64(regs.RDI) + ur.Rsi = int64(regs.RSI) + ur.Rbp = int64(regs.RBP) + ur.Rbx = int64(regs.RBX) + ur.Rdx = int64(regs.RDX) + ur.Rcx = int64(regs.RCX) + ur.Rax = int64(regs.RAX) + ur.Rip = int64(regs.RIP) + ur.Cs = int64(regs.CS) + ur.Rflags = int64(regs.RFLAGS) + ur.Rsp = int64(regs.RSP) + ur.Ss = int64(regs.SS) + return unix.PtraceSetRegs(s.pid, &ur) +} + +// FPRegs holds the x87 FPU and SSE (XMM) register state, the FXSAVE image +// the FreeBSD struct fpreg mirrors: XMM0-15 at the same offsets. +type FPRegs struct { + XMM [16][16]byte // XMM0-15 +} + +// GetFPRegs retrieves the FPU/SSE register state via PT_GETFPREGS. The +// FreeBSD struct fpreg mirrors the FXSAVE image: the x87 environment and +// stack in Env/Acc, XMM0-15 in Xacc. +func (s *Session) GetFPRegs() (FPRegs, error) { + var fp FPRegs + var fr unix.FpReg + if err := unix.PtraceGetFpRegs(s.pid, &fr); err != nil { + return fp, fmt.Errorf("debug: PT_GETFPREGS: %w", err) + } + for i := range 16 { + copy(fp.XMM[i][:], fr.Xacc[i][:]) + } + return fp, nil +} + +// VectorRegs holds the YMM register state. +type VectorRegs struct { + YMM [16][32]byte // YMM0-15 (full 256-bit values) +} + +// The XSAVE area the PT_GETXSTATE request returns follows the architectural +// layout (Intel SDM vol 1, "XSAVE"): 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; XFEATURE_STATE_BIT_AVX is bit 2 of xstate_bv. +const ( + xsaveXMMOffset = 160 + xsaveHeaderOffset = 512 + xsaveBVOffset = xsaveHeaderOffset + ymmOffset = xsaveHeaderOffset + 64 // 576 + ymmSize = 256 // 16 registers, 16 bytes each + xfeatureMaskYMM = 1 << 2 + xstateMaxBuffer = 4096 // PT_GETXSTATE_INFO bounds the size far below this +) + +// GetVectorRegs retrieves the YMM registers via PT_GETXSTATE. The low +// (XMM) halves always come from the legacy image; the high halves are +// copied only when xstate_bv reports the AVX state, and read as zero +// otherwise. When the request fails the FP image still provides correct +// XMM halves, so that is the fallback. +func (s *Session) GetVectorRegs() (VectorRegs, error) { + var v VectorRegs + buf := make([]byte, xstateMaxBuffer) + n, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + uintptr(unix.PT_GETXSTATE), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(&buf[0])), + 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 + } + for i := range 16 { + copy(v.YMM[i][:16], buf[xsaveXMMOffset+16*i:xsaveXMMOffset+16*i+16]) + } + if int(n) >= ymmOffset+ymmSize { + if binary.LittleEndian.Uint64(buf[xsaveBVOffset:xsaveBVOffset+8])&xfeatureMaskYMM != 0 { + for i := range 16 { + copy(v.YMM[i][16:], buf[ymmOffset+16*i:ymmOffset+16*i+16]) + } + } + } + return v, nil +} diff --git a/debug/ptrace_freebsd_arm64.go b/debug/ptrace_freebsd_arm64.go new file mode 100644 index 0000000..7271a9f --- /dev/null +++ b/debug/ptrace_freebsd_arm64.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 + +package debug + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// GetRegs reads the general-purpose registers of the stopped debuggee and +// converts the FreeBSD struct reg (x[30], lr, sp, elr, spsr) into the +// portable layout. +func (s *Session) GetRegs() (Regs, error) { + var ur unix.Reg + if err := unix.PtraceGetRegs(s.pid, &ur); err != nil { + return Regs{}, fmt.Errorf("debug: PT_GETREGS: %w", err) + } + return Regs{ + X0: ur.X[0], + X1: ur.X[1], + X2: ur.X[2], + X3: ur.X[3], + X4: ur.X[4], + X5: ur.X[5], + X6: ur.X[6], + X7: ur.X[7], + X8: ur.X[8], + X9: ur.X[9], + X10: ur.X[10], + X11: ur.X[11], + X12: ur.X[12], + X13: ur.X[13], + X14: ur.X[14], + X15: ur.X[15], + X16: ur.X[16], + X17: ur.X[17], + X18: ur.X[18], + X19: ur.X[19], + X20: ur.X[20], + X21: ur.X[21], + X22: ur.X[22], + X23: ur.X[23], + X24: ur.X[24], + X25: ur.X[25], + X26: ur.X[26], + X27: ur.X[27], + X28: ur.X[28], + X29: ur.X[29], + X30: ur.Lr, + SP: ur.Sp, + PC: ur.Elr, + PSTATE: uint64(ur.Spsr), + }, nil +} + +// SetRegs writes the general-purpose registers of the stopped debuggee. +func (s *Session) SetRegs(regs *Regs) error { + var ur unix.Reg + ur.X = [30]uint64{ + regs.X0, regs.X1, regs.X2, regs.X3, regs.X4, regs.X5, regs.X6, + regs.X7, regs.X8, regs.X9, regs.X10, regs.X11, regs.X12, regs.X13, + regs.X14, regs.X15, regs.X16, regs.X17, regs.X18, regs.X19, regs.X20, + regs.X21, regs.X22, regs.X23, regs.X24, regs.X25, regs.X26, regs.X27, + regs.X28, regs.X29, + } + ur.Lr = regs.X30 + ur.Sp = regs.SP + ur.Elr = regs.PC + ur.Spsr = uint32(regs.PSTATE) + return unix.PtraceSetRegs(s.pid, &ur) +} + +// FPRegs holds the arm64 FP/NEON register state: the 32 128-bit V +// registers, then FPSR and FPCR (the user_fpsimd shape). +type FPRegs struct { + V [32][16]byte // V0-V31 (128-bit NEON/FP registers) + FPSR uint32 + FPCR uint32 +} + +// GetFPRegs retrieves the FP/NEON register state via PT_GETFPREGS. The +// FreeBSD struct fpreg holds the 32 128-bit V registers followed by FPSR +// and FPCR, the user_fpsimd shape. +func (s *Session) GetFPRegs() (FPRegs, error) { + var fp FPRegs + var fr unix.FpReg + if err := unix.PtraceGetFpRegs(s.pid, &fr); err != nil { + return fp, fmt.Errorf("debug: PT_GETFPREGS: %w", err) + } + for i := range 32 { + copy(fp.V[i][:], fr.Q[i][:]) + } + return fp, nil +} + +// VectorRegs holds the full SIMD register state. +type VectorRegs struct { + V [32][16]byte // V0-V31 (128-bit) +} + +// GetVectorRegs retrieves the SIMD registers. +func (s *Session) GetVectorRegs() (VectorRegs, error) { + var v VectorRegs + fp, err := s.GetFPRegs() + if err != nil { + return v, err + } + copy(v.V[:][:], fp.V[:][:]) + return v, nil +} diff --git a/debug/ptrace_freebsd_riscv64.go b/debug/ptrace_freebsd_riscv64.go new file mode 100644 index 0000000..9487c3e --- /dev/null +++ b/debug/ptrace_freebsd_riscv64.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && riscv64 + +package debug + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// GetRegs reads the general-purpose registers of the stopped debuggee and +// converts the FreeBSD struct reg into the portable layout. Sstatus rides +// the kernel's struct but the portable surface carries the GPRs and PC. +func (s *Session) GetRegs() (Regs, error) { + var ur unix.Reg + if err := unix.PtraceGetRegs(s.pid, &ur); err != nil { + return Regs{}, fmt.Errorf("debug: PT_GETREGS: %w", err) + } + return Regs{ + PC: ur.Sepc, + Ra: ur.Ra, + Sp: ur.Sp, + Gp: ur.Gp, + Tp: ur.Tp, + T0: ur.T[0], + T1: ur.T[1], + T2: ur.T[2], + S0: ur.S[0], + S1: ur.S[1], + A0: ur.A[0], + A1: ur.A[1], + A2: ur.A[2], + A3: ur.A[3], + A4: ur.A[4], + A5: ur.A[5], + A6: ur.A[6], + A7: ur.A[7], + S2: ur.S[2], + S3: ur.S[3], + S4: ur.S[4], + S5: ur.S[5], + S6: ur.S[6], + S7: ur.S[7], + S8: ur.S[8], + S9: ur.S[9], + S10: ur.S[10], + S11: ur.S[11], + T3: ur.T[3], + T4: ur.T[4], + T5: ur.T[5], + T6: ur.T[6], + }, nil +} + +// SetRegs writes the general-purpose registers of the stopped debuggee. +// Read-modify-write keeps sstatus, which the kernel owns, intact. +func (s *Session) SetRegs(regs *Regs) error { + var ur unix.Reg + if err := unix.PtraceGetRegs(s.pid, &ur); err != nil { + return fmt.Errorf("debug: PT_GETREGS: %w", err) + } + ur.Sepc = regs.PC + ur.Ra = regs.Ra + ur.Sp = regs.Sp + ur.Gp = regs.Gp + ur.Tp = regs.Tp + ur.T = [7]uint64{regs.T0, regs.T1, regs.T2, regs.T3, regs.T4, regs.T5, regs.T6} + ur.S = [12]uint64{regs.S0, regs.S1, regs.S2, regs.S3, regs.S4, regs.S5, + regs.S6, regs.S7, regs.S8, regs.S9, regs.S10, regs.S11} + ur.A = [8]uint64{regs.A0, regs.A1, regs.A2, regs.A3, regs.A4, regs.A5, regs.A6, regs.A7} + return unix.PtraceSetRegs(s.pid, &ur) +} + +// FPRegs holds the RISC-V FP register state (32 64-bit FP registers plus +// fcsr). +type FPRegs struct { + F [32]uint64 // F0-F31 (64-bit FP registers) + FCSR uint32 +} + +// GetFPRegs retrieves the FP register state via PT_GETFPREGS. The FreeBSD +// struct fpreg carries each 64-bit FP register in a 128-bit slot (fp_x is +// the flat [64]-word area the x/sys type renders as [32][2]); the low word +// holds the register, and FCSR rides the tail. +func (s *Session) GetFPRegs() (FPRegs, error) { + var fp FPRegs + var fr unix.FpReg + if err := unix.PtraceGetFpRegs(s.pid, &fr); err != nil { + return fp, fmt.Errorf("debug: PT_GETFPREGS: %w", err) + } + for i := range 32 { + fp.F[i] = fr.X[i][0] + } + fp.FCSR = uint32(fr.Fcsr) + return fp, nil +} + +// VectorRegs holds the FP register state shown by the regs command +// (riscv64 has 32 64-bit FP registers and fcsr). +type VectorRegs struct { + F [32]uint64 + FCSR uint32 +} + +// GetVectorRegs retrieves the FP registers. +func (s *Session) GetVectorRegs() (VectorRegs, error) { + fp, err := s.GetFPRegs() + if err != nil { + return VectorRegs{}, err + } + return VectorRegs{F: fp.F, FCSR: fp.FCSR}, nil +} diff --git a/debug/ptrace_integration_freebsd_test.go b/debug/ptrace_integration_freebsd_test.go new file mode 100644 index 0000000..9c3676d --- /dev/null +++ b/debug/ptrace_integration_freebsd_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +import ( + "os/exec" + "path/filepath" + "runtime" + "testing" + + "sourcedock.dev/petrbalvin/gasm-devkit/verify" +) + +// TestLaunchAndBreakpoint is the FreeBSD twin of the Linux integration +// test: it drives the whole launch, breakpoint, trap and register-rewind +// flow end to end. It needs a real FreeBSD kernel (ptrace does not work +// under emulation), so it only runs where it can. +func TestLaunchAndBreakpoint(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("runs only on amd64 hosts") + } + // The tracer is the OS thread that forked the debuggee (PT_TRACE_ME + // binds the relation to that thread); every ptrace request must come + // from the same thread, so pin the test goroutine to one thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + bin := filepath.Join(t.TempDir(), "gasm") + out, err := exec.Command("go", "build", "-o", bin, "sourcedock.dev/petrbalvin/gasm-devkit/cmd/gasm").CombinedOutput() + if err != nil { + t.Fatalf("build gasm: %v: %s", err, out) + } + + const kernelPath = "../testdata/verify/basic_amd64.s" + k, err := verify.Load(kernelPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + fl, err := k.Func("wideCopy") + if err != nil { + t.Fatalf("Func: %v", err) + } + + sess, err := Launch(bin, kernelPath, "wideCopy", make([]byte, fl.Args)) + if err != nil { + t.Fatalf("Launch: %v", err) + } + t.Cleanup(sess.Kill) + + bm := NewBreakpoints(sess) + entry := sess.CodeBase() + uint64(fl.Offset) + if _, err := bm.Set(entry, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + + // The INT3 must be visible in the debuggee's memory. + word, err := sess.Peek(entry) + if err != nil { + t.Fatalf("Peek: %v", err) + } + if b := word & 0xFF; b != 0xCC { + t.Fatalf("int3 not patched: first byte %#02x at %#x", b, entry) + } + + // The debuggee raises a second SIGSTOP after the launch barrier (the + // child's RunTarget marks its entry), so like the REPL and the cover + // mode the test keeps resuming until the breakpoint trap arrives. + for range 10 { + if err := sess.Continue(); err != nil { + t.Fatalf("Continue: %v", err) + } + if sess.Exited() { + t.Fatal("debuggee exited instead of trapping on the breakpoint") + } + regs, err := sess.GetRegs() + if err != nil { + t.Fatalf("GetRegs: %v", err) + } + if bp := bm.HandleTrap(®s); bp != nil { + if bp.Addr != entry { + t.Fatalf("trap at %#x, want %#x", bp.Addr, entry) + } + return // trap on the entry breakpoint: the whole flow works + } + } + t.Fatal("no breakpoint trap after 10 resumes") +} diff --git a/debug/regs_freebsd_amd64.go b/debug/regs_freebsd_amd64.go new file mode 100644 index 0000000..a5fcbf2 --- /dev/null +++ b/debug/regs_freebsd_amd64.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +// Regs holds the full general-purpose register set of a traced process +// (the FreeBSD amd64 struct reg layout, sys/x86/include/reg.h). FreeBSD +// reports segment selectors (FS/GS/ES/DS), not the bases the Linux ptrace +// surface carries, and has no ORIG_RAX slot. +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 + RIP uint64 + CS uint64 + RFLAGS uint64 + RSP uint64 + SS uint64 + FS uint64 + GS uint64 + DS uint64 + ES uint64 +} + +// GetPC returns the program counter. +func (r *Regs) GetPC() uint64 { return r.RIP } + +// SetPC sets the program counter. +func (r *Regs) SetPC(pc uint64) { r.RIP = pc } + +// GetSP returns the stack pointer. +func (r *Regs) GetSP() uint64 { return r.RSP } + +// RegValue returns the value of the named register, or false if unknown. +func (r *Regs) RegValue(name string) (uint64, bool) { + switch name { + case "rax", "eax", "ax", "al": + return r.RAX, true + case "rbx", "ebx", "bx", "bl": + return r.RBX, true + case "rcx", "ecx", "cx", "cl": + return r.RCX, true + case "rdx", "edx", "dx", "dl": + return r.RDX, true + case "rsi", "esi", "si": + return r.RSI, true + case "rdi", "edi", "di": + return r.RDI, true + case "rbp", "ebp", "bp": + return r.RBP, true + case "rsp", "esp", "sp": + return r.RSP, true + case "r8": + return r.R8, true + case "r9": + return r.R9, true + case "r10": + return r.R10, true + case "r11": + return r.R11, true + case "r12": + return r.R12, true + case "r13": + return r.R13, true + case "r14": + return r.R14, true + case "r15": + return r.R15, true + case "rip", "eip": + return r.RIP, true + default: + return 0, false + } +} + +// breakpointInsn is the software breakpoint instruction. +var breakpointInsn = []byte{0xCC} // INT3 + +// breakpointPCAdjust is how far PC is past the breakpoint instruction after +// a trap. INT3 leaves the hardware PC on the following instruction (Intel +// SDM vol 3, "Debug Exceptions") and the FreeBSD T_BPTFLT path delivers +// that frame unmodified (sys/amd64/amd64/trap.c), so the trap address is +// PC-1, the same correction the Linux side applies. +const breakpointPCAdjust = 1 diff --git a/debug/regs_freebsd_arm64.go b/debug/regs_freebsd_arm64.go new file mode 100644 index 0000000..f1a9ac1 --- /dev/null +++ b/debug/regs_freebsd_arm64.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 + +package debug + +// Regs holds the full general-purpose register set of a traced process +// (the FreeBSD arm64 struct reg layout, sys/arm64/include/reg.h: x[30], lr, +// sp, elr, spsr). +type Regs struct { + X0 uint64 + X1 uint64 + X2 uint64 + X3 uint64 + X4 uint64 + X5 uint64 + X6 uint64 + X7 uint64 + X8 uint64 + X9 uint64 + X10 uint64 + X11 uint64 + X12 uint64 + X13 uint64 + X14 uint64 + X15 uint64 + X16 uint64 + X17 uint64 + X18 uint64 + X19 uint64 + X20 uint64 + X21 uint64 + X22 uint64 + X23 uint64 + X24 uint64 + X25 uint64 + X26 uint64 + X27 uint64 + X28 uint64 + X29 uint64 // FP (frame pointer) + X30 uint64 // LR (link register) + SP uint64 + PC uint64 + PSTATE uint64 +} + +// GetPC returns the program counter. +func (r *Regs) GetPC() uint64 { return r.PC } + +// SetPC sets the program counter. +func (r *Regs) SetPC(pc uint64) { r.PC = pc } + +// GetSP returns the stack pointer. +func (r *Regs) GetSP() uint64 { return r.SP } + +// RegValue returns the value of the named register, or false if unknown. +func (r *Regs) RegValue(name string) (uint64, bool) { + switch name { + case "x0": + return r.X0, true + case "x1": + return r.X1, true + case "x2": + return r.X2, true + case "x3": + return r.X3, true + case "x4": + return r.X4, true + case "x5": + return r.X5, true + case "x6": + return r.X6, true + case "x7": + return r.X7, true + case "x8": + return r.X8, true + case "x9": + return r.X9, true + case "x10": + return r.X10, true + case "x11": + return r.X11, true + case "x12": + return r.X12, true + case "x13": + return r.X13, true + case "x14": + return r.X14, true + case "x15": + return r.X15, true + case "x16": + return r.X16, true + case "x17": + return r.X17, true + case "x18": + return r.X18, true + case "x19": + return r.X19, true + case "x20": + return r.X20, true + case "x21": + return r.X21, true + case "x22": + return r.X22, true + case "x23": + return r.X23, true + case "x24": + return r.X24, true + case "x25": + return r.X25, true + case "x26": + return r.X26, true + case "x27": + return r.X27, true + case "x28": + return r.X28, true + case "x29", "fp": + return r.X29, true + case "x30", "lr": + return r.X30, true + case "sp": + return r.SP, true + case "pc": + return r.PC, true + default: + return 0, false + } +} + +// 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: 0. The BRK synchronous exception leaves ELR_EL0 on the BRK +// itself (ARM DDI 0487), and the FreeBSD EXCP_BRKPT_EL0 handler delivers +// the frame's elr unmodified (sys/arm64/arm64/trap.c), so the trap address +// is the PC as reported. +const breakpointPCAdjust = 0 diff --git a/debug/regs_freebsd_riscv64.go b/debug/regs_freebsd_riscv64.go new file mode 100644 index 0000000..158e84d --- /dev/null +++ b/debug/regs_freebsd_riscv64.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && riscv64 + +package debug + +// Regs holds the full general-purpose register set of a traced process +// (the FreeBSD riscv64 struct reg layout: ra, sp, gp, tp, t0-t6, s0-s11, +// a0-a7, sepc, sstatus). +type Regs struct { + PC uint64 // sepc + Ra uint64 // x1 (return address) + Sp uint64 // x2 + Gp uint64 // x3 + Tp uint64 // x4 + T0 uint64 // x5 + T1 uint64 // x6 + T2 uint64 // x7 + S0 uint64 // x8 (frame pointer) + S1 uint64 // x9 + A0 uint64 // x10 + A1 uint64 // x11 + A2 uint64 // x12 + A3 uint64 // x13 + A4 uint64 // x14 + A5 uint64 // x15 + A6 uint64 // x16 + A7 uint64 // x17 + S2 uint64 // x18 + S3 uint64 // x19 + S4 uint64 // x20 + S5 uint64 // x21 + S6 uint64 // x22 + S7 uint64 // x23 + S8 uint64 // x24 + S9 uint64 // x25 + S10 uint64 // x26 + S11 uint64 // x27 + T3 uint64 // x28 + T4 uint64 // x29 + T5 uint64 // x30 + T6 uint64 // x31 +} + +// GetPC returns the program counter. +func (r *Regs) GetPC() uint64 { return r.PC } + +// SetPC sets the program counter. +func (r *Regs) SetPC(pc uint64) { r.PC = pc } + +// GetSP returns the stack pointer. +func (r *Regs) GetSP() uint64 { return r.Sp } + +// RegValue returns the value of the named register, or false if unknown. +func (r *Regs) RegValue(name string) (uint64, bool) { + switch name { + case "pc": + return r.PC, true + case "ra", "x1": + return r.Ra, true + case "sp", "x2": + return r.Sp, true + case "gp", "x3": + return r.Gp, true + case "tp", "x4": + return r.Tp, true + case "t0", "x5": + return r.T0, true + case "t1", "x6": + return r.T1, true + case "t2", "x7": + return r.T2, true + case "s0", "fp", "x8": + return r.S0, true + case "s1", "x9": + return r.S1, true + case "a0", "x10": + return r.A0, true + case "a1", "x11": + return r.A1, true + case "a2", "x12": + return r.A2, true + case "a3", "x13": + return r.A3, true + case "a4", "x14": + return r.A4, true + case "a5", "x15": + return r.A5, true + case "a6", "x16": + return r.A6, true + case "a7", "x17": + return r.A7, true + case "s2", "x18": + return r.S2, true + case "s3", "x19": + return r.S3, true + case "s4", "x20": + return r.S4, true + case "s5", "x21": + return r.S5, true + case "s6", "x22": + return r.S6, true + case "s7", "x23": + return r.S7, true + case "s8", "x24": + return r.S8, true + case "s9", "x25": + return r.S9, true + case "s10", "x26": + return r.S10, true + case "s11", "x27": + return r.S11, true + case "t3", "x28": + return r.T3, true + case "t4", "x29": + return r.T4, true + case "t5", "x30": + return r.T5, true + case "t6", "x31": + return r.T6, true + default: + return 0, false + } +} + +// 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: 0. The EBREAK synchronous exception leaves sepc on the ebreak +// itself (RISC-V privileged architecture), so the trap address is the PC as +// reported. +const breakpointPCAdjust = 0 diff --git a/debug/repl.go b/debug/repl.go index cdde45d..05a3c6d 100644 --- a/debug/repl.go +++ b/debug/repl.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux +//go:build linux || (freebsd && (amd64 || arm64 || riscv64)) package debug diff --git a/debug/stopinfo_freebsd.go b/debug/stopinfo_freebsd.go new file mode 100644 index 0000000..5bdc3c6 --- /dev/null +++ b/debug/stopinfo_freebsd.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && (amd64 || arm64 || riscv64) + +package debug + +import ( + "encoding/binary" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// FreeBSD TRAP_* si_code values (sys/signal.h). A breakpoint (INT3, BRK, +// EBREAK) arrives as TRAP_BRKPT on every supported architecture; TRAP_TRACE +// is shared by the completed single-step and the hardware watchpoint hit, +// so the watchpoint layer disambiguates from the debug registers. +const ( + trapBRKPT = 1 // TRAP_BRKPT + trapTRACE = 2 // TRAP_TRACE +) + +// StopReason describes why the debuggee stopped. +type StopReason int + +const ( + StopNone StopReason = iota + StopBreakpoint // software breakpoint hit + StopWatchpoint // hardware watchpoint triggered + StopSingleStep // single-step completed + StopSignal // stopped by a signal + StopExited // process exited +) + +// StopInfo returns the reason the debuggee stopped and the faulting address +// (for watchpoints, the watched address that was accessed). FreeBSD has no +// PTRACE_GETSIGINFO; the stop's signal information comes from PT_LWPINFO, +// whose pl_siginfo carries the siginfo the kernel delivered. A ptrace stop +// with no signal behind it (a completed single-step, the initial attach) +// fills no siginfo at all. +func (s *Session) StopInfo() (StopReason, uint64) { + if s.exited { + return StopExited, 0 + } + var info unix.PtraceLwpInfoStruct + if err := unix.PtraceLwpInfo(s.pid, &info); err != nil { + return StopNone, 0 + } + // The siginfo layout is the FreeBSD siginfo_t: three leading ints + // (signo, errno, code), then the union, 8-byte aligned, whose _fault + // member puts the address at byte offset 16. The read is byte-wise + // because the blob's alignment is not guaranteed. + si := (*[64]byte)(unsafe.Pointer(&info.Siginfo)) + signo := int32(binary.LittleEndian.Uint32(si[0:4])) + code := int32(binary.LittleEndian.Uint32(si[8:12])) + switch { + case signo == 0: + // A pure ptrace stop: single-step completion, attach, or the + // events the kernel resolves internally. + return StopSingleStep, 0 + case signo != int32(syscall.SIGTRAP): + return StopSignal, uint64(code) + case code == trapBRKPT: + return StopBreakpoint, 0 + case code == trapTRACE: + addr := binary.LittleEndian.Uint64(si[16:24]) + return archStopTrace(s, addr) + default: + return StopSingleStep, 0 + } +} diff --git a/debug/target_freebsd.go b/debug/target_freebsd.go new file mode 100644 index 0000000..324bd25 --- /dev/null +++ b/debug/target_freebsd.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && (amd64 || arm64 || riscv64) + +package debug + +import ( + "encoding/hex" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" + + "sourcedock.dev/petrbalvin/gasm-devkit/asm" + "sourcedock.dev/petrbalvin/gasm-devkit/parser" + "sourcedock.dev/petrbalvin/gasm-devkit/verify" +) + +// mapRWX maps code into a read-write-execute region. +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 +} + +// setupBuffers allocates buffers in the debuggee's memory. +func setupBuffers(spec string, args []byte, tmpDir string) ([]byte, error) { + type bufSpec struct { + name string + size int + pattern string + } + var specs []bufSpec + for part := range strings.SplitSeq(spec, ",") { + fields := strings.SplitN(part, ":", 3) + if len(fields) != 3 { + continue + } + size, err := strconv.Atoi(fields[1]) + if err != nil || size <= 0 { + continue + } + specs = append(specs, bufSpec{name: fields[0], size: size, pattern: fields[2]}) + } + + if len(specs) == 0 { + return args, nil + } + + var bufAddrs []uint64 + for _, s := range specs { + buf, err := syscall.Mmap(-1, 0, s.size, + syscall.PROT_READ|syscall.PROT_WRITE, + syscall.MAP_PRIVATE|syscall.MAP_ANON) + if err != nil { + return nil, fmt.Errorf("mmap buffer %s: %w", s.name, err) + } + fillBuffer(buf, s.pattern) + bufAddrs = append(bufAddrs, uint64(uintptr(unsafe.Pointer(&buf[0])))) + } + + addrFile, err := os.Create(tmpDir + "/bufaddrs") + if err != nil { + return nil, err + } + for _, addr := range bufAddrs { + fmt.Fprintf(addrFile, "%d\n", addr) + } + addrFile.Close() + + return args, nil +} + +// fillBuffer fills a buffer with the specified pattern. +func fillBuffer(buf []byte, pattern string) { + switch pattern { + case "zero": + case "ones": + for i := range buf { + buf[i] = 0xFF + } + case "seq": + for i := range buf { + buf[i] = byte(i) + } + default: + if data, err := hex.DecodeString(pattern); err == nil && len(data) > 0 { + for i := range buf { + buf[i] = data[i%len(data)] + } + } + } +} + +// RunTarget is the debuggee entry point (gasm debug --target). +func RunTarget(asmPath, funcName, argsFile, tmpDir string) error { + 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) + } + + 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) + } + + code := img.Bytes() + exec, err := mapRWX(code) + if err != nil { + return fmt.Errorf("debug target: mmap: %w", err) + } + + 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) + } + + meta := fmt.Sprintf("%d %d %d", fl.Offset, fl.Size, fl.Args) + os.WriteFile(tmpDir+"/funcmeta", []byte(meta), 0o644) + + labelsFile, _ := os.Create(tmpDir + "/labels") + if labelsFile != nil { + for label, off := range fl.Labels { + fmt.Fprintf(labelsFile, "%s %d\n", label, off) + } + labelsFile.Close() + } + + 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 + } + + bufSpecFile := tmpDir + "/bufspec" + if bufSpec, err := os.ReadFile(bufSpecFile); err == nil && len(bufSpec) > 0 { + args, err = setupBuffers(string(bufSpec), args, tmpDir) + if err != nil { + return fmt.Errorf("debug target: setup buffers: %w", err) + } + } + + runtime.LockOSThread() + + if _, _, errno := unix.RawSyscall(unix.SYS_PTRACE, uintptr(unix.PT_TRACE_ME), 0, 0); errno != 0 { + return fmt.Errorf("debug target: PT_TRACE_ME: %v", errno) + } + os.WriteFile(tmpDir+"/ready", []byte("ok"), 0o644) + syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) + + os.WriteFile(tmpDir+"/entry", []byte("ok"), 0o644) + syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) + + fnAddr := codeBase + uintptr(fl.Offset) + stackArgs := make([]byte, fl.Args) + copy(stackArgs, args) + + if _, callErr := verify.Call(fnAddr, stackArgs); callErr != nil { + os.Exit(1) + } + // Success returns to the caller, which exits with status 0; the JIT + // code has already run to its own trampoline by the time Call returns. + return nil +} diff --git a/debug/tracer.go b/debug/tracer.go index 125e876..096c39f 100644 --- a/debug/tracer.go +++ b/debug/tracer.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux +//go:build linux || (freebsd && (amd64 || arm64 || riscv64)) package debug diff --git a/debug/watchpoint_freebsd_amd64.go b/debug/watchpoint_freebsd_amd64.go new file mode 100644 index 0000000..0a997c0 --- /dev/null +++ b/debug/watchpoint_freebsd_amd64.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && amd64 + +package debug + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/unix" +) + +// Hardware watchpoint support via x86-64 debug registers (DR0-DR3, DR7), +// read and written as one blob through PT_GETDBREGS/PT_SETDBREGS. The +// FreeBSD struct dbreg is the raw DR file: dr[16], where DR0-DR3 are the +// address registers, DR6 the status and DR7 the control (sys/x86/include/ +// reg.h; the DBREG_DRX accessor indexes the same array). + +// dbreg mirrors FreeBSD's struct dbreg for PT_GETDBREGS/PT_SETDBREGS. +type dbreg struct { + Dr [16]uint64 +} + +// dbreg indices of the registers the watchpoint layer drives. +const ( + drStatus = 6 // DR6: the trap status register + drControl = 7 // DR7: the debug control register +) + +// WatchpointType selects what triggers the watchpoint. +type WatchpointType int + +const ( + WatchWrite WatchpointType = 1 // trigger on write + WatchRead WatchpointType = 3 // trigger on read or write +) + +// maxWatchpoints reports the number of hardware watchpoint slots the +// architecture provides: four address registers, DR0-DR3. +func maxWatchpoints() int { return 4 } + +// getDbRegs reads the debug register file of the stopped debuggee. +func (s *Session) getDbRegs() (*dbreg, error) { + var dr dbreg + if _, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + uintptr(unix.PT_GETDBREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(&dr)), + 0, 0, + ); errno != 0 { + return nil, errno + } + return &dr, nil +} + +// setDbRegs writes the debug register file of the stopped debuggee. +func (s *Session) setDbRegs(dr *dbreg) error { + if _, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + uintptr(unix.PT_SETDBREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(dr)), + 0, 0, + ); errno != 0 { + return errno + } + return nil +} + +// archStopTrace classifies a TRAP_TRACE stop. On amd64 the kernel +// delivers both the completed single-step and the debug-register hit +// through T_TRCTRAP with TRAP_TRACE (sys/amd64/amd64/trap.c), and DR6's +// B0-B3 bits name the watchpoint that fired. +func archStopTrace(s *Session, siAddr uint64) (StopReason, uint64) { + dr, err := s.getDbRegs() + if err != nil { + return StopSingleStep, 0 + } + if status := dr.Dr[drStatus]; status&0xF != 0 { + for slot := range 4 { + if status&(1< 3 { + return false + } + return s.wpSlots[slot] +} + +// SetWatchpoint installs a hardware watchpoint on the given address. +// DR7's encoding is architectural: a 2-bit local/global enable pair per +// slot at bit 2*slot, the R/W field at 16+4*slot and the length field at +// 18+4*slot (Intel SDM vol 3, "Debug Registers"). +func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { + if slot < 0 || slot > 3 { + return fmt.Errorf("debug: watchpoint slot must be 0-3") + } + if s.wpSlots[slot] { + return fmt.Errorf("debug: watchpoint slot %d already in use", slot) + } + + var lenBits uint64 + switch size { + case 1: + lenBits = 0 + case 2: + lenBits = 1 + case 4: + lenBits = 3 + case 8: + lenBits = 2 + default: + return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") + } + + dr, err := s.getDbRegs() + if err != nil { + return fmt.Errorf("debug: read debug registers: %w", err) + } + dr.Dr[slot] = addr + + dr7 := dr.Dr[drControl] + enableBit := uint64(1) << (2 * slot) + rwBits := uint64(typ) << (16 + 4*slot) + lenField := lenBits << (18 + 4*slot) + mask := ^((uint64(1) << (2 * slot)) | (uint64(3) << (16 + 4*slot)) | (uint64(3) << (18 + 4*slot))) + dr.Dr[drControl] = (dr7 & mask) | enableBit | rwBits | lenField + + if err := s.setDbRegs(dr); err != nil { + return fmt.Errorf("debug: set debug registers: %w", err) + } + s.wpSlots[slot] = true + return nil +} + +// ClearWatchpoint removes a hardware watchpoint. +func (s *Session) ClearWatchpoint(slot int) error { + if slot < 0 || slot > 3 { + return fmt.Errorf("debug: watchpoint slot must be 0-3") + } + if !s.wpSlots[slot] { + return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) + } + dr, err := s.getDbRegs() + if err != nil { + return err + } + dr.Dr[slot] = 0 + dr.Dr[drControl] &^= uint64(1) << (2 * slot) + if err := s.setDbRegs(dr); err != nil { + return err + } + s.wpSlots[slot] = false + return nil +} + +// ClearAllWatchpoints removes all hardware watchpoints. +func (s *Session) ClearAllWatchpoints() error { + for slot := range maxWatchpoints() { + if s.wpSlots[slot] { + if err := s.ClearWatchpoint(slot); err != nil { + return err + } + } + } + return nil +} diff --git a/debug/watchpoint_freebsd_arm64.go b/debug/watchpoint_freebsd_arm64.go new file mode 100644 index 0000000..a408e0b --- /dev/null +++ b/debug/watchpoint_freebsd_arm64.go @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 + +package debug + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/unix" +) + +// Hardware watchpoint support via arm64 debug registers, read and written +// as one blob through PT_GETDBREGS/PT_SETDBREGS. The FreeBSD struct dbreg +// (sys/arm64/include/reg.h) opens with the debug-facility header and then +// carries 16 breakpoint and 16 watchpoint pairs of {address, control}. + +// dbreg mirrors FreeBSD's struct dbreg for PT_GETDBREGS/PT_SETDBREGS. +type dbreg struct { + DbDebugVer uint8 + DbNbkpts uint8 + DbNwtpts uint8 + _ [5]byte + DbBreakregs [16]struct { + Addr uint64 + Ctrl uint32 + _ uint32 + } + DbWatchregs [16]struct { + Addr uint64 + Ctrl uint32 + _ uint32 + } +} + +// WatchpointType selects what triggers the watchpoint. +type WatchpointType int + +const ( + WatchWrite WatchpointType = 1 // trigger on write + WatchRead WatchpointType = 3 // trigger on read or write +) + +// maxWatchpoints reports the number of hardware watchpoint slots the +// architecture provides: DBGWVR0-DBGWCR15. +func maxWatchpoints() int { return 16 } + +// getDbRegs reads the debug register file of the stopped debuggee. +func (s *Session) getDbRegs() (*dbreg, error) { + var dr dbreg + if _, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + uintptr(unix.PT_GETDBREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(&dr)), + 0, 0, + ); errno != 0 { + return nil, errno + } + return &dr, nil +} + +// setDbRegs writes the debug register file of the stopped debuggee. +func (s *Session) setDbRegs(dr *dbreg) error { + if _, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + uintptr(unix.PT_SETDBREGS), + uintptr(s.pid), + 0, + uintptr(unsafe.Pointer(dr)), + 0, 0, + ); errno != 0 { + return errno + } + return nil +} + +// archStopTrace classifies a TRAP_TRACE stop. On arm64 the kernel +// delivers both the software single step and the watchpoint hit through +// EXCP_SOFTSTP_EL0/EXCP_WATCHPT_EL0 with TRAP_TRACE (sys/arm64/arm64/ +// trap.c); the watchpoint address rides the FAR register, so a stop whose +// reported address falls inside an armed watchpoint's byte range is a +// watchpoint and everything else is a single step. +func archStopTrace(s *Session, siAddr uint64) (StopReason, uint64) { + dr, err := s.getDbRegs() + if err != nil { + return StopSingleStep, 0 + } + for slot := range 16 { + ctrl := uint64(dr.DbWatchregs[slot].Ctrl) + if ctrl&1 == 0 || dr.DbWatchregs[slot].Addr == 0 { + continue + } + if bas := (ctrl >> 5) & 0xFF; bas != 0 && siAddr >= dr.DbWatchregs[slot].Addr && siAddr < dr.DbWatchregs[slot].Addr+8 { + return StopWatchpoint, siAddr + } + } + return StopSingleStep, 0 +} + +// FindFreeWatchpointSlot returns the index of the first free watchpoint +// slot, or -1 if all of them are in use. +func (s *Session) FindFreeWatchpointSlot() int { + for i := range maxWatchpoints() { + if !s.wpSlots[i] { + return i + } + } + return -1 +} + +// IsWatchpointSlotUsed reports whether slot currently holds a watchpoint. +func (s *Session) IsWatchpointSlotUsed(slot int) bool { + if slot < 0 || slot >= maxWatchpoints() { + return false + } + return s.wpSlots[slot] +} + +// SetWatchpoint installs a hardware watchpoint on the given address. The +// control word is the architectural DBGWCR (ARM DDI 0487): bit 0 enables, +// bits 3-4 select the access type (10 store, 11 load+store) and bits 5-12 +// are the byte-address select, so the watch stays 8-byte aligned and names +// its watched bytes through BAS. +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) + } + var bas uint64 + switch size { + case 1: + bas = 0x01 + case 2: + bas = 0x03 + case 4: + bas = 0x0F + case 8: + bas = 0xFF + default: + return fmt.Errorf("debug: watchpoint size must be 1, 2, 4, or 8") + } + + dr, err := s.getDbRegs() + if err != nil { + return fmt.Errorf("debug: read debug registers: %w", err) + } + if uint8(slot) >= dr.DbNwtpts && dr.DbNwtpts != 0 { + return fmt.Errorf("debug: slot %d exceeds available watchpoints (%d)", slot, dr.DbNwtpts) + } + ctrl := uint64(1) // enable + switch typ { + case WatchWrite: + ctrl |= 2 << 3 // store only + case WatchRead: + ctrl |= 3 << 3 // load+store + } + ctrl |= bas << 5 + dr.DbWatchregs[slot].Addr = addr + dr.DbWatchregs[slot].Ctrl = uint32(ctrl) + + if err := s.setDbRegs(dr); err != nil { + return fmt.Errorf("debug: set debug registers: %w", err) + } + s.wpSlots[slot] = true + return nil +} + +// ClearWatchpoint removes a hardware watchpoint. +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 !s.wpSlots[slot] { + return fmt.Errorf("debug: watchpoint slot %d is not in use", slot) + } + dr, err := s.getDbRegs() + if err != nil { + return err + } + dr.DbWatchregs[slot].Addr = 0 + dr.DbWatchregs[slot].Ctrl = 0 + if err := s.setDbRegs(dr); err != nil { + return err + } + s.wpSlots[slot] = false + return nil +} + +// ClearAllWatchpoints removes all hardware watchpoints. +func (s *Session) ClearAllWatchpoints() error { + for slot := range maxWatchpoints() { + if s.wpSlots[slot] { + if err := s.ClearWatchpoint(slot); err != nil { + return err + } + } + } + return nil +} diff --git a/debug/watchpoint_freebsd_riscv64.go b/debug/watchpoint_freebsd_riscv64.go new file mode 100644 index 0000000..a29b7d8 --- /dev/null +++ b/debug/watchpoint_freebsd_riscv64.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && riscv64 + +package debug + +import "fmt" + +// The architecture has hardware watchpoint triggers, but FreeBSD exposes +// no PT_GETDBREGS request for riscv64, so there is no supported way to arm +// one: the watchpoint layer is honestly empty here. + +// WatchpointType selects what triggers the watchpoint. +type WatchpointType int + +const ( + WatchWrite WatchpointType = 1 // trigger on write + WatchRead WatchpointType = 3 // trigger on read or write +) + +// maxWatchpoints reports the number of hardware watchpoint slots the +// platform provides: FreeBSD exposes none for riscv64. +func maxWatchpoints() int { return 0 } + +// archStopTrace classifies a TRAP_TRACE stop; with no watchpoint layer a +// trace stop is always a completed single step. +func archStopTrace(s *Session, siAddr uint64) (StopReason, uint64) { + return StopSingleStep, 0 +} + +// FindFreeWatchpointSlot returns -1: no slots exist. +func (s *Session) FindFreeWatchpointSlot() int { return -1 } + +// IsWatchpointSlotUsed reports whether slot currently holds a watchpoint. +func (s *Session) IsWatchpointSlotUsed(slot int) bool { return false } + +// SetWatchpoint is unsupported: FreeBSD exposes no debug register request +// for riscv64. +func (s *Session) SetWatchpoint(slot int, addr uint64, typ WatchpointType, size int) error { + return fmt.Errorf("debug: hardware watchpoints are not supported on freebsd/riscv64") +} + +// ClearWatchpoint is unsupported for the same reason. +func (s *Session) ClearWatchpoint(slot int) error { + return fmt.Errorf("debug: hardware watchpoints are not supported on freebsd/riscv64") +} + +// ClearAllWatchpoints is a no-op: no watchpoint can be armed. +func (s *Session) ClearAllWatchpoints() error { return nil } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 237621b..27005f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -579,7 +579,9 @@ AST, so neither depends on an encoding. --ground-truth`, `go list -json -export` locates the archives of the packages a GOOBJ object references, and `_gen` parses `$GOROOT/src/cmd/internal/obj//anames.go` to rebuild the tables. -- **Linux process interfaces** for the dynamic work: `mmap` and `mprotect` for - the JIT mapping, ptrace with `/proc/pid/mem` for the debugger. That is why +- **Linux and FreeBSD process interfaces** for the dynamic work: `mmap` and + `mprotect` for the JIT mapping, ptrace for the debugger — with tracee + memory through `/proc/pid/mem` on Linux and through `PT_IO` on FreeBSD, + and the tracee's stop reports read from `PT_LWPINFO` there. That is why `verify` runs a JIT check only when the host architecture matches the - kernel's, and why `debug` is Linux-only. + kernel's, and why `debug` is bounded to those two kernels. diff --git a/docs/CLI.md b/docs/CLI.md index 0aa5b44..93ff3a7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -287,7 +287,8 @@ Usage: gasm debug --func The debugger re-executes the binary it is running as (`os.Executable()`) for the traced child, so the child is the same `gasm`, whether it is installed on `$PATH` or run with `go run ./cmd/gasm`; nothing has to be installed first. Requires -Linux (ptrace), and all four architectures are supported. +Linux or FreeBSD (ptrace): all four architectures on Linux, amd64, arm64 and +riscv64 on FreeBSD. REPL commands: