diff --git a/cmd/gasm/dis.go b/cmd/gasm/dis.go new file mode 100644 index 0000000..b79fd7a --- /dev/null +++ b/cmd/gasm/dis.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Petr Balvín (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] ", ` +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] ") + 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() +} diff --git a/debug/disasm_linux_amd64.go b/debug/disasm_linux_amd64.go index 8cada40..1cd1700 100644 --- a/debug/disasm_linux_amd64.go +++ b/debug/disasm_linux_amd64.go @@ -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 - } + 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 diff --git a/debug/disasm_linux_arm64.go b/debug/disasm_linux_arm64.go index e4dd813..fef8162 100644 --- a/debug/disasm_linux_arm64.go +++ b/debug/disasm_linux_arm64.go @@ -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. diff --git a/debug/disasm_linux_loong64.go b/debug/disasm_linux_loong64.go index 93f881e..4dc5ed2 100644 --- a/debug/disasm_linux_loong64.go +++ b/debug/disasm_linux_loong64.go @@ -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. diff --git a/debug/disasm_linux_riscv64.go b/debug/disasm_linux_riscv64.go index 7ff0ceb..34c6efb 100644 --- a/debug/disasm_linux_riscv64.go +++ b/debug/disasm_linux_riscv64.go @@ -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. diff --git a/disasm/disasm.go b/disasm/disasm.go new file mode 100644 index 0000000..a0e36e3 --- /dev/null +++ b/disasm/disasm.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/disasm/disasm_test.go b/disasm/disasm_test.go new file mode 100644 index 0000000..0fd61ba --- /dev/null +++ b/disasm/disasm_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Petr Balvín (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) + } +}