From 6556b85abff30cd85b88ce3990022d6cff72e645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 20 Sep 2026 19:15:05 +0200 Subject: [PATCH] feat(asm): symbol-valued DATA, division slash in symbols and plain semicolons Assisted-by: GLM 5.3 Flash --- asm/aarch64_goobj_test.go | 101 ++++++++++++ asm/elf.go | 75 +++++++-- asm/elf_test.go | 108 +++++++++++++ asm/goobj.go | 55 +++++++ asm/kernels_differential_test.go | 217 +++++++++++++++++++++++++ asm/link.go | 71 +++++++- asm/link_test.go | 252 +++++++++++++++++++++++++++++ lexer/lexer.go | 14 +- parser/parser.go | 43 ++++- testdata/verify/datarel_amd64.s | 25 +++ testdata/verify/datarel_arm64.s | 25 +++ testdata/verify/divslash_amd64.s | 19 +++ testdata/verify/divslash_arm64.s | 19 +++ testdata/verify/semicolons_amd64.s | 21 +++ 14 files changed, 1027 insertions(+), 18 deletions(-) create mode 100644 asm/kernels_differential_test.go create mode 100644 testdata/verify/datarel_amd64.s create mode 100644 testdata/verify/datarel_arm64.s create mode 100644 testdata/verify/divslash_amd64.s create mode 100644 testdata/verify/divslash_arm64.s create mode 100644 testdata/verify/semicolons_amd64.s diff --git a/asm/aarch64_goobj_test.go b/asm/aarch64_goobj_test.go index 247b440..1552af7 100644 --- a/asm/aarch64_goobj_test.go +++ b/asm/aarch64_goobj_test.go @@ -245,3 +245,104 @@ func main() { t.Error("binary does not contain expected symbol") } } + +// TestGOObjectAARCH64DataSymbolLink does for symbol-valued DATA fields what +// the rt0 files do ("DATA _rt0…lib+0(SB)/8, $_rt0…lib(SB)"): the gasm object +// carries an R_ADDR against the file's own TEXT symbol, the toolchain links +// it, and the binary is checked for the symbol (no arm64 host to run it). +func TestGOObjectAARCH64DataSymbolLink(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("no Go toolchain available") + } + dir := t.TempDir() + asmSrc := `#include "textflag.h" +GLOBL entry(SB), NOPTR, $8 +DATA entry+0(SB)/8, $·keepme(SB) + +TEXT ·keepme(SB), NOSPLIT, $0-0 + RET + +TEXT ·entryptr(SB), NOSPLIT, $0-8 + MOVD entry+0(SB), R4 + MOVD R4, ret+0(FP) + RET +` + if err := os.WriteFile(filepath.Join(dir, "main_arm64.s"), []byte(asmSrc), 0o644); err != nil { + t.Fatal(err) + } + mainSrc := `package main + +func keepme() +func entryptr() uintptr + +func main() { + if entryptr() == 0 { + panic("the entry word is empty") + } +} +` + 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 a64dlink\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatal(err) + } + + build := exec.Command(goBin, "build", "-x", "-work", "-o", filepath.Join(dir, "prog"), ".") + build.Dir = dir + build.Env = append(os.Environ(), "GOARCH=arm64") + buildLog, err := build.CombinedOutput() + if err != nil { + t.Fatalf("baseline build: %v\n%s", err, buildLog) + } + var work, linkLine, asmObj string + for line := range strings.SplitSeq(string(buildLog), "\n") { + switch { + case strings.HasPrefix(line, "WORK="): + work = strings.TrimPrefix(line, "WORK=") + case strings.Contains(line, "/asm ") && strings.Contains(line, "main_arm64.s") && !strings.Contains(line, "-gensymabis"): + asmObj = fieldAfter(line, "-o") + case strings.Contains(line, "/link ") && strings.Contains(line, "-importcfg"): + linkLine = line + } + } + if work == "" || asmObj == "" || linkLine == "" { + t.Skipf("could not parse build log (work=%q asmObj=%q link=%q)", work, asmObj, linkLine) + } + defer os.RemoveAll(work) + asmObj = strings.ReplaceAll(asmObj, "$WORK", work) + linkLine = strings.ReplaceAll(linkLine, "$WORK", work) + + src, err := os.ReadFile(filepath.Join(dir, "main_arm64.s")) + if err != nil { + t.Fatal(err) + } + f, errs := parser.Parse("main_arm64.s", string(src)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFileARM64(f) + if err != nil { + t.Fatalf("AssembleFileARM64: %v", err) + } + gasmObj, err := img.GOObjectAARCH64("a64dlink", "main_arm64.s") + if err != nil { + t.Fatalf("GOObjectAARCH64: %v", err) + } + if err := os.WriteFile(asmObj, gasmObj, 0o644); err != nil { + t.Fatalf("write gasm object: %v", err) + } + linkCmd := exec.Command("bash", "-c", "cd "+dir+" && "+linkLine) + linkCmd.Env = append(os.Environ(), "GOARCH=arm64") + if out, err := linkCmd.CombinedOutput(); err != nil { + t.Fatalf("re-link with gasm object: %v\n%s", err, out) + } + binData, err := os.ReadFile(filepath.Join(dir, "prog")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(binData), "keepme") { + t.Error("binary does not contain the keepme symbol") + } +} diff --git a/asm/elf.go b/asm/elf.go index b7ef9cc..f941db6 100644 --- a/asm/elf.go +++ b/asm/elf.go @@ -43,6 +43,9 @@ const ( stInfoShift = 4 rX8664PC32 = 2 + // R_X86_64_32 (debug/elf): the absolute 32-bit address of a symbol, the + // R_ADDR shape a 4-byte DATA field carries. + rX8664Abs32 = 10 // R_X86_64_TPOFF32 (debug/elf): the local-exec TLS offset the stack // guard loads from FS. 20 is R_X86_64_TLSLD, a different relocation. rX8664TPOFF32 = 23 @@ -158,6 +161,50 @@ func (img *Image) ELFObject() ([]byte, error) { } } + // The data symbols' symbol-valued DATA fields ("DATA s+0(SB)/8, + // $other(SB)") become .rela.data entries: an absolute relocation of the + // DATA line's width at the field's data-section offset, S + A with no + // PC term. Widths 4 and 8 have ELF relocation shapes; narrower fields + // cannot hold an address, so they are refused rather than truncated. + var dataRelas []elfRela + for _, d := range img.DataSyms { + for _, r := range d.Relocs { + idx, ok := symIdx[r.Name] + if !ok { + return nil, fmt.Errorf("data relocation references unknown symbol %q", r.Name) + } + var typ uint32 + switch r.Siz { + case 8: + typ = rX8664Abs64 + case 4: + typ = rX8664Abs32 + default: + return nil, fmt.Errorf("DATA %q: a symbol value of width %d has no ELF relocation", d.Name, r.Siz) + } + dataRelas = append(dataRelas, elfRela{ + off: uint64(d.Offset + r.Off), + sym: idx, + typ: typ, + addend: r.Addend, + }) + } + } + + // Section presence: .rela.text only when there are code relocations, + // .rela.data only when a DATA line holds a symbol value. + hasRela := len(relas) > 0 + hasDataRela := len(dataRelas) > 0 + nSections := 6 // NULL, .text, .data, .symtab, .strtab, .shstrtab + if hasRela { + nSections++ + } + if hasDataRela { + nSections++ + } + secSymtab, secStrtab := 3, 4 + secShstr := nSections - 1 + // Serialise the string tables. stNames := newElfStrtab() for _, s := range syms { @@ -167,19 +214,13 @@ func (img *Image) ELFObject() ([]byte, error) { for _, n := range []string{".text", ".data", ".symtab", ".strtab", ".rela.text", ".shstrtab"} { stSections.add(n) } + if hasDataRela { + stSections.add(".rela.data") + } for _, n := range dwarfSectionNames { stSections.add(n) } - // Section presence: .rela.text only when there are relocations. - hasRela := len(relas) > 0 - nSections := 6 // NULL, .text, .data, .symtab, .strtab, .shstrtab - if hasRela { - nSections = 7 - } - secSymtab, secStrtab := 3, 4 - secShstr := nSections - 1 - // Lay the file out: header, section data, section headers. var out []byte out = append(out, make([]byte, 64)...) // ELF header, filled last @@ -214,7 +255,7 @@ func (img *Image) ELFObject() ([]byte, error) { strtabOff := len(out) out = append(out, stNames.bytes()...) - var relaOff int + var relaOff, relaDataOff int if hasRela { align(8) relaOff = len(out) @@ -226,6 +267,17 @@ func (img *Image) ELFObject() ([]byte, error) { out = append(out, b[:]...) } } + if hasDataRela { + align(8) + relaDataOff = len(out) + for _, r := range dataRelas { + var b [24]byte + le.PutUint64(b[0:], r.off) + le.PutUint64(b[8:], uint64(r.sym)<<32|uint64(r.typ)) + le.PutUint64(b[16:], uint64(r.addend)) + out = append(out, b[:]...) + } + } shstrOff := len(out) out = append(out, stSections.bytes()...) @@ -284,6 +336,9 @@ func (img *Image) ELFObject() ([]byte, error) { if hasRela { putSh(".rela.text", shtRela, 0, relaOff, 24*len(relas), secSymtab, secText, 8, 24) } + if hasDataRela { + putSh(".rela.data", shtRela, 0, relaDataOff, 24*len(dataRelas), secSymtab, secData, 8, 24) + } putSh(".shstrtab", shtStrtab, 0, shstrOff, len(stSections.bytes()), 0, 0, 1, 0) // DWARF section headers; their indices follow the write order. diff --git a/asm/elf_test.go b/asm/elf_test.go index f5a9b17..8f92834 100644 --- a/asm/elf_test.go +++ b/asm/elf_test.go @@ -750,3 +750,111 @@ func readFormSkip(t *testing.T, r *ulebIter, form uint64) { t.Fatalf("unsupported form %#x", form) } } + +// TestELFObjectDataRelocation checks that a symbol-valued DATA field ("DATA +// s+0(SB)/8, $other(SB)") reaches the ELF object as a .rela.data entry: an +// absolute 64-bit relocation at the field's offset within .data, against +// the named symbol, external targets included. +func TestELFObjectDataRelocation(t *testing.T) { + f, errs := parser.Parse("t_amd64.s", `#include "textflag.h" +TEXT ·Keep(SB), NOSPLIT, $0-8 + RET +GLOBL holder(SB), NOPTR, $24 +DATA holder+0(SB)/8, $·Keep+5(SB) +DATA holder+8(SB)/8, $holder(SB) +DATA holder+16(SB)/8, $extvar(SB) +`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFile(f) + if err != nil { + t.Fatalf("assemble: %v", err) + } + obj, err := img.ELFObject() + if err != nil { + t.Fatalf("ELFObject: %v", err) + } + ef, err := elf.NewFile(bytes.NewReader(obj)) + if err != nil { + t.Fatalf("parse emitted object: %v", err) + } + defer ef.Close() + relaData := ef.Section(".rela.data") + if relaData == nil { + t.Fatal("missing .rela.data section") + } + if relaData.Link == 0 || ef.Sections[relaData.Link].Name != ".symtab" { + t.Errorf(".rela.data sh_link = %d, want the .symtab index", relaData.Link) + } + if ef.Sections[relaData.Info].Name != ".data" { + t.Errorf(".rela.data sh_info = %d, want the .data index", relaData.Info) + } + relas, err := relaData.Data() + if err != nil { + t.Fatal(err) + } + var got []struct { + off uint64 + sym uint32 + typ uint32 + addend int64 + } + for i := 0; i+24 <= len(relas); i += 24 { + got = append(got, struct { + off uint64 + sym uint32 + typ uint32 + addend int64 + }{ + off: binary.LittleEndian.Uint64(relas[i:]), + // r_info packs the type in the low dword and the symbol index + // in the high dword. + typ: binary.LittleEndian.Uint32(relas[i+8:]), + sym: binary.LittleEndian.Uint32(relas[i+12:]), + addend: int64(binary.LittleEndian.Uint64(relas[i+16:])), + }) + } + // debug/elf hides the table's null entry, so raw index s names syms[s-1]. + syms, err := ef.Symbols() + if err != nil { + t.Fatal(err) + } + name := func(idx uint32) string { + if idx >= 1 && int(idx) <= len(syms) { + return syms[idx-1].Name + } + return "" + } + // The offsets are data-section-relative: the field's DATA offset plus + // the symbol's position in .data (the layout aligns each symbol to 16). + base := uint64(0) + for _, d := range img.DataSyms { + if d.Name == "holder" { + base = uint64(d.Offset) + } + } + want := []struct { + off uint64 + typ uint32 + addend int64 + target string + }{ + {off: base + 0, typ: uint32(elf.R_X86_64_64), addend: 5, target: "Keep"}, + {off: base + 8, typ: uint32(elf.R_X86_64_64), addend: 0, target: "holder"}, + {off: base + 16, typ: uint32(elf.R_X86_64_64), addend: 0, target: "extvar"}, + } + if len(got) != len(want) { + t.Fatalf(".rela.data entries = %d, want %d", len(got), len(want)) + } + for i, w := range want { + g := got[i] + if g.off != w.off || g.typ != w.typ || g.addend != w.addend { + t.Errorf("entry %d = {off %d typ %d addend %d}, want {off %d typ %d addend %d}", + i, g.off, g.typ, g.addend, w.off, w.typ, w.addend) + } + if n := name(g.sym); n != w.target { + t.Errorf("entry %d names %q, want %q", i, n, w.target) + } + } +} diff --git a/asm/goobj.go b/asm/goobj.go index 2f92e33..00e5ab9 100644 --- a/asm/goobj.go +++ b/asm/goobj.go @@ -463,6 +463,61 @@ func (img *Image) emitGOObject(pkgPath, srcPath string, pre []byte, minLC int, r symRelocs[si] = append(symRelocs[si], rec[:]...) } } + // The data symbols' own relocations: the symbol-valued DATA fields + // ("DATA s+0(SB)/8, $other(SB)"). The toolchain patches each field + // with the target's absolute address through an R_ADDR of the DATA + // line's width, on every architecture (the code relocations are + // per-architecture PC-relative shapes; a data pointer word is not), so + // this mapping bypasses relocField. The definitions were appended in + // DataSyms order, so data symbol i is definition index i. + for i, d := range img.DataSyms { + for _, r := range d.Relocs { + if r.Kind != RelAddr { + return nil, fmt.Errorf("GOOBJ emission: data symbol %q carries a non-data relocation", d.Name) + } + var rec [23]byte + binary.LittleEndian.PutUint32(rec[0:], uint32(int32(r.Off))) + rec[4] = r.Siz + binary.LittleEndian.PutUint16(rec[5:], relocAddr) + binary.LittleEndian.PutUint64(rec[7:], uint64(r.Addend)) + switch { + case r.External && r.Name == goobjBuiltinMorestack: + binary.LittleEndian.PutUint32(rec[15:], pkgIdxBuiltin) + binary.LittleEndian.PutUint32(rec[19:], goobjBuiltinMorestackNoctxt) + case r.External: + pkg, name := splitQualified(r.Name) + if pkg == "" { + return nil, fmt.Errorf("GOOBJ emission: external symbol %q has no package prefix", r.Name) + } + pIdx, ok := extPkgIdx[pkg] + if !ok { + return nil, fmt.Errorf("GOOBJ emission: package %q not resolved", pkg) + } + sIdx, ok := extSymIdx[pkg+"·"+name] + if !ok { + return nil, fmt.Errorf("GOOBJ emission: symbol %s·%s not resolved", pkg, name) + } + binary.LittleEndian.PutUint32(rec[15:], uint32(pIdx)) + binary.LittleEndian.PutUint32(rec[19:], uint32(sIdx)) + default: + if di, ok := defIdx[r.Name]; ok { + binary.LittleEndian.PutUint32(rec[15:], pkgIdxSelf) + binary.LittleEndian.PutUint32(rec[19:], uint32(di)) + break + } + // A DATA field may hold the address of a TEXT function of + // the same file (the rt0 lib entry spelling), which is a + // non-package definition. + ni, isText := textNpIdx[r.Name] + if !isText { + return nil, fmt.Errorf("GOOBJ emission: reference to unknown symbol %q", r.Name) + } + binary.LittleEndian.PutUint32(rec[15:], pkgIdxNone) + binary.LittleEndian.PutUint32(rec[19:], uint32(ni)) + } + symRelocs[i] = append(symRelocs[i], rec[:]...) + } + } // The DWARF symbols' own relocations (the function address references). for _, ds := range dwarfRelocs { for _, r := range ds.relocs { diff --git a/asm/kernels_differential_test.go b/asm/kernels_differential_test.go new file mode 100644 index 0000000..dbc48eb --- /dev/null +++ b/asm/kernels_differential_test.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "bytes" + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-devkit/parser" +) + +// The differential kernels for the DATA-path and front-end gaps are kept in +// testdata/verify beside the campaign's other kernels; the verify package's +// suites are not open to the asm package, so this test is their runner: each +// kernel assembles through gasm and through go tool asm, and the functions' +// bytes must agree with the relocation sites masked on both sides. + +// toolAsmObject assembles path with the installed toolchain's assembler for +// goarch ("" = the host) and returns the object bytes. +func toolAsmObject(t *testing.T, path, goarch string) []byte { + t.Helper() + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("no Go toolchain available") + } + out, err := exec.Command(goBin, "env", "GOROOT").Output() + if err != nil { + t.Fatalf("go env GOROOT: %v", err) + } + includeDir := filepath.Join(strings.TrimSpace(string(out)), "pkg", "include") + + pkg := strings.TrimSuffix(filepath.Base(path), ".s") + pkg = strings.TrimSuffix(pkg, "_amd64") + pkg = strings.TrimSuffix(pkg, "_arm64") + + objPath := filepath.Join(t.TempDir(), "oracle.o") + cmd := exec.Command(goBin, "tool", "asm", "-I", includeDir, "-p", pkg, "-o", objPath, path) + if goarch != "" { + environ := os.Environ() + env := make([]string, 0, len(environ)+1) + for _, e := range environ { + if !strings.HasPrefix(e, "GOARCH=") { + env = append(env, e) + } + } + cmd.Env = append(env, "GOARCH="+goarch) + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go tool asm %s: %v\n%s", filepath.Base(path), err, out) + } + obj, err := os.ReadFile(objPath) + if err != nil { + t.Fatal(err) + } + return obj +} + +// oracleFuncCode extracts the non-package TEXT functions' code bytes from a +// toolchain object, keyed by the name the object records (pkg.name). +func oracleFuncCode(t *testing.T, obj []byte) map[string][]byte { + t.Helper() + v := openGoobj(t, obj) + le := binary.LittleEndian + const symSize = 21 + nps := v.syms(blkNonpkgdef) + data := v.blk(blkData) + didx := v.blk(blkDataIdx) + preceding := 0 + for _, bi := range []int{blkSymdef, blkHashed64def, blkHasheddef} { + preceding += len(v.blk(bi)) / symSize + } + total := preceding + len(nps) + out := make(map[string][]byte, len(nps)) + for i, s := range nps { + if s.typ != kindSTEXT { + continue + } + start := le.Uint32(didx[4*(preceding+i):]) + end := uint32(len(data)) + if preceding+i+1 < total { + end = le.Uint32(didx[4*(preceding+i+1):]) + } + out[s.name] = data[start:end] + } + return out +} + +// maskCode zeroes every relocation field, the way the toolchain's object +// leaves them for the linker. +func maskCode(code []byte, relocs []Reloc) []byte { + for _, r := range relocs { + for j := r.Off; j < r.Off+4 && j < len(code); j++ { + code[j] = 0 + } + } + return code +} + +// code assembles src for amd64 and returns the image's code bytes. +func code(path, src string) []byte { + f, errs := parser.Parse(path, src) + if len(errs) > 0 { + return nil + } + img, err := AssembleFile(f) + if err != nil { + return nil + } + return img.Code +} + +// TestDifferentialKernels pins the new kernels against the oracle. +func TestDifferentialKernels(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("the amd64 kernels assume an amd64 host assembler default") + } + for _, k := range []struct { + path string + goarch string + arm64 bool + }{ + {filepath.Join("..", "testdata", "verify", "datarel_amd64.s"), "", false}, + {filepath.Join("..", "testdata", "verify", "divslash_amd64.s"), "", false}, + {filepath.Join("..", "testdata", "verify", "semicolons_amd64.s"), "", false}, + {filepath.Join("..", "testdata", "verify", "datarel_arm64.s"), "arm64", true}, + {filepath.Join("..", "testdata", "verify", "divslash_arm64.s"), "arm64", true}, + } { + t.Run(filepath.Base(k.path), func(t *testing.T) { + src, err := os.ReadFile(k.path) + if err != nil { + t.Fatalf("read: %v", err) + } + f, errs := parser.Parse(k.path, string(src)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var img *Image + if k.arm64 { + img, err = AssembleFileARM64(f) + } else { + img, err = AssembleFile(f) + } + if err != nil { + t.Fatalf("assemble: %v", err) + } + gt := oracleFuncCode(t, toolAsmObject(t, k.path, k.goarch)) + // The oracle keys its functions by the qualified object name + // (pkg.name); match on the local part. + byLocal := make(map[string][]byte, len(gt)) + for name, code := range gt { + if _, after, ok := strings.Cut(name, "."); ok { + name = after + } + byLocal[name] = code + } + + matched := 0 + for _, fn := range img.Funcs { + gasmCode := maskCode(append([]byte(nil), img.Code[fn.Offset:fn.Offset+fn.Size]...), fn.Relocs) + goCode, ok := byLocal[fn.Name] + if !ok { + t.Errorf("%s: not in ground truth (%d functions: %v)", fn.Name, len(gt), keysOf(byLocal)) + continue + } + goCode = maskCode(append([]byte(nil), goCode...), fn.Relocs) + cmpLen := min(len(goCode), len(gasmCode)) + if !bytes.Equal(gasmCode[:cmpLen], goCode[:cmpLen]) { + t.Errorf("%s: MISMATCH gasm=%d go=%d bytes\ngasm %x\ngo %x", fn.Name, len(gasmCode), len(goCode), gasmCode, goCode) + continue + } + for _, b := range goCode[len(gasmCode):] { + if b != 0 { + t.Errorf("%s: non-zero trailing bytes in go tool asm output", fn.Name) + break + } + } + matched++ + t.Logf("%s: MATCH (%d bytes)", fn.Name, len(gasmCode)) + } + if matched == 0 { + t.Fatal("no functions matched") + } + }) + } +} + +func keysOf(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// TestSemicolonSpellingParity pins that the ';' statement separator changes +// nothing about the encoding: the one-line spelling assembles to exactly the +// bytes of the same statements written one per line. +func TestSemicolonSpellingParity(t *testing.T) { + for _, tt := range []struct{ one, two string }{ + {"\tROLQ $3, DI; ROLQ $13, DI\n", "\tROLQ $3, DI\n\tROLQ $13, DI\n"}, + {"\tREP; MOVSQ\n", "\tREP\n\tMOVSQ\n"}, + {"\tXORQ AX, AX; XORQ CX, CX\n", "\tXORQ AX, AX\n\tXORQ CX, CX\n"}, + } { + one := code("t.s", "TEXT \u00b7f(SB), NOSPLIT, $0\n"+tt.one+"\tRET\n") + two := code("t.s", "TEXT \u00b7f(SB), NOSPLIT, $0\n"+tt.two+"\tRET\n") + if !bytes.Equal(one, two) { + t.Errorf("semicolon spelling %q: %x, want the two-line bytes %x", tt.one, one, two) + } + } +} diff --git a/asm/link.go b/asm/link.go index 40c2ff6..ab5ef97 100644 --- a/asm/link.go +++ b/asm/link.go @@ -103,6 +103,7 @@ const ( RelArm64Branch // R_CALLARM64 (BL instruction) RelArm64LDST64 // R_ARM64_PCREL_LDST64 (ADRP + 64-bit LDR/STR pair) RelLoong64Branch // R_CALLLOONG64 (BL instruction) + RelAddr // R_ADDR: the absolute address of a symbol held in a DATA field ) type Reloc struct { @@ -112,13 +113,17 @@ type Reloc struct { // Addend select the target: the symbol plus the byte offset. An // External relocation names a symbol no GLOBL in the file defines; // the object-file emitters carry it into the output's relocation - // table. + // table. Siz is the width of the patched field and is set only for + // data-field relocations (RelAddr, Off relative to the data symbol), + // whose width is the DATA line's; code relocations take their width + // from the architecture's instruction encoding. Off int After int Name string Addend int64 External bool Kind RelocKind + Siz uint8 } // DataSymbol describes one GLOBL symbol laid out in the data section. @@ -130,6 +135,11 @@ type DataSymbol struct { Static bool // the <> marker: file-local, not exported Rodata bool // the RODATA flag: read-only data Dupok bool // the DUPOK flag: duplicate-OK + // Relocs carries the symbol-valued DATA initialisers ("DATA s+0(SB)/8, + // $other(SB)"): fields of this symbol's data that hold another symbol's + // address, resolved by the linker. Off is relative to the symbol's + // data start. + Relocs []Reloc } // Bytes returns the whole image: code, then data. @@ -257,6 +267,22 @@ func AssembleFile(f *ast.File) (*Image, error) { img.Funcs[i].Relocs = append(img.Funcs[i].Relocs, reloc) } } + // The data symbols' symbol-valued DATA fields resolve the same way the + // code references do: a name the file defines (GLOBL or TEXT) stays an + // internal reference the emitters resolve, anything else is external. + // img.DataSyms was laid out in dataSyms order, so the indexes line up. + for i := range img.DataSyms { + for _, r := range dataSyms[i].relocs { + reloc := r + if _, ok := img.Symbols[reloc.Name]; !ok { + if _, ok := textOff[reloc.Name]; !ok { + reloc.External = true + externals[reloc.Name] = true + } + } + img.DataSyms[i].Relocs = append(img.DataSyms[i].Relocs, reloc) + } + } for name := range externals { img.Externals = append(img.Externals, name) } @@ -429,6 +455,23 @@ func markExternals(img *Image, dataSyms []dataSym) { } } } + // The declared data symbols carry the file's own relocations (the + // symbol-valued DATA fields); the layouts appended img.DataSyms in + // dataSyms order, so the indexes line up. The trailing entries (the + // pooled arm64 literals) have no source relocations. + for i := range img.DataSyms { + if i >= len(dataSyms) { + break + } + for _, r := range dataSyms[i].relocs { + reloc := r + if !known[reloc.Name] { + reloc.External = true + externals[reloc.Name] = true + } + img.DataSyms[i].Relocs = append(img.DataSyms[i].Relocs, reloc) + } + } for name := range externals { img.Externals = append(img.Externals, name) } @@ -444,6 +487,9 @@ type dataSym struct { static bool rodata bool dupok bool + // relocs are the symbol-valued DATA fields, in declaration order; Off + // is relative to the symbol's data start. + relocs []Reloc } // collectData gathers the file's static symbols (GLOBL) and their initial @@ -511,8 +557,8 @@ func collectData(f *ast.File) ([]dataSym, error) { if !ok { return nil, fmt.Errorf("DATA %q: no matching GLOBL", dd.Name.Name) } - if dd.Value == nil || !dd.Value.Imm.HasVal { - return nil, fmt.Errorf("DATA %q: value must be an integer immediate", dd.Name.Name) + if dd.Value == nil { + return nil, fmt.Errorf("DATA %q: missing value", dd.Name.Name) } w := dd.Width switch w { @@ -525,6 +571,25 @@ func collectData(f *ast.File) ([]dataSym, error) { if off < 0 || off+int64(w) > int64(len(buf)) { return nil, fmt.Errorf("DATA %q+%d/%d exceeds GLOBL size %d", dd.Name.Name, off, w, len(buf)) } + // A symbol value ("DATA s+0(SB)/8, $other(SB)", the rt0 spelling) + // leaves the field zero and records a relocation against the named + // symbol: the linker patches the absolute address at this data + // offset. The toolchain emits the same shape, an R_ADDR of the + // DATA width with the value's offset as the addend, on every + // architecture. + if sym := dd.Value.Imm.Sym; !dd.Value.Imm.HasVal && sym != nil { + syms[i].relocs = append(syms[i].relocs, Reloc{ + Off: int(off), + Name: sym.Name, + Addend: sym.Offset, + Kind: RelAddr, + Siz: uint8(w), + }) + continue + } + if !dd.Value.Imm.HasVal { + return nil, fmt.Errorf("DATA %q: value must be an integer immediate or a symbol address", dd.Name.Name) + } v := dd.Value.Imm.Val if dd.Value.Imm.Neg { v = -v diff --git a/asm/link_test.go b/asm/link_test.go index 52af1de..c670f15 100644 --- a/asm/link_test.go +++ b/asm/link_test.go @@ -4,6 +4,10 @@ package asm import ( + "encoding/binary" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -166,3 +170,251 @@ func TestCollectDataNumericFlags(t *testing.T) { } } } + +// TestCollectDataSymbolValue covers the symbol-valued DATA field ("DATA +// s+0(SB)/8, $other(SB)", the rt0 spelling): the field stays zero in the +// image and the relocation is recorded against the named symbol, whatever +// the file defines (a TEXT function, a GLOBL) or leaves external. +func TestCollectDataSymbolValue(t *testing.T) { + src := `#include "textflag.h" +TEXT ·Keep(SB), NOSPLIT, $0-8 + MOVQ target+0(FP), AX + RET +GLOBL holder(SB), NOPTR, $32 +DATA holder+0(SB)/8, $·Keep(SB) +DATA holder+8(SB)/8, $·Keep+5(SB) +DATA holder+16(SB)/8, $holder(SB) +GLOBL spare(SB), NOPTR, $8 +DATA spare+0(SB)/8, $extvar(SB) +` + f, errs := parser.Parse("f_amd64.s", src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFile(f) + if err != nil { + t.Fatalf("assemble: %v", err) + } + byName := map[string]DataSymbol{} + for _, d := range img.DataSyms { + byName[d.Name] = d + } + want := []struct { + sym string + off int + name string + addend int64 + ext bool + }{ + {"holder", 0, "Keep", 0, false}, + {"holder", 8, "Keep", 5, false}, + {"holder", 16, "holder", 0, false}, + {"spare", 0, "extvar", 0, true}, + } + var flat []struct { + sym string + r Reloc + } + for _, d := range img.DataSyms { + for _, r := range d.Relocs { + flat = append(flat, struct { + sym string + r Reloc + }{d.Name, r}) + } + } + if len(flat) != len(want) { + t.Fatalf("data relocations = %d, want %d", len(flat), len(want)) + } + for i, w := range want { + g := flat[i] + r := g.r + if g.sym != w.sym { + t.Errorf("relocation %d sits on %q, want %q", i, g.sym, w.sym) + continue + } + if r.Off != w.off || r.Name != w.name || r.Addend != w.addend || r.External != w.ext { + t.Errorf("relocation %d = {+%d %q addend %d ext %v}, want {+%d %q addend %d ext %v}", + i, r.Off, r.Name, r.Addend, r.External, w.off, w.name, w.addend, w.ext) + } + if r.Kind != RelAddr { + t.Errorf("relocation %d kind = %v, want RelAddr", i, r.Kind) + } + if r.Siz != 8 { + t.Errorf("relocation %d siz = %d, want 8", i, r.Siz) + } + } + // The fields themselves stay zero: only the linker fills them. + for _, b := range img.Data { + if b != 0 { + t.Fatal("data section is not all zero before relocation") + } + } + if len(img.Externals) != 1 || img.Externals[0] != "extvar" { + t.Errorf("Externals = %v, want [extvar]", img.Externals) + } +} + +// TestGOObjectDataSymbolReloc pins the GOOBJ record a symbol-valued DATA +// field produces, against the shape the toolchain emits for the same +// source: an R_ADDR of the DATA width at the field offset, pkgIdxNone plus +// the non-package definition index when the target is the file's own TEXT +// function (the rt0 lib entry spelling). +func TestGOObjectDataSymbolReloc(t *testing.T) { + f, errs := parser.Parse("f_amd64.s", `#include "textflag.h" +TEXT ·Keep(SB), NOSPLIT, $0-8 + RET +GLOBL holder(SB), NOPTR, $16 +DATA holder+0(SB)/8, $·Keep+5(SB) +`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFile(f) + if err != nil { + t.Fatalf("assemble: %v", err) + } + obj, err := img.GOObject("main", "f_amd64.s") + if err != nil { + t.Fatalf("GOObject: %v", err) + } + v := openGoobj(t, obj) + // Walk every relocation record; the data record is the one of Siz 8 + // and type R_ADDR. + var off, add int64 + var pkg, sym uint32 + found := false + for data := v.blk(blkReloc); len(data) >= 23; data = data[23:] { + if data[4] != 8 || binary.LittleEndian.Uint16(data[5:]) != relocAddr { + continue + } + found = true + off = int64(int32(binary.LittleEndian.Uint32(data[0:]))) + add = int64(binary.LittleEndian.Uint64(data[7:])) + pkg = binary.LittleEndian.Uint32(data[15:]) + sym = binary.LittleEndian.Uint32(data[19:]) + break + } + if !found { + t.Fatal("no data relocation record in the object") + } + if off != 0 || add != 5 { + t.Errorf("data reloc = {off %d addend %d}, want {off 0 addend 5}", off, add) + } + if pkg != pkgIdxNone { + t.Errorf("data reloc pkg = %#x, want pkgIdxNone (the TEXT function)", pkg) + } + // The function's non-package definition index: the four pc tables + // precede it, so index 4. + if sym != 4 { + t.Errorf("data reloc sym = %d, want 4", sym) + } +} + +// TestGOObjectDataSymbolLink is the end-to-end proof for symbol-valued DATA +// fields: the gasm object is substituted for the toolchain's and re-linked, +// then executed, and the linked data word must hold the real address of the +// function the DATA line named (runtime.FuncForPC identifies it). +func TestGOObjectDataSymbolLink(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("no Go toolchain available") + } + dir := t.TempDir() + asmSrc := `#include "textflag.h" +GLOBL entry(SB), NOPTR, $8 +DATA entry+0(SB)/8, $·keepme(SB) + +TEXT ·keepme(SB), NOSPLIT, $0-0 + RET + +TEXT ·entryptr(SB), NOSPLIT, $0-8 + MOVQ entry+0(SB), AX + MOVQ AX, ret+0(FP) + RET +` + if err := os.WriteFile(filepath.Join(dir, "main_amd64.s"), []byte(asmSrc), 0o644); err != nil { + t.Fatal(err) + } + mainSrc := `package main + +import "runtime" + +func keepme() +func entryptr() uintptr + +func main() { + pc := entryptr() + fn := runtime.FuncForPC(pc) + if fn == nil { + panic("the entry word does not point at a function") + } + if fn.Name() != "main.keepme" { + panic("the entry word points at " + fn.Name()) + } +} +` + 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 dlink\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Capture the build: the package archive's asm object and the link line. + build := exec.Command(goBin, "build", "-x", "-work", "-o", filepath.Join(dir, "prog"), ".") + build.Dir = dir + buildLog, err := build.CombinedOutput() + if err != nil { + t.Fatalf("baseline build: %v\n%s", err, buildLog) + } + var work, linkLine, asmObj string + for line := range strings.SplitSeq(string(buildLog), "\n") { + switch { + case strings.HasPrefix(line, "WORK="): + work = strings.TrimPrefix(line, "WORK=") + case strings.Contains(line, "/asm ") && strings.Contains(line, "main_amd64.s") && !strings.Contains(line, "-gensymabis"): + asmObj = fieldAfter(line, "-o") + case strings.Contains(line, "/link ") && strings.Contains(line, "-importcfg"): + linkLine = line + } + } + if work == "" || asmObj == "" || linkLine == "" { + t.Skipf("could not parse build log (work=%q asmObj=%q link=%q)", work, asmObj, linkLine) + } + defer os.RemoveAll(work) + asmObj = strings.ReplaceAll(asmObj, "$WORK", work) + linkLine = strings.ReplaceAll(linkLine, "$WORK", work) + + // Assemble the same source with gasm and substitute the object. + src, err := os.ReadFile(filepath.Join(dir, "main_amd64.s")) + if err != nil { + t.Fatal(err) + } + f, errs := parser.Parse("main_amd64.s", string(src)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFile(f) + if err != nil { + t.Fatalf("AssembleFile: %v", err) + } + gasmObj, err := img.GOObject("dlink", "main_amd64.s") + if err != nil { + t.Fatalf("GOObject: %v", err) + } + if err := os.WriteFile(asmObj, gasmObj, 0o644); err != nil { + t.Fatalf("write gasm object: %v", err) + } + linkCmd := exec.Command("bash", "-c", "cd "+dir+" && "+linkLine) + if out, err := linkCmd.CombinedOutput(); err != nil { + t.Fatalf("re-link with gasm object: %v\n%s", err, out) + } + + // The linked program must run and find the right function behind the + // data word. + out, err := exec.Command(filepath.Join(dir, "prog")).CombinedOutput() + if err != nil { + t.Fatalf("linked program failed: %v\n%s", err, out) + } +} diff --git a/lexer/lexer.go b/lexer/lexer.go index be38828..44c8a81 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -16,8 +16,16 @@ import ( "sourcedock.dev/petrbalvin/gasm-devkit/token" ) -// middleDot is the Plan 9 symbol separator (U+00B7), used in ·funcName(SB). -const middleDot = '\u00B7' +const ( + // middleDot is the Plan 9 symbol separator (U+00B7), used in + // ·funcName(SB): it stands for the period between package path and name. + middleDot = '\u00B7' + // divisionSlash is the Plan 9 path separator (U+2215), used inside the + // package path of a symbol: internal∕runtime∕atomic·Xchg. Like the + // middle dot it is an identifier character, so a package path containing + // it lexes as one name; the ordinary slash (U+002F) stays punctuation. + divisionSlash = '\u2215' +) // Lexer scans a source string one token at a time. type Lexer struct { @@ -460,7 +468,7 @@ func isHexDigit(r rune) bool { } func isIdentStart(r rune) bool { - return r == '_' || r == middleDot || unicode.IsLetter(r) + return r == '_' || r == middleDot || r == divisionSlash || unicode.IsLetter(r) } func isIdentChar(r rune) bool { diff --git a/parser/parser.go b/parser/parser.go index 148de2c..1e77d5f 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -32,9 +32,8 @@ func (e Error) Error() string { // returned file is usable even when errors is non-empty. func Parse(path, src string) (*ast.File, []error) { tokens := lexer.Tokenize(src) - lines := splitLines(tokens) p := &state{path: path} - p.parse(lines) + p.parse(statementLines(tokens)) return p.file, p.errs } @@ -73,6 +72,46 @@ func splitLines(tokens []token.Token) [][]token.Token { return lines } +// statementLines turns the token stream into the logical lines the parser +// reads: physical lines split at the ';' statement separators, exactly the +// way the expansion path treats the expanded bodies. The runtime writes +// "ROLQ $3, DI; ROLQ $13, DI" and "REP; MOVSB" in plain files, and the +// separator carries no meaning beyond the break. Comments are statement +// text, not structure: the lexer delivers a whole comment as one token, so +// a ';' inside a comment is never a separator; a comment after a statement +// stays on that statement's line; and a comment that sits between +// statements (the runtime's "NO_LOCAL_POINTERS; /* … */" style) stands as +// its own logical line, like a whole-line comment. +func statementLines(tokens []token.Token) [][]token.Token { + var out [][]token.Token + var cur []token.Token + flush := func() { + if len(cur) > 0 { + out = append(out, cur) + cur = nil + } + } + for _, t := range tokens { + switch t.Kind { + case token.EOF: + // The stream's terminator is not statement content. + case token.Newline, token.Semicolon: + flush() + case token.Comment: + if len(cur) > 0 { + cur = append(cur, t) + } else { + out = append(out, []token.Token{t}) + } + flush() + default: + cur = append(cur, t) + } + } + flush() + return out +} + func (p *state) parse(lines [][]token.Token) { p.file = &ast.File{Path: p.path, Macros: map[string]bool{}} for _, line := range lines { diff --git a/testdata/verify/datarel_amd64.s b/testdata/verify/datarel_amd64.s new file mode 100644 index 0000000..0e9de64 --- /dev/null +++ b/testdata/verify/datarel_amd64.s @@ -0,0 +1,25 @@ +#include "textflag.h" + +// The kernel exercises the symbol-valued DATA spelling the runtime's rt0 +// files use: a data word holding the address of a symbol, resolved by the +// linker through a relocation at the field. + +// func lookup() ptr +TEXT ·lookup(SB), NOSPLIT, $0-8 + MOVQ handlers+8(SB), AX + MOVQ AX, ret+0(FP) + RET + +// func handler() int64 +TEXT ·handler(SB), NOSPLIT, $0-8 + MOVQ $42, AX + MOVQ AX, ret+0(FP) + RET + +GLOBL handlers(SB), NOPTR, $24 +DATA handlers+0(SB)/8, $·handler(SB) +DATA handlers+8(SB)/8, $table(SB) +DATA handlers+16(SB)/8, $·handler+5(SB) + +GLOBL table(SB), RODATA, $8 +DATA table+0(SB)/8, $0x123456789abcdef0 diff --git a/testdata/verify/datarel_arm64.s b/testdata/verify/datarel_arm64.s new file mode 100644 index 0000000..ab5a548 --- /dev/null +++ b/testdata/verify/datarel_arm64.s @@ -0,0 +1,25 @@ +#include "textflag.h" + +// The kernel exercises the symbol-valued DATA spelling the runtime's rt0 +// files use: a data word holding the address of a symbol, resolved by the +// linker through a relocation at the field. + +// func lookup() ptr +TEXT ·lookup(SB), NOSPLIT, $0-8 + MOVD handlers+8(SB), R4 + MOVD R4, ret+0(FP) + RET + +// func handler() int64 +TEXT ·handler(SB), NOSPLIT, $0-8 + MOVZ $42, R4 + MOVD R4, ret+0(FP) + RET + +GLOBL handlers(SB), NOPTR, $24 +DATA handlers+0(SB)/8, $·handler(SB) +DATA handlers+8(SB)/8, $table(SB) +DATA handlers+16(SB)/8, $extentry(SB) + +GLOBL table(SB), RODATA, $8 +DATA table+0(SB)/8, $0x123456789abcdef0 diff --git a/testdata/verify/divslash_amd64.s b/testdata/verify/divslash_amd64.s new file mode 100644 index 0000000..a2a8d89 --- /dev/null +++ b/testdata/verify/divslash_amd64.s @@ -0,0 +1,19 @@ +#include "textflag.h" + +// The kernel exercises the U+2215 DIVISION SLASH inside a symbol's package +// path: internal∕runtime∕atomic·Xchg, the spelling sync/atomic/asm.s uses. +// The middle dot (U+00B7) still separates the package path from the name. + +// func swap(a, b int64) int64 +TEXT ·swap(SB), NOSPLIT, $0-24 + MOVQ a+0(FP), DI + MOVQ b+8(FP), SI + CALL internal∕runtime∕atomic·Xchg(SB) + MOVQ AX, ret+16(FP) + RET + +// func note() int64 +TEXT ·note(SB), NOSPLIT, $0-8 + CALL runtime∕debug·SetGCPercent(SB) + MOVQ AX, ret+0(FP) + RET diff --git a/testdata/verify/divslash_arm64.s b/testdata/verify/divslash_arm64.s new file mode 100644 index 0000000..b0512d6 --- /dev/null +++ b/testdata/verify/divslash_arm64.s @@ -0,0 +1,19 @@ +#include "textflag.h" + +// The kernel exercises the U+2215 DIVISION SLASH inside a symbol's package +// path: internal∕runtime∕atomic·Xchg, the spelling sync/atomic/asm.s uses. +// The middle dot (U+00B7) still separates the package path from the name. + +// func swap(a, b int64) int64 +TEXT ·swap(SB), NOSPLIT, $0-24 + MOVD a+0(FP), R4 + MOVD b+8(FP), R5 + CALL internal∕runtime∕atomic·Xchg(SB) + MOVD R4, ret+16(FP) + RET + +// func note() int64 +TEXT ·note(SB), NOSPLIT, $0-8 + CALL runtime∕debug·SetGCPercent(SB) + MOVD R0, ret+0(FP) + RET diff --git a/testdata/verify/semicolons_amd64.s b/testdata/verify/semicolons_amd64.s new file mode 100644 index 0000000..e8e72ad --- /dev/null +++ b/testdata/verify/semicolons_amd64.s @@ -0,0 +1,21 @@ +#include "textflag.h" + +// The kernel exercises the ';' statement separator in a plain file, the way +// the runtime writes it ("ROLQ $3, DI; ROLQ $13, DI", "REP; MOVSQ"). Each +// statement assembles exactly as it would on a line of its own. + +// func rol(x int64) int64 +TEXT ·rol(SB), NOSPLIT, $0-16 + ROLQ $3, DI; ROLQ $13, DI + MOVQ DI, ret+0(FP) + RET + +// func move(dst, src unsafe.Pointer) +TEXT ·move(SB), NOSPLIT, $0-16 + REP ; MOVSQ + RET + +TEXT ·paired(SB), NOSPLIT, $0-8 + XORQ AX, AX; XORQ CX, CX + MOVQ AX, ret+0(FP) + RET