diff --git a/asm/arm64_poolflush_test.go b/asm/arm64_poolflush_test.go new file mode 100644 index 0000000..b5240c2 --- /dev/null +++ b/asm/arm64_poolflush_test.go @@ -0,0 +1,279 @@ +// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-sdk/parser" +) + +// The mid-function literal pool flush, pinned against the toolchain. A +// kernel of distinct pooled stores walks the conservative displacement bound +// (asm7.go's maxPCDisp) inside one body, so the pool must drain exactly +// where cmd/internal/obj/arm64's checkpool drains it: the bytes, the flush +// points and the reach of every load literal are the toolchain's own. + +// pooledKernel renders a kernel of n distinct pooled stores: each offset +// sits a step of 8 past the aligned split band (every fourth one aligned, +// but 0x2000000+8i+4 never is), so every statement pools its own four-byte +// word and expands to eight instruction bytes, the densest walk towards the +// distance bound an arm64 body can make. head carries the TEXT line for the +// splitting variant, tail the closing statements. +func pooledKernel(head string, n int, tail string) string { + var b strings.Builder + b.WriteString("#include \"textflag.h\"\n") + if head != "" { + b.WriteString(head) + b.WriteString("\n") + } + b.WriteString("TEXT \u00b7poolmid(SB), NOSPLIT, $0-0\n") + for i := 0; i < n; i++ { + b.WriteString("\tMOVD\tR1, ") + b.WriteString(decimal(0x2000000 + 8*i + 4)) + b.WriteString("(R2)\n") + } + b.WriteString(tail) + return b.String() +} + +// decimal formats v in decimal. +func decimal(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} + +// assemblePooled parses and assembles a generated kernel, returning the +// image and the single function's code words. +func assemblePooled(t *testing.T, head string, n int, tail string) (*Image, []uint32) { + t.Helper() + src := pooledKernel(head, n, tail) + dir := t.TempDir() + path := filepath.Join(dir, "poolmid_arm64.s") + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + f, errs := parser.Parse(path, src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFileARM64(f) + if err != nil { + t.Fatalf("AssembleFileARM64: %v", err) + } + if len(img.Funcs) != 1 { + t.Fatalf("functions = %d, want 1", len(img.Funcs)) + } + fn := img.Funcs[0] + return img, leWords(img.Code[fn.Offset : fn.Offset+fn.Size]) +} + +// a64BranchWord reports whether w is an unconditional B: op 000101 in bits +// 31..26, the encoding the flush guards ride. +func a64BranchWord(w uint32) bool { + return w>>26 == 0x05 +} + +// a64BranchTarget decodes a B word's target byte offset from its own pc. +func a64BranchTarget(w uint32, pc int) int { + d := int(w & 0x03FFFFFF) + if d&(1<<25) != 0 { + d |= ^0x03FFFFFF + } + return pc + 4*d +} + +// a64LoadLiteral reports whether w is a LDR W/X literal (the pool's loads +// into REGTMP: word forms 0x18 and 0x58 in the top byte) and decodes its +// target byte displacement. +func a64LoadLiteral(w uint32) (int, bool) { + if w>>24 != 0x18 && w>>24 != 0x58 { + return 0, false + } + d := int(w>>5) & 0x7FFFF + if d&(1<<18) != 0 { + d |= ^0x7FFFF + } + return d * 4, true +} + +// TestArm64PoolFlushStructure assembles the distance-bound kernel and pins +// the flush structure on gasm's own image: exactly one mid-body branch over +// a drained segment, no code words inside it, every load literal inside the +// conservative displacement bound, and the segment's byte range free of line +// rows: the pool words carry the flushing statement's source line, so the +// pc-line tables see no delta across them. +func TestArm64PoolFlushStructure(t *testing.T) { + const n = 44000 // one flush: the bound arrives at about 43690 statements + img, words := assemblePooled(t, "", n, "\tRET\n") + fn := img.Funcs[0] + + var branches []int + for i, w := range words { + if a64BranchWord(w) { + branches = append(branches, i*4) + } + } + if len(branches) != 1 { + t.Fatalf("branch words = %d, want exactly the one flush guard", len(branches)) + } + branchPC := branches[0] + target := a64BranchTarget(words[branchPC/4], branchPC) + segStart, segEnd := branchPC+4, target + if segEnd <= segStart || segEnd%4 != 0 { + t.Fatalf("flush branch target %d leaves no legal word range after %d", target, branchPC) + } + if segEnd+4 > len(words)*4 { + t.Fatalf("flush branch target %d runs past the image (%d words)", target, len(words)) + } + // The drained segment holds pool words only: four-byte constants, no + // branch opcodes, no literal loads. + for off := segStart; off < segEnd; off += 4 { + w := words[off/4] + if a64BranchWord(w) { + t.Fatalf("word at %d inside the drained segment is a branch: %08x", off, w) + } + if _, lit := a64LoadLiteral(w); lit { + t.Fatalf("word at %d inside the drained segment is a literal load: %08x", off, w) + } + } + + // Every load literal resolves inside the conservative bound and inside + // the image: the property the flush exists to keep. + for i, w := range words { + d, ok := a64LoadLiteral(w) + if !ok { + continue + } + pc := i * 4 + if pc+d < 0 || pc+d >= len(words)*4 { + t.Fatalf("literal load at %d targets %d, outside the image", pc, pc+d) + } + if d >= a64MaxPCDisp || d <= -a64MaxPCDisp { + t.Fatalf("literal load at %d displaces %d, outside the conservative bound", pc, d) + } + } + + // The drained words carry no line rows of their own: the toolchain gives + // them the flushing statement's Pos so the pc-line tables see no delta. + for _, l := range fn.Lines { + if l.Offset >= segStart && l.Offset < segEnd { + t.Fatalf("line row at %d sits inside the drained segment [%d, %d)", l.Offset, segStart, segEnd) + } + } +} + +// TestArm64PoolFlushDifferential assembles the kernel family with gasm and +// with the installed toolchain and holds the bytes equal, the toolchain's +// tail alignment padding excepted: one flush, a fall-through end behind the +// UNDEF guard, two flushes, and a splitting function whose guard prefix and +// morestack block sit behind a shifted pool. +func TestArm64PoolFlushDifferential(t *testing.T) { + if testing.Short() { + t.Skip("live go tool asm oracle: skipped in -short mode") + } + for _, k := range []struct { + name string + head string + n int + tail string + }{ + {"one flush, RET end", "", 44000, "\tRET\n"}, + {"one flush, UNDEF end", "", 44000, ""}, + {"two flushes", "", 90000, "\tRET\n"}, + {"split function", "TEXT \u00b7poolmid(SB), $8-0", 44000, "\tRET\n"}, + } { + t.Run(k.name, func(t *testing.T) { + if k.head != "" { + // The split variant spells its own TEXT: the generator's + // NOSPLIT line must give way to it. + runPoolFlushCase(t, pooledKernelFor(k.head, k.n, k.tail)) + return + } + runPoolFlushCase(t, pooledKernel("", k.n, k.tail)) + }) + } +} + +// pooledKernelFor renders the kernel with an explicit TEXT line, the split +// variant's shape: no NOSPLIT, so the frame forces the stack-split guard. +func pooledKernelFor(text string, n int, tail string) string { + var b strings.Builder + b.WriteString("#include \"textflag.h\"\n") + b.WriteString(text) + b.WriteString("\n") + for i := 0; i < n; i++ { + b.WriteString("\tMOVD\tR1, ") + b.WriteString(decimal(0x2000000 + 8*i + 4)) + b.WriteString("(R2)\n") + } + b.WriteString(tail) + return b.String() +} + +// runPoolFlushCase assembles one generated kernel both ways and compares +// the function's bytes, relocation sites masked, the toolchain's trailing +// alignment zeros excepted. +func runPoolFlushCase(t *testing.T, src string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "poolmid_arm64.s") + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + f, errs := parser.Parse(path, src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFileARM64(f) + if err != nil { + t.Fatalf("AssembleFileARM64: %v", err) + } + gt := oracleFuncCode(t, toolAsmObject(t, path, "arm64")) + byLocal := make(map[string][]byte, len(gt)) + for name, code := range gt { + if _, after, ok := strings.Cut(name, "."); ok { + name = after + } + byLocal[name] = code + } + 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.Fatalf("%s: not in the oracle output (%d functions)", fn.Name, len(gt)) + } + goCode = maskCode(append([]byte(nil), goCode...), fn.Relocs) + cmpLen := min(len(goCode), len(gasmCode)) + if !bytes.Equal(gasmCode[:cmpLen], goCode[:cmpLen]) { + for w := 0; w < cmpLen/4; w++ { + g := binary.LittleEndian.Uint32(gasmCode[w*4:]) + o := binary.LittleEndian.Uint32(goCode[w*4:]) + if g != o { + t.Fatalf("%s: word %d (offset %d) differs: gasm %08x go %08x", fn.Name, w, w*4, g, o) + } + } + t.Fatalf("%s: prefixes equal but lengths differ (gasm %d, oracle %d)", fn.Name, len(gasmCode), len(goCode)) + } + for _, b := range goCode[len(gasmCode):] { + if b != 0 { + t.Fatalf("%s: non-zero trailing bytes in the oracle output", fn.Name) + } + } + } +}