// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause package asm import ( "bytes" "debug/elf" "encoding/binary" "os" "os/exec" "path/filepath" "testing" "sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/parser" ) // The object-file tests share one source: two exported functions, one // file-local constant reached through a relocation, and one external symbol // the linker must resolve. The functions take their arguments in the System // V registers (not the Go stack ABI) so a C driver can call them directly. const elfTestSrc = ` #include "textflag.h" TEXT ·addq(SB), NOSPLIT, $0 LEAQ (DI)(SI*1), AX RET TEXT ·getanswer(SB), NOSPLIT, $0 MOVQ answer<>(SB), AX RET TEXT ·useextern(SB), NOSPLIT, $0 MOVQ extvar(SB), AX RET GLOBL answer<>(SB), RODATA, $8 DATA answer<>+0(SB)/8, $42 ` func elfTestImage(t *testing.T) *Image { t.Helper() f, errs := parser.Parse("t_amd64.s", elfTestSrc) if len(errs) > 0 { t.Fatalf("parse: %v", errs) } img, err := AssembleFile(f) if err != nil { t.Fatalf("AssembleFile: %v", err) } return img } // TestAssembleFileExternals checks that a reference to a symbol no GLOBL // defines is recorded as an external relocation instead of failing; the // raw image leaves the displacement zero, the object emitters carry it. func TestAssembleFileExternals(t *testing.T) { img := elfTestImage(t) if len(img.Externals) != 1 || img.Externals[0] != "extvar" { t.Fatalf("Externals = %v, want [extvar]", img.Externals) } var ext, local int for _, fn := range img.Funcs { for _, r := range fn.Relocs { if r.External { ext++ if r.Name != "extvar" { t.Errorf("external reloc names %q, want extvar", r.Name) } } else { local++ if r.Name != "answer" { t.Errorf("local reloc names %q, want answer", r.Name) } } } } if ext != 1 || local != 1 { t.Errorf("relocs = %d external, %d local; want 1 and 1", ext, local) } } // TestELFObject checks the structure of the emitted ELF64 relocatable // object: sections, the symbol table (bindings, types, values, sizes) and // the .rela.text relocations, parsed back with debug/elf. func TestELFObject(t *testing.T) { img := elfTestImage(t) obj, err := img.ELFObject() if err != nil { t.Fatalf("ELFObject: %v", err) } f, err := elf.NewFile(bytes.NewReader(obj)) if err != nil { t.Fatalf("parse emitted object: %v", err) } defer f.Close() if f.Type != elf.ET_REL || f.Machine != elf.EM_X86_64 { t.Errorf("type/machine = %v/%v, want ET_REL/EM_X86_64", f.Type, f.Machine) } text := f.Section(".text") data := f.Section(".data") if text == nil || data == nil { t.Fatal("missing .text or .data section") } if text.Flags&elf.SHF_EXECINSTR == 0 || text.Flags&elf.SHF_ALLOC == 0 { t.Errorf(".text flags = %v", text.Flags) } if data.Flags&elf.SHF_WRITE == 0 { t.Errorf(".data flags = %v", data.Flags) } textData, err := text.Data() if err != nil { t.Fatal(err) } if !bytes.Equal(textData, img.Code) { t.Errorf(".text contents differ from the image code") } syms, err := f.Symbols() if err != nil { t.Fatalf("symbols: %v", err) } byName := map[string]elf.Symbol{} for _, s := range syms { byName[s.Name] = s } wantSym := func(name string, bind elf.SymBind, typ elf.SymType, section elf.SectionIndex, size uint64) { t.Helper() s, ok := byName[name] if !ok { t.Errorf("symbol %q not found", name) return } if elf.ST_BIND(s.Info) != bind || elf.ST_TYPE(s.Info) != typ { t.Errorf("%s: bind/type = %v/%v, want %v/%v", name, elf.ST_BIND(s.Info), elf.ST_TYPE(s.Info), bind, typ) } if s.Section != section { t.Errorf("%s: section = %v, want %v", name, s.Section, section) } if s.Size != size { t.Errorf("%s: size = %d, want %d", name, s.Size, size) } } // The emitted layout is fixed: 0 NULL, 1 .text, 2 .data. if f.Sections[1].Name != ".text" || f.Sections[2].Name != ".data" { t.Fatalf("section layout = %s, %s; want .text, .data", f.Sections[1].Name, f.Sections[2].Name) } textIdx := elf.SectionIndex(1) dataIdx := elf.SectionIndex(2) wantSym("addq", elf.STB_GLOBAL, elf.STT_FUNC, textIdx, 5) wantSym("getanswer", elf.STB_GLOBAL, elf.STT_FUNC, textIdx, 8) wantSym("useextern", elf.STB_GLOBAL, elf.STT_FUNC, textIdx, 8) wantSym("answer", elf.STB_LOCAL, elf.STT_OBJECT, dataIdx, 8) wantSym("extvar", elf.STB_GLOBAL, elf.STT_NOTYPE, elf.SHN_UNDEF, 0) // Relocations: one for the file-local constant (resolving against the // local data symbol) and one for the external (against the undefined // global), both R_X86_64_PC32 with the −4 addend the PC-relative form // needs. debug/elf does not surface rela entries, so read the section // directly. relaSec := f.Section(".rela.text") if relaSec == nil { t.Fatal("missing .rela.text") } raw, err := relaSec.Data() if err != nil { t.Fatal(err) } if len(raw)%24 != 0 || len(raw)/24 != 2 { t.Fatalf(".rela.text has %d bytes, want two 24-byte entries", len(raw)) } // Symbol names straight from the raw tables: r_info carries an index // into .symtab including the null entry, which debug/elf's Symbols() // slice may not mirror. symtabRaw, err := f.Section(".symtab").Data() if err != nil { t.Fatal(err) } strtabRaw, err := f.Section(".strtab").Data() if err != nil { t.Fatal(err) } symName := func(idx int) string { stName := binary.LittleEndian.Uint32(symtabRaw[idx*24:]) end := bytes.IndexByte(strtabRaw[stName:], 0) return string(strtabRaw[stName : int(stName)+end]) } for i := range 2 { e := raw[i*24 : (i+1)*24] off := binary.LittleEndian.Uint64(e[0:]) info := binary.LittleEndian.Uint64(e[8:]) addend := int64(binary.LittleEndian.Uint64(e[16:])) typ := info & 0xffffffff sym := int(info >> 32) if typ != uint64(elf.R_X86_64_PC32) { t.Errorf("reloc %d: type %d, want R_X86_64_PC32", i, typ) } if addend != -4 { t.Errorf("reloc %d: addend %d, want -4", i, addend) } if name := symName(sym); name != "answer" && name != "extvar" { t.Errorf("reloc %d: symbol %q, want answer or extvar", i, name) } // The relocation offset lands on the disp32 field: the four bytes // before a RET-terminated eight-byte MOVQ. if off+4 > uint64(len(textData)) { t.Errorf("reloc %d: offset %d outside .text", i, off) } } } // TestELFObjectTLSGuardReloc checks that a non-NOSPLIT function's stack // guard carries an R_X86_64_TPOFF32 relocation against the null symbol in // .rela.text. The serialisation must honour the record's type field: a // hardcoded R_X86_64_PC32 mislinks the TLS load as an ordinary // PC-relative reference. func TestELFObjectTLSGuardReloc(t *testing.T) { f, errs := parser.Parse("g_amd64.s", ` #include "textflag.h" TEXT ·grow(SB), $0 CALL ·other(SB) RET TEXT ·other(SB), NOSPLIT, $0 RET `) if len(errs) > 0 { t.Fatalf("parse: %v", errs) } img, err := AssembleFile(f) if err != nil { t.Fatalf("AssembleFile: %v", err) } var haveTLS bool for _, fn := range img.Funcs { for _, r := range fn.Relocs { if r.Kind == RelTLSLE { haveTLS = true } } } if !haveTLS { t.Fatal("test source produced no RelTLSLE relocation") } 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() relaSec := ef.Section(".rela.text") if relaSec == nil { t.Fatal("missing .rela.text") } raw, err := relaSec.Data() if err != nil { t.Fatal(err) } found := false for i := 0; i+24 <= len(raw); i += 24 { e := raw[i:] info := binary.LittleEndian.Uint64(e[8:]) typ := info & 0xffffffff sym := int(info >> 32) if typ == uint64(elf.R_X86_64_TPOFF32) { found = true if sym != 0 { t.Errorf("TPOFF32 relocation against symbol %d, want 0 (the null symbol)", sym) } } } if !found { t.Errorf("no R_X86_64_TPOFF32 relocation in .rela.text (%d bytes)", len(raw)) } } // TestELFObjectNoRelocations checks a file with no static-symbol references // emits a valid object without a .rela.text section. func TestELFObjectNoRelocations(t *testing.T) { f, errs := parser.Parse("n_amd64.s", ` #include "textflag.h" TEXT ·nop(SB), NOSPLIT, $0 RET `) if len(errs) > 0 { t.Fatalf("parse: %v", errs) } img, err := AssembleFile(f) if err != nil { t.Fatalf("AssembleFile: %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() if ef.Section(".rela.text") != nil { t.Error("unexpected .rela.text section") } syms, err := ef.Symbols() if err != nil { t.Fatal(err) } found := false for _, s := range syms { if s.Name == "nop" && elf.ST_TYPE(s.Info) == elf.STT_FUNC { found = true } } if !found { t.Error("function symbol nop not found") } } // elfSectionHeaderCount returns the e_shnum the ELF header declares. func elfSectionHeaderCount(t *testing.T, obj []byte) int { t.Helper() return int(binary.LittleEndian.Uint16(obj[60:])) } // checkELFSectionAccounting verifies the number of section headers the // writer physically laid out equals e_shnum: every DWARF section written // after .shstrtab must be counted, or the last ones (always .debug_frame) // are invisible to every consumer, debug/elf included. func checkELFSectionAccounting(t *testing.T, obj []byte) { t.Helper() shoff := int(binary.LittleEndian.Uint64(obj[40:])) shentsize := int(binary.LittleEndian.Uint16(obj[58:])) shnum := elfSectionHeaderCount(t, obj) if shentsize != 64 { t.Fatalf("e_shentsize = %d, want 64", shentsize) } if (len(obj)-shoff)%shentsize != 0 { t.Fatalf("section header table is not a whole number of entries: shoff=%d len=%d", shoff, len(obj)) } if present := (len(obj) - shoff) / shentsize; present != shnum { t.Errorf("e_shnum = %d but %d section headers are laid out", shnum, present) } } // TestELFDWARFSectionAccounting runs the header accounting check over all // four architecture emitters, and additionally checks the .debug_frame // section is visible (its data aligned as its header declares). func TestELFDWARFSectionAccounting(t *testing.T) { parse := func(name, src string) *ast.File { f, errs := parser.Parse(name, src) if len(errs) > 0 { t.Fatalf("parse %s: %v", name, errs) } return f } cases := []struct { name string img *Image emit func(*Image) ([]byte, error) }{ {"amd64", elfTestImage(t), (*Image).ELFObject}, {"arm64", mustImage(t, func() (*Image, error) { return AssembleFileARM64(parse("k_arm64.s", ` #include "textflag.h" TEXT ·add(SB), NOSPLIT, $0-24 MOVD a+0(FP), R4 MOVD b+8(FP), R5 ADD R5, R4, R4 MOVD R4, ret+16(FP) RET `)) }), (*Image).ELFAARCH64Object}, {"riscv64", mustImage(t, func() (*Image, error) { return AssembleFileRISCV(parse("k_riscv64.s", ` #include "textflag.h" TEXT ·sb(SB), NOSPLIT, $0-0 MOV $answer<>(SB), X10 RET GLOBL answer<>(SB), RODATA, $8 DATA answer<>+0(SB)/8, $42 `)) }), (*Image).ELFRISCVObject}, {"loong64", mustImage(t, func() (*Image, error) { return AssembleFileLOONG64(parse("k_loong64.s", ` #include "textflag.h" TEXT ·add(SB), NOSPLIT, $0-24 MOVV a+0(FP), R4 MOVV b+8(FP), R5 ADDV R5, R4, R4 MOVV R4, ret+16(FP) RET `)) }), (*Image).ELFLOONG64Object}, } for _, tc := range cases { obj, err := tc.emit(tc.img) if err != nil { t.Fatalf("%s: emit: %v", tc.name, err) } checkELFSectionAccounting(t, obj) ef, err := elf.NewFile(bytes.NewReader(obj)) if err != nil { t.Fatalf("%s: parse emitted object: %v", tc.name, err) } frame := ef.Section(".debug_frame") if frame == nil { t.Errorf("%s: .debug_frame invisible to debug/elf (e_shnum too small?)", tc.name) ef.Close() continue } if frame.Offset%8 != 0 || frame.Addralign != 8 { t.Errorf("%s: .debug_frame offset %d align %d, want offset%%8==0 align 8", tc.name, frame.Offset, frame.Addralign) } ef.Close() } } func mustImage(t *testing.T, f func() (*Image, error)) *Image { t.Helper() img, err := f() if err != nil { t.Fatal(err) } return img } // TestELFDWARFRelocations checks the .rela.debug_info and .rela.debug_line // sections exist and carry absolute 64-bit relocations against the // function symbols, with r_offsets inside their target sections. func TestELFDWARFRelocations(t *testing.T) { img := elfTestImage(t) 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() // The DWARF must record the assembled file's path (threaded through // Image.SourcePath), not a placeholder name. info, err := ef.Section(".debug_info").Data() if err != nil { t.Fatal(err) } if img.SourcePath != "t_amd64.s" || !bytes.Contains(info, []byte(img.SourcePath)) { t.Errorf("DWARF compilation unit does not name the source %q", img.SourcePath) } for _, tc := range []struct { rela string target string want uint32 }{ {".rela.debug_info", ".debug_info", rX8664Abs64}, {".rela.debug_line", ".debug_line", rX8664Abs64}, {".rela.debug_frame", ".debug_frame", rX8664Abs64}, } { rs := ef.Section(tc.rela) if rs == nil { t.Fatalf("missing %s", tc.rela) } if rs.Type != elf.SHT_RELA { t.Errorf("%s: type %v, want SHT_RELA", tc.rela, rs.Type) } target := ef.Section(tc.target) if target == nil { t.Fatalf("missing %s", tc.target) } if rs.Link == 0 || ef.Sections[rs.Info] != target { t.Errorf("%s: link %d info %d, want the symtab and %s", tc.rela, rs.Link, rs.Info, tc.target) } b, err := rs.Data() if err != nil { t.Fatal(err) } // .debug_line has one address per function; .debug_info adds the // compile unit's own low_pc. want := len(img.Funcs) if tc.target == ".debug_info" { want++ } if len(b)/24 != want { t.Errorf("%s: %d entries, want %d", tc.rela, len(b)/24, want) } for i := 0; i+24 <= len(b); i += 24 { r_offset := binary.LittleEndian.Uint64(b[i:]) info := binary.LittleEndian.Uint64(b[i+8:]) typ := uint32(info) sym := int(info >> 32) if typ != tc.want { t.Errorf("%s entry %d: type %d, want R_X86_64_64 (%d)", tc.rela, i/24, typ, tc.want) } if r_offset >= uint64(target.Size) { t.Errorf("%s entry %d: r_offset %d outside %s (%d bytes)", tc.rela, i/24, r_offset, tc.target, target.Size) } if sym == 0 { t.Errorf("%s entry %d: against the null symbol", tc.rela, i/24) } } } } // TestELFDataOnly checks a source with GLOBL data and no TEXT emits a valid // ELF object: the DWARF compilation unit of a code-less image has no // function to relocate against and must not reach for one. func TestELFDataOnly(t *testing.T) { f, errs := parser.Parse("d0_amd64.s", ` GLOBL table<>(SB), RODATA, $8 DATA table<>+0(SB)/8, $12345 `) if len(errs) > 0 { t.Fatalf("parse: %v", errs) } img, err := AssembleFile(f) if err != nil { t.Fatalf("AssembleFile: %v", err) } obj, err := img.ELFObject() if err != nil { t.Fatalf("ELFObject: %v", err) } checkELFSectionAccounting(t, obj) ef, err := elf.NewFile(bytes.NewReader(obj)) if err != nil { t.Fatalf("parse emitted object: %v", err) } defer ef.Close() syms, err := ef.Symbols() if err != nil { t.Fatal(err) } found := false for _, s := range syms { if s.Name == "table" && s.Size == 8 { found = true } } if !found { t.Errorf("data symbol table missing: %v", syms) } if ef.Section(".rela.debug_info") != nil || ef.Section(".rela.debug_line") != nil { t.Error("data-only image must not emit DWARF address relocations") } } // TestELFLinkAndRun is the end-to-end check: assemble the test functions, // link the emitted object with a C driver that defines the external symbol, // and run the result. Skipped when no C compiler is available. func TestELFLinkAndRun(t *testing.T) { cc, err := exec.LookPath("cc") if err != nil { t.Skip("no C compiler available") } dir := t.TempDir() img := elfTestImage(t) obj, err := img.ELFObject() if err != nil { t.Fatalf("ELFObject: %v", err) } objPath := filepath.Join(dir, "t.o") if err := os.WriteFile(objPath, obj, 0o644); err != nil { t.Fatal(err) } const driver = ` #include long addq(long a, long b); long getanswer(void); long useextern(void); long extvar = 7; int main(void) { printf("%ld %ld %ld\n", addq(41, 1), getanswer(), useextern()); return 0; } ` driverPath := filepath.Join(dir, "driver.c") if err := os.WriteFile(driverPath, []byte(driver), 0o644); err != nil { t.Fatal(err) } // -no-pie: the encoder emits R_X86_64_PC32 for external references, // which a position-independent executable would reject (it wants // PLT32/GOT relocations, a future increment). appPath := filepath.Join(dir, "app") out, err := exec.Command(cc, "-no-pie", "-o", appPath, driverPath, objPath).CombinedOutput() if err != nil { t.Fatalf("link failed: %v\n%s", err, out) } run, err := exec.Command(appPath).CombinedOutput() if err != nil { t.Fatalf("run failed: %v\n%s", err, run) } if got := string(run); got != "42 42 7\n" { t.Errorf("output %q, want \"42 42 7\\n\"", got) } // The DWARF addresses must have resolved at link time: the .debug_info // placeholders were carried by .rela.debug_info, so every subprogram's // low_pc must now equal its linked symbol address. bin, err := os.ReadFile(appPath) if err != nil { t.Fatal(err) } lef, err := elf.NewFile(bytes.NewReader(bin)) if err != nil { t.Fatalf("parse linked binary: %v", err) } defer lef.Close() syms, err := lef.Symbols() if err != nil { t.Fatal(err) } addrByName := map[string]uint64{} for _, s := range syms { if elf.ST_TYPE(s.Info) == elf.STT_FUNC && s.Value != 0 { addrByName[s.Name] = s.Value } } lowPCs := dwarfSubprogramLowPCs(t, lef) if len(lowPCs) == 0 { t.Fatal("no subprogram DW_AT_low_pc parsed from the linked binary") } for name, pc := range lowPCs { addr, ok := addrByName[name] if !ok { t.Errorf("subprogram %q not in the linked symbol table", name) continue } if pc != addr { t.Errorf("subprogram %q: DW_AT_low_pc = %#x, linked address %#x (DWARF relocation unresolved)", name, pc, addr) } } } // dwarfSubprogramLowPCs walks the linked binary's .debug_info with its own // .debug_abbrev and returns each DW_TAG_subprogram's DW_AT_low_pc by name. func dwarfSubprogramLowPCs(t *testing.T, ef *elf.File) map[string]uint64 { t.Helper() abbrevSec := ef.Section(".debug_abbrev") infoSec := ef.Section(".debug_info") if abbrevSec == nil || infoSec == nil { t.Fatal("linked binary lacks .debug_abbrev or .debug_info") } abbrev, err := abbrevSec.Data() if err != nil { t.Fatal(err) } info, err := infoSec.Data() if err != nil { t.Fatal(err) } abs := parseAbbrevs(t, abbrev) le := binary.LittleEndian out := map[string]uint64{} r := &ulebIter{b: info} r.uint32At(t) // unit_length if v := le.Uint16(info[4:]); v != 5 { t.Fatalf(".debug_info version %d, want 5", v) } r.i = 6 r.byteAt(t) // unit_type r.byteAt(t) // address_size r.uint32At(t) // debug_abbrev_offset var name string var lowPC uint64 for r.i < len(r.b) { code := r.uleb(t) if code == 0 { continue // end of the CU's children } ab, ok := abs[code] if !ok { t.Fatalf("unknown abbreviation code %d", code) } name, lowPC = "", 0 for _, a := range ab.attrs { switch a.attr { case dwAtName: readFormKeep(t, r, a.form, &name, nil) case dwAtLowPC: readFormKeep(t, r, a.form, nil, &lowPC) default: readFormSkip(t, r, a.form) } } if ab.tag == dwTagSubprog && name != "" { out[name] = lowPC } } return out } // readFormKeep reads one DIE attribute value, keeping a string or an // address into the pointer it was given (nil keeps nothing). func readFormKeep(t *testing.T, r *ulebIter, form uint64, name *string, addr *uint64) { t.Helper() switch form { case dwFormString: end := r.i for end < len(r.b) && r.b[end] != 0 { end++ } if name != nil { *name = string(r.b[r.i:end]) } r.i = end + 1 case dwFormAddr: if addr != nil { *addr = binary.LittleEndian.Uint64(r.b[r.i:]) } r.i += 8 default: readFormSkip(t, r, form) } } func readFormSkip(t *testing.T, r *ulebIter, form uint64) { t.Helper() switch form { case dwFormString: for r.i < len(r.b) && r.b[r.i] != 0 { r.i++ } r.i++ case dwFormAddr, dwFormData8: r.i += 8 case dwFormSecOff: r.i += 4 case dwFormExprloc: r.i += int(r.uleb(t)) case dwFormData1, 0x0c: r.i++ default: t.Fatalf("unsupported form %#x", form) } }