From fbdad8424f444a6e8cc5a027ddcb1b712fc04bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 7 Oct 2026 00:50:11 +0200 Subject: [PATCH] feat(asm): encode the arm64 FP immediate moves Assisted-by: GLM 5.3 Flash --- asm/arm64_assemble.go | 96 ++++++++++++++++++++++++++++++++ asm/arm64_encode_test.go | 50 +++++++++++++++++ testdata/verify/fpimm_arm64.s | 34 +++++++++++ verify/arm64_groundtruth_test.go | 1 + 4 files changed, 181 insertions(+) create mode 100644 testdata/verify/fpimm_arm64.s diff --git a/asm/arm64_assemble.go b/asm/arm64_assemble.go index cf07e8e..97c5437 100644 --- a/asm/arm64_assemble.go +++ b/asm/arm64_assemble.go @@ -5,6 +5,7 @@ package asm import ( "fmt" + "math" "math/bits" "slices" "strconv" @@ -1603,6 +1604,39 @@ func encodeARM64Mov(instr *ast.Instr, mnem string, wb string, fi arm64FrameInfo, return encodeARM64SBAddr(src.Imm.Sym, rd, relocs), nil } rd := arm64RegNum(operandRegName(dst)) + // The FP immediates: FMOVS/FMOVD $f, Fd ride the FMOV (immediate) + // instruction when the 8-bit field carries the value and the + // FMOV-from-ZR move for zero; the toolchain pools anything else + // (obj7.go's preprocess), which needs pool symbols gasm does not + // carry. An integer immediate to an FP register is rejected, the + // zero spelling alone excepted, and FMOVQ has no immediate row. + if mnem == "FMOVS" || mnem == "FMOVD" { + if arm64RegClassOf(operandRegName(dst)) == arm64ClsFP { + f, ok := arm64FloatOperandValue(src) + if !ok { + return nil, fmt.Errorf("%s: illegal combination: an integer immediate needs a real register", mnem) + } + if enc := arm64ChipFloat(f); enc > 0 { + base := uint32(0x1e201000) // FMOV Sd, #imm + if mnem == "FMOVD" { + base = 0x1e601000 // FMOV Dd, #imm + } + return a64wordLE(base | uint32(enc)<<13 | uint32(rd)), nil + } + zero := math.Float64bits(f) == 0 + if mnem == "FMOVS" { + zero = math.Float32bits(float32(f)) == 0 + } + if zero { + base := uint32(0x1e270000) // FMOV Sd, (W)ZR + if mnem == "FMOVD" { + base = 0x9e670000 // FMOV Dd, XZR + } + return a64wordLE(base | 31<<5 | uint32(rd)), nil + } + return nil, fmt.Errorf("%s $%v: floating-point immediate needs the constant pool", mnem, f) + } + } // The con(register) form: MOVD $con(Rn), Rd adds the displacement to // the base register (asm7.go case 4). The toolchain rejects every // other width and the ZR destination outright (RSP is a real register @@ -2248,6 +2282,68 @@ func arm64AddSubImmWord(op uint32, v int64, rn, rd uint32) uint32 { return a64AddSub(1, op, 0, sh, uint32(v), rn, rd) } +// arm64FloatOperandValue recovers a floating-point immediate: the bare +// spelling fills Imm.Float and the parenthesised one stays in the raw text +// ($ ( 4.0 )), so the token shape comes off the raw string. An integer zero +// rides Imm.HasVal and counts (the toolchain's AFMOVD zero row), any other +// integer immediate does not. ok is false for every other shape. +func arm64FloatOperandValue(op *ast.Operand) (float64, bool) { + if op.Imm.Float != "" { + f, err := strconv.ParseFloat(op.Imm.Float, 64) + if err != nil { + return 0, false + } + if op.Imm.Neg { + f = -f + } + return f, true + } + s := strings.Join(strings.Fields(op.Raw), "") + s = strings.TrimPrefix(s, "$") + if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { + inner := s[1 : len(s)-1] + if strings.ContainsAny(inner, ".eE") { + f, err := strconv.ParseFloat(inner, 64) + if err != nil { + return 0, false + } + return f, true + } + } + if op.Imm.HasVal && op.Imm.Val == 0 && !op.Imm.Neg { + return 0, true + } + return 0, false +} + +// arm64ChipFloat ports the toolchain's chipfloat7: the 8-bit FMOV immediate +// field for a double whose low 48 mantissa bits are zero and whose exponent +// sits in the -3..4 band, with bit 7 the sign, bit 6 the negative-exponent +// flag and bits 4:0 the exponent tail plus mantissa top. The toolchain +// takes the encoded value only when this reports above zero; zero itself +// pools (or moves from ZR), so 0 is not a valid encoding here either. +func arm64ChipFloat(e float64) int { + ei := math.Float64bits(e) + l := uint32(ei) + h := uint32(ei >> 32) + if l != 0 || h&0xffff != 0 { + return -1 + } + h1 := h & 0x7fc00000 + if h1 != 0x40000000 && h1 != 0x3fc00000 { + return -1 + } + n := 0 + if h&0x80000000 != 0 { + n |= 1 << 7 + } + if h1 == 0x3fc00000 { + n |= 1 << 6 + } + n |= int((h >> 16) & 0x3f) + return n +} + // arm64ImmWithBase reports whether an immediate operand spells the // con(register) form, $con(REG): the parser leaves it unstructured (an // immediate whose raw spelling carries the parenthesised register), so the diff --git a/asm/arm64_encode_test.go b/asm/arm64_encode_test.go index a48dfc7..d9d8940 100644 --- a/asm/arm64_encode_test.go +++ b/asm/arm64_encode_test.go @@ -2069,3 +2069,53 @@ func TestArm64ConRnRejections(t *testing.T) { } } } + +// TestArm64FPImmediate pins the FP immediate moves against `go tool asm` +// words: the FMOV (immediate) instruction for the 8-bit encodable values and +// the FMOV-from-ZR move for zero, plus the rejections the toolchain raises +// (integer immediates to FP registers, FMOVQ immediates) and the values that +// only the toolchain's $f64 pool reaches. +func TestArm64FPImmediate(t *testing.T) { + got := arm64Words(t, + "\tFMOVS $(4.0), F0\n"+ + "\tFMOVD $(4.0), F0\n"+ + "\tFMOVS $(0.265625), F1\n"+ + "\tFMOVD $(0.1796875), F2\n"+ + "\tFMOVD $(28.0), F4\n"+ + "\tFMOVD $(-4.0), F6\n"+ + "\tFMOVD $0, F0\n"+ + "\tFMOVD $(0.0), F5\n") + want := []uint32{ + 0x1e221000, // FMOV S0, #4.0 + 0x1e621000, // FMOV D0, #4.0 + 0x1e2a3001, // FMOV S1, #0.265625 + 0x1e68f002, // FMOV D2, #0.1796875 + 0x1e679004, // FMOV D4, #28.0 + 0x1e721006, // FMOV D6, #-4.0 + 0x9e6703e0, // FMOV D0, XZR + 0x9e6703e5, // FMOV D5, XZR + 0xd65f03c0, // RET + } + 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]) + } + } + for _, src := range []string{ + "\tFMOVD\t$5, F0\n", + "\tFMOVS\t$4, F0\n", + "\tFMOVQ\t$(4.0), F0\n", + "\tFMOVD\t$(2.0), F0\n", + } { + f, errs := parser.Parse("test_arm64.s", "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n"+src+"\tRET\n") + if len(errs) > 0 { + continue // a parse rejection is a rejection + } + if _, err := AssembleFileARM64(f); err == nil { + t.Errorf("expected rejection for %q, got nil", strings.TrimSpace(src)) + } + } +} diff --git a/testdata/verify/fpimm_arm64.s b/testdata/verify/fpimm_arm64.s new file mode 100644 index 0000000..42f7322 --- /dev/null +++ b/testdata/verify/fpimm_arm64.s @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +// Differential kernel for the arm64 FP immediate moves: the FMOV (immediate) +// instruction for the 8-bit encodable values and the FMOV-from-ZR move for +// zero. The values outside the 8-bit field pool through the toolchain's +// $f64 symbols with a PC-relative relocation; gasm carries no such pool and +// rejects the spelling instead (its previous encoding was wrong code). +// Every function is byte-compared against go tool asm. + +#include "textflag.h" + +// func encodable() +TEXT ·encodable(SB), NOSPLIT, $0-0 + FMOVS $ (4.0), F0 + FMOVD $ (4.0), F0 + FMOVS $ (0.265625), F1 + FMOVD $ (0.1796875), F2 + FMOVS $ (0.96875), F3 + FMOVD $ (28.0), F4 + FMOVD $ (-4.0), F6 + FMOVD $ (1.0), F7 + FMOVD $ (0.5), F8 + RET + +// func zeroes() +TEXT ·zeroes(SB), NOSPLIT, $0-0 + FMOVD $0, F0 + FMOVS $0, F1 + FMOVD $ (0.0), F5 + FMOVS $ (0.0), F6 + FMOVD ZR, F2 + FMOVS ZR, F3 + RET diff --git a/verify/arm64_groundtruth_test.go b/verify/arm64_groundtruth_test.go index 7536518..c262e0b 100644 --- a/verify/arm64_groundtruth_test.go +++ b/verify/arm64_groundtruth_test.go @@ -39,6 +39,7 @@ func TestGroundTruthARM64(t *testing.T) { "../testdata/verify/splits_arm64.s", "../testdata/verify/regoffset_arm64.s", "../testdata/verify/simdarr_arm64.s", + "../testdata/verify/fpimm_arm64.s", "../testdata/verify/crypto_arm64.s", "../testdata/verify/integer_arm64.s", "../testdata/verify/simd_arm64.s",