Files
gasm-sdk/lint/liveness_test.go
T
2026-09-19 23:49:19 +02:00

293 lines
11 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lint
import (
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// TestRegisterClobber checks the register-clobber audit is calibrated to the
// Go ABI (cmd/compile/abi-internal.md), not the platform ABI: Go's
// stack-based ABI0, which hand-written assembly uses, has no System V
// style callee-saved registers, so argument and scratch registers may be
// clobbered freely. Only the registers the ABI fixes across calls (the
// frame pointer, the goroutine pointer, OS-reserved registers) are audited.
func TestRegisterClobber(t *testing.T) {
// amd64: BX, R12, R13 and R15 are argument/permanent-scratch registers in
// Go ABI0; writing them unsaved is legal (a System V calibration would
// report all of these).
scratch := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVQ CX, BX\n"+
"\tXORL R12, R12\n"+
"\tXORL R13, R13\n"+
"\tXORL R15, R15\n"+
"\tRET\n")
if codes(scratch)[CodeRegisterClobber] != 0 {
t.Fatalf("Go ABI0 scratch registers must not be flagged: %+v", scratch)
}
// amd64: R14 (the goroutine pointer) in a NOSPLIT function without calls
// is the runtime's own pattern (the ABI0 transition restores it), so it
// is not flagged.
leaf := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tXORL R14, R14\n"+
"\tRET\n")
if codes(leaf)[CodeRegisterClobber] != 0 {
t.Fatalf("R14 in a NOSPLIT leaf must not be flagged: %+v", leaf)
}
// amd64: R14 in a function that makes a call is a genuine hazard.
withCall := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tXORL R14, R14\n"+
"\tCALL ·g(SB)\n"+
"\tRET\n")
if codes(withCall)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved R14 with a call should be flagged: %+v", withCall)
}
// amd64: R14 in a non-NOSPLIT function is a hazard regardless of calls.
split := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), $0\n"+
"\tMOVQ CX, R14\n"+
"\tRET\n")
if codes(split)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved R14 in a non-NOSPLIT function should be flagged: %+v", split)
}
// amd64: R14 saved and restored around the call is preserved.
saved := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $8\n"+
"\tPUSHQ R14\n"+
"\tXORL R14, R14\n"+
"\tCALL ·g(SB)\n"+
"\tPOPQ R14\n"+
"\tRET\n")
if codes(saved)[CodeRegisterClobber] != 0 {
t.Fatalf("saved/restored R14 must not be flagged: %+v", saved)
}
// amd64: BP maintains the frame chain and is always audited.
bp := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVQ CX, BP\n"+
"\tRET\n")
if codes(bp)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved BP write should be flagged: %+v", bp)
}
// arm64: R20 is scratch; R28 (goroutine pointer) and R18 (OS-reserved)
// are fixed by the Go ABI.
armScratch := lintSrcArch(t, "t_arm64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVD R0, R20\n"+
"\tRET\n")
if codes(armScratch)[CodeRegisterClobber] != 0 {
t.Fatalf("arm64 scratch register must not be flagged: %+v", armScratch)
}
armG := lintSrcArch(t, "t_arm64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVD R0, R28\n"+
"\tRET\n")
if codes(armG)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved arm64 R28 write should be flagged: %+v", armG)
}
armReserved := lintSrcArch(t, "t_arm64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVD R0, R18\n"+
"\tRET\n")
if codes(armReserved)[CodeRegisterClobber] != 1 {
t.Fatalf("arm64 R18 write should be flagged: %+v", armReserved)
}
// riscv64: X27 holds the goroutine; X5-X7 are scratch.
riscScratch := lintSrcArch(t, "t_riscv64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOV X5, X6\n"+
"\tRET\n")
if codes(riscScratch)[CodeRegisterClobber] != 0 {
t.Fatalf("riscv64 scratch register must not be flagged: %+v", riscScratch)
}
riscG := lintSrcArch(t, "t_riscv64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOV X5, X27\n"+
"\tRET\n")
if codes(riscG)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved riscv64 X27 write should be flagged: %+v", riscG)
}
// loong64: R22 holds the goroutine; R5-R19 are argument/scratch.
loongScratch := lintSrcArch(t, "t_loong64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVV R5, R6\n"+
"\tRET\n")
if codes(loongScratch)[CodeRegisterClobber] != 0 {
t.Fatalf("loong64 scratch register must not be flagged: %+v", loongScratch)
}
loongG := lintSrcArch(t, "t_loong64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVV R5, R22\n"+
"\tRET\n")
if codes(loongG)[CodeRegisterClobber] != 1 {
t.Fatalf("unsaved loong64 R22 write should be flagged: %+v", loongG)
}
}
// TestRegisterClobberGoroutineAlias checks the architectural alias `g` for
// the goroutine register. go tool asm accepts it on every architecture
// (lowercase only), and on arm64, riscv64 and loong64 it is the only
// spelling of the register at all, so a clobber written through the alias
// must be audited exactly like the numeric one.
func TestRegisterClobberGoroutineAlias(t *testing.T) {
// amd64: `g` is R14, a runtime-class register: the NOSPLIT-leaf pattern
// of the runtime's own assembly stays unflagged, a call makes it a
// hazard.
leaf := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVQ AX, g\n"+
"\tRET\n")
if codes(leaf)[CodeRegisterClobber] != 0 {
t.Fatalf("amd64 g in a NOSPLIT leaf must not be flagged: %+v", leaf)
}
withCall := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVQ AX, g\n"+
"\tCALL ·h(SB)\n"+
"\tRET\n")
if codes(withCall)[CodeRegisterClobber] != 1 {
t.Fatalf("amd64 unsaved g with a call should be flagged: %+v", withCall)
}
// arm64, riscv64, loong64: the goroutine register is always-fixed, so an
// unsaved write through the alias is flagged; a bare read is not.
for _, tc := range []struct{ filename, src string }{
{"t_arm64.s", "#include \"textflag.h\"\n" + "TEXT ·f(SB), NOSPLIT, $0\n" + "\tMOVD R0, g\n" + "\tRET\n"},
{"t_riscv64.s", "#include \"textflag.h\"\n" + "TEXT ·f(SB), NOSPLIT, $0\n" + "\tMOV X5, g\n" + "\tRET\n"},
{"t_loong64.s", "#include \"textflag.h\"\n" + "TEXT ·f(SB), NOSPLIT, $0\n" + "\tMOVV R5, g\n" + "\tRET\n"},
} {
if got := codes(lintSrcArch(t, tc.filename, tc.src))[CodeRegisterClobber]; got != 1 {
t.Fatalf("%s: unsaved write to g should be flagged once, got %d", tc.filename, got)
}
}
armRead := lintSrcArch(t, "t_arm64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tMOVD g, R1\n"+
"\tRET\n")
if codes(armRead)[CodeRegisterClobber] != 0 {
t.Fatalf("reading g must not be flagged: %+v", armRead)
}
}
// TestBranchToTrailingLabel pins the CFG guard for a label that ends a
// function body: no block is flushed for it, and the liveness dataflow must
// not index past the block list on the edge a branch to it would carry.
func TestBranchToTrailingLabel(t *testing.T) {
diags := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tJMP done\n"+
"done:\n")
if codes(diags)[CodeRegisterClobber] != 0 {
t.Fatalf("branch to a trailing label must not be flagged: %+v", diags)
}
}
// TestConditionalBranchToTrailingLabel exercises a multi-operand conditional
// branch (CBZ R0, done): the branch target is the last bare-symbol operand,
// so the taken edge is wired to the trailing label and the guard of
// TestBranchToTrailingLabel is what keeps the dataflow in range. The run
// doubles as the termination check for the fixed-point loop.
func TestConditionalBranchToTrailingLabel(t *testing.T) {
diags := lintSrcArch(t, "t_arm64.s", "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tCBZ R0, done\n"+
"\tRET\n"+
"done:\n")
if codes(diags)[CodeRegisterClobber] != 0 {
t.Fatalf("conditional branch to a trailing label must not be flagged: %+v", diags)
}
}
// TestMalformedTextNoSymbol checks that a TEXT line without a symbol name,
// which the parser keeps in the tree under a "?" placeholder, lints without
// a panic and still reports the ordinary findings.
func TestMalformedTextNoSymbol(t *testing.T) {
f, _ := parser.Parse("test_amd64.s", "// func f(a int) int\nTEXT $0\n\tMOVQ AX, BX\n")
diags := File(f, Config{Arch: arch.AMD64})
c := codes(diags)
if c[CodeMissingRet] != 1 {
t.Fatalf("malformed TEXT should report missing-ret, got %+v", diags)
}
if c[CodeABI0RegisterArgs] != 1 {
t.Fatalf("malformed TEXT should report abi0-register-args, got %+v", diags)
}
}
// TestFuncdata validates the FUNCDATA/PCDATA structural checks.
func TestFuncdata(t *testing.T) {
// Well formed: no findings.
good := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tFUNCDATA $0, gclocals·abc(SB)\n"+
"\tPCDATA $1, $0\n"+
"\tRET\n")
if codes(good)[CodeFuncdata] != 0 {
t.Fatalf("well-formed FUNCDATA/PCDATA must not be flagged: %+v", good)
}
// FUNCDATA with one operand.
bad1 := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tFUNCDATA $0\n"+
"\tRET\n")
if codes(bad1)[CodeFuncdata] == 0 {
t.Fatal("FUNCDATA with one operand should be flagged")
}
// PCDATA with a non-immediate value.
bad2 := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tPCDATA $0, AX\n"+
"\tRET\n")
if codes(bad2)[CodeFuncdata] == 0 {
t.Fatal("PCDATA with a register value should be flagged")
}
// FUNCDATA index out of range: the Go 1.27 runtime defines 0-7
// (FUNCDATA_WrapInfo) and PCDATA 0-4 (PCDATA_PanicBounds).
bad3 := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tFUNCDATA $99, gclocals·abc(SB)\n"+
"\tRET\n")
if codes(bad3)[CodeFuncdata] == 0 {
t.Fatal("out-of-range FUNCDATA index should be flagged")
}
fdHigh := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tFUNCDATA $7, gclocals·abc(SB)\n"+
"\tPCDATA $4, $0\n"+
"\tRET\n")
if codes(fdHigh)[CodeFuncdata] != 0 {
t.Fatalf("the highest defined FUNCDATA/PCDATA indices must pass: %+v", fdHigh)
}
fdOver := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tFUNCDATA $8, gclocals·abc(SB)\n"+
"\tRET\n")
if codes(fdOver)[CodeFuncdata] != 1 {
t.Fatal("FUNCDATA index above FUNCDATA_WrapInfo should be flagged")
}
pcOver := lintSrc(t, "#include \"textflag.h\"\n"+
"TEXT ·f(SB), NOSPLIT, $0\n"+
"\tPCDATA $5, $0\n"+
"\tRET\n")
if codes(pcOver)[CodeFuncdata] != 1 {
t.Fatal("PCDATA index above PCDATA_PanicBounds should be flagged")
}
}