feat(cmd): add gasm dis and share the decoder with the debugger

This commit is contained in:
2026-09-14 18:47:08 +02:00
parent 50db6615b2
commit edc20ffa97
7 changed files with 405 additions and 26 deletions
+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"os"
"sort"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// cmdDis disassembles machine code: either a raw binary (standard input with
// "-") whose architecture is given with -a, or a .s file, which is assembled
// first so the listing shows the real function and label layout.
func cmdDis(args []string) int {
fs := newCommand("dis", "gasm dis [-a arch] <file>", `
Disassemble machine code to instruction text (via golang.org/x/arch).
With a .s file, the file is assembled first and the listing follows the
real layout: one block per TEXT function, local labels printed at their
offsets. The architecture comes from the file name suffix, or from -a.
With any other file, or "-" for standard input, the bytes are disassembled
linearly and -a selects the architecture (amd64, arm64, riscv64 or
loong64).
`)
archName := fs.String("a", "", "architecture for raw input: amd64, arm64, riscv64 or loong64")
fs.Parse(args)
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: gasm dis [-a arch] <file>")
return 2
}
path := fs.Arg(0)
var target arch.Arch
if *archName != "" {
var err error
target, err = auditArch(*archName)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm dis: %v\n", err)
return 2
}
}
if strings.HasSuffix(path, ".s") {
if target == arch.Unknown {
target = arch.FromFilename(path)
}
if target == arch.Unknown {
fmt.Fprintln(os.Stderr, "gasm dis: cannot infer the architecture from the file name; use -a")
return 2
}
return disSource(path, target)
}
if target == arch.Unknown {
fmt.Fprintln(os.Stderr, "gasm dis: raw input needs -a (amd64, arm64, riscv64 or loong64)")
return 2
}
src, err := readSource(path)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm dis:", err)
return 1
}
printListing(target, []byte(src), 0, nil)
return 0
}
// disSource assembles a .s file and prints one listing block per function.
func disSource(path string, target arch.Arch) int {
src, err := readSource(path)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm dis:", err)
return 1
}
f, errs := parser.Parse(path, src)
for _, e := range errs {
fmt.Fprintf(os.Stderr, "%s: %v\n", path, e)
}
if len(errs) > 0 {
return 1
}
img, err := assembleFile(target, f)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm dis: %v\n", err)
return 1
}
if len(img.Funcs) == 0 {
fmt.Fprintln(os.Stderr, "gasm dis: no assemblable TEXT functions found")
return 1
}
for _, fn := range img.Funcs {
code := img.Code[fn.Offset : fn.Offset+fn.Size]
fmt.Printf("%s: %d bytes\n", fn.Name, fn.Size)
labels := make(map[int][]string, len(fn.Labels))
for name, off := range fn.Labels {
labels[off] = append(labels[off], name)
}
for off := range labels {
sort.Strings(labels[off])
}
printListing(target, code, uint64(fn.Offset), labels)
}
if len(img.Data) > 0 {
fmt.Printf("data: %d bytes at 0x%x\n", len(img.Data), len(img.Code))
}
return 0
}
// printListing decodes code linearly from offset base, printing label lines
// (label name to offset within the block) as they are reached.
func printListing(a arch.Arch, code []byte, base uint64, labels map[int][]string) {
pc := 0
for pc < len(code) {
for _, name := range labels[pc] {
fmt.Printf("%s:\n", name)
}
ins, err := disasm.Decode(a, code[pc:], base+uint64(pc))
if err != nil {
break
}
end := min(pc+ins.Len, len(code))
fmt.Printf(" %04x: %-16s %s\n", base+uint64(pc), hexBytes(code[pc:end]), ins.Text)
if ins.Len <= 0 {
break
}
pc += ins.Len
}
}
// hexBytes renders up to 8 bytes as contiguous hex.
func hexBytes(b []byte) string {
var sb strings.Builder
for i, c := range b {
if i == 8 {
break
}
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02x", c)
}
return sb.String()
}
+5 -10
View File
@@ -9,27 +9,22 @@ import (
"fmt"
"strings"
"golang.org/x/arch/x86/x86asm"
"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) {
// Read up to 15 bytes (max x86 instruction length).
mem, err := s.ReadMemory(addr, 15)
if err != nil {
// Try a shorter read if we're near a page boundary.
mem, err = s.ReadMemory(addr, 1)
if err != nil {
return "", 0, err
}
}
inst, err := x86asm.Decode(mem, 64)
ins, err := disasm.Decode(arch.AMD64, mem, addr)
if err != nil {
return "???", 1, nil
return "", 0, err
}
text := x86asm.IntelSyntax(inst, addr, nil)
return text, inst.Len, nil
return ins.Text, ins.Len, nil
}
// DisassembleN decodes up to n instructions starting at addr and returns
+5 -5
View File
@@ -8,7 +8,8 @@ package debug
import (
"fmt"
"golang.org/x/arch/arm64/arm64asm"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
)
// Disassemble decodes the instruction at the given address in the debuggee's
@@ -18,12 +19,11 @@ func (s *Session) Disassemble(addr uint64) (string, int, error) {
if err != nil {
return "", 0, err
}
inst, err := arm64asm.Decode(mem)
ins, err := disasm.Decode(arch.ARM64, mem, addr)
if err != nil {
return "???", 4, nil
return "", 0, err
}
text := arm64asm.GoSyntax(inst, addr, nil, nil)
return text, 4, nil
return ins.Text, ins.Len, nil
}
// DisassembleN decodes up to n instructions starting at addr.
+5 -5
View File
@@ -8,7 +8,8 @@ package debug
import (
"fmt"
"golang.org/x/arch/loong64/loong64asm"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
)
// Disassemble decodes the instruction at the given address in the debuggee's
@@ -18,12 +19,11 @@ func (s *Session) Disassemble(addr uint64) (string, int, error) {
if err != nil {
return "", 0, err
}
inst, err := loong64asm.Decode(mem)
ins, err := disasm.Decode(arch.LOONG64, mem, addr)
if err != nil {
return "???", 4, nil
return "", 0, err
}
text := loong64asm.GoSyntax(inst, addr, nil)
return text, 4, nil
return ins.Text, ins.Len, nil
}
// DisassembleN decodes up to n instructions starting at addr.
+5 -5
View File
@@ -8,7 +8,8 @@ package debug
import (
"fmt"
"golang.org/x/arch/riscv64/riscv64asm"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/disasm"
)
// Disassemble decodes the instruction at the given address in the debuggee's
@@ -18,12 +19,11 @@ func (s *Session) Disassemble(addr uint64) (string, int, error) {
if err != nil {
return "", 0, err
}
inst, err := riscv64asm.Decode(mem)
ins, err := disasm.Decode(arch.RISCV, mem, addr)
if err != nil {
return "???", 4, nil
return "", 0, err
}
text := riscv64asm.GoSyntax(inst, addr, nil, nil)
return text, inst.Len, nil
return ins.Text, ins.Len, nil
}
// DisassembleN decodes up to n instructions starting at addr.
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Package disasm decodes machine code back to instruction text for the four
// architectures gasm assembles. It is a thin, platform-independent wrapper
// over golang.org/x/arch and backs both the `gasm dis` command and the live
// debugger views.
package disasm
import (
"fmt"
"golang.org/x/arch/arm64/arm64asm"
"golang.org/x/arch/loong64/loong64asm"
"golang.org/x/arch/riscv64/riscv64asm"
"golang.org/x/arch/x86/x86asm"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
)
// Instruction is one decoded instruction: its text form, its length in bytes
// and the address it was decoded at.
type Instruction struct {
Addr uint64
Text string
Len int
}
// Decode decodes the instruction at the start of code, located at addr.
// code needs to hold at least the one instruction being decoded (amd64 may
// consume up to 15 bytes). Undecodable bytes yield the placeholder text "???"
// and a length of one word (four bytes, one on amd64) so that a listing can
// keep making progress, mirroring the debugger's behaviour.
func Decode(a arch.Arch, code []byte, addr uint64) (Instruction, error) {
if len(code) == 0 {
return Instruction{}, fmt.Errorf("disasm: empty input")
}
switch a {
case arch.ARM64:
if len(code) < 4 {
return Instruction{}, fmt.Errorf("disasm: need 4 bytes, have %d", len(code))
}
inst, err := arm64asm.Decode(code)
if err != nil {
return Instruction{Addr: addr, Text: "???", Len: 4}, nil
}
return Instruction{Addr: addr, Text: arm64asm.GoSyntax(inst, addr, nil, nil), Len: 4}, nil
case arch.RISCV:
// The compressed extensions are decoded transparently; a 16-bit
// instruction only needs its two bytes.
inst, err := riscv64asm.Decode(code)
if err != nil {
return Instruction{Addr: addr, Text: "???", Len: 2}, nil
}
return Instruction{Addr: addr, Text: riscv64asm.GoSyntax(inst, addr, nil, nil), Len: inst.Len}, nil
case arch.LOONG64:
if len(code) < 4 {
return Instruction{}, fmt.Errorf("disasm: need 4 bytes, have %d", len(code))
}
inst, err := loong64asm.Decode(code)
if err != nil {
return Instruction{Addr: addr, Text: "???", Len: 4}, nil
}
return Instruction{Addr: addr, Text: loong64asm.GoSyntax(inst, addr, nil), Len: 4}, nil
default: // amd64
inst, err := x86asm.Decode(code, 64)
if err != nil {
return Instruction{Addr: addr, Text: "???", Len: 1}, nil
}
return Instruction{Addr: addr, Text: x86asm.IntelSyntax(inst, addr, nil), Len: inst.Len}, nil
}
}
// Block decodes up to max instructions from code starting at addr and returns
// them in order. Decoding stops at the end of code or once an instruction
// would run past it.
func Block(a arch.Arch, code []byte, addr uint64, max int) []Instruction {
var out []Instruction
pc := 0
for len(out) < max && pc < len(code) {
ins, err := Decode(a, code[pc:], addr+uint64(pc))
if err != nil {
break
}
if ins.Len <= 0 || pc+ins.Len > len(code) {
break
}
out = append(out, ins)
pc += ins.Len
}
return out
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package disasm
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/asm"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
func TestDecodeKnownBytes(t *testing.T) {
for _, tt := range []struct {
a arch.Arch
code []byte
text string
want int
}{
{arch.AMD64, []byte{0x55}, "push rbp", 1},
{arch.AMD64, []byte{0x48, 0x89, 0xE5}, "mov rbp, rsp", 3},
{arch.ARM64, []byte{0xc0, 0x03, 0x5f, 0xd6}, "RET", 4},
{arch.RISCV, []byte{0x67, 0x80, 0x00, 0x00}, "RET", 4},
{arch.LOONG64, []byte{0x20, 0x00, 0x00, 0x4c}, "RET", 4},
} {
ins, err := Decode(tt.a, tt.code, 0)
if err != nil {
t.Errorf("%s: %v", tt.a, err)
continue
}
if ins.Text != tt.text || ins.Len != tt.want {
t.Errorf("%s: % x decoded to %q (%d bytes), want %q (%d)",
tt.a, tt.code, ins.Text, ins.Len, tt.text, tt.want)
}
}
}
func TestDecodeUndecodable(t *testing.T) {
// Zero words do not encode a usable instruction on arm64 and loong64; the
// placeholder keeps a listing going. RISC-V is the exception: an all-zero
// word is the defined UNIMP instruction.
for _, a := range []arch.Arch{arch.ARM64, arch.LOONG64} {
ins, err := Decode(a, []byte{0, 0, 0, 0}, 0)
if err != nil {
t.Fatalf("%s: %v", a, err)
}
if ins.Text != "???" {
t.Errorf("%s: text = %q, want ???", a, ins.Text)
}
}
// The compressed quadrant claims the zero halfword first, so the zero
// word decodes as the 2-byte compressed UNIMP.
if ins, err := Decode(arch.RISCV, []byte{0, 0, 0, 0}, 0); err != nil || ins.Text != "UNIMP" || ins.Len != 2 {
t.Errorf("riscv zero word: %q len %d err %v, want UNIMP with 2 bytes", ins.Text, ins.Len, err)
}
if _, err := Decode(arch.ARM64, []byte{0, 0}, 0); err == nil {
t.Error("short input: expected an error")
}
if _, err := Decode(arch.AMD64, nil, 0); err == nil {
t.Error("empty input: expected an error")
}
}
// TestBlockRoundTrip assembles a small kernel with the gasm encoder for every
// architecture and disassembles it back: the listing must cover the whole
// function and end in RET.
func TestBlockRoundTrip(t *testing.T) {
for _, tt := range []struct {
a arch.Arch
name string
}{
{arch.AMD64, "k_amd64.s"},
{arch.ARM64, "k_arm64.s"},
{arch.RISCV, "k_riscv64.s"},
{arch.LOONG64, "k_loong64.s"},
} {
src := "TEXT \u00b7k(SB), NOSPLIT, $0\n\tMOVQ AX, CX\n\tRET\n"
if tt.a != arch.AMD64 {
src = "TEXT \u00b7k(SB), NOSPLIT, $0\n\tRET\n"
}
f, errs := parser.Parse(tt.name, src)
if len(errs) > 0 {
t.Fatalf("%s: parse: %v", tt.a, errs)
}
img, err := assemble(t, tt.a, f)
if err != nil {
t.Fatalf("%s: assemble: %v", tt.a, err)
}
fn := img.Funcs[0]
code := img.Code[fn.Offset : fn.Offset+fn.Size]
ins := Block(tt.a, code, 0, 100)
if len(ins) == 0 {
t.Fatalf("%s: empty listing", tt.a)
}
consumed := 0
for _, in := range ins {
if in.Text == "" || in.Text == "???" {
t.Errorf("%s: undecoded instruction at %#x: %q", tt.a, in.Addr, in.Text)
}
consumed += in.Len
}
if consumed != len(code) {
t.Errorf("%s: listing consumed %d of %d bytes", tt.a, consumed, len(code))
}
if last := ins[len(ins)-1]; !strings.Contains(strings.ToLower(last.Text), "ret") {
t.Errorf("%s: last instruction = %q, want RET", tt.a, last.Text)
}
}
}
func TestBlockLimits(t *testing.T) {
code := []byte{0x55, 0x55, 0x55, 0x55, 0x55}
if got := Block(arch.AMD64, code, 0, 3); len(got) != 3 {
t.Errorf("max=3 produced %d instructions, want 3", len(got))
}
if got := Block(arch.AMD64, code, 0, 100); len(got) != 5 {
t.Errorf("code end produced %d instructions, want 5", len(got))
}
if got := Block(arch.AMD64, nil, 0, 3); len(got) != 0 {
t.Errorf("empty code produced %d instructions, want 0", len(got))
}
}
// assemble assembles the parsed file with the encoder for a.
func assemble(t *testing.T, a arch.Arch, f *ast.File) (*asm.Image, error) {
t.Helper()
switch a {
case arch.ARM64:
return asm.AssembleFileARM64(f)
case arch.RISCV:
return asm.AssembleFileRISCV(f)
case arch.LOONG64:
return asm.AssembleFileLOONG64(f)
default:
return asm.AssembleFile(f)
}
}