diff --git a/asm/arm64_assemble.go b/asm/arm64_assemble.go index 51d3ba7..d2a0fdd 100644 --- a/asm/arm64_assemble.go +++ b/asm/arm64_assemble.go @@ -236,6 +236,11 @@ func encodeARM64Instr(instr *ast.Instr, pc int, offsets map[string]int, fi arm64 return encodeARM64BranchCond(mnem, enc.op, ops, pc, offsets, resolve) } + // Unconditional register branches (BR, BLR). + if enc, ok := a64InstrTable[mnem]; ok && enc.format == a64FUncondBranch { + return encodeARM64RegBranch(mnem, enc.op, ops) + } + // ADD/SUB immediate. if mnem == "ADD" || mnem == "ADDW" || mnem == "SUB" || mnem == "SUBW" || mnem == "CMP" || mnem == "CMPW" || mnem == "CMN" || mnem == "CMNW" { @@ -337,6 +342,24 @@ func encodeARM64Branch(mnem string, ops []*ast.Operand, pc int, offsets map[stri } op := ops[0] + // Register-indirect: JMP (R0) is BR R0, CALL (R0) is BLR R0. The + // toolchain's spelling carries no offset and no index; anything else + // is reported rather than silently dropped. + if op.Addr.Sym == nil && op.Addr.Base != "" { + if op.Addr.Offset != 0 || op.Addr.Index != "" { + return nil, fmt.Errorf("%s: invalid indirect branch operand %q", mnem, op.Raw) + } + rn := arm64RegNum(op.Addr.Base) + if rn < 0 { + return nil, fmt.Errorf("%s: unknown branch register %q", mnem, op.Addr.Base) + } + opc := uint32(0) // BR + if link { + opc = 1 // BLR + } + return a64wordLE(a64UncondBranch(opc, uint32(rn), 0)), nil + } + // Symbol reference: BL sym(SB), or B sym(SB) for a tail call, against a // relocation (R_CALLARM64 either way). if op.Addr.Sym != nil && op.Addr.Sym.Pseudo == "SB" { @@ -373,6 +396,19 @@ func encodeARM64Branch(mnem string, ops []*ast.Operand, pc int, offsets map[stri return a64wordLE(a64Branch(bop, int32(rel))), nil } +// encodeARM64RegBranch encodes BR/BLR through a register operand: +// BR Xn = 0xd61f0000 | Rn<<5, BLR Xn = 0xd63f0000 | Rn<<5. +func encodeARM64RegBranch(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, error) { + if len(ops) != 1 { + return nil, fmt.Errorf("%s expects 1 operand, got %d", mnem, len(ops)) + } + rn := arm64RegNum(operandRegName(ops[0])) + if rn < 0 { + return nil, fmt.Errorf("%s expects a register operand", mnem) + } + return a64wordLE(uint32(baseOp) | 31<<16 | uint32(rn)<<5), nil +} + // encodeARM64BranchCond encodes a conditional branch (B.cond) to a label. func encodeARM64BranchCond(mnem string, baseOp uint32, ops []*ast.Operand, pc int, offsets map[string]int, resolve func(string) string) ([]byte, error) { if len(ops) != 1 { diff --git a/asm/arm64_encode_test.go b/asm/arm64_encode_test.go index c463c4c..5250fd6 100644 --- a/asm/arm64_encode_test.go +++ b/asm/arm64_encode_test.go @@ -572,3 +572,42 @@ func leWords(b []byte) []uint32 { } return w } + +// TestArm64IndirectBranch pins the indirect branch forms in a leaf function: +// JMP (Rn) lowers to BR Rn, matching the toolchain's spelling, and the raw +// BR/BLR mnemonics encode directly (a gasm superset the toolchain's front +// end does not accept). CALL (Rn) shares the BLR path and its non-leaf +// prologue parity is covered by the ground-truth kernel. +func TestArm64IndirectBranch(t *testing.T) { + src := `#include "textflag.h" + +TEXT ·f(SB), NOSPLIT, $0-0 + JMP (R0) + BR R5 + BLR R6 + RET +` + f, errs := parser.Parse("test_arm64.s", src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + img, err := AssembleFileARM64(f) + if err != nil { + t.Fatalf("AssembleFileARM64: %v", err) + } + want := []uint32{ + 0xd61f0000, // BR R0 + 0xd61f00a0, // BR R5 + 0xd63f00c0, // BLR R6 + 0xd65f03c0, // RET (BR LR) + } + got := leWords(img.Code) + if len(got) != len(want) { + t.Fatalf("word count = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("word %d = %08x, want %08x", i, got[i], want[i]) + } + } +} diff --git a/asm/assemble.go b/asm/assemble.go index 79be2dd..d8d31e0 100644 --- a/asm/assemble.go +++ b/asm/assemble.go @@ -337,7 +337,18 @@ func computeFrame(t *ast.Text) frameInfo { if t.Frame != nil && t.Frame.Imm.HasVal { fi.size = int(t.Frame.Imm.Val) } - if fi.size > 0 { + if fi.size == 0 && hasCall(t) { + // The toolchain gives a frameless function containing a CALL an + // 8-byte frame for the pushed base pointer: the prologue saves BP + // with no stack adjustment, every RET pops it back, FP references + // pass one extra slot, and the virtual SP is the hardware SP. + fi.size = 8 + fi.useFP = true + fi.fpAdjust = int64(fi.size) + 16 // return address + saved BP + args base + fi.spAdjust = 0 + fi.prologue = []byte{0x55, 0x48, 0x89, 0xE5} // PUSHQ BP; MOVQ SP, BP + fi.epilogue = []byte{0x5D} // POPQ BP + } else if fi.size > 0 { fi.useFP = true fi.fpAdjust = int64(fi.size) + 16 // frame + saved BP + return address fi.spAdjust = int64(fi.size) @@ -518,6 +529,13 @@ func instrSize(s *ast.Instr, fi frameInfo, long bool, link *linkInfo) (int, erro if (mnem == "CALL" || mnem == "JMP") && isSBCall(s) { return 5, nil // opcode + rel32, always the long form } + if (mnem == "CALL" || mnem == "JMP") && indirectJumpTarget(s) { + code, err := encodeIndirectJump(s, mnem) + if err != nil { + return 0, err + } + return len(code), nil + } return jumpSize(mnem, long), nil } code, _, err := encodeInstr(s, 0, nil, fi, false, nil, link) @@ -585,6 +603,15 @@ func encodeInstr(s *ast.Instr, pc int, offsets map[string]int, fi frameInfo, lon } return append(prefix, code...), ps, nil } + if (mnem == "CALL" || mnem == "JMP") && indirectJumpTarget(s) { + // JMP/CALL through a register or memory: no relocation and no + // label to resolve, the operand fully determines the bytes. + code, err = encodeIndirectJump(s, mnem) + if err != nil { + return nil, nil, err + } + return append(prefix, code...), nil, nil + } code, err = encodeJump(s, mnem, pc+len(prefix), offsets, long, resolve) } else { code, ps, err = encodeNormal(s, fi, link) @@ -706,6 +733,44 @@ func labelName(op *ast.Operand) (string, bool) { return "", false } +// indirectJumpTarget reports whether the JMP/CALL operand addresses a +// register or a memory location rather than a label or a static symbol. +// A bare identifier is a register when the register table knows the name and +// a label otherwise, which is exactly how the parser cannot distinguish them. +func indirectJumpTarget(s *ast.Instr) bool { + if len(s.Operands) != 1 || s.Operands[0].Kind != ast.OpAddr { + return false + } + a := s.Operands[0].Addr + if a.Base != "" || a.Index != "" { + return true + } + if a.Sym != nil && a.Sym.Pseudo == "" && a.Sym.Name != "" { + if _, ok := ParseReg(a.Sym.Name); ok { + return true + } + } + return false +} + +// encodeIndirectJump assembles a JMP/CALL through a register or memory +// operand, which carries no relocation and no label to resolve. +func encodeIndirectJump(s *ast.Instr, mnem string) ([]byte, error) { + ops := make([]Operand, len(s.Operands)) + for i, op := range s.Operands { + o, err := operandFromAST(op, 8, frameInfo{}, nil) + if err != nil { + return nil, err + } + ops[i] = o + } + e := &enc{} + if err := e.encodeIndirectBranch(mnem, ops); err != nil { + return nil, err + } + return e.out, nil +} + // spReg is the hardware stack pointer used to realise FP/SP pseudo-operands. var spReg = Reg{idx: 4, size: 8} diff --git a/asm/encode.go b/asm/encode.go index fcc2326..0d85317 100644 --- a/asm/encode.go +++ b/asm/encode.go @@ -40,10 +40,20 @@ func (e *enc) encode(mnem string, ops []Operand) error { return e.encodeRet() case upper == "NOP": return e.emit(&instr{opcode: []byte{0x90}, modrm: -1, sib: -1}) - case upper == "CALL": - return e.encodeJmpRel(ops, []byte{0xE8}) - case upper == "JMP": - return e.encodeJmpRel(ops, []byte{0xE9}) + case upper == "CALL" || upper == "JMP": + // Through a register or memory: FF /2 (CALL) or FF /4 (JMP). + // Anything else is a rel32 against a label resolved by the assembler. + if len(ops) == 1 { + switch ops[0].(type) { + case Reg, Mem: + return e.encodeIndirectBranch(upper, ops) + } + } + opcode := []byte{0xE8} + if upper == "JMP" { + opcode = []byte{0xE9} + } + return e.encodeJmpRel(ops, opcode) } if cc, ok := condCode(upper); ok { return e.encodeJcc(cc, ops) diff --git a/asm/encode_test.go b/asm/encode_test.go index 92e9fa5..09e7912 100644 --- a/asm/encode_test.go +++ b/asm/encode_test.go @@ -181,6 +181,39 @@ func TestControl(t *testing.T) { checkOp(t, x86asm.JBE, "JLS", Imm(0)) } +// TestIndirectControlFlow pins the indirect JMP/CALL forms: FF /4 for JMP and +// FF /2 for CALL through a register or memory. A REX appears only for the +// extended registers, never REX.W: the branch operand size is fixed at 64 +// bits in long mode. +func TestIndirectControlFlow(t *testing.T) { + cases := []struct { + name string + mnem string + ops []Operand + want string + }{ + {"JMP AX", "JMP", []Operand{AX}, "ffe0"}, + {"CALL AX", "CALL", []Operand{AX}, "ffd0"}, + {"JMP (BX)", "JMP", []Operand{Ptr(BX, 0, 8)}, "ff23"}, + {"CALL (BX)", "CALL", []Operand{Ptr(BX, 0, 8)}, "ff13"}, + {"JMP 8(BX)", "JMP", []Operand{Ptr(BX, 8, 8)}, "ff6308"}, + {"CALL -16(BX)", "CALL", []Operand{Ptr(BX, -16, 8)}, "ff53f0"}, + {"JMP R8", "JMP", []Operand{Reg{idx: 8, size: 2}}, "41ffe0"}, + {"CALL R9", "CALL", []Operand{Reg{idx: 9, size: 2}}, "41ffd1"}, + {"JMP R15", "JMP", []Operand{Reg{idx: 15, size: 2}}, "41ffe7"}, + } + for _, c := range cases { + code, err := Encode(c.mnem, c.ops...) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if got := fmt.Sprintf("%x", code); got != c.want { + t.Errorf("%s: got %s, want %s", c.name, got, c.want) + } + } +} + // TestSSEMoveGroundTruth checks the legacy (non-VEX) SSE moves byte for byte // against the Go assembler. wantOp is the decoder's name, which differs from // the Plan 9 spelling for the octa moves (MOVOU = MOVDQU, MOVO = MOVDQA). diff --git a/asm/instrs.go b/asm/instrs.go index 8d75d53..a4878d0 100644 --- a/asm/instrs.go +++ b/asm/instrs.go @@ -575,6 +575,24 @@ func (e *enc) encodeJmpRel(ops []Operand, opcode []byte) error { return e.emit(&instr{opcode: opcode, modrm: -1, sib: -1, imm: le32(int64(imm))}) } +// encodeIndirectBranch encodes JMP/CALL through a register or memory operand: +// FF /4 for JMP, FF /2 for CALL. The operand size is fixed at 64 bits in +// 64-bit mode, so no REX.W is emitted; a REX appears only for R8-R15 bases. +func (e *enc) encodeIndirectBranch(mnem string, ops []Operand) error { + if len(ops) != 1 { + return fmt.Errorf("%s expects 1 operand, got %d", mnem, len(ops)) + } + digit := 4 // JMP r/m64 + if mnem == "CALL" { + digit = 2 // CALL r/m64 + } + i := &instr{opcode: []byte{0xFF}, modrm: -1, sib: -1} + if err := setRMDigit(i, digit, ops[0], 8); err != nil { + return err + } + return e.emit(i) +} + // condCode maps a Plan 9 conditional-jump mnemonic to its x86 condition code. func condCode(upper string) (int, bool) { if len(upper) < 2 || upper[0] != 'J' || upper == "JMP" { diff --git a/asm/loong64_assemble.go b/asm/loong64_assemble.go index 18fcb08..aac8468 100644 --- a/asm/loong64_assemble.go +++ b/asm/loong64_assemble.go @@ -6,6 +6,7 @@ package asm import ( "fmt" "math/bits" + "strconv" "strings" "sourcedock.dev/petrbalvin/gasm-devkit/ast" @@ -258,6 +259,9 @@ func encodeLOONG64Instr(instr *ast.Instr, pc int, offsets map[string]int, fi loo // 16-bit branches (BEQ/BNE/BLT/BGE/BLTU/BGEU) and JIRL. if op, ok := l64branchTable[mnem]; ok { + if mnem == "JIRL" { + return encodeLOONG64Jirl(op, ops) + } return encodeLOONG64Branch16(mnem, op, ops, pc, offsets, resolve) } // Single-register branches with 21-bit offsets (BLTZ/BGEZ/BLEZ/BGTZ, @@ -554,6 +558,46 @@ func encodeLOONG64Branch(instr *ast.Instr, mnem string, pc int, offsets map[stri return l64wordLE(l64bbl(opc, v)), nil } +// encodeLOONG64Jirl encodes the raw JIRL spelling, JIRL rd, rj, offset, the +// form the verify trampolines use. The (rj) indirect form without an offset +// is handled by encodeLOONG64Branch. +func encodeLOONG64Jirl(op uint32, ops []*ast.Operand) ([]byte, error) { + if len(ops) != 3 { + return nil, fmt.Errorf("JIRL expects 3 operands, got %d", len(ops)) + } + rd := l64Reg(ops[0]) + rj := l64Reg(ops[1]) + if rd < 0 || rj < 0 { + return nil, fmt.Errorf("invalid register operand") + } + off, ok := l64offsetOperand(ops[2]) + if !ok { + return nil, fmt.Errorf("JIRL expects an immediate offset, got %q", ops[2].Raw) + } + if (int64(off)<<16)>>16 != int64(off) { + return nil, fmt.Errorf("JIRL offset %d out of the 16-bit range", off) + } + return l64wordLE(l64irr16(op, int(off), rj, rd)), nil +} + +// l64offsetOperand reads a bare numeric branch offset: an immediate ($n) or a +// plain number, which parses as an empty address carrying the digits in Raw. +func l64offsetOperand(op *ast.Operand) (int32, bool) { + if op.Imm.HasVal { + v := op.Imm.Val + if op.Imm.Neg { + v = -v + } + return int32(v), true + } + if op.Kind == ast.OpAddr && op.Addr.Sym == nil && op.Addr.Base == "" && op.Addr.Index == "" { + if v, err := strconv.ParseInt(op.Raw, 0, 64); err == nil { + return int32(v), true + } + } + return 0, false +} + // encodeLOONG64Branch16 encodes a 16-bit branch (BEQ/BNE/BLT/BGE/BLTU/BGEU): // INSTR rj, rd, label, or INSTR rj, label with rd = R0, which the toolchain // turns into the 21-bit BEQZ/BNEZ form when the register is the only operand. diff --git a/asm/loong64_encode_test.go b/asm/loong64_encode_test.go index 43668c6..8a20117 100644 --- a/asm/loong64_encode_test.go +++ b/asm/loong64_encode_test.go @@ -291,3 +291,40 @@ done: t.Errorf("code = % x\nwant % x", code, want) } } + +// TestLOONG64IndirectBranch pins the indirect branch encodings: JMP (Rj) and +// JAL (Rj) lower to jirl, and the raw JIRL spelling encodes the written +// offset (the Go loong64 assembler deletes raw JIRL instructions entirely, +// so this form is a gasm-only superset with faithful semantics). +func TestLOONG64IndirectBranch(t *testing.T) { + fn := firstTextLOONG64(t, `#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0-0 + JMP (R4) + JIRL R0, R4, 8 + RET +`) + code := assembleLOONG64Helper(t, fn) + wantWords(t, code, + 0x4C000080, // jirl r0, r4, 0 + 0x4C002080, // jirl r0, r4, 8 + 0x4C000020, // jirl r0, r1, 0 (RET) + ) + + // JAL (R5) links, so the toolchain gives the function its autosize-8 + // prologue and epilogue around the call and the closing RET. + fn = firstTextLOONG64(t, `#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0-0 + JAL (R5) + RET +`) + code = assembleLOONG64Helper(t, fn) + wantWords(t, code, + 0x29FFE061, // addi.d r1, r2, -8 (prologue) + 0x02FFE063, // addi.d r3, r3, -8 + 0x29C00061, // st.d r1, r2, 0 (prologue saves RA) + 0x4C0000A1, // jirl r1, r5, 0 + 0x28C00061, // ld.d r1, r2, 0 (epilogue restores RA) + 0x02C02063, // addi.d r3, r3, 8 + 0x4C000020, // jirl r0, r1, 0 (RET) + ) +} diff --git a/asm/riscv_assemble.go b/asm/riscv_assemble.go index 043c3c1..c33885f 100644 --- a/asm/riscv_assemble.go +++ b/asm/riscv_assemble.go @@ -208,6 +208,18 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv } op := ops[0] if op.Addr.Sym == nil || op.Addr.Sym.Pseudo != "SB" { + // CALL (X5): an indirect call, the toolchain's JALR X1, 0(X5). + if op.Addr.Sym == nil && op.Addr.Base != "" { + if op.Addr.Offset != 0 || op.Addr.Index != "" { + return nil, fmt.Errorf("CALL: invalid indirect operand %q", op.Raw) + } + rs1 := riscvRegNum(op.Addr.Base) + if rs1 < 0 { + return nil, fmt.Errorf("CALL: unknown branch register %q", op.Addr.Base) + } + word = riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, 0) + return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil + } return nil, fmt.Errorf("CALL: local branch target is not supported (use CALL sym(SB))") } if relocs != nil { @@ -229,6 +241,18 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil } target = labelFromOperand(ops[0]) + // JMP (X5): an indirect branch, the toolchain's JALR X0, 0(X5). + if ops[0].Addr.Sym == nil && ops[0].Addr.Base != "" { + if ops[0].Addr.Offset != 0 || ops[0].Addr.Index != "" { + return nil, fmt.Errorf("JMP: invalid indirect operand %q", ops[0].Raw) + } + rs1 := riscvRegNum(ops[0].Addr.Base) + if rs1 < 0 { + return nil, fmt.Errorf("JMP: unknown branch register %q", ops[0].Addr.Base) + } + word = riscvIType(riscvEnc{0x67, 0x0, 0x00}, 0, rs1, 0) + return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil + } } targetOff, ok := offsets[target] if !ok { @@ -886,25 +910,34 @@ func word16(w uint16) []byte { } // encodeRISCVJALR encodes the JALR indirect jump/call instruction. -// Plan 9: JALR rs1, rd (2 regs) or JALR offset(rs1) (memory → rd=X1). +// Plan 9: JALR rs1, rd (2 regs), JALR rd, offset(rs1) (the trampoline +// form), or JALR offset(rs1) (memory → rd=X1). func encodeRISCVJALR(instr *ast.Instr, fi riscvFrameInfo) ([]byte, error) { ops := instr.Operands + // JALR rd, offset(rs1): the memory operand's base is the jump-target + // register, not the destination. + if len(ops) == 2 && isMemOperand(ops[1]) { + rd := regFromOperand(ops[0]) + rs1, imm := memFromOperandWithFrame(ops[1], fi) + if rd < 0 || rs1 < 0 { + return nil, fmt.Errorf("JALR: invalid register operand") + } + return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, imm)), nil + } if len(ops) == 2 { rs1 := regFromOperand(ops[0]) rd := regFromOperand(ops[1]) if rd < 0 || rs1 < 0 { return nil, fmt.Errorf("JALR: invalid register operand") } - word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0) - return wordLE(word), nil + return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0)), nil } if len(ops) == 1 { rs1, imm := memFromOperandWithFrame(ops[0], fi) if rs1 < 0 { return nil, fmt.Errorf("JALR: invalid memory operand") } - word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm) - return wordLE(word), nil + return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm)), nil } return nil, fmt.Errorf("JALR expects 1 or 2 operands, got %d", len(ops)) } diff --git a/asm/riscv_encode_test.go b/asm/riscv_encode_test.go index fbba3aa..e029d29 100644 --- a/asm/riscv_encode_test.go +++ b/asm/riscv_encode_test.go @@ -761,3 +761,24 @@ sub: t.Error("expected error for CALL to local label, got nil") } } + +// TestRISCVIndirectBranch pins the indirect branch encodings: JMP (X5) is the +// toolchain's JALR X0, 0(X5), and the trampoline form JALR rd, offset(rs1) +// takes its destination from the first operand (regression: the base +// register was once read as the destination, silently jumping to X0). +func TestRISCVIndirectBranch(t *testing.T) { + fn := firstTextRISCV(t, `#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0-0 + JMP (X5) + JALR X0, 0(X6) + JALR X28, 0(X9) + RET +`) + code := assembleRISCVHelper(t, fn) + wantWords(t, code, + 0x00028067, // jalr x0, 5(x0), 0 + 0x00030067, // jalr x0, 6(x0), 0 + 0x00048e67, // jalr x28, 9(x0), 0 + 0x00008067, // jalr x0, 1(x0), 0 (RET) + ) +} diff --git a/asm/riscv_frame.go b/asm/riscv_frame.go index 66e5e66..10be6dc 100644 --- a/asm/riscv_frame.go +++ b/asm/riscv_frame.go @@ -93,12 +93,19 @@ func riscvIsLeaf(t *ast.Text) bool { return false } case "JALR": - // JALR rs1, rd, a call when rd is X1; JALR offset(rs1) always - // links to X1. + // JALR rd, offset(rs1) links when the destination register (the + // first operand) is X1; JALR rs1, rd links when the second + // register is X1; JALR offset(rs1) always links to X1. if len(in.Operands) == 1 { return false } - if len(in.Operands) >= 2 && regFromOperand(in.Operands[1]) == 1 { + if isMemOperand(in.Operands[1]) { + if regFromOperand(in.Operands[0]) == 1 { + return false + } + continue + } + if regFromOperand(in.Operands[1]) == 1 { return false } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fd90c8..b1798b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -161,7 +161,8 @@ Two deeper analyses sit on top of the AST: - **`unreachable-code`.** Code after a `RET` and before the next label is dead. The check is suppressed for any function whose reachability cannot be decided statically: those using PC-relative jumps (`JMP 2(PC)`), - register-indirect branches (`JALR`/`JR`/`JIRL`/`BR`/`BLR`), or living in a + register-indirect branches (`JALR`/`JR`/`JIRL`/`BR`/`BLR`, or a `JMP`/`CALL` + through a register or memory operand), or living in a file with `#ifdef` conditionals. `UNDEF` is deliberately not a terminator: code after it is occasionally intentional metadata. - **`register-clobber` (register liveness).** The linter builds the function's diff --git a/lint/lint.go b/lint/lint.go index 74ffef2..8e415e3 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -209,10 +209,10 @@ func lintText(t *ast.Text, tab *arch.Table, archKnown bool, cfg Config, macros m lastTerminal := false hasMacro := false instrCount := 0 - dead := false // inside a region unreachable from above - reportedDead := false // the current dead region has already been reported - hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis - hasIndirect := hasIndirectBranch(t) // register-indirect branches do too + dead := false // inside a region unreachable from above + reportedDead := false // the current dead region has already been reported + hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis + hasIndirect := hasIndirectBranch(t, tab) // register-indirect branches do too // Unreachable-code analysis is only sound in functions whose control flow is // fully label-resolvable: no PC-relative jumps, no register-indirect // branches, and (file-level) no preprocessor conditionals. @@ -549,10 +549,12 @@ func referencesPC(t *ast.Text) bool { } // hasIndirectBranch reports whether a function transfers control through a -// register (JALR/JR/JIRL/BR/BLR). Such targets are computed at runtime, so -// reachability cannot be determined statically and the unreachable-code check is -// suppressed for the whole function. -func hasIndirectBranch(t *ast.Text) bool { +// register or a computed memory address: the RISC branch-register mnemonics +// (JALR/JR/JIRL/BR/BLR), or a JMP/CALL whose target is a register or memory +// operand rather than a label or symbol. Such targets are computed at +// runtime, so reachability cannot be determined statically and the +// unreachable-code check is suppressed for the whole function. +func hasIndirectBranch(t *ast.Text, tab *arch.Table) bool { for _, s := range t.Body { in, ok := s.(*ast.Instr) if !ok { @@ -561,11 +563,30 @@ func hasIndirectBranch(t *ast.Text) bool { switch strings.ToUpper(in.Mnemonic.Text) { case "JALR", "JR", "JIRL", "BR", "BLR": return true + case "JMP", "CALL": + if indirectJumpTarget(in, tab) { + return true + } } } return false } +// indirectJumpTarget reports whether the JMP/CALL operand addresses a +// register or a memory location rather than a label or a static symbol. The +// parser delivers a bare register and a bare label in the same shape, so +// register membership decides. +func indirectJumpTarget(in *ast.Instr, tab *arch.Table) bool { + if len(in.Operands) != 1 || in.Operands[0].Kind != ast.OpAddr { + return false + } + a := in.Operands[0].Addr + if a.Base != "" || a.Index != "" { + return true + } + return a.Sym != nil && a.Sym.Pseudo == "" && a.Sym.Name != "" && tab.IsRegister(a.Sym.Name) +} + // isMacroInvocation reports whether a mnemonic is a macro invocation rather // than a machine instruction. No Plan 9 mnemonic contains an underscore, so an // underscore is a reliable macro marker (the runtime headers define macros such diff --git a/lint/lint_test.go b/lint/lint_test.go index 08e4eca..67175b7 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -8,6 +8,7 @@ import ( "testing" "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/parser" ) @@ -495,3 +496,33 @@ TEXT ·f(SB), NOSPLIT, $0 t.Fatalf("amd64 must not be flagged: %+v", diags) } } + +// TestHasIndirectBranchShape checks that a JMP/CALL through a register or +// memory suppresses reachability analysis, while a same-named label does not. +func TestHasIndirectBranchShape(t *testing.T) { + tab := arch.ForArch(arch.AMD64) + indirect := `TEXT ·f(SB), NOSPLIT, $0 + JMP AX + RET +` + f, errs := parser.Parse("t_amd64.s", indirect) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if !hasIndirectBranch(f.Decls[0].(*ast.Text), tab) { + t.Error("JMP AX: indirect branch not detected") + } + + label := `TEXT ·f(SB), NOSPLIT, $0 +loop: + JMP loop + RET +` + f, errs = parser.Parse("t_amd64.s", label) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if hasIndirectBranch(f.Decls[0].(*ast.Text), tab) { + t.Error("JMP loop: label treated as an indirect branch") + } +} diff --git a/testdata/verify/indirect_amd64.s b/testdata/verify/indirect_amd64.s new file mode 100644 index 0000000..e26858e --- /dev/null +++ b/testdata/verify/indirect_amd64.s @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +// Indirect control flow: JMP/CALL through a register or memory, byte-compared +// against go tool asm. A CALL in the body also exercises the toolchain's +// forced base-pointer frame on a frameless function. + +#include "textflag.h" + +// func f() +TEXT ·f(SB), NOSPLIT, $0 + JMP AX + CALL AX + JMP (BX) + CALL (BX) + JMP 8(BX) + JMP R8 + RET diff --git a/testdata/verify/indirect_arm64.s b/testdata/verify/indirect_arm64.s new file mode 100644 index 0000000..9a74429 --- /dev/null +++ b/testdata/verify/indirect_arm64.s @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +// Indirect control flow: JMP (R0) and CALL (R0) lower to BR/BLR, the only +// indirect-branch spellings the toolchain accepts (the raw BR/BLR mnemonics +// stay a gasm superset). + +#include "textflag.h" + +// func f() +TEXT ·f(SB), NOSPLIT, $0 + JMP (R0) + CALL (R0) + RET diff --git a/testdata/verify/indirect_loong64.s b/testdata/verify/indirect_loong64.s new file mode 100644 index 0000000..e0dea61 --- /dev/null +++ b/testdata/verify/indirect_loong64.s @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +// Indirect control flow: JMP (R4) and JAL (R5) are the toolchain's spellings +// for jirl; the raw JIRL instruction is deliberately absent, because the Go +// loong64 assembler deletes it and a parity kernel could not hold it. + +#include "textflag.h" + +// func f() +TEXT ·f(SB), NOSPLIT, $0-0 + JMP (R4) + JAL (R5) + RET diff --git a/testdata/verify/indirect_riscv64.s b/testdata/verify/indirect_riscv64.s new file mode 100644 index 0000000..6f73d53 --- /dev/null +++ b/testdata/verify/indirect_riscv64.s @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +// Indirect control flow: JMP (X5) lowers to JALR X0, 0(X5), the trampoline +// form JALR rd, offset(rs1) encodes with the destination first, and a linking +// JALR through X1 is the toolchain's only indirect call. + +#include "textflag.h" + +// func f() +TEXT ·leaf(SB), NOSPLIT, $0-0 + JMP (X5) + JALR X0, 0(X6) + RET + +// func g() +TEXT ·calls(SB), NOSPLIT, $0-0 + JALR X1, 0(X8) + RET diff --git a/verify/abi_loong64.s b/verify/abi_loong64.s index 211dc08..b57a51e 100644 --- a/verify/abi_loong64.s +++ b/verify/abi_loong64.s @@ -39,7 +39,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA MOVV R5, R3 // SP stays on the leave slot: the kernel // reads its first argument at SP+8 - JIRL R0, R4, 0 // jump to JIT function + JMP (R4) // jump to JIT function // leaveJITCheckedRaw is the raw return trampoline. It has NO Go function // declaration, so no ABIInternal wrapper is generated; the JIT function's @@ -61,6 +61,6 @@ g_ok: MOVV savedRA(SB), R1 // restore return address MOVV savedG(SB), g // restore g: Go code needs it the moment it // resumes, violation or not - JIRL R0, R1, 0 // return to Go caller + JMP (R1) // return to Go caller GLOBL savedG(SB), NOPTR, $8 diff --git a/verify/arm64_groundtruth_test.go b/verify/arm64_groundtruth_test.go index 3be96f9..7db2f92 100644 --- a/verify/arm64_groundtruth_test.go +++ b/verify/arm64_groundtruth_test.go @@ -25,6 +25,7 @@ func TestGroundTruthARM64(t *testing.T) { "../testdata/verify/call_arm64.s", "../testdata/verify/bigframe_arm64.s", "../testdata/verify/guard_arm64.s", + "../testdata/verify/indirect_arm64.s", } { t.Run(path, func(t *testing.T) { src, err := os.ReadFile(path) diff --git a/verify/groundtruth_test.go b/verify/groundtruth_test.go index b8dc68b..a9acbe9 100644 --- a/verify/groundtruth_test.go +++ b/verify/groundtruth_test.go @@ -98,6 +98,7 @@ func TestGroundTruthAMD64(t *testing.T) { "../testdata/verify/basic_amd64.s", "../testdata/verify/bigframe_amd64.s", "../testdata/verify/guard_amd64.s", + "../testdata/verify/indirect_amd64.s", } { t.Run(path, func(t *testing.T) { f, errs := parser.Parse(path, mustRead(t, path)) diff --git a/verify/l64_groundtruth_test.go b/verify/l64_groundtruth_test.go index d59c4ee..548bc3e 100644 --- a/verify/l64_groundtruth_test.go +++ b/verify/l64_groundtruth_test.go @@ -24,6 +24,8 @@ func TestGroundTruthLOONG64(t *testing.T) { "../testdata/verify/fp_loong64.s", "../testdata/verify/bigframe_loong64.s", "../testdata/verify/guard_loong64.s", + "../testdata/verify/indirect_loong64.s", + "trampoline_loong64.s", } { t.Run(path, func(t *testing.T) { src, err := os.ReadFile(path) diff --git a/verify/riscv_groundtruth_test.go b/verify/riscv_groundtruth_test.go index 5c4ec36..146a945 100644 --- a/verify/riscv_groundtruth_test.go +++ b/verify/riscv_groundtruth_test.go @@ -27,6 +27,8 @@ func TestGroundTruthRISCV(t *testing.T) { "../testdata/verify/call_riscv64.s", "../testdata/verify/bigframe_riscv64.s", "../testdata/verify/guard_riscv64.s", + "../testdata/verify/indirect_riscv64.s", + "trampoline_riscv64.s", } { t.Run(path, func(t *testing.T) { testGroundTruthRISCVFile(t, path) diff --git a/verify/trampoline_loong64.s b/verify/trampoline_loong64.s index e3d9283..9952e3f 100644 --- a/verify/trampoline_loong64.s +++ b/verify/trampoline_loong64.s @@ -6,7 +6,7 @@ // ABI0 JIT trampoline for LoongArch 64. // // enterJIT saves Go SP and RA (R1), switches to the prepared stack, and -// jumps to the JIT function. When the function RETs (JIRL zero, ra, 0), +// jumps to the JIT function. When the function RETs (JMP (R1), the toolchain's spelling for jirl zero, ra, 0), // control lands in leaveJIT. // func enterJIT(fn uintptr, stack uintptr) @@ -19,14 +19,14 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16 MOVV R5, R3 // switch to the prepared stack: SP stays on // the leave slot, so the kernel reads its // first argument at SP+8 - JIRL R0, R4, 0 // jump to JIT function + JMP (R4) // jump to JIT function // func leaveJIT() TEXT ·leaveJIT(SB), NOSPLIT, $0-0 MOVV savedSP(SB), R5 // restore Go stack pointer MOVV R5, R3 // restore SP MOVV savedRA(SB), R1 // restore return address - JIRL R0, R1, 0 // return to Go caller + JMP (R1) // return to Go caller GLOBL savedRA(SB), NOPTR, $8