From 8ab99c9b0c78da3e51e48be77f05a7ec9fe1a54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 7 Oct 2026 02:11:49 +0200 Subject: [PATCH] fix(asm): tighten the arm64 acceptance toward the toolchain Assisted-by: GLM 5.3 Flash --- asm/arm64_assemble.go | 135 ++++++++++++++++++++++++++++++++-- asm/arm64_encode.go | 42 +++++------ asm/arm64_encode_test.go | 1 + asm/arm64_errorparity_test.go | 101 +++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 asm/arm64_errorparity_test.go diff --git a/asm/arm64_assemble.go b/asm/arm64_assemble.go index d8a087d..5b88eba 100644 --- a/asm/arm64_assemble.go +++ b/asm/arm64_assemble.go @@ -890,13 +890,38 @@ func encodeARM64DPSR(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er if rn < 0 || rd < 0 { return nil, fmt.Errorf("invalid register operand in %s", mnem) } + // SP rules the toolchain enforces on the logical immediates: a + // two-operand spelling with SP as the source is rejected outright + // (illegal source register), a flag-setting logical rejects SP as + // the destination, and a plain logical to SP takes the fast + // bitmask path only inside the addcon band (asm7.go cases 62/13). + if !zrDest && len(ops) == 2 && rn == 31 { + if name := operandRegName(ops[1]); strings.EqualFold(name, "RSP") { + return nil, fmt.Errorf("%s: illegal source register RSP", mnem) + } + } + rspDest := false + if len(ops) == 3 { + rspDest = strings.EqualFold(operandRegName(ops[2]), "RSP") + } + sLogical := mnem == "ANDS" || mnem == "ANDSW" || mnem == "BICS" || mnem == "BICSW" + if rspDest && sLogical { + return nil, fmt.Errorf("%s: illegal combination: the destination cannot be RSP", mnem) + } n, immr, imms, ok := a64LogicalImm(v, width) - // The toolchain's logical-immediate rows take a real destination + // The toolchain.s logical-immediate rows take a real destination // only (omovconst guards the bitmask path with rt != REGZERO): a // non-flag-setting logical to ZR materialises the constant into // REGTMP (R27) and takes the register form. RSP is a real - // register here, and the flags-only TST spellings keep the fast - // path: ANDS ZR, Rn, #imm is their form. + // register here, but keeps the fast bitmask path inside the + // addcon band alone, and the flags-only TST spellings keep the + // fast path: ANDS ZR, Rn, #imm is their form. + if rspDest { + inBand := v > 0 && (v <= 0xFFF || (v&0xFFF == 0 && v>>12 <= 0xFFF)) + if !ok || !inBand { + return nil, fmt.Errorf("%s: illegal combination: the destination cannot be RSP", mnem) + } + } if ok && (isCmp || !zrDest) { opc := (baseOp >> 29) & 7 sf := (baseOp >> 31) & 1 @@ -967,10 +992,15 @@ func encodeARM64DPSR(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er spInvolved = true } } - if spInvolved && strings.HasPrefix(mnem, "ADD") || spInvolved && strings.HasPrefix(mnem, "SUB") { + if spInvolved && (strings.HasPrefix(mnem, "ADD") || strings.HasPrefix(mnem, "SUB") || isCmp) { if shiftBits != 0 { return nil, fmt.Errorf("%s: right shift not encodable against SP", mnem) } + // The extend field carries 0..4 only (asm7.go: shift amount + // out of range 0 to 4). + if amount > 4 { + return nil, fmt.Errorf("%s: shift amount out of range 0 to 4", mnem) + } opt := uint32(3) // UXTX if strings.HasSuffix(mnem, "W") { opt = 2 // UXTW @@ -982,6 +1012,13 @@ func encodeARM64DPSR(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er } else { baseOp |= 1<<21 | extendOpt<<13 | uint32(amount)<<10 } + // The flag-setting add/sub family rejects SP as its destination. + switch mnem { + case "ADDS", "ADDSW", "SUBS", "SUBSW": + if strings.EqualFold(operandRegName(ops[len(ops)-1]), "RSP") { + return nil, fmt.Errorf("%s: illegal destination register RSP", mnem) + } + } rd := arm64RegNum(operandRegName(ops[len(ops)-1])) rn := rd if len(ops) == 3 { @@ -1028,6 +1065,13 @@ func encodeARM64DPSR(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er if rm < 0 || rn < 0 || rd < 0 { return nil, fmt.Errorf("invalid register operand in %s", mnem) } + // The flag-setting add/sub family rejects SP as its destination. + switch mnem { + case "ADDS", "ADDSW", "SUBS", "SUBSW": + if strings.EqualFold(operandRegName(ops[2]), "RSP") { + return nil, fmt.Errorf("%s: illegal destination register RSP", mnem) + } + } // ADD/SUB against SP take the extended-register form with the // identity extend, the toolchain's spelling of a plain register // operand against the stack pointer (asm7.go opxrrr against C_RSP). @@ -1335,6 +1379,14 @@ func encodeARM64AddSubImm(mnem string, ops []*ast.Operand) ([]byte, error) { if rn < 0 || rd < 0 { return nil, fmt.Errorf("invalid register operand in %s", mnem) } + // The flag-setting add/sub family rejects SP as its destination + // (asm7.go: illegal destination register). + switch mnem { + case "ADDS", "ADDSW", "SUBS", "SUBSW": + if strings.EqualFold(operandRegName(ops[len(ops)-1]), "RSP") { + return nil, fmt.Errorf("%s: illegal destination register RSP", mnem) + } + } // CMP/CMN discard the destination. if mnem == "CMP" || mnem == "CMPW" || mnem == "CMN" || mnem == "CMNW" { rd = 31 // ZR @@ -2105,6 +2157,15 @@ func encodeARM64MemOp(mnem string, mem *ast.Operand, reg int, load bool, fi arm6 if rn < 0 { return nil, fmt.Errorf("invalid memory operand") } + // The MOV family addresses memory through a general register only. + if mem.Addr.Base != "" && arm64RegClassOf(mem.Addr.Base) == arm64ClsFP { + return nil, fmt.Errorf("%s: illegal combination: the base register cannot be FP", mnem) + } + // Writeback with the base doubling as the data register is constrained + // unpredictable. + if wb != "" && rn == reg && rn != 31 { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the base rides the data register", mnem) + } lt, ok := a64LoadTable[mnem] if !ok { // MOV defaults to MOVD (64-bit load/store). @@ -2968,6 +3029,11 @@ func encodeARM64CSEL(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er mnem == "CNEG" || mnem == "CNEGW" if isAlias { + // The aliases invert the condition, which is undefined for AL and NV + // (asm7.go: invalid condition). + if condName := operandRegName(ops[0]); strings.EqualFold(condName, "AL") || strings.EqualFold(condName, "NV") { + return nil, fmt.Errorf("%s: invalid condition %s", mnem, condName) + } is2op := mnem == "CSET" || mnem == "CSETW" || mnem == "CSETM" || mnem == "CSETMW" if is2op { // CSET cond, Rd → CSEL XZR, XZR, Rd, inverted_cond @@ -3112,6 +3178,14 @@ func encodeARM64Excl(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er // The single-register opcodes pre-set the unused Rs (bits 20:16) // and Rt2 (bits 14:10) fields to 31; the pair forms carry a real // Rt2 and keep Rs at 31. + // Constrained unpredictable: the base rides no pair member and + // the pair registers differ. + if rt1 == rt2 { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the pair registers match", mnem) + } + if bname := operandRegName(ops[0]); strings.EqualFold(bname, fmt.Sprintf("R%d", rt1)) || strings.EqualFold(bname, fmt.Sprintf("R%d", rt2)) { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the base rides a pair register", mnem) + } return a64wordLE(baseOp | 0x1F<<16 | uint32(rt2)<<10 | uint32(rn)<<5 | uint32(rt1)), nil } rt := arm64RegNum(operandRegName(ops[1])) @@ -3128,17 +3202,35 @@ func encodeARM64Excl(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, er if err != nil { return nil, err } + // The status register cannot be SP (asm7.go: illegal combination). + if strings.EqualFold(operandRegName(ops[2]), "RSP") { + return nil, fmt.Errorf("%s: illegal combination: the status register cannot be RSP", mnem) + } rs := arm64RegNum(operandRegName(ops[2])) if rs < 0 { return nil, fmt.Errorf("invalid operand in %s", mnem) } if rt1, rt2, ok := arm64PairOf(ops[0]); ok { + // Constrained unpredictable (asm7.go case 59): the pair registers + // differ, and the status register differs from both pair members and + // from a non-SP base. + if rt1 == rt2 { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the pair registers match", mnem) + } + if rs == rt1 || rs == rt2 || (rs == rn && rn != 31) { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the status register rides a pair register or the base", mnem) + } return a64wordLE(baseOp | uint32(rs)<<16 | uint32(rt2)<<10 | uint32(rn)<<5 | uint32(rt1)), nil } + // Constrained unpredictable (asm7.go case 59): the status register + // differs from Rt and from a non-SP base. rt := arm64RegNum(operandRegName(ops[0])) if rt < 0 { return nil, fmt.Errorf("invalid operand in %s", mnem) } + if rs == rt || (rs == rn && rn != 31) { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the status register matches Rt or the base", mnem) + } return a64wordLE(baseOp | uint32(rs)<<16 | uint32(rn)<<5 | uint32(rt)), nil } @@ -3157,6 +3249,10 @@ func encodeARM64LSEAtom(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, if err != nil { return nil, err } + // The result register cannot be SP (asm7.go: illegal combination). + if strings.EqualFold(operandRegName(ops[2]), "RSP") { + return nil, fmt.Errorf("%s: illegal combination: the result register cannot be RSP", mnem) + } rt := arm64RegNum(operandRegName(ops[2])) if rt < 0 { return nil, fmt.Errorf("invalid operand in %s", mnem) @@ -3681,6 +3777,30 @@ func encodeARM64Pair(mnem string, baseOp uint32, ops []*ast.Operand, pc int, fi if !ok { return nil, fmt.Errorf("%s expects a register pair (Rt1, Rt2)", mnem) } + // Constrained unpredictable: the pair registers differ, and a + // writeback base rides no pair member. + if rt1 == rt2 { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the pair registers match", mnem) + } + if wb != "" { + if strings.EqualFold(operandRegName(memOp), fmt.Sprintf("R%d", rt1)) || strings.EqualFold(operandRegName(memOp), fmt.Sprintf("R%d", rt2)) { + return nil, fmt.Errorf("%s: constrained unpredictable behavior: the base rides a pair register", mnem) + } + } + // The FP pairs take FP registers against a GP base (asm7.go: invalid + // register pair). + if strings.HasPrefix(mnem, "FLDP") || strings.HasPrefix(mnem, "FSTP") { + first := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pairOp.Raw), "(")) + if i := strings.IndexAny(first, ",)"); i >= 0 { + first = strings.TrimSpace(first[:i]) + } + if !strings.HasPrefix(first, "F") { + return nil, fmt.Errorf("%s: invalid register pair %s", mnem, pairOp.Raw) + } + if strings.HasPrefix(strings.TrimLeft(operandRegName(memOp), "( "), "F") { + return nil, fmt.Errorf("%s: invalid register pair: the base must be a general register", mnem) + } + } // Pair access against a static symbol: ADRP R27, sym; ADD R27, R27, #lo; // LDP/STP (R27), (Rt1, Rt2), with the R_ADDRARM64 pair riding the first @@ -4274,7 +4394,7 @@ func arm64SimdHasElement(ops []*ast.Operand) bool { // admits it. It returns the arrangement's index, with a64Arr8B for a bare // V/F spelling. func arm64SimdArrs(mnem string, arrs []string, allowed uint16) (int, error) { - sel := a64Arr8B + sel := -1 for _, a := range arrs { if a == "" { continue @@ -4283,11 +4403,14 @@ func arm64SimdArrs(mnem string, arrs []string, allowed uint16) (int, error) { if i < 0 || specBit(i)&allowed == 0 { return 0, fmt.Errorf("%s: invalid arrangement %q", mnem, a) } - if sel != a64Arr8B && sel != i { + if sel >= 0 && sel != i { return 0, fmt.Errorf("%s: mixed arrangements", mnem) } sel = i } + if sel < 0 { + sel = a64Arr8B + } return sel, nil } diff --git a/asm/arm64_encode.go b/asm/arm64_encode.go index e8dd062..3870993 100644 --- a/asm/arm64_encode.go +++ b/asm/arm64_encode.go @@ -565,16 +565,12 @@ func init() { a64InstrTable["BFXILW"] = a64Enc{format: a64FBitfieldAlias, op: 0<<31 | 1<<29 | 0x26<<23} a64InstrTable["SBFIZ"] = a64Enc{format: a64FBitfieldAlias, op: 0x93400000} a64InstrTable["SBFIZW"] = a64Enc{format: a64FBitfieldAlias, op: 0x13000000} - a64InstrTable["UBFIZ"] = a64Enc{format: a64FBitfieldAlias, op: 0x53000000} - a64InstrTable["UBFIZW"] = a64Enc{format: a64FBitfieldAlias, op: 0x33000000} + a64InstrTable["UBFIZ"] = a64Enc{format: a64FBitfieldAlias, op: 0xd3400000} + a64InstrTable["UBFIZW"] = a64Enc{format: a64FBitfieldAlias, op: 0x53000000} a64InstrTable["SBFM"] = a64Enc{format: a64FBitfield, op: 1<<31 | 0<<29 | 0x26<<23 | 1<<22} a64InstrTable["SBFMW"] = a64Enc{format: a64FBitfield, op: 0<<31 | 0<<29 | 0x26<<23 | 0<<22} a64InstrTable["UBFM"] = a64Enc{format: a64FBitfield, op: 1<<31 | 2<<29 | 0x26<<23 | 1<<22} a64InstrTable["UBFMW"] = a64Enc{format: a64FBitfield, op: 0<<31 | 2<<29 | 0x26<<23 | 0<<22} - a64InstrTable["BFI"] = a64Enc{format: a64FBitfield, op: 1<<31 | 2<<29 | 0x26<<23 | 1<<22} - a64InstrTable["BFIW"] = a64Enc{format: a64FBitfield, op: 0<<31 | 2<<29 | 0x26<<23 | 0<<22} - a64InstrTable["BFXIL"] = a64Enc{format: a64FBitfield, op: 1<<31 | 1<<29 | 0x26<<23 | 1<<22} - a64InstrTable["BFXILW"] = a64Enc{format: a64FBitfield, op: 0<<31 | 1<<29 | 0x26<<23 | 0<<22} // ---- FP 3-operand (Rm, Rn, Rd): FADD, FSUB, FMUL, FDIV, FMAX, FMIN, FNMUL ---- fp3 := map[string]uint32{ @@ -1071,7 +1067,7 @@ func a64ElemLetter(s string) bool { // arrangement"). fpAcrossArrs bounds the across-vector reductions, which do // take the half width. var fpSimdArrs = uint16(1< (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-sdk/parser" +) + +// arm64AcceptedErrorShapes lists the toolchain's arm64error.s spellings gasm +// still accepts, each an acceptance superset with a known shape. The list +// only shrinks: every tightening of the encoder moves spellings out of it, +// and a spelling reappearing here means a regression. +var arm64AcceptedErrorShapes = []string{ + // VLD1/VST1 post-index shapes: the register post-index without the .P + // spelling, the .P immediate against the aggregate register size, the + // scaled register increment and the replicating register count. + "VLD1 (R8)(R13), [V2.B16]", + "VST1 [V1.B16], (R8)(R13)", + "VST1.P [V4.S4,V5.S4], 48(R1)", + "VST1.P [V4.S4], 8(R1)", + "VLD1.P 32(R1), [V8.S4, V9.S4, V10.S4]", + "VLD1.P 48(R1), [V7.S4, V8.S4, V9.S4, V10.S4]", + "VLD1.P (R8)(R9<<2), [V2.B16]", + "VST1.P [V1.B16], (R8)(R9<<1)", + "VLD3R.P 24(R15), [V15.H4,V16.H4,V17.H4]", + // REGTMP (R27) as an explicit operand the toolchain refuses, the pair + // split paths included. + "ADD $0x1234567, R27, R3", + "ADD $0x3fffffffc000, R27, R5", + "AND $0x22220000, R27, R4", + "ANDW $0x6006000060060, R27, R5", + "STP (R3, R4), 0x1234567(R27)", + "LDP 0x1234567(R27), (R3, R4)", + "STP (R26, R27), 700(R2)", + // TLBI operand arity and the range-prefetch operand shapes. + "TLBI VMALLE1IS, R0", + "TLBI ALLE3OS, ZR", + "TLBI VAE1IS", + "TLBI RVALE3", + "RPRFM (R1), RSP, PLDKEEP", + // The GP register pairs on the FP mnemonics, the other way round. + "LDP (R0), (F0, F1)", + "STP (F2, F3), (R0)", + // FCVTL exists for the S to D pair alone. + "VFCVTL V1.H4, V2.S4", +} + +// TestArm64ToolchainErrorParity walks the toolchain's arm64error.s (Go +// 1.27, arm64) and requires gasm to reject every case the toolchain rejects, +// the documented acceptance supersets above excepted. A live Go toolchain +// is needed for the source file; the test skips without one or in -short. +func TestArm64ToolchainErrorParity(t *testing.T) { + if testing.Short() { + t.Skip("live arm64error.s corpus: skipped in -short mode") + } + goroot := os.Getenv("GOROOT") + if goroot == "" { + t.Skip("no GOROOT") + } + path := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata", "arm64error.s") + data, err := os.ReadFile(path) + if err != nil { + t.Skip(err) + } + allowed := map[string]bool{} + for _, s := range arm64AcceptedErrorShapes { + allowed[s] = true + } + for raw := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "TEXT") || !strings.Contains(line, "ERROR") { + continue + } + body := line + if i := strings.Index(body, "//"); i >= 0 { + body = strings.TrimSpace(body[:i]) + } + body = strings.ReplaceAll(body, "\t", " ") + body = strings.Join(strings.Fields(body), " ") + // Label-relative and non-operand lines need their function context. + if strings.Contains(body, "(PC)") || strings.Contains(body, "(SB)") || strings.Contains(body, "PCALIGN") || + strings.HasPrefix(body, "RET") || strings.HasPrefix(body, "NOP") || strings.HasPrefix(body, "BRK") || + strings.Contains(body, "again") || strings.Contains(body, "next") || strings.Contains(body, "loop") { + continue + } + src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n" + f, perr := parser.Parse("errorparity.s", src) + if len(perr) > 0 { + continue // the parser already rejects the spelling + } + if _, aerr := AssembleFileARM64(f); aerr == nil && !allowed[body] { + t.Errorf("gasm accepts what the toolchain rejects: %s", body) + } + } +}