Files
gasm-sdk/lint/liveness.go
T

456 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lint
import (
"fmt"
"sort"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
)
// This file implements register liveness by dataflow over a function's
// control-flow graph, and the checks built on it. The def/use model is
// deliberately conservative: where an instruction's effect is uncertain it is
// treated as both a use and a def of its register operands, which can only
// suppress a finding, never invent one.
// regEffect is the register-level effect of one instruction.
type regEffect struct {
def []string // registers written (killed)
use []string // registers read
saveGPR []string // callee-saved-style: register written to the stack
restGPR []string // register restored from the stack
}
// analyzeLiveness builds the control-flow graph of a function and computes
// live-in/live-out register sets by iterative backward dataflow.
type liveness struct {
blocks []*block
liveIn []map[string]bool
}
type block struct {
label string // label that begins this block, if any
instrs []*ast.Instr
succ []int // successor block indices
}
func analyzeLiveness(t *ast.Text, a arch.Arch) *liveness {
l := &liveness{}
l.buildCFG(t)
l.dataflow(a)
return l
}
// buildCFG splits the function body into basic blocks and wires up successors.
func (l *liveness) buildCFG(t *ast.Text) {
labelToBlock := map[string]int{}
var cur *block
flush := func() {
if cur != nil && len(cur.instrs) > 0 {
l.blocks = append(l.blocks, cur)
}
cur = nil
}
startBlock := func(lbl string) {
flush()
cur = &block{label: lbl}
}
startBlock("")
for _, s := range t.Body {
switch st := s.(type) {
case *ast.Label:
// A label begins a new block and is a jump target.
startBlock(st.Name.Text)
labelToBlock[st.Name.Text] = len(l.blocks) // index once flushed
case *ast.Instr:
if cur == nil {
startBlock("")
}
cur.instrs = append(cur.instrs, st)
if terminates(st) || isConditionalBranch(st) {
startBlock("")
}
}
}
flush()
// Fix up label->block indices (labels were recorded before the following
// block was appended) and build successor edges.
for i, b := range l.blocks {
if b.label != "" {
labelToBlock[b.label] = i
}
}
for i, b := range l.blocks {
if len(b.instrs) == 0 {
if i+1 < len(l.blocks) {
b.succ = append(b.succ, i+1)
}
continue
}
last := b.instrs[len(b.instrs)-1]
mnem := strings.ToUpper(last.Mnemonic.Text)
switch {
case mnem == "RET" || mnem == "UNDEF":
// No successors.
case isConditionalBranch(last):
if tgt, ok := branchTarget(last); ok {
if j, found := labelToBlock[tgt]; found {
b.succ = append(b.succ, j)
}
}
if i+1 < len(l.blocks) {
b.succ = append(b.succ, i+1) // fall-through
}
case isUnconditionalBranchAny(mnem):
if tgt, ok := branchTarget(last); ok {
if j, found := labelToBlock[tgt]; found {
b.succ = append(b.succ, j)
}
}
default:
if i+1 < len(l.blocks) {
b.succ = append(b.succ, i+1)
}
}
}
}
// dataflow runs the standard backward liveness iteration to a fixed point.
func (l *liveness) dataflow(a arch.Arch) {
n := len(l.blocks)
l.liveIn = make([]map[string]bool, n)
liveOut := make([]map[string]bool, n)
use := make([]map[string]bool, n)
def := make([]map[string]bool, n)
for i, b := range l.blocks {
use[i], def[i] = blockUseDef(b, a)
l.liveIn[i] = map[string]bool{}
liveOut[i] = map[string]bool{}
}
for changed := true; changed; {
changed = false
for i := n - 1; i >= 0; i-- {
out := map[string]bool{}
for _, s := range l.blocks[i].succ {
for r := range l.liveIn[s] {
out[r] = true
}
}
if !sameSet(out, liveOut[i]) {
liveOut[i] = out
changed = true
}
// in = use ∪ (out − def)
in := map[string]bool{}
for r := range use[i] {
in[r] = true
}
for r := range out {
if !def[i][r] {
in[r] = true
}
}
if !sameSet(in, l.liveIn[i]) {
l.liveIn[i] = in
changed = true
}
}
}
}
// blockUseDef computes the registers used before definition (use) and the
// registers defined (def) within a basic block.
func blockUseDef(b *block, a arch.Arch) (use, def map[string]bool) {
use = map[string]bool{}
def = map[string]bool{}
for _, in := range b.instrs {
eff := instrEffect(in, a)
for _, r := range eff.use {
if !def[r] {
use[r] = true
}
}
for _, r := range eff.def {
def[r] = true
}
}
return use, def
}
// terminates reports whether an instruction ends basic-block flow unconditionally.
func terminates(in *ast.Instr) bool {
m := strings.ToUpper(in.Mnemonic.Text)
return m == "RET" || m == "UNDEF" || isUnconditionalBranchAny(m)
}
func isConditionalBranch(in *ast.Instr) bool {
m := strings.ToUpper(in.Mnemonic.Text)
// Conditional jumps/branches, but not the unconditional ones.
if isUnconditionalBranchAny(m) || m == "RET" || m == "UNDEF" || m == "CALL" {
return false
}
return strings.HasPrefix(m, "J") || strings.HasPrefix(m, "B") ||
strings.HasPrefix(m, "CBZ") || strings.HasPrefix(m, "CBNZ") ||
strings.HasPrefix(m, "TBZ") || strings.HasPrefix(m, "TBNZ") ||
strings.HasPrefix(m, "BEQ") || strings.HasPrefix(m, "BNE")
}
// isUnconditionalBranchAny is an arch-agnostic unconditional-branch test.
func isUnconditionalBranchAny(m string) bool {
switch m {
case "JMP", "J", "JR", "B", "BR", "JIRL":
return true
}
return false
}
// branchTarget returns the local-label target of a branch, if it is one.
func branchTarget(in *ast.Instr) (string, bool) {
for _, op := range in.Operands {
if op.Kind == ast.OpAddr && op.Addr.Sym != nil && op.Addr.Sym.Pseudo == "" &&
op.Addr.Base == "" && op.Addr.Sym.Name != "" {
return op.Addr.Sym.Name, true
}
}
return "", false
}
// instrEffect returns the register-level effect of one instruction.
func instrEffect(in *ast.Instr, a arch.Arch) regEffect {
var eff regEffect
mnem := strings.ToUpper(in.Mnemonic.Text)
// PUSH/POP move a register to/from the stack.
if strings.HasPrefix(mnem, "PUSH") {
for _, op := range in.Operands {
if r := gprName(op, a); r != "" {
eff.use = append(eff.use, r)
eff.saveGPR = append(eff.saveGPR, r)
}
}
return eff
}
if strings.HasPrefix(mnem, "POP") {
for _, op := range in.Operands {
if r := gprName(op, a); r != "" {
eff.def = append(eff.def, r)
eff.restGPR = append(eff.restGPR, r)
}
}
return eff
}
compare := isCompare(mnem)
dstIdx := dstIndex(in, a)
for i, op := range in.Operands {
r := gprName(op, a)
if r != "" {
if i == dstIdx && !compare {
eff.def = append(eff.def, r)
// Arithmetic also reads its destination.
eff.use = append(eff.use, r)
} else {
eff.use = append(eff.use, r)
}
}
// Detect saves/restores through the stack frame.
if isStackAddr(op) {
// The other operand (the register) is being saved or restored.
for j, other := range in.Operands {
if j == i {
continue
}
if rr := gprName(other, a); rr != "" {
if j == dstIdx && !compare {
eff.restGPR = append(eff.restGPR, rr) // reg loaded from stack
} else {
eff.saveGPR = append(eff.saveGPR, rr) // reg stored to stack
}
}
}
}
}
return eff
}
// dstIndex returns the operand index of the destination register: last for the
// Plan 9 (amd64) spelling, first for arm64/riscv64/loong64.
func dstIndex(in *ast.Instr, a arch.Arch) int {
if a == arch.AMD64 {
return len(in.Operands) - 1
}
return 0
}
// isCompare reports whether the mnemonic only reads its operands (setting flags).
func isCompare(m string) bool {
return strings.HasPrefix(m, "CMP") || strings.HasPrefix(m, "TEST") ||
strings.HasPrefix(m, "CMN") || strings.HasPrefix(m, "TST") ||
m == "FCMP" || m == "FCMPE"
}
// gprName returns the canonical general-purpose register name of an operand, or
// "" if the operand is not a bare GPR reference.
func gprName(op *ast.Operand, a arch.Arch) string {
if op == nil || op.Kind != ast.OpAddr || op.Addr.Sym == nil {
return ""
}
if op.Addr.Base != "" || op.Addr.Sym.Pseudo != "" || op.Addr.Sym.Name == "" {
return ""
}
name := op.Addr.Sym.Name
if r, ok := arch.ForArch(a).Register(name); ok && (r.Class == arch.GPR || r.Class == arch.GPRSub) {
return canonicalGPR(name)
}
return ""
}
// canonicalGPR maps a sized sub-register to its base GPR (amd64 only).
func canonicalGPR(name string) string {
upper := strings.ToUpper(name)
// Named 8/16/32-bit forms of the classic registers.
switch upper {
case "AL", "AH", "AX":
return "AX"
case "BL", "BH", "BX":
return "BX"
case "CL", "CH", "CX":
return "CX"
case "DL", "DH", "DX":
return "DX"
case "SIL":
return "SI"
case "DIL":
return "DI"
case "BPL":
return "BP"
case "SPL":
return "SP"
}
// Numbered sub-registers R8B/R8W/R8D → R8.
if len(upper) >= 3 && upper[0] == 'R' {
switch upper[len(upper)-1] {
case 'B', 'W', 'D':
return upper[:len(upper)-1]
}
}
return upper
}
// isStackAddr reports whether an operand addresses the stack frame
// (base SP, or an FP/SP-relative symbol).
func isStackAddr(op *ast.Operand) bool {
if op == nil || op.Kind != ast.OpAddr {
return false
}
if op.Addr.Base == "SP" {
return true
}
if op.Addr.Sym != nil && (op.Addr.Sym.Pseudo == "SP" || op.Addr.Sym.Pseudo == "FP") {
return true
}
return false
}
func sameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
// calleeSavedGPRs returns the general-purpose registers an assembly function
// must preserve for its caller, using the register names the assembler accepts
// for each architecture.
func calleeSavedGPRs(a arch.Arch) map[string]bool {
switch a {
case arch.AMD64:
return gprSet("BX", "BP", "R12", "R13", "R14", "R15")
case arch.ARM64:
names := []string{"R29", "R30"} // FP, LR
for i := 19; i <= 28; i++ {
names = append(names, fmt.Sprintf("R%d", i))
}
return gprSet(names...)
case arch.RISCV:
// RA (X1) and the S registers (X8, X9, X18–X27) are callee-saved.
names := []string{"X1", "RA", "X8", "X9", "S0", "S1", "FP"}
for i := 18; i <= 27; i++ {
names = append(names, fmt.Sprintf("X%d", i))
}
for i := 2; i <= 11; i++ {
names = append(names, fmt.Sprintf("S%d", i))
}
return gprSet(names...)
case arch.LOONG64:
// RA (R1), FP (R22) and S0–S8 (R23–R31) are callee-saved.
names := []string{"R1", "RA", "R22", "FP"}
for i := 23; i <= 31; i++ {
names = append(names, fmt.Sprintf("R%d", i))
}
for i := 0; i <= 8; i++ {
names = append(names, fmt.Sprintf("S%d", i))
}
return gprSet(names...)
}
return nil
}
func gprSet(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}
// clobberedCalleeSaved returns the callee-saved registers a function writes
// without also saving and restoring them — i.e. registers whose caller-owned
// value is lost across the call. It walks the blocks of the liveness analysis
// (so the control-flow graph is what supplies the instruction set) and
// aggregates each instruction's register effects.
func clobberedCalleeSaved(l *liveness, a arch.Arch) []string {
callee := calleeSavedGPRs(a)
if len(callee) == 0 {
return nil
}
def := map[string]bool{}
saved := map[string]bool{}
restored := map[string]bool{}
for _, b := range l.blocks {
for _, in := range b.instrs {
eff := instrEffect(in, a)
for _, r := range eff.def {
def[r] = true
}
for _, r := range eff.saveGPR {
saved[r] = true
}
for _, r := range eff.restGPR {
restored[r] = true
}
}
}
var out []string
for r := range callee {
if def[r] && !(saved[r] && restored[r]) {
out = append(out, r)
}
}
sort.Strings(out)
return out
}