feat(lint): add stack-imbalance rule

Assisted-by: MiMo V2.5 Pro
This commit is contained in:
2026-08-21 00:35:21 +02:00
parent ce5ec24fa8
commit ac02c83a86
2 changed files with 148 additions and 0 deletions
+121
View File
@@ -76,6 +76,7 @@ const (
CodeFuncdata = "funcdata-pcdata" CodeFuncdata = "funcdata-pcdata"
CodeUnusedLabel = "unused-label" CodeUnusedLabel = "unused-label"
CodeInvalidFlag = "invalid-textflag" CodeInvalidFlag = "invalid-textflag"
CodeStackImbalance = "stack-imbalance"
) )
// knownTextFlags are the flags recognised by the Go assembler's textflag.h. // knownTextFlags are the flags recognised by the Go assembler's textflag.h.
@@ -350,6 +351,27 @@ func lintText(t *ast.Text, tab *arch.Table, archKnown bool, cfg Config, macros m
}) })
} }
// Stack imbalance: track SP changes and flag if the net delta at RET
// does not match the declared frame size. Only checked for functions
// with a declared frame, no macros, and no indirect branches.
if doLabelChecks && !cfg.Disable[CodeStackImbalance] && !hasMacro && !hasIndirect {
frameSize := int64(0)
if t.Frame != nil && t.Frame.Imm.HasVal {
frameSize = t.Frame.Imm.Val
}
if frameSize > 0 {
delta := stackDelta(t, cfg.Arch)
if delta != 0 && delta != -frameSize {
out = append(out, Diagnostic{
Pos: t.Keyword.Pos,
Severity: Warning,
Code: CodeStackImbalance,
Message: fmt.Sprintf("function %q has net SP delta %d (frame size %d)", t.Name.Name, delta, frameSize),
})
}
}
}
// ABI conformance: the argument area declared in the TEXT directive should // ABI conformance: the argument area declared in the TEXT directive should
// match the size computed from the // func signature in the doc comment. // match the size computed from the // func signature in the doc comment.
// Only applies to stack-argument (ABI0) functions, which reference their // Only applies to stack-argument (ABI0) functions, which reference their
@@ -594,6 +616,105 @@ func isUnconditionalJump(a arch.Arch, upper string) bool {
} }
} }
// stackDelta computes the net SP change across a function body.
// It tracks PUSH/POP and SUB/ADD on SP. Returns the net delta (negative
// means SP decreased, which is the normal direction for stack growth).
func stackDelta(t *ast.Text, a arch.Arch) int64 {
var delta int64
for _, s := range t.Body {
in, ok := s.(*ast.Instr)
if !ok {
continue
}
upper := strings.ToUpper(in.Mnemonic.Text)
switch a {
case arch.AMD64:
switch upper {
case "PUSHQ", "PUSHL", "PUSHW":
delta -= 8
case "POPQ", "POPL", "POPW":
delta += 8
case "SUBQ", "SUBL":
if len(in.Operands) >= 2 && isSPReg(in.Operands[1], a) {
if in.Operands[0].Imm.HasVal {
delta -= in.Operands[0].Imm.Val
}
}
case "ADDQ", "ADDL":
if len(in.Operands) >= 2 && isSPReg(in.Operands[1], a) {
if in.Operands[0].Imm.HasVal {
delta += in.Operands[0].Imm.Val
}
}
}
case arch.ARM64:
switch upper {
case "STP":
// STP with pre-index: STP Xt1, Xt2, [SP, #imm]!
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
// Could be pre-index decrement; skip for simplicity.
}
case "SUB":
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
if in.Operands[1].Imm.HasVal {
delta -= in.Operands[1].Imm.Val
}
}
case "ADD":
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
if in.Operands[1].Imm.HasVal {
delta += in.Operands[1].Imm.Val
}
}
}
case arch.RISCV:
switch upper {
case "ADDI":
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
if in.Operands[1].Imm.HasVal {
delta += in.Operands[1].Imm.Val
}
}
}
case arch.LOONG64:
switch upper {
case "ADDI.D", "ADDI.W":
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
if in.Operands[1].Imm.HasVal {
delta += in.Operands[1].Imm.Val
}
}
case "ADD.D", "ADD.W":
if len(in.Operands) >= 3 && isSPReg(in.Operands[2], a) {
if in.Operands[1].Imm.HasVal {
delta += in.Operands[1].Imm.Val
}
}
}
}
}
return delta
}
// isSPReg reports whether the operand is the stack pointer register.
func isSPReg(op *ast.Operand, a arch.Arch) bool {
if op == nil || op.Addr.Sym == nil {
return false
}
name := strings.ToUpper(op.Addr.Sym.Name)
switch a {
case arch.AMD64:
return name == "RSP" || name == "ESP" || name == "SP"
case arch.ARM64:
return name == "R31" || name == "SP"
case arch.RISCV:
return name == "X2" || name == "SP"
case arch.LOONG64:
return name == "R3" || name == "SP"
}
return false
}
func countRange(min, max int) string { func countRange(min, max int) string {
if min == max { if min == max {
return fmt.Sprintf("%d operand(s)", min) return fmt.Sprintf("%d operand(s)", min)
+27
View File
@@ -282,3 +282,30 @@ TEXT ·f(SB), NOSPLIT|NOFRAME|DUPOK, $0
t.Fatalf("valid flags must not be flagged: %+v", diags) t.Fatalf("valid flags must not be flagged: %+v", diags)
} }
} }
func TestStackImbalance(t *testing.T) {
// Function with frame size 16 but only SUB 8, SP — imbalance.
diags := lintSrc(t, `
#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $16-0
SUBQ $8, SP
RET
`)
if codes(diags)[CodeStackImbalance] != 1 {
t.Fatalf("want one stack-imbalance, got %+v", diags)
}
}
func TestStackBalanced(t *testing.T) {
// Function with frame size 16 and matching SUB/ADD — balanced.
diags := lintSrc(t, `
#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $16-0
SUBQ $16, SP
ADDQ $16, SP
RET
`)
if codes(diags)[CodeStackImbalance] != 0 {
t.Fatalf("balanced stack must not be flagged: %+v", diags)
}
}