Files
gasm-sdk/asm/arm64_poolflush_test.go
T
2026-10-07 19:54:22 +02:00

280 lines
8.8 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"
"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 := range n {
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 := range n {
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)
}
}
}
}