Compare commits
9
Commits
main
...
332c63e440
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332c63e440 | ||
|
|
b306c210c6 | ||
|
|
b9015e1c2e | ||
|
|
26c5008136 | ||
|
|
74d6b90d69 | ||
|
|
7b11c62f53 | ||
|
|
8eed54b3da | ||
|
|
4be16dcdf5 | ||
|
|
ded9cabdf4 |
@@ -56,6 +56,28 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: go build ./...
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: FreeBSD build (amd64)
|
||||||
|
# The debugger's ptrace surface and the JIT substrate are the two
|
||||||
|
# FreeBSD-portable layers the tree carries; the forge has no FreeBSD
|
||||||
|
# runner, so a push can only compile-gate them. Running the ptrace
|
||||||
|
# suite needs real FreeBSD hardware.
|
||||||
|
env:
|
||||||
|
GOOS: freebsd
|
||||||
|
GOARCH: amd64
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: FreeBSD build (arm64)
|
||||||
|
env:
|
||||||
|
GOOS: freebsd
|
||||||
|
GOARCH: arm64
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: FreeBSD build (riscv64)
|
||||||
|
env:
|
||||||
|
GOOS: freebsd
|
||||||
|
GOARCH: riscv64
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: |
|
run: |
|
||||||
perl -e '
|
perl -e '
|
||||||
|
|||||||
+50
-1
@@ -9,7 +9,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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
|
||||||
|
reach files that were never opened. An open buffer always shadows its
|
||||||
|
disk copy, and watched-file events together with a per-query freshness
|
||||||
|
check keep the index current.
|
||||||
|
- **Quick fixes for the textflag include and the argument area.** The
|
||||||
|
`missing-textflag-include` warning offers to add the include after the
|
||||||
|
last one in the file, and the `abi-argsize` warning offers to set the
|
||||||
|
TEXT argument area to the size the `// func` signature implies, computed
|
||||||
|
by the new `lint.ExpectedArgSize`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The corpus audit assembles like the build.** A file's `//go:build`
|
||||||
|
constraint decides which target architectures attempt it: cpu_x86.s is
|
||||||
|
an x86 build alone, and the msan and goexperiment.runtimesecret trees
|
||||||
|
are compiled by no supported build, so they leave the measured set
|
||||||
|
instead of failing it. The headline now reads "assemble for every
|
||||||
|
applicable target": every real-code GOROOT assembly file, the tree
|
||||||
|
without testdata, assembles for all four architectures (250 of 250,
|
||||||
|
100 %); over the whole tree including testdata the measure is 271 of
|
||||||
|
322 (84.2 %).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Rename edits land in their own documents.** A rename collected the
|
||||||
|
ranges of every reference across the open documents but applied them all
|
||||||
|
to the document that started it, so renaming a symbol used in a second
|
||||||
|
file moved that file's text into the first. Each edit now applies to the
|
||||||
|
document it was collected in.
|
||||||
|
- **Negative numeric PC-relative jumps.** `JMP -3(PC)`, the shape the
|
||||||
|
runtime's exit loops write (sys_linux_amd64.s, sys_netbsd_amd64.s),
|
||||||
|
resolved to nothing: only the forward forms counted. A negative count
|
||||||
|
now walks the same instruction statements backwards, labels excluded,
|
||||||
|
byte-identical with the toolchain.
|
||||||
|
- **The arm64 move-wide family reads its immediate as an unsigned
|
||||||
|
pattern.** `MOVK $(40000<<48)` folds to a negative int64 and was
|
||||||
|
rejected; the toolchain picks the 16-bit lane from the 64-bit bit
|
||||||
|
pattern, so the encoder now does the same, and a zero immediate is
|
||||||
|
rejected where the toolchain rejects it.
|
||||||
|
|
||||||
## [0.35.0] - 2026-09-22
|
## [0.35.0] - 2026-09-22
|
||||||
|
|
||||||
|
|||||||
@@ -94,13 +94,16 @@ to give that syntax the tooling it deserves.
|
|||||||
- **Debugger.** `gasm debug` is a source-level ptrace debugger with
|
- **Debugger.** `gasm debug` is a source-level ptrace debugger with
|
||||||
breakpoints (optionally conditional), hardware watchpoints, register and
|
breakpoints (optionally conditional), hardware watchpoints, register and
|
||||||
memory inspection, and headless script runs that report instruction 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,
|
- **Language server.** `gasm lsp` serves completion, hover, document symbols,
|
||||||
push and pull diagnostics, semantic-token highlighting, go-to-definition,
|
push and pull diagnostics, semantic-token highlighting, go-to-definition,
|
||||||
find references, rename, formatting, inlay hints, code actions, signature
|
find references, rename, formatting, inlay hints, code actions, signature
|
||||||
help, document highlights, workspace symbol search, #include document
|
help, document highlights, workspace symbol search, #include document
|
||||||
links and folding ranges over stdio; definition, references and rename
|
links and folding ranges over stdio; definition, references and rename
|
||||||
work across every open document.
|
work across every open document and the indexed workspace files beyond
|
||||||
|
them, and the quick fixes add a missing textflag.h include and set the
|
||||||
|
argument area from the // func signature.
|
||||||
- **Comparators and audits.** `gasm diff` compares the machine code of two
|
- **Comparators and audits.** `gasm diff` compares the machine code of two
|
||||||
assembly files byte-for-byte, `gasm profile` shows basic-block structure,
|
assembly files byte-for-byte, `gasm profile` shows basic-block structure,
|
||||||
`gasm audit-instructions` diffs the encoder against the installed toolchain,
|
`gasm audit-instructions` diffs the encoder against the installed toolchain,
|
||||||
@@ -127,10 +130,13 @@ can emit today is narrower, and a recognised but unencodable instruction is
|
|||||||
reported as an explicit error, never as a wrong byte.
|
reported as an explicit error, never as a wrong byte.
|
||||||
|
|
||||||
The same measurement runs over GOROOT's whole assembly corpus:
|
The same measurement runs over GOROOT's whole assembly corpus:
|
||||||
`gasm audit-instructions --corpus` reports 291 of 353 attemptable files
|
`gasm audit-instructions --corpus` reports every real-code GOROOT assembly
|
||||||
(82.4 %) assembling for every target architecture today (files named for
|
file (the tree without testdata) assembling for every target its build
|
||||||
other Go ports are counted but never attempted), with the top failure
|
admits: 250 of 250, 100 %. Over the whole tree including testdata the
|
||||||
reasons per architecture; the number moves with every release.
|
measure is 271 of 322 attemptable (84.2 %); files named for other Go ports
|
||||||
|
are counted but never attempted, and `//go:build` constraints decide which
|
||||||
|
targets attempt a file at all, exactly as the build does. The number moves
|
||||||
|
with every release.
|
||||||
|
|
||||||
### Validation status
|
### Validation status
|
||||||
|
|
||||||
@@ -145,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) |
|
| 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 |
|
| 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
|
Consequences, stated plainly. An emulator is a model of a CPU, not the
|
||||||
CPU: instruction semantics are implemented in software and can differ
|
CPU: instruction semantics are implemented in software and can differ
|
||||||
@@ -215,9 +221,11 @@ The plan, in the order it is being worked:
|
|||||||
toolchain itself does not support; through ELF, Plan 9 assembly becomes
|
toolchain itself does not support; through ELF, Plan 9 assembly becomes
|
||||||
usable outside Go entirely.
|
usable outside Go entirely.
|
||||||
- **Platforms: Linux and FreeBSD.** Linux is supported today on all four
|
- **Platforms: Linux and FreeBSD.** Linux is supported today on all four
|
||||||
architectures and is where the binary builds. FreeBSD follows: the
|
architectures and is where the binary builds. FreeBSD follows on amd64,
|
||||||
JIT's executable-memory mapping and the ptrace debugger layer are the
|
arm64 and riscv64: the JIT's executable-memory mapping and the ptrace
|
||||||
two pieces of porting work. Other unix systems may follow those two.
|
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.
|
- **Four architectures, no more.** amd64, arm64, riscv64 and loong64.
|
||||||
No others are planned.
|
No others are planned.
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -3069,29 +3069,29 @@ func encodeARM64MoveWide(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte
|
|||||||
// base, so MOVZ and MOVN come along for free.
|
// base, so MOVZ and MOVN come along for free.
|
||||||
opc := baseOp >> 29 & 3
|
opc := baseOp >> 29 & 3
|
||||||
sf := baseOp >> 31 & 1
|
sf := baseOp >> 31 & 1
|
||||||
v := arm64Imm64(ops[0])
|
// The toolchain's optab case 33, shared by the whole family in both
|
||||||
if v < 0 {
|
// widths: the immediate is one unsigned 64-bit pattern (a high-lane
|
||||||
return nil, fmt.Errorf("%s: negative immediate %d", mnem, v)
|
// constant such as $(40000<<48) arrives negative through int64
|
||||||
|
// folding), it must occupy exactly one 16-bit lane, zero is rejected,
|
||||||
|
// and the W forms cannot reach the top half.
|
||||||
|
u := uint64(arm64Imm64(ops[0]))
|
||||||
|
if u == 0 {
|
||||||
|
return nil, fmt.Errorf("%s: zero immediate cannot be handled", mnem)
|
||||||
}
|
}
|
||||||
hw := -1
|
hw := -1
|
||||||
for i := range 4 {
|
for lane := range 4 {
|
||||||
if v>>(uint(i)*16)&0xFFFF != 0 {
|
if u&^(uint64(0xFFFF)<<(lane*16)) == 0 {
|
||||||
hw = i
|
hw = lane
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hw < 0 {
|
if hw < 0 {
|
||||||
hw = 0 // zero: every chunk is zero, hw = 0 carries it
|
return nil, fmt.Errorf("%s: immediate %#x does not fit one 16-bit chunk", mnem, u)
|
||||||
}
|
|
||||||
for i := hw + 1; i < 4; i++ {
|
|
||||||
if v>>(uint(i)*16)&0xFFFF != 0 {
|
|
||||||
return nil, fmt.Errorf("%s: immediate %d does not fit one 16-bit chunk", mnem, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if sf == 0 && hw > 1 {
|
if sf == 0 && hw > 1 {
|
||||||
return nil, fmt.Errorf("%s: immediate %d out of range for the 32-bit form", mnem, v)
|
return nil, fmt.Errorf("%s: immediate %#x out of range for the 32-bit form", mnem, u)
|
||||||
}
|
}
|
||||||
return a64wordLE(a64MoveWide(sf, opc, uint32(hw), uint32(v>>uint(hw*16)&0xFFFF), uint32(rd))), nil
|
return a64wordLE(a64MoveWide(sf, opc, uint32(hw), uint32(u>>uint(hw*16)&0xFFFF), uint32(rd))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Bitfield/EXTR encoding ----
|
// ---- Bitfield/EXTR encoding ----
|
||||||
|
|||||||
@@ -1051,6 +1051,41 @@ func TestArm64MOVK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArm64MOVKHighLane pins the shifted high-lane immediate the arm64 test
|
||||||
|
// kernels write: $(40000<<48) folds to a negative int64, and the toolchain
|
||||||
|
// reads the value as an unsigned 64-bit pattern when it picks the lane.
|
||||||
|
func TestArm64MOVKHighLane(t *testing.T) {
|
||||||
|
got := arm64Words(t, "\tMOVK $(40000<<48), R0\n\tMOVK $0x9c40000000000000, R1\n")
|
||||||
|
want := []uint32{
|
||||||
|
0xf2f38800, // MOVK $(40000<<48), R0 (go tool asm: f2f38800)
|
||||||
|
0xf2f38801, // MOVK hw=3
|
||||||
|
0xd65f03c0,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("word count = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("word %d = %08x, want %08x", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArm64MoveWideZeroImmediate pins the toolchain's rejection of a zero
|
||||||
|
// immediate in the move-wide family (optab case 33: "zero shifts cannot be
|
||||||
|
// handled"): every lane is zero, so no hw field can carry it.
|
||||||
|
func TestArm64MoveWideZeroImmediate(t *testing.T) {
|
||||||
|
for _, mnem := range []string{"MOVK", "MOVZ", "MOVN"} {
|
||||||
|
f, errs := parser.Parse("test_arm64.s", "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t"+mnem+" $0, R0\n\tRET\n")
|
||||||
|
if len(errs) > 0 {
|
||||||
|
t.Fatalf("%s: parse: %v", mnem, errs)
|
||||||
|
}
|
||||||
|
if _, err := AssembleFileARM64(f); err == nil {
|
||||||
|
t.Errorf("%s $0: expected error, got nil", mnem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestArm64LoadImm64 tests 64-bit immediate loading.
|
// TestArm64LoadImm64 tests 64-bit immediate loading.
|
||||||
func TestArm64LoadImm64(t *testing.T) {
|
func TestArm64LoadImm64(t *testing.T) {
|
||||||
src := `#include "textflag.h"
|
src := `#include "textflag.h"
|
||||||
|
|||||||
+17
-1
@@ -543,11 +543,27 @@ func pcJumpOffset(op *ast.Operand) (int, bool) {
|
|||||||
// pcJumpTarget resolves a numeric jump at statement index j: N counts the
|
// pcJumpTarget resolves a numeric jump at statement index j: N counts the
|
||||||
// instruction statements after the jump itself (N = 0 is the jump's own
|
// instruction statements after the jump itself (N = 0 is the jump's own
|
||||||
// address, the classic park loop), and the target is the start of the Nth
|
// address, the classic park loop), and the target is the start of the Nth
|
||||||
// one. It reports false when the count runs past the end of the function.
|
// one. A negative N counts the same way backwards, before the jump: the
|
||||||
|
// exit loops write JMP -3(PC) to land three instructions earlier. Labels
|
||||||
|
// count not, in either direction. It reports false when the count runs
|
||||||
|
// past the end of the function, or before its first instruction.
|
||||||
func pcJumpTarget(t *ast.Text, j, n int, pcs []int) (int, bool) {
|
func pcJumpTarget(t *ast.Text, j, n int, pcs []int) (int, bool) {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return pcs[j], true
|
return pcs[j], true
|
||||||
}
|
}
|
||||||
|
if n < 0 {
|
||||||
|
seen := 0
|
||||||
|
for k := j - 1; k >= 0; k-- {
|
||||||
|
if _, ok := t.Body[k].(*ast.Instr); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen--
|
||||||
|
if seen == n {
|
||||||
|
return pcs[k], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
seen := 0
|
seen := 0
|
||||||
for k := j + 1; k < len(t.Body); k++ {
|
for k := j + 1; k < len(t.Body); k++ {
|
||||||
if _, ok := t.Body[k].(*ast.Instr); !ok {
|
if _, ok := t.Body[k].(*ast.Instr); !ok {
|
||||||
|
|||||||
@@ -370,6 +370,58 @@ end:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAssembleNumericPCJumps pins the numeric ±N(PC) branch operands: N
|
||||||
|
// counts instruction statements, skipping labels, in both directions (the
|
||||||
|
// runtime's exit loops write JMP -3(PC)), N = 0 parks on the jump itself.
|
||||||
|
func TestAssembleNumericPCJumps(t *testing.T) {
|
||||||
|
fn := firstText(t, `
|
||||||
|
#include "textflag.h"
|
||||||
|
TEXT ·exit(SB), NOSPLIT, $0
|
||||||
|
MOVB $1, AL
|
||||||
|
lab:
|
||||||
|
MOVB $2, AL
|
||||||
|
MOVB $3, AL
|
||||||
|
JMP -3(PC)
|
||||||
|
MOVB $4, AL
|
||||||
|
park:
|
||||||
|
JMP 0(PC)
|
||||||
|
MOVB $5, AL
|
||||||
|
JMP 2(PC)
|
||||||
|
MOVB $6, AL
|
||||||
|
RET
|
||||||
|
`)
|
||||||
|
code, _, err := Assemble(fn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assemble: %v", err)
|
||||||
|
}
|
||||||
|
// From the Go-assembled function:
|
||||||
|
// MOVB $1, AL b001
|
||||||
|
// MOVB $2, AL b002
|
||||||
|
// MOVB $3, AL b003
|
||||||
|
// JMP -3(PC) ebf8 (three instructions back, past lab:)
|
||||||
|
// MOVB $4, AL b004
|
||||||
|
// JMP 0(PC) ebfe (the park loop)
|
||||||
|
// MOVB $5, AL b005
|
||||||
|
// JMP 2(PC) eb02 (over MOVB $6 to the RET)
|
||||||
|
// MOVB $6, AL b006
|
||||||
|
// RET c3
|
||||||
|
want := []byte{
|
||||||
|
0xb0, 0x01,
|
||||||
|
0xb0, 0x02,
|
||||||
|
0xb0, 0x03,
|
||||||
|
0xeb, 0xf8,
|
||||||
|
0xb0, 0x04,
|
||||||
|
0xeb, 0xfe,
|
||||||
|
0xb0, 0x05,
|
||||||
|
0xeb, 0x02,
|
||||||
|
0xb0, 0x06,
|
||||||
|
0xc3,
|
||||||
|
}
|
||||||
|
if hexBytes(code) != hexBytes(want) {
|
||||||
|
t.Errorf("numeric-PC mismatch:\n got: %s\n want: %s", hexBytes(code), hexBytes(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssemblePrefetch(t *testing.T) {
|
func TestAssemblePrefetch(t *testing.T) {
|
||||||
fn := firstText(t, `
|
fn := firstText(t, `
|
||||||
#include "textflag.h"
|
#include "textflag.h"
|
||||||
|
|||||||
@@ -393,6 +393,52 @@ func TestRunCorpusAuditGOOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunCorpusAuditBuildConstraint covers the //go:build classification end
|
||||||
|
// to end: a generic-named file whose constraint admits one target is
|
||||||
|
// attempted there alone (cpu_x86.s on amd64), and a file whose constraint
|
||||||
|
// admits none of the four targets is never attempted (the msan and
|
||||||
|
// goexperiment trees).
|
||||||
|
func TestRunCorpusAuditBuildConstraint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, src string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("x86.s", "//go:build 386 || amd64\n\nTEXT \xc2\xb7f(SB), NOSPLIT, $0\n\tRET\n")
|
||||||
|
write("racey.s", "//go:build race\n\nTEXT \xc2\xb7r(SB), NOSPLIT, $0\n\tRET\n")
|
||||||
|
write("plain.s", "TEXT \xc2\xb7p(SB), NOSPLIT, $0\n\tRET\n")
|
||||||
|
|
||||||
|
stats, err := runCorpusAudit(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runCorpusAudit: %v", err)
|
||||||
|
}
|
||||||
|
tally := func(name string) *corpusTally {
|
||||||
|
for i, tg := range stats.targets {
|
||||||
|
if tg.name == name {
|
||||||
|
return stats.tallies[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no tally for %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if stats.narrowed != 1 || stats.excluded != 1 || stats.generic != 1 {
|
||||||
|
t.Errorf("buckets = narrowed %d, excluded %d, generic %d; want 1, 1, 1", stats.narrowed, stats.excluded, stats.generic)
|
||||||
|
}
|
||||||
|
if a := tally("amd64"); a.attempted != 2 || a.assembled != 2 {
|
||||||
|
t.Errorf("amd64 = %d/%d, want 2/2 (x86.s and plain.s)", a.assembled, a.attempted)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"arm64", "riscv64", "loong64"} {
|
||||||
|
if a := tally(name); a.attempted != 1 || a.assembled != 1 {
|
||||||
|
t.Errorf("%s = %d/%d, want 1/1 (plain.s only)", name, a.assembled, a.attempted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stats.full != 2 {
|
||||||
|
t.Errorf("full = %d, want 2 (x86.s over its one target, plain.s over all four)", stats.full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGenerateGoAsmHeaderRuntime pins the generator against the real thing:
|
// TestGenerateGoAsmHeaderRuntime pins the generator against the real thing:
|
||||||
// the runtime package of the ambient toolchain, whose header the toolchain's
|
// the runtime package of the ambient toolchain, whose header the toolchain's
|
||||||
// own -asmhdr output was sampled from. Skipped in short mode: it type-checks
|
// own -asmhdr output was sampled from. Skipped in short mode: it type-checks
|
||||||
|
|||||||
+122
-14
@@ -5,6 +5,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go/build/constraint"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -476,8 +477,10 @@ type corpusStats struct {
|
|||||||
root string
|
root string
|
||||||
files int
|
files int
|
||||||
generic int // files attempted for all four architectures
|
generic int // files attempted for all four architectures
|
||||||
|
narrowed int // files whose //go:build admits a proper subset of the four
|
||||||
|
excluded int // files whose //go:build admits none of the four: never compiled
|
||||||
otherPort int // files named for another Go port: never attempted
|
otherPort int // files named for another Go port: never attempted
|
||||||
full int // files that assembled for every target architecture
|
full int // files that assembled for every applicable target architecture
|
||||||
targets []corpusTarget
|
targets []corpusTarget
|
||||||
tallies []*corpusTally
|
tallies []*corpusTally
|
||||||
}
|
}
|
||||||
@@ -573,6 +576,57 @@ func otherGOOSFile(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildConstraint returns the file's leading //go:build expression, or nil
|
||||||
|
// when the file carries none. The constraint governs the same header block
|
||||||
|
// go/build reads: blank lines and comments may precede it, and the first
|
||||||
|
// line that is neither ends the block. A constraint that does not parse
|
||||||
|
// narrows nothing, so the file stays in the attempted set: the audit must
|
||||||
|
// never exclude a file the toolchain would compile.
|
||||||
|
func buildConstraint(src string) constraint.Expr {
|
||||||
|
for line := range strings.SplitSeq(src, "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case t == "":
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(t, "//"):
|
||||||
|
if constraint.IsGoBuild(t) {
|
||||||
|
e, err := constraint.Parse(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unixOS is go/build's unixOS set: the GOOSes the unix build tag admits.
|
||||||
|
var unixOS = map[string]bool{
|
||||||
|
"aix": true, "android": true, "darwin": true, "dragonfly": true,
|
||||||
|
"freebsd": true, "hurd": true, "illumos": true, "ios": true,
|
||||||
|
"linux": true, "netbsd": true, "openbsd": true, "solaris": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// constraintTags answers the build tags a plain `go build` sets for a
|
||||||
|
// target: the GOOS and GOARCH, gc, and unix on the unix-like GOOSes. No
|
||||||
|
// experiment, sanitiser or cgo tag is ever true: the audit models the
|
||||||
|
// default build, and no GOROOT assembly file's constraint hinges on cgo.
|
||||||
|
func constraintTags(goarch, goos string) func(string) bool {
|
||||||
|
return func(tag string) bool {
|
||||||
|
switch tag {
|
||||||
|
case goarch, goos, "gc":
|
||||||
|
return true
|
||||||
|
case "unix":
|
||||||
|
return unixOS[goos]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||||
files, err := asmFiles(root)
|
files, err := asmFiles(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -590,8 +644,8 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
|||||||
tallies[i] = &corpusTally{reasons: map[string]int{}, example: map[string]string{}}
|
tallies[i] = &corpusTally{reasons: map[string]int{}, example: map[string]string{}}
|
||||||
}
|
}
|
||||||
// full is the north-star number: a file counts when every architecture
|
// full is the north-star number: a file counts when every architecture
|
||||||
// its name allows assembles it.
|
// its build admits assembles it.
|
||||||
full, generic, otherPort := 0, 0, 0
|
full, generic, otherPort, narrowedCount, excluded := 0, 0, 0, 0, 0
|
||||||
|
|
||||||
// Header generation is created on first use, so a corpus with no
|
// Header generation is created on first use, so a corpus with no
|
||||||
// go_asm.h includes never pays for a temp directory.
|
// go_asm.h includes never pays for a temp directory.
|
||||||
@@ -614,28 +668,78 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
|||||||
// invisible to a file-name rule).
|
// invisible to a file-name rule).
|
||||||
goos := goosFromFilename(path)
|
goos := goosFromFilename(path)
|
||||||
|
|
||||||
|
// The GOOS the header generation type-checks under follows the
|
||||||
|
// file's name when the name carries one; the ambient GOOS is the
|
||||||
|
// honest guess otherwise.
|
||||||
|
namedArch := arch.FromFilename(path)
|
||||||
var wanted []int // indexes into targets
|
var wanted []int // indexes into targets
|
||||||
if a := arch.FromFilename(path); a != arch.Unknown {
|
other := false
|
||||||
|
switch {
|
||||||
|
case namedArch != arch.Unknown:
|
||||||
for i, tg := range targets {
|
for i, tg := range targets {
|
||||||
if tg.a == a {
|
if tg.a == namedArch {
|
||||||
wanted = append(wanted, i)
|
wanted = append(wanted, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if otherPortFile(path) {
|
case otherPortFile(path):
|
||||||
// A file named for a Go port gasm does not support (arm,
|
// A file named for a Go port gasm does not support (arm,
|
||||||
// 386, s390x, ...) or for another GOOS is compiled by no
|
// 386, s390x, ...) or for another GOOS is compiled by no
|
||||||
// supported-arch build, so it is neither generic nor a
|
// supported-arch build, so it is neither generic nor a
|
||||||
// per-arch attempt: counting it as generic would make the
|
// per-arch attempt: counting it as generic would make the
|
||||||
// headline unreachably low for reasons no supported target
|
// headline unreachably low for reasons no supported target
|
||||||
// can fix.
|
// can fix.
|
||||||
|
other = true
|
||||||
otherPort++
|
otherPort++
|
||||||
} else {
|
default:
|
||||||
generic++
|
|
||||||
for i := range targets {
|
for i := range targets {
|
||||||
wanted = append(wanted, i)
|
wanted = append(wanted, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A //go:build constraint narrows the set of targets the file is
|
||||||
|
// assembled for, the way the go command compiles the file only for
|
||||||
|
// the targets the expression admits: cpu_x86.s belongs to the x86
|
||||||
|
// build alone, and a file whose constraint admits none of the four
|
||||||
|
// targets (the goexperiment.runtimesecret and msan trees) is
|
||||||
|
// compiled by no supported build. The tags mirror what a plain
|
||||||
|
// `go build` sets: the GOOS and GOARCH, gc, and unix on the
|
||||||
|
// unix-like GOOSes; no experiment, sanitiser or cgo tag is ever
|
||||||
|
// true. The GOOS is the file's own when the name carries one,
|
||||||
|
// else the ambient one.
|
||||||
|
goosForEval := goos
|
||||||
|
if goosForEval == "" {
|
||||||
|
goosForEval = runtime.GOOS
|
||||||
|
}
|
||||||
|
narrowed := false
|
||||||
|
if len(wanted) > 0 {
|
||||||
|
if ce := buildConstraint(src); ce != nil {
|
||||||
|
kept := make([]int, 0, len(wanted))
|
||||||
|
for _, i := range wanted {
|
||||||
|
tg := targets[i]
|
||||||
|
if ce.Eval(constraintTags(goarchName(tg.a), goosForEval)) {
|
||||||
|
kept = append(kept, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) < len(wanted) {
|
||||||
|
narrowed = true
|
||||||
|
}
|
||||||
|
wanted = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case other:
|
||||||
|
// already tallied above
|
||||||
|
case len(wanted) == 0:
|
||||||
|
excluded++
|
||||||
|
case namedArch != arch.Unknown:
|
||||||
|
// a per-arch attempt over the constraint's subset
|
||||||
|
case narrowed:
|
||||||
|
narrowedCount++
|
||||||
|
default:
|
||||||
|
generic++
|
||||||
|
}
|
||||||
|
|
||||||
// A file that includes go_asm.h parses against a per-target header:
|
// A file that includes go_asm.h parses against a per-target header:
|
||||||
// the defines differ per architecture (internal/cpu's layout, for
|
// the defines differ per architecture (internal/cpu's layout, for
|
||||||
// one) and per GOOS (sys_darwin_arm64.s's trampoline constants,
|
// one) and per GOOS (sys_darwin_arm64.s's trampoline constants,
|
||||||
@@ -719,6 +823,8 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
|||||||
root: root,
|
root: root,
|
||||||
files: len(files),
|
files: len(files),
|
||||||
generic: generic,
|
generic: generic,
|
||||||
|
narrowed: narrowedCount,
|
||||||
|
excluded: excluded,
|
||||||
otherPort: otherPort,
|
otherPort: otherPort,
|
||||||
full: full,
|
full: full,
|
||||||
targets: targets,
|
targets: targets,
|
||||||
@@ -728,13 +834,15 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
|||||||
|
|
||||||
// printCorpusStats renders the corpus audit report.
|
// printCorpusStats renders the corpus audit report.
|
||||||
func printCorpusStats(s *corpusStats, list bool) {
|
func printCorpusStats(s *corpusStats, list bool) {
|
||||||
fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures; %d named for other Go ports, never attempted)\n", s.root, s.files, s.generic, s.otherPort)
|
fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures; %d narrowed by //go:build; %d excluded by //go:build; %d named for other Go ports, never attempted)\n",
|
||||||
|
s.root, s.files, s.generic, s.narrowed, s.excluded, s.otherPort)
|
||||||
// The rate is over the files a supported build would attempt: the
|
// The rate is over the files a supported build would attempt: the
|
||||||
// other ports' files sit in the count for completeness but can never
|
// other ports' files and the ones no supported target compiles sit in
|
||||||
// assemble, so counting them in the denominator would report the gap
|
// the count for completeness but can never assemble, so counting them
|
||||||
// of architectures gasm deliberately does not target.
|
// in the denominator would report the gap of platforms gasm
|
||||||
attemptable := max(s.files-s.otherPort, 1)
|
// deliberately does not target.
|
||||||
fmt.Printf(" assemble for every target architecture: %d of %d attemptable (%.1f%%)\n", s.full, attemptable, 100*float64(s.full)/float64(attemptable))
|
attemptable := max(s.files-s.otherPort-s.excluded, 1)
|
||||||
|
fmt.Printf(" assemble for every applicable target: %d of %d attemptable (%.1f%%)\n", s.full, attemptable, 100*float64(s.full)/float64(attemptable))
|
||||||
for i, tg := range s.targets {
|
for i, tg := range s.targets {
|
||||||
t := s.tallies[i]
|
t := s.tallies[i]
|
||||||
fmt.Printf(" %s: %d/%d attempted\n", tg.name, t.assembled, t.attempted)
|
fmt.Printf(" %s: %d/%d attempted\n", tg.name, t.assembled, t.attempted)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
|
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
|
||||||
@@ -64,3 +65,62 @@ func TestGasmEncodable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildConstraint pins the //go:build reader: the constraint governs the
|
||||||
|
// leading comment block, the first non-comment line ends it (a tag below a
|
||||||
|
// #include governs nothing, exactly as go/build drops it), and a file
|
||||||
|
// without one admits every target.
|
||||||
|
func TestBuildConstraint(t *testing.T) {
|
||||||
|
admits := func(src, goarch, goos string) bool {
|
||||||
|
t.Helper()
|
||||||
|
e := buildConstraint(src)
|
||||||
|
if e == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return e.Eval(constraintTags(goarch, goos))
|
||||||
|
}
|
||||||
|
const ret = "TEXT \xc2\xb7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
src string
|
||||||
|
amd64, arm64 bool
|
||||||
|
}{
|
||||||
|
{"no constraint", ret, true, true},
|
||||||
|
{"x86 only", "//go:build 386 || amd64\n\n" + ret, true, false},
|
||||||
|
{"arm64 and linux", "//go:build arm64 && linux\n\n" + ret, false, true},
|
||||||
|
{"msan never", "//go:build msan\n\n" + ret, false, false},
|
||||||
|
{"experiment never", "//go:build goexperiment.runtimesecret\n\n" + ret, false, false},
|
||||||
|
{"below an include governs nothing", "#include \"textflag.h\"\n//go:build amd64\n" + ret, true, true},
|
||||||
|
{"unparsable narrows nothing", "//go:build (amd64\n" + ret, true, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := admits(c.src, "amd64", runtime.GOOS); got != c.amd64 {
|
||||||
|
t.Errorf("amd64 admission = %v, want %v", got, c.amd64)
|
||||||
|
}
|
||||||
|
if got := admits(c.src, "arm64", runtime.GOOS); got != c.arm64 {
|
||||||
|
t.Errorf("arm64 admission = %v, want %v", got, c.arm64)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConstraintTags pins the tag set a plain `go build` sets: the GOOS and
|
||||||
|
// GOARCH, gc, unix on the unix-like GOOSes; nothing else is ever true.
|
||||||
|
func TestConstraintTags(t *testing.T) {
|
||||||
|
ok := constraintTags("amd64", "linux")
|
||||||
|
for _, tag := range []string{"amd64", "linux", "gc", "unix"} {
|
||||||
|
if !ok(tag) {
|
||||||
|
t.Errorf("tag %q = false, want true", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range []string{"arm64", "freebsd", "darwin", "cgo", "race", "msan", "goexperiment.runtimesecret"} {
|
||||||
|
if ok(tag) {
|
||||||
|
t.Errorf("tag %q = true, want false", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fb := constraintTags("arm64", "freebsd")
|
||||||
|
if !fb("unix") {
|
||||||
|
t.Error("unix on freebsd = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build !linux
|
//go:build !(linux || (freebsd && (amd64 || arm64 || riscv64)))
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -11,6 +11,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func cmdDebug(args []string) int {
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux
|
//go:build linux || (freebsd && (amd64 || arm64 || riscv64))
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux
|
//go:build linux || (freebsd && (amd64 || arm64 || riscv64))
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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: <error: %v>\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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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: <error: %v>\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
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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: <error: %v>\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
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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" }
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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" }
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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" }
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux
|
//go:build linux || (freebsd && (amd64 || arm64 || riscv64))
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux
|
//go:build linux || (freebsd && (amd64 || arm64 || riscv64))
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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<<slot) != 0 && dr.Dr[slot] != 0 {
|
||||||
|
return StopWatchpoint, dr.Dr[slot]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StopSingleStep, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFreeWatchpointSlot returns the index of the first free watchpoint slot
|
||||||
|
// (0-3), or -1 if all four hardware watchpoints are in use.
|
||||||
|
func (s *Session) FindFreeWatchpointSlot() int {
|
||||||
|
for i := range 4 {
|
||||||
|
if !s.wpSlots[i] {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWatchpointSlotUsed reports whether slot (0-3) currently holds a watchpoint.
|
||||||
|
func (s *Session) IsWatchpointSlotUsed(slot int) bool {
|
||||||
|
if slot < 0 || slot > 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 }
|
||||||
+14
-5
@@ -246,11 +246,18 @@ document store, republishes diagnostics on every change, and provides:
|
|||||||
pseudo-registers, labels, immediates, comments, directives, textflag macros);
|
pseudo-registers, labels, immediates, comments, directives, textflag macros);
|
||||||
- **navigation**: go-to-definition from a label reference to its definition,
|
- **navigation**: go-to-definition from a label reference to its definition,
|
||||||
find references, document highlights of every use of the symbol under the
|
find references, document highlights of every use of the symbol under the
|
||||||
cursor, rename, and workspace symbol search over the open documents;
|
cursor, rename, and workspace symbol search over the open documents and
|
||||||
|
the indexed workspace files: the `.s` files under the workspace root that
|
||||||
|
the editor has never opened, where an open buffer shadows its disk copy
|
||||||
|
and watched-file events plus a per-query freshness check keep the index
|
||||||
|
current;
|
||||||
- **assists**: document formatting through the `format` package, inlay hints
|
- **assists**: document formatting through the `format` package, inlay hints
|
||||||
(the frame size after the TEXT argument area), signature help (the callee's
|
(the frame size after the TEXT argument area), signature help (the callee's
|
||||||
`// func` signature while the cursor is on a `CALL`), and code actions
|
`// func` signature while the cursor is on a `CALL`), and code actions
|
||||||
offering quick fixes for the `missing-ret` and `unused-label` diagnostics.
|
offering quick fixes for the `missing-ret` and `unused-label` diagnostics,
|
||||||
|
the `missing-textflag-include` warning (the include after the last one in
|
||||||
|
the file) and the `abi-argsize` warning (the argument area set to the size
|
||||||
|
the `// func` signature implies).
|
||||||
- **document information**: pull diagnostics (`textDocument/diagnostic`),
|
- **document information**: pull diagnostics (`textDocument/diagnostic`),
|
||||||
#include document links (resolved against the document directory, then
|
#include document links (resolved against the document directory, then
|
||||||
`$GOROOT/pkg/include`) and folding ranges (one collapsible region per
|
`$GOROOT/pkg/include`) and folding ranges (one collapsible region per
|
||||||
@@ -572,7 +579,9 @@ AST, so neither depends on an encoding.
|
|||||||
--ground-truth`, `go list -json -export` locates the archives of the packages
|
--ground-truth`, `go list -json -export` locates the archives of the packages
|
||||||
a GOOBJ object references, and `_gen` parses
|
a GOOBJ object references, and `_gen` parses
|
||||||
`$GOROOT/src/cmd/internal/obj/<arch>/anames.go` to rebuild the tables.
|
`$GOROOT/src/cmd/internal/obj/<arch>/anames.go` to rebuild the tables.
|
||||||
- **Linux process interfaces** for the dynamic work: `mmap` and `mprotect` for
|
- **Linux and FreeBSD process interfaces** for the dynamic work: `mmap` and
|
||||||
the JIT mapping, ptrace with `/proc/pid/mem` for the debugger. That is why
|
`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
|
`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.
|
||||||
|
|||||||
+9
-2
@@ -287,7 +287,8 @@ Usage: gasm debug <file.s> --func <name>
|
|||||||
The debugger re-executes the binary it is running as (`os.Executable()`) for the
|
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`
|
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
|
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:
|
REPL commands:
|
||||||
|
|
||||||
@@ -455,7 +456,13 @@ semantic tokens, go-to-definition, find references, rename, document
|
|||||||
formatting, inlay hints, code actions, signature help, document highlights,
|
formatting, inlay hints, code actions, signature help, document highlights,
|
||||||
workspace symbol search, #include document links, and folding ranges for
|
workspace symbol search, #include document links, and folding ranges for
|
||||||
function bodies. Definition, references and rename work across every open
|
function bodies. Definition, references and rename work across every open
|
||||||
document.
|
document and the wider workspace on disk: the server indexes the `.s` files
|
||||||
|
under the workspace root that the editor has never opened, an open buffer
|
||||||
|
always shadows its disk copy, and watched-file events together with a
|
||||||
|
per-query freshness check keep the index current. The quick fixes add the
|
||||||
|
missing `#include "textflag.h"`, set the TEXT argument area to the size the
|
||||||
|
`// func` signature implies, add a missing `RET`, and remove an unused
|
||||||
|
label.
|
||||||
|
|
||||||
## version
|
## version
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ module sourcedock.dev/petrbalvin/gasm-devkit
|
|||||||
|
|
||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require golang.org/x/arch v0.30.0
|
require (
|
||||||
|
golang.org/x/arch v0.30.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
|
||||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
|
||||||
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||||
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExpectedArgSize computes the argument-area size (parameters plus results,
|
||||||
|
// laid out with Go's alignment rules on a 64-bit target) implied by the
|
||||||
|
// `// func …` signature in a doc comment. It returns ok=false when there is
|
||||||
|
// no parseable signature or it uses a type whose size cannot be determined
|
||||||
|
// (a named type), so the caller can skip the fix rather than guess.
|
||||||
|
func ExpectedArgSize(doc string) (int64, bool) {
|
||||||
|
return abiExpectedArgSize(doc)
|
||||||
|
}
|
||||||
|
|
||||||
// abiExpectedArgSize computes the argument-area size (parameters plus results,
|
// abiExpectedArgSize computes the argument-area size (parameters plus results,
|
||||||
// laid out with Go's alignment rules on a 64-bit target) from the `// func …`
|
// laid out with Go's alignment rules on a 64-bit target) from the `// func …`
|
||||||
// signature in a TEXT function's doc comment. It returns ok=false when there
|
// signature in a TEXT function's doc comment. It returns ok=false when there
|
||||||
|
|||||||
+87
-33
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
@@ -127,8 +128,8 @@ func (s *Server) openASTs() []openAST {
|
|||||||
|
|
||||||
// definition returns the location of the named label or function: a local
|
// definition returns the location of the named label or function: a local
|
||||||
// label in the current document wins, then the TEXT functions of every open
|
// label in the current document wins, then the TEXT functions of every open
|
||||||
// document are searched, so a `CALL ·helper(SB)` jumps to its definition in
|
// document and indexed workspace file are searched, so a `CALL ·helper(SB)`
|
||||||
// another file.
|
// jumps to its definition even when the defining file was never opened.
|
||||||
func (s *Server) definition(p definitionParams) []Location {
|
func (s *Server) definition(p definitionParams) []Location {
|
||||||
text := s.docs[p.TextDocument.URI]
|
text := s.docs[p.TextDocument.URI]
|
||||||
word, _ := wordAt(text, p.Position)
|
word, _ := wordAt(text, p.Position)
|
||||||
@@ -156,15 +157,15 @@ func (s *Server) definition(p definitionParams) []Location {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function definitions across the open workspace.
|
// Function definitions across the workspace.
|
||||||
for _, of := range s.openASTs() {
|
for _, u := range s.scanUnits() {
|
||||||
for _, d := range of.file.Decls {
|
for _, d := range u.file.Decls {
|
||||||
t, ok := d.(*ast.Text)
|
t, ok := d.(*ast.Text)
|
||||||
if !ok || !realSymbol(t.Name) {
|
if !ok || !realSymbol(t.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t.Name.Name == name {
|
if t.Name.Name == name {
|
||||||
return []Location{{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}}
|
return []Location{{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,10 +173,11 @@ func (s *Server) definition(p definitionParams) []Location {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// references returns all locations where the symbol under the cursor appears
|
// references returns all locations where the symbol under the cursor appears
|
||||||
// across every open document. The current document matches labels and any
|
// across every open document and indexed workspace file. The current
|
||||||
// operand name, as before; other documents only match SB-qualified operand
|
// document matches labels and any operand name, as before; every other
|
||||||
// references and the definition itself, because a bare name is a
|
// source only matches SB-qualified operand references and the definition
|
||||||
// function-local label whose repeats in other files are unrelated.
|
// itself, because a bare name is a function-local label whose repeats in
|
||||||
|
// other files are unrelated.
|
||||||
func (s *Server) references(p referenceParams) []Location {
|
func (s *Server) references(p referenceParams) []Location {
|
||||||
text := s.docs[p.TextDocument.URI]
|
text := s.docs[p.TextDocument.URI]
|
||||||
word, _ := wordAt(text, p.Position)
|
word, _ := wordAt(text, p.Position)
|
||||||
@@ -186,22 +188,22 @@ func (s *Server) references(p referenceParams) []Location {
|
|||||||
uri := p.TextDocument.URI
|
uri := p.TextDocument.URI
|
||||||
|
|
||||||
var out []Location
|
var out []Location
|
||||||
for _, of := range s.openASTs() {
|
for _, u := range s.scanUnits() {
|
||||||
sameDoc := of.uri == uri
|
sameDoc := u.uri == uri
|
||||||
for _, d := range of.file.Decls {
|
for _, d := range u.file.Decls {
|
||||||
t, ok := d.(*ast.Text)
|
t, ok := d.(*ast.Text)
|
||||||
if !ok || !realSymbol(t.Name) {
|
if !ok || !realSymbol(t.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Include the definition if requested.
|
// Include the definition if requested.
|
||||||
if p.Context.IncludeDeclaration && t.Name.Name == name {
|
if p.Context.IncludeDeclaration && t.Name.Name == name {
|
||||||
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))})
|
out = append(out, Location{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))})
|
||||||
}
|
}
|
||||||
for _, stmt := range t.Body {
|
for _, stmt := range t.Body {
|
||||||
switch st := stmt.(type) {
|
switch st := stmt.(type) {
|
||||||
case *ast.Label:
|
case *ast.Label:
|
||||||
if sameDoc && st.Name.Text == name {
|
if sameDoc && st.Name.Text == name {
|
||||||
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], tokenRange(st.Name))})
|
out = append(out, Location{URI: u.uri, Range: clientRange(u.text, tokenRange(st.Name))})
|
||||||
}
|
}
|
||||||
case *ast.Instr:
|
case *ast.Instr:
|
||||||
for _, op := range st.Operands {
|
for _, op := range st.Operands {
|
||||||
@@ -215,8 +217,8 @@ func (s *Server) references(p referenceParams) []Location {
|
|||||||
// identifier, `·`/package prefix included, so a
|
// identifier, `·`/package prefix included, so a
|
||||||
// rename replaces the whole spelling.
|
// rename replaces the whole spelling.
|
||||||
out = append(out, Location{
|
out = append(out, Location{
|
||||||
URI: of.uri,
|
URI: u.uri,
|
||||||
Range: clientRange(s.docs[of.uri], Range{
|
Range: clientRange(u.text, Range{
|
||||||
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1},
|
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1},
|
||||||
End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)},
|
End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)},
|
||||||
}),
|
}),
|
||||||
@@ -229,7 +231,9 @@ func (s *Server) references(p referenceParams) []Location {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename renames a label across the document.
|
// rename renames a label across every document the references reach. Each
|
||||||
|
// edit is grouped under the URI it belongs to: a range collected in another
|
||||||
|
// file must never be applied to the document that started the rename.
|
||||||
func (s *Server) rename(p renameParams) *WorkspaceEdit {
|
func (s *Server) rename(p renameParams) *WorkspaceEdit {
|
||||||
text := s.docs[p.TextDocument.URI]
|
text := s.docs[p.TextDocument.URI]
|
||||||
word, _ := wordAt(text, p.Position)
|
word, _ := wordAt(text, p.Position)
|
||||||
@@ -244,13 +248,11 @@ func (s *Server) rename(p renameParams) *WorkspaceEdit {
|
|||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
edits := make([]TextEdit, 0, len(refs))
|
edits := make(map[string][]TextEdit, len(refs))
|
||||||
for _, loc := range refs {
|
for _, loc := range refs {
|
||||||
edits = append(edits, TextEdit{Range: loc.Range, NewText: p.NewName})
|
edits[loc.URI] = append(edits[loc.URI], TextEdit{Range: loc.Range, NewText: p.NewName})
|
||||||
}
|
|
||||||
return &WorkspaceEdit{
|
|
||||||
Changes: map[string][]TextEdit{p.TextDocument.URI: edits},
|
|
||||||
}
|
}
|
||||||
|
return &WorkspaceEdit{Changes: edits}
|
||||||
}
|
}
|
||||||
|
|
||||||
// documentFormatting returns a single edit that replaces the whole document
|
// documentFormatting returns a single edit that replaces the whole document
|
||||||
@@ -385,6 +387,61 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "missing-textflag-include":
|
||||||
|
// Offer to #include the header that defines the flag macros:
|
||||||
|
// after the last existing include, or at the top of the file
|
||||||
|
// when there is none.
|
||||||
|
insertLine := 0
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if includeRe.MatchString(line) {
|
||||||
|
insertLine = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions = append(actions, CodeAction{
|
||||||
|
Title: `Add #include "textflag.h"`,
|
||||||
|
Kind: "quickfix",
|
||||||
|
Edit: &WorkspaceEdit{
|
||||||
|
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
|
||||||
|
Range: Range{Start: Position{Line: insertLine, Character: 0}, End: Position{Line: insertLine, Character: 0}},
|
||||||
|
NewText: "#include \"textflag.h\"\n",
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "abi-argsize":
|
||||||
|
// Offer to set the TEXT argument area to the size the // func
|
||||||
|
// signature implies. The diagnostic's range covers the TEXT
|
||||||
|
// keyword, so a line match picks the function it belongs to.
|
||||||
|
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
|
||||||
|
if f == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
t, ok := d.(*ast.Text)
|
||||||
|
if !ok || t.Keyword.Pos.Line-1 != int(diag.Range.Start.Line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, ok := lint.ExpectedArgSize(t.Doc)
|
||||||
|
if !ok || t.Args == nil || !t.Args.Imm.HasVal || t.Args.Imm.Val == want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The parser records the argument area with its leading
|
||||||
|
// minus, so the replacement spans the whole `-16` shape.
|
||||||
|
rng := clientRange(text, Range{
|
||||||
|
Start: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1},
|
||||||
|
End: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1 + runeLen(t.Args.Raw)},
|
||||||
|
})
|
||||||
|
actions = append(actions, CodeAction{
|
||||||
|
Title: fmt.Sprintf("Set arg size to %d", want),
|
||||||
|
Kind: "quickfix",
|
||||||
|
Edit: &WorkspaceEdit{
|
||||||
|
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
|
||||||
|
Range: rng,
|
||||||
|
NewText: "-" + strconv.FormatInt(want, 10),
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return actions
|
return actions
|
||||||
@@ -510,19 +567,16 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// workspaceSymbols searches all open documents for symbols matching the query.
|
// workspaceSymbols searches all open documents and indexed workspace files
|
||||||
|
// for symbols matching the query.
|
||||||
func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
||||||
if p.Query == "" {
|
if p.Query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
query := strings.ToLower(p.Query)
|
query := strings.ToLower(p.Query)
|
||||||
var out []WorkspaceSymbol
|
var out []WorkspaceSymbol
|
||||||
for uri, text := range s.docs {
|
for _, u := range s.scanUnits() {
|
||||||
f, _ := parser.Parse(uriPath(uri), text)
|
for _, d := range u.file.Decls {
|
||||||
if f == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, d := range f.Decls {
|
|
||||||
switch dd := d.(type) {
|
switch dd := d.(type) {
|
||||||
case *ast.Text:
|
case *ast.Text:
|
||||||
// A malformed TEXT line is kept in the tree under a "?"
|
// A malformed TEXT line is kept in the tree under a "?"
|
||||||
@@ -535,7 +589,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
|||||||
out = append(out, WorkspaceSymbol{
|
out = append(out, WorkspaceSymbol{
|
||||||
Name: dd.Name.Name,
|
Name: dd.Name.Name,
|
||||||
Kind: symFunction,
|
Kind: symFunction,
|
||||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case *ast.Globl:
|
case *ast.Globl:
|
||||||
@@ -543,7 +597,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
|||||||
out = append(out, WorkspaceSymbol{
|
out = append(out, WorkspaceSymbol{
|
||||||
Name: dd.Name.Name,
|
Name: dd.Name.Name,
|
||||||
Kind: symConstant,
|
Kind: symConstant,
|
||||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case *ast.Data:
|
case *ast.Data:
|
||||||
@@ -551,7 +605,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
|||||||
out = append(out, WorkspaceSymbol{
|
out = append(out, WorkspaceSymbol{
|
||||||
Name: dd.Name.Name,
|
Name: dd.Name.Name,
|
||||||
Kind: symConstant,
|
Kind: symConstant,
|
||||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,56 @@ type ServerCapabilities struct {
|
|||||||
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
|
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
|
||||||
DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"`
|
DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"`
|
||||||
FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"`
|
FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"`
|
||||||
|
Workspace map[string]any `json:"workspace,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- workspace ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// initializeParams carries the workspace roots the client announces.
|
||||||
|
type initializeParams struct {
|
||||||
|
RootURI string `json:"rootUri"`
|
||||||
|
WorkspaceFolders []workspaceFolder `json:"workspaceFolders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceFolder is one root folder the client opened.
|
||||||
|
type workspaceFolder struct {
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// roots returns the workspace roots as filesystem paths: the workspace
|
||||||
|
// folders when the client sends them, else the root URI.
|
||||||
|
func (p initializeParams) roots() []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range p.WorkspaceFolders {
|
||||||
|
if path := uriPath(f.URI); path != "" {
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 && p.RootURI != "" {
|
||||||
|
if path := uriPath(p.RootURI); path != "" {
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// didChangeWatchedFilesParams announces on-disk changes the client watched.
|
||||||
|
type didChangeWatchedFilesParams struct {
|
||||||
|
Changes []fileEvent `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// File change types (LSP FileChangeType).
|
||||||
|
const (
|
||||||
|
fileCreated = 1
|
||||||
|
fileChanged = 2
|
||||||
|
fileDeleted = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileEvent is one created, changed or deleted file.
|
||||||
|
type fileEvent struct {
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Type int `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- pull diagnostics ---------------------------------------------------------
|
// --- pull diagnostics ---------------------------------------------------------
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Server struct {
|
|||||||
out io.Writer
|
out io.Writer
|
||||||
mu sync.Mutex // guards writes to out
|
mu sync.Mutex // guards writes to out
|
||||||
docs map[string]string
|
docs map[string]string
|
||||||
|
ws workspaceIndex // the on-disk workspace beyond the open documents
|
||||||
version string // reported in the initialize result ("" omits it)
|
version string // reported in the initialize result ("" omits it)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +170,11 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
|
|
||||||
switch msg.Method {
|
switch msg.Method {
|
||||||
case "initialize":
|
case "initialize":
|
||||||
|
var ip initializeParams
|
||||||
|
if len(msg.Params) > 0 {
|
||||||
|
json.Unmarshal(msg.Params, &ip)
|
||||||
|
}
|
||||||
|
s.ws.roots = ip.roots()
|
||||||
info := map[string]string{"name": "gasm"}
|
info := map[string]string{"name": "gasm"}
|
||||||
if s.version != "" {
|
if s.version != "" {
|
||||||
info["version"] = s.version
|
info["version"] = s.version
|
||||||
@@ -198,6 +204,7 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
|
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
|
||||||
DocumentLinkProvider: map[string]any{},
|
DocumentLinkProvider: map[string]any{},
|
||||||
FoldingRangeProvider: true,
|
FoldingRangeProvider: true,
|
||||||
|
Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}},
|
||||||
},
|
},
|
||||||
ServerInfo: info,
|
ServerInfo: info,
|
||||||
})
|
})
|
||||||
@@ -308,6 +315,14 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
json.Unmarshal(msg.Params, &p)
|
json.Unmarshal(msg.Params, &p)
|
||||||
s.respond(msg.ID, s.workspaceSymbols(p))
|
s.respond(msg.ID, s.workspaceSymbols(p))
|
||||||
|
|
||||||
|
case "workspace/didChangeWatchedFiles":
|
||||||
|
var p didChangeWatchedFilesParams
|
||||||
|
if json.Unmarshal(msg.Params, &p) == nil {
|
||||||
|
for _, ch := range p.Changes {
|
||||||
|
s.ws.watched(uriPath(ch.URI), ch.Type == fileDeleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "shutdown":
|
case "shutdown":
|
||||||
s.respond(msg.ID, nil)
|
s.respond(msg.ID, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -1014,6 +1014,134 @@ func TestCodeActionsTargetsFlaggedFunctionOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCodeActionInsertsTextflagInclude offers the include a file must carry
|
||||||
|
// when it uses flag macros: at the top of the file when there is no include
|
||||||
|
// yet, after the last one otherwise.
|
||||||
|
func TestCodeActionInsertsTextflagInclude(t *testing.T) {
|
||||||
|
doc := "TEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||||
|
withInclude := "#include \"go_asm.h\"\n\nTEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||||
|
in := session("file:///f_amd64.s", doc) +
|
||||||
|
frame(30, "textDocument/codeAction", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 1, "character": 0}},
|
||||||
|
"context": map[string]any{"diagnostics": []map[string]any{
|
||||||
|
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}},
|
||||||
|
}},
|
||||||
|
}) +
|
||||||
|
frame(nil, "textDocument/didChange", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
|
||||||
|
"contentChanges": []map[string]any{{"text": withInclude}},
|
||||||
|
}) +
|
||||||
|
frame(31, "textDocument/codeAction", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 3, "character": 0}},
|
||||||
|
"context": map[string]any{"diagnostics": []map[string]any{
|
||||||
|
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 2, "character": 0}, "end": map[string]any{"line": 2, "character": 4}}},
|
||||||
|
}},
|
||||||
|
}) +
|
||||||
|
frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
|
||||||
|
resp := findByID(msgs, 30)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no codeAction response")
|
||||||
|
}
|
||||||
|
var actions []CodeAction
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(actions) != 1 {
|
||||||
|
t.Fatalf("actions = %d, want 1", len(actions))
|
||||||
|
}
|
||||||
|
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
|
||||||
|
if len(edits) != 1 || edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != 0 {
|
||||||
|
t.Fatalf("edit = %+v, want an insertion at line 0 character 0", edits)
|
||||||
|
}
|
||||||
|
if got := applyEdits(doc, edits); !strings.HasPrefix(got, "#include \"textflag.h\"\nTEXT \u00b7f") {
|
||||||
|
t.Errorf("edited document = %q, want the include as the first line", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = findByID(msgs, 31)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no codeAction response for the with-include variant")
|
||||||
|
}
|
||||||
|
actions = nil
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(actions) != 1 || actions[0].Edit.Changes["file:///f_amd64.s"][0].Range.Start.Line != 1 {
|
||||||
|
t.Fatalf("actions = %+v, want one insertion after the last include (line 1)", actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCodeActionFixesArgSize offers the argument area the // func signature
|
||||||
|
// implies; without a parseable signature there is nothing to offer.
|
||||||
|
func TestCodeActionFixesArgSize(t *testing.T) {
|
||||||
|
doc := "// func Add(x int64) int64\n" +
|
||||||
|
"TEXT \u00b7Add(SB), NOSPLIT, $0-8\n" +
|
||||||
|
"\tMOVQ x+0(FP), AX\n" +
|
||||||
|
"\tADDQ y+8(FP), AX\n" +
|
||||||
|
"\tMOVQ AX, ret+16(FP)\n" +
|
||||||
|
"\tRET\n"
|
||||||
|
bare := "TEXT \u00b7Bare(SB), NOSPLIT, $0-8\n\tRET\n"
|
||||||
|
in := session("file:///f_amd64.s", doc) +
|
||||||
|
frame(32, "textDocument/codeAction", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
"range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}},
|
||||||
|
"context": map[string]any{"diagnostics": []map[string]any{
|
||||||
|
{"code": "abi-argsize", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}},
|
||||||
|
}},
|
||||||
|
}) +
|
||||||
|
frame(nil, "textDocument/didChange", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
|
||||||
|
"contentChanges": []map[string]any{{"text": bare}},
|
||||||
|
}) +
|
||||||
|
frame(33, "textDocument/codeAction", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}},
|
||||||
|
"context": map[string]any{"diagnostics": []map[string]any{
|
||||||
|
{"code": "abi-argsize", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}},
|
||||||
|
}},
|
||||||
|
}) +
|
||||||
|
frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
|
||||||
|
resp := findByID(msgs, 32)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no codeAction response")
|
||||||
|
}
|
||||||
|
var actions []CodeAction
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(actions) != 1 {
|
||||||
|
t.Fatalf("actions = %d, want 1", len(actions))
|
||||||
|
}
|
||||||
|
if actions[0].Title != "Set arg size to 16" {
|
||||||
|
t.Errorf("action title = %q, want Set arg size to 16", actions[0].Title)
|
||||||
|
}
|
||||||
|
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
|
||||||
|
if len(edits) != 1 || edits[0].NewText != "-16" || edits[0].Range.Start.Line != 1 {
|
||||||
|
t.Fatalf("edit = %+v, want -16 on line 1", edits)
|
||||||
|
}
|
||||||
|
if got := applyEdits(doc, edits); !strings.Contains(got, "$0-16") {
|
||||||
|
t.Errorf("edited document = %q, want $0-16", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No // func signature, no offer: the expected size cannot be computed.
|
||||||
|
resp = findByID(msgs, 33)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no codeAction response for the bare variant")
|
||||||
|
}
|
||||||
|
actions = nil
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(actions) != 0 {
|
||||||
|
t.Fatalf("actions = %+v, want none without a // func signature", actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCrossFileDefinitionAndReferences opens two documents: docA calls
|
// TestCrossFileDefinitionAndReferences opens two documents: docA calls
|
||||||
// ·helper(SB), docB defines it. Definition must jump to docB and references
|
// ·helper(SB), docB defines it. Definition must jump to docB and references
|
||||||
// must collect the call site in docA plus the definition in docB.
|
// must collect the call site in docA plus the definition in docB.
|
||||||
@@ -1074,3 +1202,49 @@ func TestCrossFileDefinitionAndReferences(t *testing.T) {
|
|||||||
t.Errorf("references by uri = %v, want one in each file", byURI)
|
t.Errorf("references by uri = %v, want one in each file", byURI)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenameGroupsEditsByDocument pins the grouping contract: every edit is
|
||||||
|
// applied to the document it was collected in, so a cross-file rename never
|
||||||
|
// corrupts the origin buffer.
|
||||||
|
func TestRenameGroupsEditsByDocument(t *testing.T) {
|
||||||
|
docA := "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
|
||||||
|
"\tCALL \u00b7helper(SB)\n" +
|
||||||
|
"\tRET\n"
|
||||||
|
docB := "TEXT \u00b7helper(SB), NOSPLIT, $0\n" +
|
||||||
|
"\tRET\n"
|
||||||
|
uriA := "file:///rename_a_amd64.s"
|
||||||
|
uriB := "file:///rename_b_amd64.s"
|
||||||
|
in := frame(1, "initialize", map[string]any{}) +
|
||||||
|
frame(nil, "initialized", map[string]any{}) +
|
||||||
|
frame(nil, "textDocument/didOpen", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": uriA, "languageId": "gasm", "version": 1, "text": docA},
|
||||||
|
}) +
|
||||||
|
frame(nil, "textDocument/didOpen", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": uriB, "languageId": "gasm", "version": 1, "text": docB},
|
||||||
|
}) +
|
||||||
|
frame(2, "textDocument/rename", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": uriA},
|
||||||
|
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||||
|
"newName": "renamed",
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 2)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no rename response")
|
||||||
|
}
|
||||||
|
var edit WorkspaceEdit
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &edit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(edit.Changes[uriA]) != 1 || len(edit.Changes[uriB]) != 1 {
|
||||||
|
t.Fatalf("edits per uri = %d and %d, want one each", len(edit.Changes[uriA]), len(edit.Changes[uriB]))
|
||||||
|
}
|
||||||
|
gotA := applyEdits(docA, edit.Changes[uriA])
|
||||||
|
gotB := applyEdits(docB, edit.Changes[uriB])
|
||||||
|
if !strings.Contains(gotA, "CALL renamed(SB)") || strings.Contains(gotA, "renamed(SB), NOSPLIT") {
|
||||||
|
t.Errorf("caller document rename wrong: %q", gotA)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotB, "TEXT renamed(SB)") {
|
||||||
|
t.Errorf("helper document rename wrong: %q", gotB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||||
|
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workspaceIndex indexes the .s files under the workspace roots that the
|
||||||
|
// client has not opened. Every workspace-scoped answer (definition,
|
||||||
|
// references, workspace symbols) reads it after the open documents, so a
|
||||||
|
// symbol resolves to and collects references from files the editor has never
|
||||||
|
// shown. An open buffer always shadows its disk copy: the entry stays in the
|
||||||
|
// index but is skipped at query time, because the buffer may hold unsaved
|
||||||
|
// edits the disk has never seen.
|
||||||
|
//
|
||||||
|
// Freshness does not rest on the client's watched-file events alone: every
|
||||||
|
// query re-stats the indexed files and re-reads the ones whose stat identity
|
||||||
|
// changed. Watched-file events remain necessary for files created after the
|
||||||
|
// build, which a stat of the known set cannot discover. The index builds on
|
||||||
|
// first use, since the roots arrive with initialize. The server serves one
|
||||||
|
// message at a time, so the index needs no lock.
|
||||||
|
type workspaceIndex struct {
|
||||||
|
roots []string
|
||||||
|
built bool
|
||||||
|
files map[string]*workspaceFile // keyed by absolute path
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceFile is one indexed on-disk file: its content, the parsed tree and
|
||||||
|
// the stat identity the freshness check compares.
|
||||||
|
type workspaceFile struct {
|
||||||
|
text string
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
file *ast.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure builds the index on first use and refreshes it on every query
|
||||||
|
// afterwards: a stat per file, a re-read only for the ones that changed.
|
||||||
|
func (w *workspaceIndex) ensure() {
|
||||||
|
if !w.built {
|
||||||
|
w.built = true
|
||||||
|
w.files = make(map[string]*workspaceFile)
|
||||||
|
for _, root := range w.roots {
|
||||||
|
w.walk(root)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for path := range w.files {
|
||||||
|
w.load(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk adds every .s file below root, skipping the directories the go tool
|
||||||
|
// itself ignores (hidden and underscore-prefixed names, testdata): the index
|
||||||
|
// covers the buildable surface, not the whole tree.
|
||||||
|
func (w *workspaceIndex) walk(root string) {
|
||||||
|
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // an unreadable entry never breaks the scan
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
if path != root && skipDir(d.Name()) {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, ".s") {
|
||||||
|
w.load(path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipDir reports whether a directory is outside the buildable surface.
|
||||||
|
func skipDir(name string) bool {
|
||||||
|
return name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// load reads one path into the index, or evicts it when it has disappeared.
|
||||||
|
// A file whose stat identity still matches is left untouched.
|
||||||
|
func (w *workspaceIndex) load(path string) {
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil || st.IsDir() {
|
||||||
|
delete(w.files, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ef, ok := w.files[path]; ok && ef.size == st.Size() && ef.modTime.Equal(st.ModTime()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
delete(w.files, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Parsing is tolerant: a file with syntax errors still contributes its
|
||||||
|
// usable declarations, exactly as an open buffer mid-edit does.
|
||||||
|
body := string(text)
|
||||||
|
f, _ := parser.Parse(path, body)
|
||||||
|
w.files[path] = &workspaceFile{text: body, modTime: st.ModTime(), size: st.Size(), file: f}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watched applies one client watched-file event: the named file is re-read
|
||||||
|
// (created or changed) or evicted (deleted). Events for anything but .s
|
||||||
|
// files are ignored, and an event arriving before the first build no-ops:
|
||||||
|
// the build then reads the disk state that already includes it.
|
||||||
|
func (w *workspaceIndex) watched(path string, deleted bool) {
|
||||||
|
if !w.built || !strings.HasSuffix(path, ".s") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
delete(w.files, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanUnit is one parseable source the workspace answers draw from: an open
|
||||||
|
// buffer or an indexed disk file.
|
||||||
|
type scanUnit struct {
|
||||||
|
uri string
|
||||||
|
text string
|
||||||
|
file *ast.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanUnits returns every source the workspace answers cover, in URI order
|
||||||
|
// for deterministic results: the open documents, then the indexed disk files
|
||||||
|
// that no open buffer shadows.
|
||||||
|
func (s *Server) scanUnits() []scanUnit {
|
||||||
|
open := make(map[string]bool, len(s.docs))
|
||||||
|
for uri := range s.docs {
|
||||||
|
open[uriPath(uri)] = true
|
||||||
|
}
|
||||||
|
units := make([]scanUnit, 0, len(s.docs))
|
||||||
|
for _, of := range s.openASTs() {
|
||||||
|
units = append(units, scanUnit{uri: of.uri, text: s.docs[of.uri], file: of.file})
|
||||||
|
}
|
||||||
|
s.ws.ensure()
|
||||||
|
for path, ef := range s.ws.files {
|
||||||
|
if ef.file == nil || open[path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
units = append(units, scanUnit{uri: fileToURI(path), text: ef.text, file: ef.file})
|
||||||
|
}
|
||||||
|
slices.SortFunc(units, func(a, b scanUnit) int { return cmp.Compare(a.uri, b.uri) })
|
||||||
|
return units
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileToURI renders an absolute path as a file URI, percent-encoding the
|
||||||
|
// characters the URI rules reserve.
|
||||||
|
func fileToURI(path string) string {
|
||||||
|
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// diskWorkspace writes one helper file on disk and returns the workspace root
|
||||||
|
// and the caller document that references ·helper and ·late by symbol.
|
||||||
|
func diskWorkspace(t *testing.T) (root, callerURI string, caller string) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
helper := "TEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "helper_amd64.s"), []byte(helper), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caller = "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
|
||||||
|
"\tCALL \u00b7helper(SB)\n" +
|
||||||
|
"\tCALL \u00b7late(SB)\n" +
|
||||||
|
"\tRET\n"
|
||||||
|
callerURI = "file://" + root + "/caller_amd64.s"
|
||||||
|
return root, callerURI, caller
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCaller frames the initialize/didOpen scripting around the caller doc.
|
||||||
|
func openCaller(root, callerURI, caller string) string {
|
||||||
|
return frame(1, "initialize", map[string]any{"rootUri": "file://" + root}) +
|
||||||
|
frame(nil, "initialized", map[string]any{}) +
|
||||||
|
frame(nil, "textDocument/didOpen", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefinitionReachesDiskFile checks that go-to-definition resolves a
|
||||||
|
// symbol to a workspace file the editor has never opened.
|
||||||
|
func TestDefinitionReachesDiskFile(t *testing.T) {
|
||||||
|
root, callerURI, caller := diskWorkspace(t)
|
||||||
|
in := openCaller(root, callerURI, caller) +
|
||||||
|
frame(2, "textDocument/definition", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI},
|
||||||
|
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 2)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no definition response")
|
||||||
|
}
|
||||||
|
var locs []Location
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantURI := fileToURI(filepath.Join(root, "helper_amd64.s"))
|
||||||
|
if len(locs) != 1 || locs[0].URI != wantURI || locs[0].Range.Start.Line != 0 {
|
||||||
|
t.Fatalf("definition = %+v, want the TEXT in %s line 0", locs, wantURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReferencesReachDiskFiles checks that find references collects the call
|
||||||
|
// site in the open document plus the definition in the unopened disk file.
|
||||||
|
func TestReferencesReachDiskFiles(t *testing.T) {
|
||||||
|
root, callerURI, caller := diskWorkspace(t)
|
||||||
|
in := openCaller(root, callerURI, caller) +
|
||||||
|
frame(2, "textDocument/references", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI},
|
||||||
|
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||||
|
"context": map[string]any{"includeDeclaration": true},
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 2)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no references response")
|
||||||
|
}
|
||||||
|
var locs []Location
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
byURI := map[string]int{}
|
||||||
|
for _, l := range locs {
|
||||||
|
byURI[l.URI]++
|
||||||
|
}
|
||||||
|
if byURI[callerURI] != 1 || byURI[fileToURI(filepath.Join(root, "helper_amd64.s"))] != 1 {
|
||||||
|
t.Fatalf("references by uri = %v, want the call in the open document and the definition on disk", byURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceSymbolsReachesDiskFiles checks the workspace symbol search
|
||||||
|
// against a file the editor has never opened.
|
||||||
|
func TestWorkspaceSymbolsReachesDiskFiles(t *testing.T) {
|
||||||
|
root, callerURI, caller := diskWorkspace(t)
|
||||||
|
in := openCaller(root, callerURI, caller) +
|
||||||
|
frame(2, "workspace/symbol", map[string]any{"query": "helper"}) +
|
||||||
|
frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 2)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no workspace/symbol response")
|
||||||
|
}
|
||||||
|
var syms []WorkspaceSymbol
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &syms); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(syms) != 1 || syms[0].Name != "helper" ||
|
||||||
|
syms[0].Location.URI != fileToURI(filepath.Join(root, "helper_amd64.s")) {
|
||||||
|
t.Fatalf("symbols = %+v, want helper from the disk file", syms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenBufferShadowsDiskFile pins the shadowing rule: when an open buffer
|
||||||
|
// carries unsaved edits, the answers come from the buffer, never from the
|
||||||
|
// stale disk copy.
|
||||||
|
func TestOpenBufferShadowsDiskFile(t *testing.T) {
|
||||||
|
root, callerURI, caller := diskWorkspace(t)
|
||||||
|
helperURI := "file://" + root + "/helper_amd64.s"
|
||||||
|
// The buffer moved the definition to line 1; the disk copy has line 0.
|
||||||
|
buffered := "\nTEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
|
||||||
|
in := openCaller(root, callerURI, caller) +
|
||||||
|
frame(nil, "textDocument/didOpen", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": helperURI, "languageId": "gasm", "version": 1, "text": buffered},
|
||||||
|
}) +
|
||||||
|
frame(2, "textDocument/definition", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI},
|
||||||
|
"position": map[string]any{"line": 1, "character": 8},
|
||||||
|
}) +
|
||||||
|
frame(3, "textDocument/references", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI},
|
||||||
|
"position": map[string]any{"line": 1, "character": 8},
|
||||||
|
"context": map[string]any{"includeDeclaration": true},
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
|
||||||
|
dresp := findByID(msgs, 2)
|
||||||
|
if dresp == nil {
|
||||||
|
t.Fatal("no definition response")
|
||||||
|
}
|
||||||
|
var locs []Location
|
||||||
|
if err := json.Unmarshal(mustResult(t, dresp), &locs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(locs) != 1 || locs[0].URI != helperURI || locs[0].Range.Start.Line != 1 {
|
||||||
|
t.Fatalf("definition = %+v, want the buffered TEXT on line 1", locs)
|
||||||
|
}
|
||||||
|
|
||||||
|
rresp := findByID(msgs, 3)
|
||||||
|
if rresp == nil {
|
||||||
|
t.Fatal("no references response")
|
||||||
|
}
|
||||||
|
locs = nil
|
||||||
|
if err := json.Unmarshal(mustResult(t, rresp), &locs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(locs) != 2 {
|
||||||
|
t.Fatalf("references = %+v, want the call and the buffered definition", locs)
|
||||||
|
}
|
||||||
|
for _, l := range locs {
|
||||||
|
if l.URI == helperURI && l.Range.Start.Line != 1 {
|
||||||
|
t.Errorf("reference in the opened file points at line %d, want the buffered line 1", l.Range.Start.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatchedFileEventsUpdateIndex pins the one case a per-query refresh
|
||||||
|
// cannot cover: a file created after the build is unknown to the index until
|
||||||
|
// the client's watched-file event names it. The steps run through dispatch
|
||||||
|
// directly, because the file must appear on disk between two requests.
|
||||||
|
func TestWatchedFileEventsUpdateIndex(t *testing.T) {
|
||||||
|
root, callerURI, caller := diskWorkspace(t)
|
||||||
|
latePath := filepath.Join(root, "late_amd64.s")
|
||||||
|
lateURI := "file://" + latePath
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
srv := New(strings.NewReader(""), &buf)
|
||||||
|
request := func(id int, method string, params any) *rpcMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var msgID *json.RawMessage
|
||||||
|
if id > 0 {
|
||||||
|
msgID = rawID(t, id)
|
||||||
|
}
|
||||||
|
srv.dispatch(&rpcMessage{ID: msgID, Method: method, Params: raw})
|
||||||
|
msgs := readFrames(t, &buf)
|
||||||
|
buf.Reset()
|
||||||
|
if id > 0 {
|
||||||
|
return findByID(msgs, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
definitionOnLate := func(id int) []Location {
|
||||||
|
t.Helper()
|
||||||
|
resp := request(id, "textDocument/definition", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI},
|
||||||
|
"position": map[string]any{"line": 2, "character": 8}, // on late in CALL ·late(SB)
|
||||||
|
})
|
||||||
|
var locs []Location
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return locs
|
||||||
|
}
|
||||||
|
|
||||||
|
request(1, "initialize", map[string]any{"rootUri": "file://" + root})
|
||||||
|
request(0, "textDocument/didOpen", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The first request builds the index; the late file does not exist yet.
|
||||||
|
if locs := definitionOnLate(2); len(locs) != 0 {
|
||||||
|
t.Fatalf("definition before the file exists = %+v, want no result", locs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(latePath, []byte("TEXT \u00b7late(SB), NOSPLIT, $0\n\tRET\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request(0, "workspace/didChangeWatchedFiles", map[string]any{
|
||||||
|
"changes": []map[string]any{{"uri": lateURI, "type": fileCreated}},
|
||||||
|
})
|
||||||
|
if locs := definitionOnLate(4); len(locs) != 1 || locs[0].URI != fileToURI(latePath) {
|
||||||
|
t.Fatalf("definition after the created event = %+v, want late_amd64.s", locs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(latePath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request(0, "workspace/didChangeWatchedFiles", map[string]any{
|
||||||
|
"changes": []map[string]any{{"uri": lateURI, "type": fileDeleted}},
|
||||||
|
})
|
||||||
|
if locs := definitionOnLate(6); len(locs) != 0 {
|
||||||
|
t.Fatalf("definition after the deleted event = %+v, want no result", locs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileToURIRoundTrip pins the URI encoding of the disk entries: the
|
||||||
|
// constructed URI decodes back to the path it came from.
|
||||||
|
func TestFileToURIRoundTrip(t *testing.T) {
|
||||||
|
const path = "/tmp/Repositáře/kernels/helper.s"
|
||||||
|
if got := uriPath(fileToURI(path)); got != path {
|
||||||
|
t.Errorf("fileToURI round trip = %q, want %q", got, path)
|
||||||
|
}
|
||||||
|
if got := fileToURI("/plain/k.s"); got != "file:///plain/k.s" {
|
||||||
|
t.Errorf("fileToURI plain = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -19,6 +19,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Executable maps a copy of code into a read-execute memory region suitable
|
// Executable maps a copy of code into a read-execute memory region suitable
|
||||||
@@ -49,8 +51,9 @@ func Map(code []byte) (*Executable, error) {
|
|||||||
}
|
}
|
||||||
copy(mem, code)
|
copy(mem, code)
|
||||||
|
|
||||||
// Remove write permission (W^X).
|
// Remove write permission (W^X). x/sys carries Mprotect on every
|
||||||
if err := syscall.Mprotect(mem, syscall.PROT_READ|syscall.PROT_EXEC); err != nil {
|
// platform the package builds for; the stdlib syscall one is Linux-only.
|
||||||
|
if err := unix.Mprotect(mem, syscall.PROT_READ|syscall.PROT_EXEC); err != nil {
|
||||||
syscall.Munmap(mem)
|
syscall.Munmap(mem)
|
||||||
return nil, fmt.Errorf("verify: mprotect: %w", err)
|
return nil, fmt.Errorf("verify: mprotect: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user