478 lines
14 KiB
Go
478 lines
14 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package asm
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
|
)
|
|
|
|
// goobjView is a minimal parsed view of a GOOBJ payload, enough to check
|
|
// the emitter's output block by block.
|
|
type goobjView struct {
|
|
t *testing.T
|
|
b []byte
|
|
offs [blkEnd + 1]uint32
|
|
strOff uint32
|
|
}
|
|
|
|
func openGoobj(t *testing.T, data []byte) *goobjView {
|
|
t.Helper()
|
|
i := bytes.Index(data, []byte(goobjMagic))
|
|
if i < 0 {
|
|
t.Fatal("no GOOBJ magic in output")
|
|
}
|
|
v := &goobjView{t: t, b: data[i:], strOff: uint32(i + 96)}
|
|
for j := 0; j <= blkEnd; j++ {
|
|
v.offs[j] = binary.LittleEndian.Uint32(v.b[20+4*j:])
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (v *goobjView) blk(i int) []byte { return v.b[v.offs[i]:v.offs[i+1]] }
|
|
|
|
func (v *goobjView) str(off, ln uint32) string {
|
|
return string(v.b[off : off+ln])
|
|
}
|
|
|
|
type goobjSymView struct {
|
|
name string
|
|
abi uint16
|
|
typ uint8
|
|
flag uint8
|
|
flag2 uint8
|
|
size uint32
|
|
align uint32
|
|
}
|
|
|
|
func (v *goobjView) syms(i int) []goobjSymView {
|
|
var out []goobjSymView
|
|
for x := v.blk(i); len(x) >= 21; x = x[21:] {
|
|
le := binary.LittleEndian
|
|
out = append(out, goobjSymView{
|
|
name: v.str(le.Uint32(x[4:]), le.Uint32(x[0:])),
|
|
abi: le.Uint16(x[8:]),
|
|
typ: x[10],
|
|
flag: x[11],
|
|
flag2: x[12],
|
|
size: le.Uint32(x[13:]),
|
|
align: le.Uint32(x[17:]),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestGOObjectStructure checks the emitted object's blocks against the
|
|
// ground truth captured from go tool asm: the symbol tables, the FuncInfo
|
|
// contents, the pc-value tables, the relocation and the aux wiring.
|
|
func TestGOObjectStructure(t *testing.T) {
|
|
f, errs := parser.Parse("t_amd64.s", `
|
|
#include "textflag.h"
|
|
|
|
TEXT ·addq(SB), NOSPLIT, $0-24
|
|
MOVQ a+0(FP), AX
|
|
MOVQ b+8(FP), CX
|
|
ADDQ CX, AX
|
|
MOVQ AX, ret+16(FP)
|
|
RET
|
|
|
|
TEXT ·loadmask(SB), NOSPLIT, $0-8
|
|
VMOVDQU mask<>(SB), X0
|
|
VPMOVMSKB X0, AX
|
|
MOVQ AX, ret+0(FP)
|
|
RET
|
|
|
|
GLOBL mask<>(SB), RODATA, $16
|
|
DATA mask<>+0(SB)/8, $0x0807060504030201
|
|
DATA mask<>+8(SB)/8, $0x800f0e0d0c0b0a09
|
|
`)
|
|
if len(errs) > 0 {
|
|
t.Fatalf("parse: %v", errs)
|
|
}
|
|
img, err := AssembleFile(f)
|
|
if err != nil {
|
|
t.Fatalf("AssembleFile: %v", err)
|
|
}
|
|
obj, err := img.GOObject("testpkg", "t_amd64.s")
|
|
if err != nil {
|
|
t.Fatalf("GOObject: %v", err)
|
|
}
|
|
v := openGoobj(t, obj)
|
|
|
|
if flags := binary.LittleEndian.Uint32(v.b[16:]); flags != 4 {
|
|
t.Errorf("flags = %#x, want ObjFlagFromAssembly (4)", flags)
|
|
}
|
|
|
|
// Package defs: the static GLOBL, then one anonymous FuncInfo per
|
|
// function.
|
|
defs := v.syms(blkSymdef)
|
|
if len(defs) != 3 {
|
|
t.Fatalf("symdefs = %d, want 3", len(defs))
|
|
}
|
|
if defs[0].name != "mask" || defs[0].abi != 0xffff || defs[0].typ != kindSRODATA || defs[0].size != 16 || defs[0].flag2 != symFlag2Link {
|
|
t.Errorf("mask symbol = %+v", defs[0])
|
|
}
|
|
if defs[1].name != "" || defs[1].typ != kindSDATA || defs[1].size != 28 {
|
|
t.Errorf("funcinfo symbol = %+v", defs[1])
|
|
}
|
|
|
|
// Non-package defs: four pc tables and the function, per function.
|
|
nps := v.syms(blkNonpkgdef)
|
|
if len(nps) != 10 {
|
|
t.Fatalf("nonpkgdefs = %d, want 10", len(nps))
|
|
}
|
|
fn := nps[4]
|
|
if fn.name != "testpkg.addq" || fn.typ != kindSTEXT || fn.flag != symFlagNoSplit || fn.size != 19 {
|
|
t.Errorf("addq symbol = %+v", fn)
|
|
}
|
|
for i, s := range []int{0, 1, 2, 3, 5, 6, 7, 8} {
|
|
if nps[s].typ != kindSRODATA || nps[s].align != 1 || nps[s].name != "" {
|
|
t.Errorf("pc table %d = %+v", i, nps[s])
|
|
}
|
|
}
|
|
|
|
// FuncInfo: args 24, FuncFlag Asm, one file, no inline tree.
|
|
le := binary.LittleEndian
|
|
data := v.blk(blkData)
|
|
fi := data[16:44]
|
|
if le.Uint32(fi[0:]) != 24 || le.Uint32(fi[4:]) != 0 || fi[8] != 0 || fi[9] != funcFlagAsm ||
|
|
le.Uint32(fi[16:]) != 1 || le.Uint32(fi[20:]) != 0 || le.Uint32(fi[24:]) != 0 {
|
|
t.Errorf("funcinfo bytes %x", fi)
|
|
}
|
|
|
|
// pcsp: a flat zero over the whole function (zero-frame NOSPLIT).
|
|
if got := data[72:75]; !bytes.Equal(got, []byte{0x02, 19, 0x00}) {
|
|
t.Errorf("pcsp = %x, want 021300", got)
|
|
}
|
|
// pcinline: a flat -1.
|
|
if got := data[81:84]; !bytes.Equal(got, []byte{0x00, 19, 0x00}) {
|
|
t.Errorf("pcinline = %x, want 001300", got)
|
|
}
|
|
|
|
// The one relocation: R_PCREL, four bytes wide, against the GLOBL,
|
|
// with the field in the function code left zero. The loadmask code's
|
|
// offset comes from the data index (symbol 3 defs + 9 non-package).
|
|
relocs := v.blk(blkReloc)
|
|
if len(relocs) != 23 {
|
|
t.Fatalf("relocs = %d bytes, want one 23-byte entry", len(relocs))
|
|
}
|
|
off := int32(le.Uint32(relocs[0:]))
|
|
if off != 4 || relocs[4] != 4 || le.Uint16(relocs[5:]) != relocPCRel ||
|
|
le.Uint64(relocs[7:]) != 0 || le.Uint32(relocs[15:]) != pkgIdxSelf || le.Uint32(relocs[19:]) != 0 {
|
|
t.Errorf("reloc = %x", relocs)
|
|
}
|
|
didx := v.blk(blkDataIdx)
|
|
lm := le.Uint32(didx[4*(3+9):])
|
|
code := data[lm : lm+18]
|
|
if !bytes.Equal(code[4:8], []byte{0, 0, 0, 0}) {
|
|
t.Errorf("relocated field = %x, want zeroed", code[4:8])
|
|
}
|
|
|
|
// Aux wiring: FuncInfo (package symbol), then the four pc tables
|
|
// (non-package symbols).
|
|
auxs := v.blk(blkAux)
|
|
if len(auxs) != 2*5*9 {
|
|
t.Fatalf("aux = %d bytes, want 10 entries", len(auxs))
|
|
}
|
|
wantAux := []struct {
|
|
typ uint8
|
|
pkg uint32
|
|
idx uint32
|
|
}{
|
|
{auxFuncInfo, pkgIdxSelf, 1},
|
|
{auxPcsp, pkgIdxNone, uint32(len(defs) + 0)},
|
|
{auxPcfile, pkgIdxNone, uint32(len(defs) + 1)},
|
|
{auxPcline, pkgIdxNone, uint32(len(defs) + 2)},
|
|
{auxPcinline, pkgIdxNone, uint32(len(defs) + 3)},
|
|
{auxFuncInfo, pkgIdxSelf, 2},
|
|
{auxPcsp, pkgIdxNone, uint32(len(defs) + 5)},
|
|
{auxPcfile, pkgIdxNone, uint32(len(defs) + 6)},
|
|
{auxPcline, pkgIdxNone, uint32(len(defs) + 7)},
|
|
{auxPcinline, pkgIdxNone, uint32(len(defs) + 8)},
|
|
}
|
|
for i, w := range wantAux {
|
|
e := auxs[i*9:]
|
|
if e[0] != w.typ || le.Uint32(e[1:]) != w.pkg || le.Uint32(e[5:]) != w.idx {
|
|
t.Errorf("aux[%d] = {%d,%d,%d}, want {%d,%d,%d}", i, e[0], le.Uint32(e[1:]), le.Uint32(e[5:]), w.typ, w.pkg, w.idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// decodePCValues decodes a pc-value table into (pc, value) steps. The
|
|
// table ends with a final unsigned pc delta covering the rest of the
|
|
// function, followed by a zero byte that carries no value delta.
|
|
func decodePCValues(b []byte) (pcs, vals []int64) {
|
|
val, n := binary.Varint(b)
|
|
b = b[n:]
|
|
val-- // the first delta is against the implicit -1
|
|
var pc int64
|
|
pcs = append(pcs, pc)
|
|
vals = append(vals, val)
|
|
for {
|
|
pcd, n := binary.Uvarint(b)
|
|
b = b[n:]
|
|
if pcd == 0 { // zero pc delta terminates the table
|
|
break
|
|
}
|
|
pc += int64(pcd)
|
|
if len(b) == 1 && b[0] == 0 { // final coverage, no value change
|
|
break
|
|
}
|
|
vd, n := binary.Varint(b)
|
|
b = b[n:]
|
|
val += vd
|
|
pcs = append(pcs, pc)
|
|
vals = append(vals, val)
|
|
}
|
|
return pcs, vals
|
|
}
|
|
|
|
// TestGOObjectPcspFrame checks the pcsp table of a frame-pointer function:
|
|
// the prologue raises the stack delta to 8+frame, the RET's epilogue
|
|
// restores it to zero.
|
|
func TestGOObjectPcspFrame(t *testing.T) {
|
|
f, errs := parser.Parse("frame_amd64.s", `
|
|
#include "textflag.h"
|
|
TEXT ·framed(SB), NOSPLIT, $8-0
|
|
MOVQ BP, AX
|
|
RET
|
|
`)
|
|
if len(errs) > 0 {
|
|
t.Fatalf("parse: %v", errs)
|
|
}
|
|
img, err := AssembleFile(f)
|
|
if err != nil {
|
|
t.Fatalf("AssembleFile: %v", err)
|
|
}
|
|
fn := img.Funcs[0]
|
|
pcs, vals := decodePCValues(pcspTable(fn))
|
|
// Prologue: PUSHQ BP (1 byte, +8), MOVQ SP, BP (3 bytes, no change),
|
|
// SUBQ $8, SP (4 bytes, +16 in total); the RET's epilogue unwinds
|
|
// ADDQ $8, SP (+8) then POPQ BP (0).
|
|
wantPCs := []int64{0, 1, 8}
|
|
wantVals := []int64{0, 8, 16}
|
|
if len(pcs) < len(wantPCs) {
|
|
t.Fatalf("pcsp pcs = %v vals = %v", pcs, vals)
|
|
}
|
|
for i := range wantPCs {
|
|
if pcs[i] != wantPCs[i] || vals[i] != wantVals[i] {
|
|
t.Errorf("pcsp[%d] = (%d,%d), want (%d,%d) — all: %v %v", i, pcs[i], vals[i], wantPCs[i], wantVals[i], pcs, vals)
|
|
}
|
|
}
|
|
// The last two steps unwind the epilogue to zero.
|
|
n := len(pcs)
|
|
if vals[n-1] != 0 || vals[n-2] != 8 {
|
|
t.Errorf("epilogue steps = %v %v, want …8, 0", pcs, vals)
|
|
}
|
|
// The table covers the whole function.
|
|
if last := pcs[n-1]; last >= int64(fn.Size) {
|
|
t.Errorf("last pc %d beyond function size %d", last, fn.Size)
|
|
}
|
|
}
|
|
|
|
// TestGOObjectExternalRejected checks that a reference to a symbol no GLOBL
|
|
// defines is reported: GOOBJ emission resolves only file-local symbols so
|
|
// far.
|
|
func TestGOObjectExternalRejected(t *testing.T) {
|
|
f, errs := parser.Parse("ext_amd64.s", `
|
|
#include "textflag.h"
|
|
TEXT ·useext(SB), NOSPLIT, $0-8
|
|
MOVQ elsewhere(SB), AX
|
|
MOVQ AX, ret+0(FP)
|
|
RET
|
|
`)
|
|
if len(errs) > 0 {
|
|
t.Fatalf("parse: %v", errs)
|
|
}
|
|
img, err := AssembleFile(f)
|
|
if err != nil {
|
|
t.Fatalf("AssembleFile: %v", err)
|
|
}
|
|
if _, err := img.GOObject("p", "ext_amd64.s"); err == nil || !strings.Contains(err.Error(), "external") {
|
|
t.Errorf("error = %v, want an external-symbol error", err)
|
|
}
|
|
}
|
|
|
|
// TestGOObjectLinkAndRun is the end-to-end check: assemble the test
|
|
// functions to a GOOBJ, swap it into a go build in place of the toolchain's
|
|
// assembly object, link, and run — the output must match the baseline
|
|
// binary the Go assembler produced. Skipped when no Go toolchain is
|
|
// available.
|
|
func TestGOObjectLinkAndRun(t *testing.T) {
|
|
goBin, err := exec.LookPath("go")
|
|
if err != nil {
|
|
t.Skip("no Go toolchain available")
|
|
}
|
|
dir := t.TempDir()
|
|
|
|
const asmSrc = `
|
|
#include "textflag.h"
|
|
|
|
TEXT ·addq(SB), NOSPLIT, $0-24
|
|
MOVQ a+0(FP), AX
|
|
MOVQ b+8(FP), CX
|
|
ADDQ CX, AX
|
|
MOVQ AX, ret+16(FP)
|
|
RET
|
|
|
|
TEXT ·loadmask(SB), NOSPLIT, $0-8
|
|
VMOVDQU mask<>(SB), X0
|
|
VPMOVMSKB X0, AX
|
|
MOVQ AX, ret+0(FP)
|
|
RET
|
|
|
|
GLOBL mask<>(SB), RODATA, $16
|
|
DATA mask<>+0(SB)/8, $0x0807060504030201
|
|
DATA mask<>+8(SB)/8, $0x800f0e0d0c0b0a09
|
|
`
|
|
const mainSrc = `package main
|
|
|
|
func addq(a, b int64) int64
|
|
func loadmask() int64
|
|
|
|
func main() {
|
|
println(addq(41, 1))
|
|
println(loadmask())
|
|
}
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "main_amd64.s"), []byte(asmSrc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSrc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module goobjtest\n\ngo 1.26\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Baseline build with the toolchain's assembler; keep the work
|
|
// directory and the commands the build used.
|
|
cmd := exec.Command(goBin, "build", "-x", "-work", "-o", "app", ".")
|
|
cmd.Dir = dir
|
|
buildLog, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("baseline build: %v\n%s", err, buildLog)
|
|
}
|
|
var work string
|
|
var asmObj, pkgArch, linkLine string
|
|
for _, line := range strings.Split(string(buildLog), "\n") {
|
|
switch {
|
|
case strings.HasPrefix(line, "WORK="):
|
|
work = strings.TrimPrefix(line, "WORK=")
|
|
case strings.Contains(line, "/asm ") && strings.Contains(line, "-o ") && strings.Contains(line, "main_amd64.s") && !strings.Contains(line, "-gensymabis"):
|
|
asmObj = fieldAfter(line, "-o")
|
|
case strings.Contains(line, "pack r") && strings.Contains(line, "_pkg_.a"):
|
|
pkgArch = strings.TrimSpace(strings.SplitN(line, "pack r", 2)[1])
|
|
pkgArch = strings.Fields(strings.SplitN(pkgArch, "#", 2)[0])[0]
|
|
case strings.Contains(line, "/link ") && strings.Contains(line, "-importcfg"):
|
|
linkLine = line
|
|
}
|
|
}
|
|
if work == "" || asmObj == "" || pkgArch == "" || linkLine == "" {
|
|
t.Fatalf("could not locate the build steps:\n%s", buildLog)
|
|
}
|
|
asmObj = strings.ReplaceAll(asmObj, "$WORK", work)
|
|
pkgArch = strings.ReplaceAll(pkgArch, "$WORK", work)
|
|
|
|
// The baseline's answer.
|
|
baseOut, err := exec.Command(filepath.Join(dir, "app")).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("run baseline: %v\n%s", err, baseOut)
|
|
}
|
|
|
|
// Assemble the same source with gasm and swap the object in.
|
|
pf, perrs := parser.Parse(filepath.Join(dir, "main_amd64.s"), asmSrc)
|
|
if len(perrs) > 0 {
|
|
t.Fatalf("parse: %v", perrs)
|
|
}
|
|
img, err := AssembleFile(pf)
|
|
if err != nil {
|
|
t.Fatalf("AssembleFile: %v", err)
|
|
}
|
|
obj, err := img.GOObject("main", filepath.Join(dir, "main_amd64.s"))
|
|
if err != nil {
|
|
t.Fatalf("GOObject: %v", err)
|
|
}
|
|
if err := os.WriteFile(asmObj, obj, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Rebuild the package archive with our object in place of the
|
|
// toolchain's (go tool pack has no replace-in-place that dedupes, so
|
|
// extract, substitute and repack).
|
|
extract := exec.Command(goBin, "tool", "pack", "x", pkgArch)
|
|
membersDir := filepath.Join(dir, "members")
|
|
if err := os.MkdirAll(membersDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
extract.Dir = membersDir
|
|
if out, err := extract.CombinedOutput(); err != nil {
|
|
t.Fatalf("pack x: %v\n%s", err, out)
|
|
}
|
|
listCmd := exec.Command(goBin, "tool", "pack", "t", pkgArch)
|
|
listOut, err := listCmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("pack t: %v\n%s", err, listOut)
|
|
}
|
|
newArch := filepath.Join(dir, "pkg.a")
|
|
args := []string{"tool", "pack", "c", newArch}
|
|
seen := map[string]bool{}
|
|
for _, m := range strings.Fields(string(listOut)) {
|
|
if seen[m] {
|
|
continue
|
|
}
|
|
seen[m] = true
|
|
if err := os.Chmod(filepath.Join(membersDir, m), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
args = append(args, filepath.Join(membersDir, m))
|
|
}
|
|
pack := exec.Command(goBin, args...)
|
|
pack.Dir = membersDir
|
|
if out, err := pack.CombinedOutput(); err != nil {
|
|
t.Fatalf("pack c: %v\n%s", err, out)
|
|
}
|
|
|
|
// Link with our archive. The link line carries a GOROOT assignment
|
|
// and $WORK placeholders; run it through the shell with the
|
|
// GOEXPERIMENT the toolchain expects (the linker compares the object
|
|
// header against its own, experiments included).
|
|
goExp, _ := exec.Command(goBin, "env", "GOEXPERIMENT").Output()
|
|
linkLine = strings.ReplaceAll(linkLine, "$WORK", work)
|
|
linkLine = strings.ReplaceAll(linkLine, filepath.Join(work, "b001", "_pkg_.a"), newArch)
|
|
linkLine = strings.ReplaceAll(linkLine, filepath.Join(work, "b001", "exe", "a.out"), filepath.Join(dir, "app2"))
|
|
link := exec.Command("sh", "-c", linkLine)
|
|
link.Dir = dir
|
|
link.Env = append(os.Environ(), "GOEXPERIMENT="+strings.TrimSpace(string(goExp)))
|
|
if out, err := link.CombinedOutput(); err != nil {
|
|
t.Fatalf("link with gasm object: %v\n%s", err, out)
|
|
}
|
|
got, err := exec.Command(filepath.Join(dir, "app2")).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("run gasm-linked binary: %v\n%s", err, got)
|
|
}
|
|
if !bytes.Equal(got, baseOut) {
|
|
t.Errorf("gasm-linked output %q, want baseline %q", got, baseOut)
|
|
}
|
|
}
|
|
|
|
// fieldAfter returns the whitespace-delimited field following the first
|
|
// occurrence of flag in line.
|
|
func fieldAfter(line, flag string) string {
|
|
fields := strings.Fields(line)
|
|
for i, f := range fields {
|
|
if f == flag && i+1 < len(fields) {
|
|
return fields[i+1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|