feat(asm): encode indirect JMP and CALL on all four architectures

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-19 19:17:07 +02:00
parent 96e81cc98d
commit 23c001be51
24 changed files with 494 additions and 27 deletions
+36
View File
@@ -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) 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. // ADD/SUB immediate.
if mnem == "ADD" || mnem == "ADDW" || mnem == "SUB" || mnem == "SUBW" || if mnem == "ADD" || mnem == "ADDW" || mnem == "SUB" || mnem == "SUBW" ||
mnem == "CMP" || mnem == "CMPW" || mnem == "CMN" || mnem == "CMNW" { 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] 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 // Symbol reference: BL sym(SB), or B sym(SB) for a tail call, against a
// relocation (R_CALLARM64 either way). // relocation (R_CALLARM64 either way).
if op.Addr.Sym != nil && op.Addr.Sym.Pseudo == "SB" { 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 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. // 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) { 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 { if len(ops) != 1 {
+39
View File
@@ -572,3 +572,42 @@ func leWords(b []byte) []uint32 {
} }
return w 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])
}
}
}
+66 -1
View File
@@ -337,7 +337,18 @@ func computeFrame(t *ast.Text) frameInfo {
if t.Frame != nil && t.Frame.Imm.HasVal { if t.Frame != nil && t.Frame.Imm.HasVal {
fi.size = int(t.Frame.Imm.Val) 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.useFP = true
fi.fpAdjust = int64(fi.size) + 16 // frame + saved BP + return address fi.fpAdjust = int64(fi.size) + 16 // frame + saved BP + return address
fi.spAdjust = int64(fi.size) 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) { if (mnem == "CALL" || mnem == "JMP") && isSBCall(s) {
return 5, nil // opcode + rel32, always the long form 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 return jumpSize(mnem, long), nil
} }
code, _, err := encodeInstr(s, 0, nil, fi, false, nil, link) 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 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) code, err = encodeJump(s, mnem, pc+len(prefix), offsets, long, resolve)
} else { } else {
code, ps, err = encodeNormal(s, fi, link) code, ps, err = encodeNormal(s, fi, link)
@@ -706,6 +733,44 @@ func labelName(op *ast.Operand) (string, bool) {
return "", false 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. // spReg is the hardware stack pointer used to realise FP/SP pseudo-operands.
var spReg = Reg{idx: 4, size: 8} var spReg = Reg{idx: 4, size: 8}
+14 -4
View File
@@ -40,10 +40,20 @@ func (e *enc) encode(mnem string, ops []Operand) error {
return e.encodeRet() return e.encodeRet()
case upper == "NOP": case upper == "NOP":
return e.emit(&instr{opcode: []byte{0x90}, modrm: -1, sib: -1}) return e.emit(&instr{opcode: []byte{0x90}, modrm: -1, sib: -1})
case upper == "CALL": case upper == "CALL" || upper == "JMP":
return e.encodeJmpRel(ops, []byte{0xE8}) // Through a register or memory: FF /2 (CALL) or FF /4 (JMP).
case upper == "JMP": // Anything else is a rel32 against a label resolved by the assembler.
return e.encodeJmpRel(ops, []byte{0xE9}) 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 { if cc, ok := condCode(upper); ok {
return e.encodeJcc(cc, ops) return e.encodeJcc(cc, ops)
+33
View File
@@ -181,6 +181,39 @@ func TestControl(t *testing.T) {
checkOp(t, x86asm.JBE, "JLS", Imm(0)) 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 // TestSSEMoveGroundTruth checks the legacy (non-VEX) SSE moves byte for byte
// against the Go assembler. wantOp is the decoder's name, which differs from // 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). // the Plan 9 spelling for the octa moves (MOVOU = MOVDQU, MOVO = MOVDQA).
+18
View File
@@ -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))}) 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. // condCode maps a Plan 9 conditional-jump mnemonic to its x86 condition code.
func condCode(upper string) (int, bool) { func condCode(upper string) (int, bool) {
if len(upper) < 2 || upper[0] != 'J' || upper == "JMP" { if len(upper) < 2 || upper[0] != 'J' || upper == "JMP" {
+44
View File
@@ -6,6 +6,7 @@ package asm
import ( import (
"fmt" "fmt"
"math/bits" "math/bits"
"strconv"
"strings" "strings"
"sourcedock.dev/petrbalvin/gasm-devkit/ast" "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. // 16-bit branches (BEQ/BNE/BLT/BGE/BLTU/BGEU) and JIRL.
if op, ok := l64branchTable[mnem]; ok { if op, ok := l64branchTable[mnem]; ok {
if mnem == "JIRL" {
return encodeLOONG64Jirl(op, ops)
}
return encodeLOONG64Branch16(mnem, op, ops, pc, offsets, resolve) return encodeLOONG64Branch16(mnem, op, ops, pc, offsets, resolve)
} }
// Single-register branches with 21-bit offsets (BLTZ/BGEZ/BLEZ/BGTZ, // 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 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): // 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 // 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. // turns into the 21-bit BEQZ/BNEZ form when the register is the only operand.
+37
View File
@@ -291,3 +291,40 @@ done:
t.Errorf("code = % x\nwant % x", code, want) 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)
)
}
+38 -5
View File
@@ -208,6 +208,18 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
} }
op := ops[0] op := ops[0]
if op.Addr.Sym == nil || op.Addr.Sym.Pseudo != "SB" { 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))") return nil, fmt.Errorf("CALL: local branch target is not supported (use CALL sym(SB))")
} }
if relocs != nil { 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 return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
} }
target = labelFromOperand(ops[0]) 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] targetOff, ok := offsets[target]
if !ok { if !ok {
@@ -886,25 +910,34 @@ func word16(w uint16) []byte {
} }
// encodeRISCVJALR encodes the JALR indirect jump/call instruction. // 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) { func encodeRISCVJALR(instr *ast.Instr, fi riscvFrameInfo) ([]byte, error) {
ops := instr.Operands 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 { if len(ops) == 2 {
rs1 := regFromOperand(ops[0]) rs1 := regFromOperand(ops[0])
rd := regFromOperand(ops[1]) rd := regFromOperand(ops[1])
if rd < 0 || rs1 < 0 { if rd < 0 || rs1 < 0 {
return nil, fmt.Errorf("JALR: invalid register operand") return nil, fmt.Errorf("JALR: invalid register operand")
} }
word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0) return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0)), nil
return wordLE(word), nil
} }
if len(ops) == 1 { if len(ops) == 1 {
rs1, imm := memFromOperandWithFrame(ops[0], fi) rs1, imm := memFromOperandWithFrame(ops[0], fi)
if rs1 < 0 { if rs1 < 0 {
return nil, fmt.Errorf("JALR: invalid memory operand") return nil, fmt.Errorf("JALR: invalid memory operand")
} }
word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm) return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm)), nil
return wordLE(word), nil
} }
return nil, fmt.Errorf("JALR expects 1 or 2 operands, got %d", len(ops)) return nil, fmt.Errorf("JALR expects 1 or 2 operands, got %d", len(ops))
} }
+21
View File
@@ -761,3 +761,24 @@ sub:
t.Error("expected error for CALL to local label, got nil") 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)
)
}
+10 -3
View File
@@ -93,12 +93,19 @@ func riscvIsLeaf(t *ast.Text) bool {
return false return false
} }
case "JALR": case "JALR":
// JALR rs1, rd, a call when rd is X1; JALR offset(rs1) always // JALR rd, offset(rs1) links when the destination register (the
// links to X1. // 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 { if len(in.Operands) == 1 {
return false 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 return false
} }
} }
+2 -1
View File
@@ -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 - **`unreachable-code`.** Code after a `RET` and before the next label is
dead. The check is suppressed for any function whose reachability cannot be dead. The check is suppressed for any function whose reachability cannot be
decided statically: those using PC-relative jumps (`JMP 2(PC)`), 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: file with `#ifdef` conditionals. `UNDEF` is deliberately not a terminator:
code after it is occasionally intentional metadata. code after it is occasionally intentional metadata.
- **`register-clobber` (register liveness).** The linter builds the function's - **`register-clobber` (register liveness).** The linter builds the function's
+29 -8
View File
@@ -209,10 +209,10 @@ func lintText(t *ast.Text, tab *arch.Table, archKnown bool, cfg Config, macros m
lastTerminal := false lastTerminal := false
hasMacro := false hasMacro := false
instrCount := 0 instrCount := 0
dead := false // inside a region unreachable from above dead := false // inside a region unreachable from above
reportedDead := false // the current dead region has already been reported reportedDead := false // the current dead region has already been reported
hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis
hasIndirect := hasIndirectBranch(t) // register-indirect branches do too hasIndirect := hasIndirectBranch(t, tab) // register-indirect branches do too
// Unreachable-code analysis is only sound in functions whose control flow is // Unreachable-code analysis is only sound in functions whose control flow is
// fully label-resolvable: no PC-relative jumps, no register-indirect // fully label-resolvable: no PC-relative jumps, no register-indirect
// branches, and (file-level) no preprocessor conditionals. // 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 // hasIndirectBranch reports whether a function transfers control through a
// register (JALR/JR/JIRL/BR/BLR). Such targets are computed at runtime, so // register or a computed memory address: the RISC branch-register mnemonics
// reachability cannot be determined statically and the unreachable-code check is // (JALR/JR/JIRL/BR/BLR), or a JMP/CALL whose target is a register or memory
// suppressed for the whole function. // operand rather than a label or symbol. Such targets are computed at
func hasIndirectBranch(t *ast.Text) bool { // 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 { for _, s := range t.Body {
in, ok := s.(*ast.Instr) in, ok := s.(*ast.Instr)
if !ok { if !ok {
@@ -561,11 +563,30 @@ func hasIndirectBranch(t *ast.Text) bool {
switch strings.ToUpper(in.Mnemonic.Text) { switch strings.ToUpper(in.Mnemonic.Text) {
case "JALR", "JR", "JIRL", "BR", "BLR": case "JALR", "JR", "JIRL", "BR", "BLR":
return true return true
case "JMP", "CALL":
if indirectJumpTarget(in, tab) {
return true
}
} }
} }
return false 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 // isMacroInvocation reports whether a mnemonic is a macro invocation rather
// than a machine instruction. No Plan 9 mnemonic contains an underscore, so an // 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 // underscore is a reliable macro marker (the runtime headers define macros such
+31
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
"sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/parser"
) )
@@ -495,3 +496,33 @@ TEXT ·f(SB), NOSPLIT, $0
t.Fatalf("amd64 must not be flagged: %+v", diags) 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")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+2 -2
View File
@@ -39,7 +39,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA
MOVV R5, R3 // SP stays on the leave slot: the kernel MOVV R5, R3 // SP stays on the leave slot: the kernel
// reads its first argument at SP+8 // 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 // leaveJITCheckedRaw is the raw return trampoline. It has NO Go function
// declaration, so no ABIInternal wrapper is generated; the JIT function's // 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 savedRA(SB), R1 // restore return address
MOVV savedG(SB), g // restore g: Go code needs it the moment it MOVV savedG(SB), g // restore g: Go code needs it the moment it
// resumes, violation or not // resumes, violation or not
JIRL R0, R1, 0 // return to Go caller JMP (R1) // return to Go caller
GLOBL savedG(SB), NOPTR, $8 GLOBL savedG(SB), NOPTR, $8
+1
View File
@@ -25,6 +25,7 @@ func TestGroundTruthARM64(t *testing.T) {
"../testdata/verify/call_arm64.s", "../testdata/verify/call_arm64.s",
"../testdata/verify/bigframe_arm64.s", "../testdata/verify/bigframe_arm64.s",
"../testdata/verify/guard_arm64.s", "../testdata/verify/guard_arm64.s",
"../testdata/verify/indirect_arm64.s",
} { } {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
+1
View File
@@ -98,6 +98,7 @@ func TestGroundTruthAMD64(t *testing.T) {
"../testdata/verify/basic_amd64.s", "../testdata/verify/basic_amd64.s",
"../testdata/verify/bigframe_amd64.s", "../testdata/verify/bigframe_amd64.s",
"../testdata/verify/guard_amd64.s", "../testdata/verify/guard_amd64.s",
"../testdata/verify/indirect_amd64.s",
} { } {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
f, errs := parser.Parse(path, mustRead(t, path)) f, errs := parser.Parse(path, mustRead(t, path))
+2
View File
@@ -24,6 +24,8 @@ func TestGroundTruthLOONG64(t *testing.T) {
"../testdata/verify/fp_loong64.s", "../testdata/verify/fp_loong64.s",
"../testdata/verify/bigframe_loong64.s", "../testdata/verify/bigframe_loong64.s",
"../testdata/verify/guard_loong64.s", "../testdata/verify/guard_loong64.s",
"../testdata/verify/indirect_loong64.s",
"trampoline_loong64.s",
} { } {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
+2
View File
@@ -27,6 +27,8 @@ func TestGroundTruthRISCV(t *testing.T) {
"../testdata/verify/call_riscv64.s", "../testdata/verify/call_riscv64.s",
"../testdata/verify/bigframe_riscv64.s", "../testdata/verify/bigframe_riscv64.s",
"../testdata/verify/guard_riscv64.s", "../testdata/verify/guard_riscv64.s",
"../testdata/verify/indirect_riscv64.s",
"trampoline_riscv64.s",
} { } {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
testGroundTruthRISCVFile(t, path) testGroundTruthRISCVFile(t, path)
+3 -3
View File
@@ -6,7 +6,7 @@
// ABI0 JIT trampoline for LoongArch 64. // ABI0 JIT trampoline for LoongArch 64.
// //
// enterJIT saves Go SP and RA (R1), switches to the prepared stack, and // 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. // control lands in leaveJIT.
// func enterJIT(fn uintptr, stack uintptr) // 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 MOVV R5, R3 // switch to the prepared stack: SP stays on
// the leave slot, so the kernel reads its // the leave slot, so the kernel reads its
// first argument at SP+8 // first argument at SP+8
JIRL R0, R4, 0 // jump to JIT function JMP (R4) // jump to JIT function
// func leaveJIT() // func leaveJIT()
TEXT ·leaveJIT(SB), NOSPLIT, $0-0 TEXT ·leaveJIT(SB), NOSPLIT, $0-0
MOVV savedSP(SB), R5 // restore Go stack pointer MOVV savedSP(SB), R5 // restore Go stack pointer
MOVV R5, R3 // restore SP MOVV R5, R3 // restore SP
MOVV savedRA(SB), R1 // restore return address 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 GLOBL savedRA(SB), NOPTR, $8