ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_asm.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Structural and formatting checker for hand-written GNU assembler sources.
5
6WHY THIS EXISTS INSTEAD OF AN OFF-THE-SHELF LINTER
7==================================================
8There is no linter for GNU `as`. This was searched for rather than assumed, and
9the conclusion is worth writing down so the next person does not repeat it:
10
11 * No tool in the shape of cmake-lint / yamllint / shellcheck exists for
12 assembly. The language is whatever the target's `as` accepts, there is no
13 published style guide to encode, and the analysers that do read assembly
14 (objdump, radare2, Ghidra) consume ASSEMBLED objects, not source text.
15 * `as` / `gcc -c` does validate syntax -- but only for one target, with that
16 target's toolchain present and the right `-I` and `-D` set. The first-party
17 assembly in the tree is `port/threadx/.../*.S`, Armv8.1-M Thumb, and only
18 arm-none-eabi is provisioned. An assemble-based gate would therefore be
19 silent on any source written for a target whose toolchain is absent -- and
20 a gate that skips is the exact defect this suite exists to eliminate.
21 * Where assembling IS possible it is already happening: the ThreadX port file
22 is assembled by the `build-cross` gate as part of every ThreadX app. This
23 text-level checker is the toolchain-independent layer on top of that, and is
24 the ONLY coverage for any assembly a provisioned toolchain cannot build.
25
26So this enforces what is mechanically checkable about assembly source as text,
27in the same spirit as `check_linker_scripts.py` (which exists because no linter
28for GNU ld script exists either):
29
30 AS001 licence header -- SPDX-License-Identifier and a Copyright line.
31 check-copyright.py's EXTENSIONS set covers
32 .c/.h/.cpp/.hpp/.cmake/.sh/.py and has never
33 included .S, so assembly was exempt from the
34 header rule the whole tree otherwise follows.
35 AS002 explicit section -- a `.section` / `.text` / `.data` directive before
36 the first label or instruction. Falling into the
37 assembler's default section is how hand-written
38 startup code silently lands somewhere the linker
39 script does not place.
40 AS003 exported symbols -- every `.globl` / `.global` symbol must have a
41 `.type`, a `.size`, and an actual label. Each
42 half is load-bearing: without `.type ...,%function`
43 the ARM linker does not set the Thumb bit and
44 indirect calls to the symbol land one byte off in
45 Arm state; without `.size` the symbol has length
46 zero, so `--gc-sections` may discard it and
47 debuggers cannot attribute a backtrace to it; and
48 a `.globl` with no label exports nothing at all.
49 AS004 unified syntax -- ARM sources must declare `.syntax unified`. The
50 pre-UAL divided syntax is still the assembler
51 DEFAULT, and it silently changes how flag-setting
52 mnemonics are parsed. Applied only to files that
53 carry an ARM-specific directive, so the RISC-V
54 source is not held to an ARM rule.
55 AS005 formatting -- 7-bit ASCII, LF endings, a final newline, no tab
56 indentation, no trailing whitespace.
57
58WHAT IS DELIBERATELY NOT ENFORCED
59---------------------------------
60Instruction-level style (operand column, mnemonic case, register naming) is not
61checked. The two files follow different house styles inherited from their
62upstreams -- the ThreadX port keeps Express Logic's deep indentation so it can
63be diffed against the vendor original, which is a real maintenance property.
64Imposing one layout would mean rewriting a file whose value is that it diffs
65cleanly, so the rule would cost more than it protects. That is a decision, not
66an oversight; AS005 still holds the parts that cannot be justified either way.
67
68Run with --selftest to prove every rule fires on a deliberately malformed
69source and stays quiet on a legal one.
70"""
71
72from __future__ import annotations
73
74import argparse
75import re
76import subprocess
77import sys
78from collections.abc import Callable
79from pathlib import Path
80
81sys.path.insert(0, str(Path(__file__).resolve().parent))
82
83from selftest_assert import expect, report
84
85REPO_ROOT = Path(
86 subprocess.run(
87 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 -- trusted: fixed git argv
88 capture_output=True,
89 text=True,
90 check=True,
91 ).stdout.strip()
92)
93
94EXCLUDED_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/", "libs/ra8_fonts/")
95
96# A tree that has assembly cannot legitimately have none. Same trip-wire as
97# every other gate here: an empty scan must fail, never pass.
98FILE_FLOOR = 1
99
100# Directives that mark a source as ARM, and so subject to AS004.
101_ARM_MARKERS = (".thumb_func", ".eabi_attribute", ".cpu", ".fpu", ".arm", ".thumb")
102
103# Directives that establish an output section.
104_SECTION_DIRECTIVES = (".section", ".text", ".data", ".bss", ".rodata")
105
106_EXPORT_RE = re.compile(r"^\s*\.(?:globl|global)\s+([A-Za-z_.$][\w.$]*)")
107_TYPE_RE = re.compile(r"^\s*\.type\s+([A-Za-z_.$][\w.$]*)")
108_SIZE_RE = re.compile(r"^\s*\.size\s+([A-Za-z_.$][\w.$]*)")
109_LABEL_RE = re.compile(r"^\s*([A-Za-z_.$][\w.$]*)\s*:")
110_DIRECTIVE_RE = re.compile(r"^\s*\.")
111# A line whose first non-space character is `#` is a C preprocessor directive
112# (#ifdef / #include / #endif), which both of these sources use. It is NOT an
113# instruction. `#` is not treated as a comment marker anywhere else here,
114# because on ARM it introduces an immediate operand (`mov r0, #1`) -- but an
115# immediate can never be the first token on a line, so anchoring to the start
116# of the line separates the two cases exactly.
117_CPP_RE = re.compile(r"^\s*#")
118
119
120def strip_comments(text: str) -> str:
121 """Blank out /* */ and // comment bodies, preserving line structure.
122
123 `#` is NOT treated as a comment marker: it introduces an immediate operand
124 on ARM (`mov r0, #1`) and a preprocessor directive on both targets. Blanking
125 it would erase real code.
126 """
127
128 def blank(match: re.Match[str]) -> str:
129 """Replace a comment's characters with spaces, keeping its newlines.
130
131 Preserving length and line breaks is what lets every later rule report
132 accurate line and column numbers against the blanked view.
133 """
134 return re.sub(r"[^\n]", " ", match.group(0))
135
136 text = re.sub(r"/\*.*?\*/", blank, text, flags=re.DOTALL)
137 return re.sub(r"//[^\n]*", blank, text)
138
139
140class Finding:
141 """One rule violation, reported as path:line: [CODE] message."""
142
143 def __init__(self, rel: str, line: int, code: str, msg: str) -> None:
144 """Record one finding; all four fields are required and none is derived."""
145 self.rel, self.line, self.code, self.msg = rel, line, code, msg
146
147 def __str__(self) -> str:
148 """Render as ``rel:line: [CODE] message`` -- editor-jumpable."""
149 return f"{self.rel}:{self.line}: [{self.code}] {self.msg}"
150
151
152def _check_format(raw: bytes, add: Callable[[int, str, str], None]) -> str:
153 """AS005, operating on the raw bytes. Returns the decoded text."""
154 try:
155 text = raw.decode("ascii")
156 except UnicodeDecodeError as exc:
157 add(raw[: exc.start].count(b"\n") + 1, "AS005", f"non-ASCII byte 0x{raw[exc.start]:02x}")
158 text = raw.decode("ascii", errors="replace")
159 if b"\r\n" in raw:
160 add(raw.split(b"\r\n")[0].count(b"\n") + 1, "AS005", "CRLF line ending")
161 if raw and not raw.endswith(b"\n"):
162 add(text.count("\n") + 1, "AS005", "no final newline")
163 for num, line in enumerate(text.splitlines(), start=1):
164 if line.startswith("\t") or re.match(r"^ *\t", line):
165 add(num, "AS005", "tab indentation (use spaces)")
166 if line != line.rstrip():
167 add(num, "AS005", "trailing whitespace")
168 return text
169
170
171def _check_exports(code_lines: list[str], add: Callable[[int, str, str], None]) -> None:
172 """AS003: every exported symbol has a type, a size and a label."""
173 exports: dict[str, int] = {}
174 typed: set[str] = set()
175 sized: set[str] = set()
176 labels: set[str] = set()
177 for num, line in enumerate(code_lines, start=1):
178 match = _EXPORT_RE.match(line)
179 if match:
180 exports.setdefault(match.group(1), num)
181 match = _TYPE_RE.match(line)
182 if match:
183 typed.add(match.group(1))
184 match = _SIZE_RE.match(line)
185 if match:
186 sized.add(match.group(1))
187 match = _LABEL_RE.match(line)
188 if match and not _DIRECTIVE_RE.match(line):
189 labels.add(match.group(1))
190 for sym, num in sorted(exports.items(), key=lambda kv: kv[1]):
191 if sym not in labels:
192 add(num, "AS003", f"'{sym}' is exported but no label defines it in this file")
193 if sym not in typed:
194 add(num, "AS003", f"'{sym}' is exported without a `.type {sym}, %function` directive")
195 if sym not in sized:
196 add(num, "AS003", f"'{sym}' is exported without a `.size {sym}, . - {sym}` directive")
197
198
199def check_file(rel: str, raw: bytes) -> list[Finding]:
200 """Every finding for one assembly source."""
201 findings: list[Finding] = []
202
203 def add(line: int, code: str, msg: str) -> None:
204 """Append one finding, closing over this file's relative path."""
205 findings.append(Finding(rel, line, code, msg))
206
207 text = _check_format(raw, add)
208
209 # AS001 -- licence header, read from the full text (it lives in a comment).
210 if "SPDX-License-Identifier" not in text:
211 add(1, "AS001", "no SPDX-License-Identifier in the file header")
212 if "Copyright" not in text:
213 add(1, "AS001", "no Copyright line in the file header")
214
215 code = strip_comments(text)
216 code_lines = code.splitlines()
217
218 # AS002 -- an explicit section before the first label or instruction.
219 section_at = None
220 for num, line in enumerate(code_lines, start=1):
221 stripped = line.strip()
222 if not stripped or _CPP_RE.match(line):
223 continue
224 if stripped.startswith(_SECTION_DIRECTIVES):
225 section_at = num
226 break
227 if _LABEL_RE.match(line) and not _DIRECTIVE_RE.match(line):
228 add(num, "AS002", f"label '{stripped}' precedes any .section directive")
229 break
230 if not _DIRECTIVE_RE.match(line):
231 add(num, "AS002", "instruction precedes any .section directive")
232 break
233 if section_at is None and not any(
234 ln.strip().startswith(_SECTION_DIRECTIVES) for ln in code_lines
235 ):
236 add(1, "AS002", "no .section / .text / .data directive anywhere in the file")
237
238 # AS004 -- unified syntax, for ARM sources only.
239 if any(marker in code for marker in _ARM_MARKERS) and ".syntax unified" not in code:
240 add(1, "AS004", "ARM source does not declare `.syntax unified` (divided is the default)")
241
242 _check_exports(code_lines, add)
243 return findings
244
245
246def targets() -> list[str]:
247 """Every first-party assembly source, repo-relative and sorted."""
248 proc = subprocess.run(
249 ["git", "ls-files", "-z", "*.S", "*.s"], # noqa: S607 -- git from PATH is intended
250 cwd=REPO_ROOT,
251 capture_output=True,
252 text=True,
253 check=True,
254 )
255 return sorted(
256 rel for rel in proc.stdout.split("\0") if rel and not rel.startswith(EXCLUDED_PREFIXES)
257 )
258
259
260GOOD = b"""/*
261 * SPDX-License-Identifier: MIT
262 * Copyright (c) 2026 Brighton Sikarskie
263 */
264 .section .text.boot, "ax", @progbits
265 .syntax unified
266 .thumb_func
267 .globl _start
268 .type _start, %function
269_start:
270 b _start
271 .size _start, . - _start
272"""
273
274BAD = b" .globl _start\n_start:\n\tb _start \n .thumb_func\n"
275
276# The ThreadX port opens with #ifdef/#include before its .section; AS002 must
277# read those as preprocessor lines, not as instructions.
278HEADER = b"/*\n * SPDX-License-Identifier: MIT\n * Copyright (c) 2026 x\n */\n"
279CPP_BEFORE_SECTION = HEADER + b'#ifdef TX_INCLUDE_USER_DEFINE_FILE\n#include "tx_user.h"\n#endif\n'
280
281
282def _assert_rule_codes(failures: list[str]) -> None:
283 """Assert a conforming source is silent and a malformed one fires every rule."""
284 quiet = check_file("good.S", GOOD)
285 expect(not quiet, f"a conforming source yields no findings (got {quiet})", failures)
286
287 loud = check_file("bad.S", BAD)
288 codes = {f.code for f in loud}
289 for code, why in (
290 ("AS001", "missing licence header fires"),
291 ("AS002", "a label before any .section fires"),
292 ("AS003", "an export with no .type / .size fires"),
293 ("AS004", "an ARM source without .syntax unified fires"),
294 ("AS005", "tab indentation and trailing whitespace fire"),
295 ):
296 expect(code in codes, why, failures)
297
298
299def _assert_as003_halves(failures: list[str]) -> None:
300 """Assert each half of AS003 fires on its own.
301
302 AS003 is a three-part rule (``.type``, ``.size``, a defining label), and a
303 conjunction that only ever fires when all three are missing would pass the
304 all-at-once case above while missing every realistic defect.
305 """
306 no_size = GOOD.replace(b" .size _start, . - _start\n", b"")
307 expect(
308 any("without a `.size" in f.msg for f in check_file("x.S", no_size)),
309 "dropping only .size fires AS003",
310 failures,
311 )
312 no_type = GOOD.replace(b" .type _start, %function\n", b"")
313 expect(
314 any("without a `.type" in f.msg for f in check_file("x.S", no_type)),
315 "dropping only .type fires AS003",
316 failures,
317 )
318 no_label = GOOD.replace(b"_start:\n", b"")
319 expect(
320 any("no label defines it" in f.msg for f in check_file("x.S", no_label)),
321 "dropping only the label fires AS003",
322 failures,
323 )
324
325
326def _assert_section_scan(failures: list[str]) -> None:
327 """Assert the section/syntax scan is not fooled by non-instruction text.
328
329 These are the false-positive cases: a rule that fires on a comment or on a
330 preprocessor line would make the gate unusable on the ThreadX port, and a
331 rule that holds a RISC-V source to an ARM directive is simply wrong.
332 """
333 # AS004 must NOT fire on a non-ARM source.
334 riscv = GOOD.replace(b" .syntax unified\n", b"").replace(b" .thumb_func\n", b"")
335 expect(
336 not any(f.code == "AS004" for f in check_file("rv.S", riscv)),
337 "a non-ARM source is not held to the ARM .syntax rule",
338 failures,
339 )
340
341 # A preprocessor directive is not an instruction: the ThreadX port opens
342 # with #ifdef/#include before its .section, and AS002 must not fire on it.
343 cpp = CPP_BEFORE_SECTION + GOOD.split(b"*/\n", 1)[1]
344 expect(
345 not any(f.code == "AS002" for f in check_file("cpp.S", cpp)),
346 "a #ifdef / #include before .section is not an instruction (AS002 quiet)",
347 failures,
348 )
349 expect(
350 any(f.code == "AS002" for f in check_file("i.S", HEADER + b" nop\n")),
351 "a real instruction before .section still fires AS002",
352 failures,
353 )
354
355 # A comment mentioning a directive must not satisfy a rule.
356 commented = GOOD.replace(b" .section .text.boot", b" /* .section */\n .section .x")
357 expect(
358 not any(f.code == "AS002" for f in check_file("c.S", commented)),
359 "a commented-out directive does not confuse the section scan",
360 failures,
361 )
362
363
364def _assert_byte_hygiene(failures: list[str]) -> None:
365 """Assert the AS005 byte-level rules (final newline, ASCII-only) fire."""
366 expect(
367 any("no final newline" in f.msg for f in check_file("n.S", GOOD.rstrip(b"\n"))),
368 "a missing final newline fires AS005",
369 failures,
370 )
371 expect(
372 any("non-ASCII" in f.msg for f in check_file("u.S", GOOD.replace(b"MIT", b"MIT\xc2\xa9"))),
373 "a non-ASCII byte fires AS005",
374 failures,
375 )
376
377
378def selftest() -> int:
379 """Assert every rule fires on a malformed source and stays quiet on a good one."""
380 print("check_asm.py --selftest")
381 failures: list[str] = []
382 _assert_rule_codes(failures)
383 _assert_as003_halves(failures)
384 _assert_section_scan(failures)
385 _assert_byte_hygiene(failures)
386 return report(failures)
387
388
389def main(argv: list[str]) -> int:
390 """Check every tracked assembly file, or run the selftest / scope listing.
391
392 Assembly is the one language in the tree with no formatter and no
393 compiler-side style enforcement, so these structural rules are the only
394 thing standing between a .S file and arbitrary layout.
395
396 Returns 0 when clean, 1 on any finding or a failing selftest.
397 """
398 ap = argparse.ArgumentParser(description="Structural checker for GNU assembler sources")
399 ap.add_argument("--selftest", action="store_true", help="assert every rule, both directions")
400 ap.add_argument("--list-files", action="store_true", help="print the scanned file list")
401 args = ap.parse_args(argv[1:])
402
403 if args.selftest:
404 return selftest()
405
406 files = targets()
407 if args.list_files:
408 print("\n".join(files))
409 return 0
410
411 if len(files) < FILE_FLOOR:
412 print(
413 f"check_asm.py: FATAL -- {len(files)} assembly source(s) found, floor is "
414 f"{FILE_FLOOR}. An empty scan reports success because it saw nothing.",
415 file=sys.stderr,
416 )
417 return 2
418
419 findings: list[Finding] = []
420 for rel in files:
421 findings.extend(check_file(rel, (REPO_ROOT / rel).read_bytes()))
422 if findings:
423 print(
424 f"\n{len(findings)} finding(s) in {len(files)} assembly source(s):\n", file=sys.stderr
425 )
426 for finding in findings:
427 print(f" {finding}", file=sys.stderr)
428 return 1
429 print(f"check_asm.py: {len(files)} assembly source(s), no findings.")
430 return 0
431
432
433if __name__ == "__main__":
434 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298