From 75bd83fd527e29d4970144e8b414cf153f709b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 30 Aug 2026 21:07:45 +0200 Subject: [PATCH] feat(lint): flag writes to the platform-reserved register --- CHANGELOG.md | 4 ++++ lint/lint.go | 7 ++++++ lint/lint_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++ lint/reserved.go | 56 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 lint/reserved.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dd966af..e2fc081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,10 @@ Unreleased changes on the `development` branch. - **Lint: `unencodable-instruction` rule.** Flags mnemonics the architecture table knows but the encoder cannot yet emit, at edit time instead of at assembly time. +- **Lint: `reserved-register-write` rule.** Flags writes to arm64 R18, + the platform-reserved register the ABI checks cannot observe at runtime + and the Go assembler cannot even spell. Reads and macro-using files + are exempt. - **DWARF5 debug sections in ELF output.** All four ELF emitters now emit `.debug_abbrev`, `.debug_info`, `.debug_line`, and `.debug_line_str` sections, enabling `addr2line` and GDB/LLDB source-level debugging, and diff --git a/lint/lint.go b/lint/lint.go index 6362b88..8573826 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -81,6 +81,7 @@ const ( CodeABI0RegisterArgs = "abi0-register-args" CodeNonportableRegister = "nonportable-register-name" CodeUnencodable = "unencodable-instruction" + CodeReservedRegister = "reserved-register-write" ) // knownTextFlags are the flags recognised by the Go assembler's textflag.h. @@ -469,6 +470,12 @@ func lintText(t *ast.Text, tab *arch.Table, archKnown bool, cfg Config, macros m out = append(out, scanNonportableRegisters(t)...) } + // Writes to the platform-reserved register (arm64 R18), which the ABI + // checks cannot observe at runtime. + if cfg.Arch == arch.ARM64 && !hasMacro && !cfg.Disable[CodeReservedRegister] { + out = append(out, scanReservedRegisterWrites(t, cfg.Arch)...) + } + // FUNCDATA / PCDATA structural validation. out = append(out, checkFuncdata(t, cfg)...) diff --git a/lint/lint_test.go b/lint/lint_test.go index 451b7a6..a5809ca 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -436,3 +436,62 @@ TEXT ·f(SB), NOSPLIT, $0 t.Fatalf("canonical names must not be flagged: %+v", diags) } } + +func TestReservedRegisterWrite(t *testing.T) { + // A write to R18, the arm64 platform register, is flagged. + diags := lintSrcArch(t, "k_arm64.s", ` +#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0 + MOVD $1, R18 + RET +`) + if codes(diags)[CodeReservedRegister] != 1 { + t.Fatalf("want one reserved-register-write, got %+v", diags) + } + + // Reading R18 (as a base address) is legitimate. + diags = lintSrcArch(t, "k_arm64.s", ` +#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0 + MOVD (R18), R0 + RET +`) + if codes(diags)[CodeReservedRegister] != 0 { + t.Fatalf("reads must not be flagged: %+v", diags) + } + + // Other registers are unaffected. + diags = lintSrcArch(t, "k_arm64.s", ` +#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0 + MOVD $1, R19 + RET +`) + if codes(diags)[CodeReservedRegister] != 0 { + t.Fatalf("R19 must not be flagged: %+v", diags) + } + + // Macro-using files are exempt: an opaque macro may save and restore R18. + diags = lintSrcArch(t, "k_arm64.s", ` +#include "textflag.h" +#define SAVE_R18 MOVD R18, R4 +TEXT ·f(SB), NOSPLIT, $0 + SAVE_R18 + MOVD $1, R18 + RET +`) + if codes(diags)[CodeReservedRegister] != 0 { + t.Fatalf("macro-using files must be exempt: %+v", diags) + } + + // The rule is arm64-only: an amd64 file has no reserved register. + diags = lintSrc(t, ` +#include "textflag.h" +TEXT ·f(SB), NOSPLIT, $0 + MOVQ $1, AX + RET +`) + if codes(diags)[CodeReservedRegister] != 0 { + t.Fatalf("amd64 must not be flagged: %+v", diags) + } +} diff --git a/lint/reserved.go b/lint/reserved.go new file mode 100644 index 0000000..9531243 --- /dev/null +++ b/lint/reserved.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package lint + +import ( + "fmt" + "strings" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/ast" +) + +// reservedRegister returns the platform-reserved general-purpose register of +// the architecture, or "" when it has none. arm64 reserves R18 for platform +// use: the OS may own it (the Windows TEB, the Darwin el0 TLS), the Go +// toolchain never allocates it, and the Go assembler offers no spelling that +// even addresses it, so a kernel writing R18 cannot be assembled by the tool +// it must ship with. riscv64, loong64 and amd64 reserve no register beyond +// the ones the Go ABI fixes, which the register-clobber audit covers. +func reservedRegister(a arch.Arch) string { + if a == arch.ARM64 { + return "R18" + } + return "" +} + +// scanReservedRegisterWrites flags instructions whose destination is the +// platform-reserved register. Only the Plan 9 destination (the last +// operand) is checked: reads such as MOVD (R18), R0 are legitimate address +// uses, and multi-register stores (STP) keep the rule silent rather than +// guess. Like the label heuristics, the check is suppressed in macro-using +// files, where an opaque macro may save and restore the register. +func scanReservedRegisterWrites(t *ast.Text, a arch.Arch) []Diagnostic { + res := reservedRegister(a) + if res == "" { + return nil + } + var out []Diagnostic + for _, s := range t.Body { + in, ok := s.(*ast.Instr) + if !ok || len(in.Operands) == 0 { + continue + } + dst := in.Operands[dstIndex(in)] + if r := gprName(dst, a); r != "" && strings.EqualFold(r, res) { + out = append(out, Diagnostic{ + Pos: dst.Pos, + Severity: Warning, + Code: CodeReservedRegister, + Message: fmt.Sprintf("write to %s, the platform-reserved register: the OS may own it and the Go assembler cannot spell it", res), + }) + } + } + return out +}