ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_linker_scripts.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 GNU ld linker scripts.
5
6WHY THIS EXISTS INSTEAD OF AN OFF-THE-SHELF LINTER
7==================================================
8There is no linter for the GNU ld script language. Nothing on the scale of
9cmake-lint, yamllint or actionlint exists for `.ld`: the language is defined
10only by the ld manual and its bison grammar, it has no published style guide,
11and the one adjacent tool -- `ld --verbose` -- validates a script solely as a
12side effect of attempting a real link, so it needs a full object set and a
13target toolchain and reports nothing about the file as a file.
14
15Leaving the type unenforced was not an option (84 first-party scripts decide
16where every byte of this firmware lands), so this file enforces what IS
17mechanically checkable about a linker script without linking it:
18
19 LD001 licence header -- SPDX-License-Identifier and a Copyright line.
20 LD002 ENTRY declared -- exactly one ENTRY(symbol) outside comments.
21 LD003 MEMORY block -- a MEMORY {} block declaring >= 1 region, each
22 with both ORIGIN and LENGTH.
23 LD004 region closure -- every region named by an output-section
24 placement (`> REGION`, `AT> REGION`) is one of
25 the regions MEMORY declares. A typo here is an
26 ld hard error, but ONLY for an app something
27 actually links; this reaches the scripts no
28 job builds.
29 LD005 formatting -- 7-bit ASCII, LF endings, a final newline, no
30 tab indentation, no trailing whitespace.
31 LD006 symbol closure -- every g_ra8_ls_* symbol a first-party C source
32 references is defined by at least one
33 first-party linker script. That catches a link
34 error which would otherwise surface only in
35 whichever app happens to pull the TU in -- and
36 for a script no CI job links, never at all.
37 LD007 option-setting -- no phantom data-flash, and every option-setting
38 address matches the HUM (see OPTION_SETTING_ADDR).
39 LD008 option completeness -- the option-setting family is ALL-OR-NOTHING and
40 CLOSED: a script either declares none of it (the
41 CPU1 / non-secure-image scripts) or declares every
42 word the HUM lists, with the matching output
43 section for each; and it may not place an
44 `.option_setting_*` section outside that family.
45 LD009 fits the silicon -- no MEMORY region declared inside the on-chip
46 SRAM window (0x22000000 .. 0x221A0000, i.e.
47 k_ra8_mem_sram_size = 1664 KiB) may extend past
48 that end. LD003/LD004 prove a region is declared
49 and closed; only this proves it is real memory,
50 the one enforcement a 0-byte placeholder no CI
51 job links can have (an ASSERT there never fires,
52 #544).
53
54The REVERSE direction (a script defines a g_ra8_ls_* nothing in C names) is
55deliberately NOT a finding, and that is a statement about what is enforceable
56rather than an exemption. A linker script legitimately exports boundary
57symbols with no C consumer at all: g_ra8_ls_exidx_start/end delimit the
58unwind table for the runtime, g_ra8_ls_noinit_start is documented in
59ra8_crashlog.h purely as a GDB inspection point, and several are read only by
60ASSERT() expressions elsewhere in the script or by whoever is reading the .map
61file. "Unused" for such a symbol is not decidable from the source tree, so a
62rule asserting it would be guessing -- it fired on 28 healthy symbols when
63tried. What IS decidable is the direction above, and that is what runs.
64
65LD006 is whole-tree, so it is reported once rather than per file.
66
67WHY LD008 IS ALL-OR-NOTHING RATHER THAN PER-DEVICE
68==================================================
69The obvious rule here would be "check the emitted OFS words against the target
70device's feature set". That rule is the bug. Issue #223 deleted the OFS3 family
71from the four RA8P1 app scripts because Renesas FSP's `BSP_FEATURE_BSP_HAS_OFS3`
72is 0 for ra8p1 -- but the RA8P1 Hardware User's Manual (R01UH1064EJ0130 Ch 7.2.6
73p 288, Ch 7.2.7 p 290) documents OFS3, OFS3_SEC and OFS3_SEL at the same
74addresses and with the same WDT1 bit fields as the RA8D2. A device-feature table
75would have had to encode that same wrong premise to pass, and would then have
76enforced it (#516).
77
78So LD008 asserts a structural invariant that needs no device knowledge: the
79option-setting block is indivisible. Every RA8 script either owns the option
80bytes and declares the COMPLETE family, or owns none of them and declares
81nothing -- and that is exactly how the tree partitions: the boot scripts are
82complete, the CPU1 / non-secure-image scripts are empty, and nothing sits in
83between. (No count is quoted here on purpose: it would drift with every added
84script and rot into a lie. `--list-files` reports the live scope, and
85OPTION_SETTING_FILE_FLOOR below is the number that is actually enforced.)
86A script at 23-of-26 is the defect signature in both directions: it catches a
87word deleted from one script and not its siblings, AND a word added to one
88script that the HUM does not list. The authority is OPTION_SETTING_ADDR, derived
89from the HUM, so no constant is ever compared against itself.
90
91Both option-setting rules carry a vacuity floor (OPTION_SETTING_FILE_FLOOR):
92if the PROVIDE spelling ever changes, these rules would match zero files and
93report a clean tree forever. Matching nothing is a failure, not a pass.
94
95Run with --selftest to prove the rules fire on a deliberately malformed script
96and stay quiet on a legal-but-tricky one.
97"""
98
99from __future__ import annotations
100
101import argparse
102import pathlib
103import re
104import subprocess
105import sys
106import tempfile
107
108sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
109sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "dev"))
110
111from git_environment import isolated_git_environment, trusted_git_executable
112from linker_script_fixtures import MALFORMED, OFS_BAD, OFS_GOOD, TRICKY
113
114SYMBOL_PREFIX = "g_ra8_ls_"
115EXCLUDED_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/", "libs/ra8_fonts/")
116
117# A comment in an ld script is /* ... */ only -- there is no line-comment form.
118_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
119
120
121def strip_comments(text: str) -> str:
122 """Blank out comment bodies, preserving newlines so line numbers hold."""
123
124 def blank(m: re.Match[str]) -> str:
125 return re.sub(r"[^\n]", " ", m.group(0))
126
127 return _COMMENT.sub(blank, text)
128
129
130def repo_files(root: pathlib.Path, pattern: str) -> list[pathlib.Path]:
131 """Live candidate files matching a git pathspec, minus vendored prefixes.
132
133 The candidate is the worktree, not merely the index: an unstaged move must
134 drop the deleted source and include its untracked destination. Ignored build
135 artefacts stay excluded, so a stale generated .ld in a build tree is never
136 held to the same rules as an authored one.
137 """
138 argv = [
139 "git",
140 "-C",
141 str(root),
142 "ls-files",
143 "--cached",
144 "--others",
145 "--exclude-standard",
146 "--",
147 pattern,
148 ]
149 out = subprocess.run( # noqa: S603 # fixed git argv, no shell
150 argv,
151 capture_output=True,
152 text=True,
153 check=True,
154 ).stdout.splitlines()
155 return [root / p for p in out if not p.startswith(EXCLUDED_PREFIXES) and (root / p).is_file()]
156
157
158class Finding:
159 """One linker-script rule violation, identified by its LDxxx code.
160
161 ``code`` is the stable identity the selftest asserts on, so message
162 wording can be improved without disarming the test that proves the rule
163 still fires.
164 """
165
166 def __init__(self, path: pathlib.Path, line: int, code: str, msg: str) -> None:
167 """Record one finding; all four fields are required and none is derived."""
168 self.path, self.line, self.code, self.msg = path, line, code, msg
169
170 def __str__(self) -> str:
171 """Render as ``path:line: [CODE] message`` -- editor-jumpable."""
172 return f"{self.path}:{self.line}: [{self.code}] {self.msg}"
173
174
175def _check_formatting(path: pathlib.Path, raw: bytes) -> tuple[list[Finding], str]:
176 """LD005 -- encoding, line endings, indentation, trailing whitespace.
177
178 Returns the findings and the decoded text, because the decode is where a
179 non-ASCII byte is discovered and the rest of the checks need the result.
180 """
181 findings: list[Finding] = []
182 try:
183 text = raw.decode("ascii")
184 except UnicodeDecodeError as exc:
185 bad_line = raw[: exc.start].count(b"\n") + 1
186 findings.append(Finding(path, bad_line, "LD005", f"non-ASCII byte 0x{raw[exc.start]:02x}"))
187 text = raw.decode("ascii", errors="replace")
188
189 if b"\r\n" in raw:
190 line = raw.split(b"\r\n")[0].count(b"\n") + 1
191 findings.append(Finding(path, line, "LD005", "CRLF line ending"))
192 if raw and not raw.endswith(b"\n"):
193 findings.append(Finding(path, text.count("\n") + 1, "LD005", "no final newline"))
194
195 for i, ln in enumerate(text.splitlines(), 1):
196 if ln.startswith("\t") or re.match(r"^ *\t", ln):
197 findings.append(
198 Finding(
199 path, i, "LD005", "tab indentation (this tree indents ld scripts with spaces)"
200 )
201 )
202 if ln != ln.rstrip():
203 findings.append(Finding(path, i, "LD005", "trailing whitespace"))
204 return findings, text
205
206
207def _check_licence(path: pathlib.Path, lines: list[str]) -> list[Finding]:
208 """LD001 -- SPDX identifier and copyright line in the file head."""
209 head = "\n".join(lines[:60])
210 findings: list[Finding] = []
211 if "SPDX-License-Identifier:" not in head:
212 findings.append(
213 Finding(path, 1, "LD001", "no SPDX-License-Identifier in the first 60 lines")
214 )
215 if not re.search(r"Copyright \‍(c\‍) \d{4}", head):
216 findings.append(
217 Finding(path, 1, "LD001", "no 'Copyright (c) <year>' line in the first 60 lines")
218 )
219 return findings
220
221
222def _check_entry(path: pathlib.Path, code: str) -> list[Finding]:
223 """LD002 -- exactly one ENTRY(symbol) declaration."""
224 entries = re.findall(r"\bENTRY\s*\‍(\s*([A-Za-z_.$][\w.$]*)\s*\‍)", code)
225 if not entries:
226 return [Finding(path, 1, "LD002", "no ENTRY(symbol) declaration")]
227 if len(entries) > 1:
228 return [Finding(path, 1, "LD002", f"{len(entries)} ENTRY declarations, expected exactly 1")]
229 return []
230
231
232def _check_memory(path: pathlib.Path, code: str) -> tuple[list[Finding], set[str]]:
233 """LD003 -- a MEMORY block whose every region carries ORIGIN and LENGTH.
234
235 Also returns the declared region names, which LD004 needs to decide
236 whether an output section lands somewhere that exists.
237 """
238 findings: list[Finding] = []
239 regions: set[str] = set()
240 mem_blocks = re.findall(r"\bMEMORY\s*\{(.*?)\n\}", code, re.DOTALL)
241 if not mem_blocks:
242 findings.append(Finding(path, 1, "LD003", "no MEMORY { } block"))
243 for block in mem_blocks:
244 for m in re.finditer(r"^\s*([A-Za-z_][\w]*)\s*(\‍([rwxail!]+\‍))?\s*:", block, re.MULTILINE):
245 regions.add(m.group(1))
246 if not regions:
247 findings.append(Finding(path, 1, "LD003", "MEMORY block declares no regions"))
248 for name in sorted(regions):
249 decl = re.search(
250 rf"^\s*{re.escape(name)}\s*(\‍([rwxail!]+\‍))?\s*:([^\n]*)$",
251 code,
252 re.MULTILINE,
253 )
254 if decl and not ("ORIGIN" in decl.group(2) and "LENGTH" in decl.group(2)):
255 line = code[: decl.start()].count("\n") + 1
256 findings.append(
257 Finding(path, line, "LD003", f"region '{name}' lacks ORIGIN and/or LENGTH")
258 )
259 return findings, regions
260
261
262def _check_region_closure(path: pathlib.Path, code: str, regions: set[str]) -> list[Finding]:
263 """LD004 -- every output section is placed in a region MEMORY declares."""
264 findings: list[Finding] = []
265 for m in re.finditer(r"(?:AT)?>\s*([A-Za-z_][\w]*)", code):
266 name = m.group(1)
267 if name in regions:
268 continue
269 line = code[: m.start()].count("\n") + 1
270 findings.append(
271 Finding(path, line, "LD004", f"output section placed in undeclared region '{name}'")
272 )
273 return findings
274
275
276# On-chip system SRAM extent, identical on RA8D2 and RA8P1 and mirrored by
277# k_ra8_mem_sram_size (libs/ra8_core/inc/ra8_device.h): 1664 KiB = SRAM0 1024 KiB
278# + SRAM1 640 KiB, so SRAM_WINDOW_END is the first address past the array.
279SRAM_WINDOW_BASE = 0x22000000
280SRAM_WINDOW_SIZE = 0x001A0000 # k_ra8_mem_sram_size: 1664 KiB, both parts.
281SRAM_WINDOW_END = SRAM_WINDOW_BASE + SRAM_WINDOW_SIZE
282
283_SIZE_UNIT = {"": 1, "K": 1024, "M": 1024 * 1024}
284# A size/address expression this checker can evaluate statically: one or more
285# `<number><K|M?>` terms joined by + or -, e.g. `1024K - 256`, `640K`, `0x22100000`.
286_SIZE_TERM = re.compile(r"([+-]?)\s*(0x[0-9A-Fa-f_]+|\d+)\s*([KM]?)")
287_SIZE_WHOLE = re.compile(r"(0x[0-9A-Fa-f_]+|\d+)\s*[KM]?(\s*[+-]\s*(0x[0-9A-Fa-f_]+|\d+)\s*[KM]?)*")
288
289
290def eval_size(expr: str) -> int | None:
291 """Evaluate an ORIGIN/LENGTH literal, or None when it is not static.
292
293 Handles hex, decimal and K/M suffixes joined by + or - (``1024K - 256``). A
294 symbolic ``ORIGIN()`` reference fails the whole-string match and returns
295 None, so LD009 skips a region it cannot bound rather than guessing.
296 """
297 text = expr.strip()
298 if not text or not _SIZE_WHOLE.fullmatch(text):
299 return None
300 total = 0
301 for sign, num, unit in _SIZE_TERM.findall(text):
302 total += (-1 if sign == "-" else 1) * int(num.replace("_", ""), 0) * _SIZE_UNIT[unit]
303 return total
304
305
306def _check_sram_fit(path: pathlib.Path, code: str, regions: set[str]) -> list[Finding]:
307 """LD009 -- a region inside the SRAM window may not run past the array end.
308
309 Only a region whose ORIGIN is statically evaluable AND lands inside
310 [SRAM_WINDOW_BASE, SRAM_WINDOW_END) is judged -- SRAM, the NOINIT slice, and
311 the NS_SRAM placeholder. The 0x32.. non-secure alias and every off-SRAM
312 region fall outside the window and keep their own ASSERTs.
313 """
314 findings: list[Finding] = []
315 for name in sorted(regions):
316 decl = re.search(
317 rf"^\s*{re.escape(name)}\s*(\‍([rwxail!]+\‍))?\s*:([^\n]*)$",
318 code,
319 re.MULTILINE,
320 )
321 if not decl:
322 continue
323 om = re.search(r"ORIGIN\s*=\s*([^,\n]+)", decl.group(2))
324 lm = re.search(r"LENGTH\s*=\s*([^,\n]+)", decl.group(2))
325 if not (om and lm):
326 continue # LD003 already reports a region missing ORIGIN/LENGTH.
327 origin = eval_size(om.group(1))
328 length = eval_size(lm.group(1))
329 if origin is None or length is None:
330 continue
331 if not (SRAM_WINDOW_BASE <= origin < SRAM_WINDOW_END):
332 continue
333 end = origin + length
334 if end > SRAM_WINDOW_END:
335 line = code[: decl.start()].count("\n") + 1
336 findings.append(
337 Finding(
338 path,
339 line,
340 "LD009",
341 f"region '{name}' spans 0x{origin:08X}..0x{end:08X}, "
342 f"{end - SRAM_WINDOW_END} bytes past the end of on-chip SRAM "
343 f"(0x{SRAM_WINDOW_END:08X}); the array is 1664 KiB "
344 f"(k_ra8_mem_sram_size)",
345 )
346 )
347 return findings
348
349
350# Real RA8D2 option-setting layout, HUM Ch 7 Figure 7.1 p 279 (secure aliases;
351# OFS1/OFS3/BPS/PBPS are listed at the Non-secure alias 0x12.., the secure alias
352# below addresses the same cell). This is the authority the linker scripts are
353# checked against, and it is the same table scripts/gen has no business owning:
354# a wrong option-byte address silently programs the wrong OTP cell (#391).
355OPTION_SETTING_ADDR = {
356 "OFS0_ADDR": 0x02C9F040,
357 "OFS1_ADDR": 0x02C9F4C0,
358 "OFS2_ADDR": 0x02C9F044,
359 "OFS3_ADDR": 0x02C9F4C4,
360 "SAS_ADDR": 0x02C9F074,
361 "OFS1_SEC_ADDR": 0x02C9F0C0,
362 "OFS1_SEL_ADDR": 0x02C9F120,
363 "OFS3_SEC_ADDR": 0x02C9F0C4,
364 "OFS3_SEL_ADDR": 0x02C9F124,
365 "BPS_ADDR": 0x02C9F600,
366 "BPS_SEC_ADDR": 0x02C9F200,
367 "OTP_FSBLCTRL0_ADDR": 0x02E07600,
368 "OTP_FSBLCTRL1_ADDR": 0x02E07604,
369 "OTP_FSBLCTRL2_ADDR": 0x02E07608,
370 "OTP_SAMR_ADDR": 0x02E07614,
371 "OTP_SACC00_ADDR": 0x02E07620,
372 "OTP_SACC10_ADDR": 0x02E07630,
373 "OTP_SACC01_ADDR": 0x02E07640,
374 "OTP_SACC11_ADDR": 0x02E07650,
375 "OTP_SACC02_ADDR": 0x02E07660,
376 "OTP_SACC12_ADDR": 0x02E07670,
377 "OTP_SACC03_ADDR": 0x02E07680,
378 "OTP_SACC13_ADDR": 0x02E07690,
379 "OTP_PBPS_ADDR": 0x02E17780,
380 "OTP_PBPS_SEC_ADDR": 0x02E17700,
381 "OTP_ZHUK_ADDR": 0x02E17920,
382}
383
384
385def _check_option_setting(path: pathlib.Path, code: str) -> list[Finding]:
386 """LD007 -- no phantom data-flash, and option-setting addresses match the HUM.
387
388 The RA8D2 has no general-purpose data-flash / EEPROM array: 0x27000000 (the
389 conventional RA-family data-flash base) faults on this silicon (#397). And
390 the option bytes must land on their true addresses (#391) or the flasher
391 programs the wrong OTP cell. Only scripts that actually declare the
392 option-setting words are address-checked; every RA8 script is phantom-checked.
393 """
394 findings: list[Finding] = []
395 for m in re.finditer(r"\bDATA_FLASH\b|0x2700_?0000", code):
396 line = code[: m.start()].count("\n") + 1
397 findings.append(
398 Finding(
399 path,
400 line,
401 "LD007",
402 "phantom data-flash 0x27000000 -- the RA8D2 has no such region (#397)",
403 )
404 )
405 for name, expect in OPTION_SETTING_ADDR.items():
406 m = re.search(r"PROVIDE\‍(\s*" + re.escape(name) + r"\s*=\s*(0x[0-9A-Fa-f_]+)\s*\‍)", code)
407 if not m:
408 continue
409 got = int(m.group(1).replace("_", ""), 16)
410 if got != expect:
411 line = code[: m.start()].count("\n") + 1
412 findings.append(
413 Finding(
414 path,
415 line,
416 "LD007",
417 f"{name} = {m.group(1)}, expected 0x{expect:08X} (HUM Ch 7 Figure 7.1 p 279)",
418 )
419 )
420 return findings
421
422
423# A script that owns the option bytes must carry every word in the family; one
424# that does not own them carries none. The complete population is comfortably
425# into the sixties, so this floor sits far below a healthy tree -- low enough
426# never to fight normal churn, high enough that a PROVIDE rename which silently
427# stopped LD007/LD008 matching anything cannot slip past as a clean run.
428OPTION_SETTING_FILE_FLOOR = 40
429
430
431def option_section(name: str) -> str:
432 """Map a PROVIDE symbol to the output section that word lands in.
433
434 ``OFS3_SEC_ADDR`` -> ``.option_setting_ofs3_sec``. Deriving the section name
435 from OPTION_SETTING_ADDR rather than keeping a second hand-written list is
436 deliberate: two lists would drift, and the drift would disarm the rule.
437 """
438 return ".option_setting_" + name.removesuffix("_ADDR").lower()
439
440
441def declared_option_words(code: str) -> set[str]:
442 """The option-setting PROVIDE names this script declares (comment-blanked)."""
443 return {
444 name
445 for name in OPTION_SETTING_ADDR
446 if re.search(r"PROVIDE\‍(\s*" + re.escape(name) + r"\s*=", code)
447 }
448
449
450def placed_option_sections(code: str) -> set[str]:
451 """Every ``.option_setting_*`` output section this script places."""
452 return set(re.findall(r"^\s*(\.option_setting_\w+)", code, re.MULTILINE))
453
454
455def _check_option_completeness(path: pathlib.Path, code: str) -> list[Finding]:
456 """LD008 -- the option-setting family is all-or-nothing, and closed.
457
458 See the module docstring for why this is structural rather than per-device.
459 """
460 findings: list[Finding] = []
461 declared = declared_option_words(code)
462 placed = placed_option_sections(code)
463 known_sections = {option_section(n) for n in OPTION_SETTING_ADDR}
464
465 # Closed: an .option_setting_* section outside the HUM family is a word the
466 # silicon does not have, whichever part this script targets.
467 for stray in sorted(placed - known_sections):
468 line = next(
469 (i for i, ln in enumerate(code.splitlines(), 1) if stray in ln),
470 1,
471 )
472 findings.append(
473 Finding(
474 path,
475 line,
476 "LD008",
477 f"unknown option-setting section '{stray}' -- not a word the HUM lists",
478 )
479 )
480
481 if not declared and not (placed & known_sections):
482 return findings # Owns no option bytes at all -- legitimate, nothing to complete.
483
484 missing_provides = sorted(set(OPTION_SETTING_ADDR) - declared)
485 if missing_provides:
486 findings.append(
487 Finding(
488 path,
489 1,
490 "LD008",
491 f"declares {len(declared)}/{len(OPTION_SETTING_ADDR)} option-setting words; "
492 f"the family is all-or-nothing, missing PROVIDE: {', '.join(missing_provides)}",
493 )
494 )
495
496 missing_sections = sorted(known_sections - placed)
497 if missing_sections:
498 findings.append(
499 Finding(
500 path,
501 1,
502 "LD008",
503 f"places {len(placed & known_sections)}/{len(known_sections)} option-setting "
504 f"sections; missing: {', '.join(missing_sections)}",
505 )
506 )
507 return findings
508
509
510def check_file(path: pathlib.Path, raw: bytes) -> list[Finding]:
511 """Every linker-script rule, one function per finding code.
512
513 The rule list is the call sequence below: LD005 formatting, LD001 licence,
514 LD002 ENTRY, LD003 MEMORY, LD004 region closure, LD009 SRAM fit, LD007
515 option-setting addresses, LD008 option-setting completeness.
516 """
517 findings, text = _check_formatting(path, raw)
518 findings += _check_licence(path, text.splitlines())
519 code = strip_comments(text)
520 findings += _check_entry(path, code)
521 memory_findings, regions = _check_memory(path, code)
522 findings += memory_findings
523 findings += _check_region_closure(path, code, regions)
524 findings += _check_sram_fit(path, code, regions)
525 findings += _check_option_setting(path, code)
526 findings += _check_option_completeness(path, code)
527 return findings
528
529
530def defined_symbols(text: str) -> set[str]:
531 """Linker symbols this script DEFINES, in any of the three spellings.
532
533 Recognises ``sym = expr;``, ``PROVIDE(sym = expr)`` and
534 ``PROVIDE_HIDDEN(sym = expr)`` alike, since all three make the symbol
535 available to C and the closure check must not care which was used.
536
537 Runs on the comment-blanked view, so a symbol named only in a comment is
538 not counted as defined.
539 """
540 code = strip_comments(text)
541 found: set[str] = set()
542 # `sym = expr;`, `PROVIDE(sym = expr)`, `PROVIDE_HIDDEN(sym = expr)`
543 for m in re.finditer(rf"\b({SYMBOL_PREFIX}\w+)\s*=", code):
544 found.add(m.group(1))
545 return found
546
547
548def referenced_symbols(text: str) -> set[str]:
549 """Linker symbols a C/C++ file REFERENCES, by prefix match.
550
551 Comments are dropped first so a symbol discussed in prose does not count
552 as a use -- otherwise documenting a symbol would keep it alive in the
553 closure check forever.
554 """
555 # Drop C comments so a symbol named only in prose does not count as a use.
556 stripped = re.sub(r"/\*.*?\*/", " ", text, flags=re.DOTALL)
557 stripped = re.sub(r"//[^\n]*", " ", stripped)
558 return set(re.findall(rf"\b{SYMBOL_PREFIX}\w+", stripped))
559
560
561def closure_problems(defined: dict[str, list[str]], referenced: dict[str, list[str]]) -> list[str]:
562 """Pure half of LD006 so --selftest can drive it without a repo."""
563 problems: list[str] = []
564 for sym, users in sorted(referenced.items()):
565 if sym not in defined:
566 problems.append(
567 f"[LD006] '{sym}' is referenced by C but no linker script "
568 f"defines it (first use: {users[0]})"
569 )
570 return problems
571
572
573def check_symbol_closure(root: pathlib.Path) -> list[str]:
574 """LD006 -- cross-check symbols defined in .ld files against their uses in C.
575
576 A whole-tree question by nature: a symbol is defined in one file and used
577 in another, so unlike the per-file rules this cannot be answered from a
578 staged subset and always scans everything.
579
580 Returns one message per problem; an empty list means the closure holds.
581 """
582 defined: dict[str, list[str]] = {}
583 for p in repo_files(root, "*.ld"):
584 for s in defined_symbols(p.read_text(encoding="ascii", errors="replace")):
585 defined.setdefault(s, []).append(str(p.relative_to(root)))
586
587 referenced: dict[str, list[str]] = {}
588 for pattern in ("*.c", "*.h", "*.cpp", "*.hpp"):
589 for p in repo_files(root, pattern):
590 text = p.read_text(encoding="ascii", errors="replace")
591 if SYMBOL_PREFIX not in text:
592 continue
593 for s in referenced_symbols(text):
594 referenced.setdefault(s, []).append(str(p.relative_to(root)))
595
596 return closure_problems(defined, referenced)
597
598
599# ---------------------------------------------------------------------------
600# selftest
601# ---------------------------------------------------------------------------
602def _selftest_option_setting() -> int:
603 """LD007 fires on the phantom region and a wrong OFS0 address, quiet on the twin."""
604 rc = 0
605 with tempfile.TemporaryDirectory() as td:
606 bad = pathlib.Path(td) / "ofs_bad.ld"
607 bad.write_bytes(OFS_BAD.encode())
608 codes = {f.code for f in check_file(bad, bad.read_bytes())}
609 if "LD007" not in codes:
610 print("SELFTEST FAIL: ofs_bad.ld did not report LD007")
611 rc = 1
612 else:
613 print("selftest: ofs_bad.ld -> LD007 (phantom + wrong OFS0) OK")
614
615 good = pathlib.Path(td) / "ofs_good.ld"
616 good.write_bytes(OFS_GOOD.encode())
617 ld007 = [f for f in check_file(good, good.read_bytes()) if f.code == "LD007"]
618 if ld007:
619 print("SELFTEST FAIL: ofs_good.ld should have no LD007 but reported:")
620 for f in ld007:
621 print(f" {f}")
622 rc = 1
623 else:
624 print("selftest: ofs_good.ld -> no LD007 OK")
625 return rc
626
627
628def _synth_option_script(omit: tuple[str, ...] = (), stray: str = "") -> str:
629 """Build a syntactically real .ld declaring the option family minus `omit`.
630
631 Generated from OPTION_SETTING_ADDR so the "complete" case cannot rot as the
632 HUM table grows -- the point of that case is that LD008 stays SILENT on a
633 complete script, which is only meaningful if "complete" tracks the table.
634 """
635 names = [n for n in OPTION_SETTING_ADDR if n not in omit]
636 provides = "\n".join(f"PROVIDE({n} = 0x{OPTION_SETTING_ADDR[n]:08X});" for n in names)
637 sections = "\n".join(
638 f" {option_section(n)} {n} : {{ KEEP(*({option_section(n)})) }} > OFS_CFG" for n in names
639 )
640 if stray:
641 sections += f"\n {stray} 0x02C9F800 : {{ KEEP(*({stray})) }} > OFS_CFG"
642 return (
643 "/*\n * Copyright (c) 2026 Brighton Sikarskie\n"
644 " * SPDX-License-Identifier: MIT\n */\n\n"
645 "ENTRY(Reset_Handler)\n\n"
646 "MEMORY\n{\n"
647 " MRAM (rx) : ORIGIN = 0x02000000, LENGTH = 1024K\n"
648 " OFS_CFG (r) : ORIGIN = 0x02C9F000, LENGTH = 2K\n"
649 " OFS_OTP (r) : ORIGIN = 0x02E07000, LENGTH = 68K\n}\n\n"
650 f"{provides}\n\n"
651 "SECTIONS\n{\n"
652 " .text : { *(.text) } > MRAM\n"
653 f"{sections}\n}}\n"
654 )
655
656
657# The exact trio #223 deleted from the four RA8P1 app scripts. LD008 exists to
658# make that deletion impossible to land again, so the selftest reproduces it
659# rather than an invented omission.
660OFS3_FAMILY = ("OFS3_ADDR", "OFS3_SEC_ADDR", "OFS3_SEL_ADDR")
661
662# Below this many words, OPTION_SETTING_ADDR has plainly been gutted and every
663# LD008 case built from it would pass without asserting anything.
664MIN_OPTION_WORDS = 20
665
666
667def _selftest_option_completeness() -> int:
668 """LD008 fires on a partial family and on a stray section, silent when complete."""
669 rc = 0
670 # Anchor: an emptied or OFS3-less table would make every case below vacuous.
671 missing = [n for n in OFS3_FAMILY if n not in OPTION_SETTING_ADDR]
672 n_words = len(OPTION_SETTING_ADDR)
673 if missing or n_words < MIN_OPTION_WORDS:
674 print(f"SELFTEST FAIL: OPTION_SETTING_ADDR lost {missing or 'entries'}; LD008 vacuous")
675 return 1
676 print(f"selftest: OPTION_SETTING_ADDR has {n_words} words incl. the OFS3 family OK")
677
678 with tempfile.TemporaryDirectory() as td:
679 cases = [
680 ("partial.ld", _synth_option_script(omit=OFS3_FAMILY), True, "OFS3 family cut (#223)"),
681 ("complete.ld", _synth_option_script(), False, "complete family"),
682 ("stray.ld", _synth_option_script(stray=".option_setting_ofs4"), True, "phantom ofs4"),
683 ]
684 for fname, text, want_fire, label in cases:
685 p = pathlib.Path(td) / fname
686 p.write_bytes(text.encode())
687 ld008 = [f for f in check_file(p, p.read_bytes()) if f.code == "LD008"]
688 if want_fire and not ld008:
689 print(f"SELFTEST FAIL: {fname} ({label}) did not report LD008")
690 rc = 1
691 elif not want_fire and ld008:
692 print(f"SELFTEST FAIL: {fname} ({label}) should have no LD008 but reported:")
693 for f in ld008:
694 print(f" {f}")
695 rc = 1
696 else:
697 verdict = "LD008" if want_fire else "no LD008"
698 print(f"selftest: {fname} -> {verdict} ({label}) OK")
699
700 # A script owning no option bytes at all must stay silent -- that is the
701 # CPU1 / non-secure-image shape, 64 files in this tree.
702 none = pathlib.Path(td) / "none.ld"
703 none.write_bytes(TRICKY.encode())
704 if [f for f in check_file(none, none.read_bytes()) if f.code == "LD008"]:
705 print("SELFTEST FAIL: a script with no option-setting block reported LD008")
706 rc = 1
707 else:
708 print("selftest: none.ld -> no LD008 (owns no option bytes) OK")
709 return rc
710
711
712def _selftest_fixtures() -> int:
713 """The two whole-file fixtures: every code must fire, nothing may over-fire."""
714 rc = 0
715 with tempfile.TemporaryDirectory() as td:
716 bad = pathlib.Path(td) / "malformed.ld"
717 bad.write_bytes(MALFORMED.encode())
718 got = check_file(bad, bad.read_bytes())
719 codes = {f.code for f in got}
720 expected = {"LD001", "LD002", "LD003", "LD004", "LD005"}
721 missing = expected - codes
722 if missing:
723 print(f"SELFTEST FAIL: malformed.ld did not report {sorted(missing)}")
724 for f in got:
725 print(f" got: {f}")
726 rc = 1
727 else:
728 print(f"selftest: malformed.ld -> {len(got)} findings {sorted(codes)} OK")
729
730 good = pathlib.Path(td) / "tricky.ld"
731 good.write_bytes(TRICKY.encode())
732 got = check_file(good, good.read_bytes())
733 if got:
734 print("SELFTEST FAIL: tricky.ld should be clean but reported:")
735 for f in got:
736 print(f" {f}")
737 rc = 1
738 else:
739 print("selftest: tricky.ld -> 0 findings OK")
740 return rc
741
742
743def _selftest_symbol_scan() -> tuple[int, set[str], set[str]]:
744 """LD006 halves: a symbol named only in a comment is neither defined nor used."""
745 rc = 0
746 ld_text = (
747 "/* mentions g_ra8_ls_in_comment_only, which is NOT a definition */\n"
748 "g_ra8_ls_alpha = .;\n"
749 "PROVIDE(g_ra8_ls_beta = 0x20000000);\n"
750 )
751 c_text = (
752 "/* prose naming g_ra8_ls_prose_only must not count as a use */\n"
753 "// nor g_ra8_ls_slash_comment\n"
754 "extern uint32_t g_ra8_ls_alpha;\n"
755 "extern uint32_t g_ra8_ls_missing;\n"
756 )
757 got_def = defined_symbols(ld_text)
758 if got_def != {"g_ra8_ls_alpha", "g_ra8_ls_beta"}:
759 print(f"SELFTEST FAIL: defined_symbols -> {sorted(got_def)}")
760 rc = 1
761 else:
762 print("selftest: defined_symbols ignores comment mentions OK")
763
764 got_ref = referenced_symbols(c_text)
765 if got_ref != {"g_ra8_ls_alpha", "g_ra8_ls_missing"}:
766 print(f"SELFTEST FAIL: referenced_symbols -> {sorted(got_ref)}")
767 rc = 1
768 else:
769 print("selftest: referenced_symbols ignores comment mentions OK")
770 return rc, got_def, got_ref
771
772
773def _selftest_closure(got_def: set[str], got_ref: set[str]) -> int:
774 """LD006 closure, both directions: fires on a gap, silent when resolved."""
775 rc = 0
776 defined = {s: ["fake.ld"] for s in got_def}
777 referenced = {s: ["fake.c"] for s in got_ref}
778 problems = closure_problems(defined, referenced)
779 if len(problems) != 1 or "g_ra8_ls_missing" not in problems[0]:
780 print(f"SELFTEST FAIL: closure_problems -> {problems}")
781 rc = 1
782 else:
783 print("selftest: closure fires on an undefined symbol OK")
784
785 if closure_problems({"g_ra8_ls_a": ["x.ld"]}, {"g_ra8_ls_a": ["x.c"]}):
786 print("SELFTEST FAIL: closure fired on a fully-resolved symbol")
787 rc = 1
788 else:
789 print("selftest: closure quiet when every symbol resolves OK")
790 return rc
791
792
793def _selftest_worktree_inventory() -> int:
794 """Candidate scope includes an unstaged move target and drops its source."""
795 with tempfile.TemporaryDirectory() as td:
796 root = pathlib.Path(td)
797 subprocess.run( # noqa: S603 -- fixed Git argv and private fixture path
798 [trusted_git_executable(), "init", "-q", str(root)],
799 check=True,
800 )
801 old = root / "old.c"
802 old.write_text("int old_symbol;\n", encoding="utf-8")
803 subprocess.run( # noqa: S603 -- fixed Git argv and private fixture path
804 [trusted_git_executable(), "-C", str(root), "add", "old.c"],
805 check=True,
806 )
807 old.unlink()
808 new = root / "new.c"
809 new.write_text("int new_symbol;\n", encoding="utf-8")
810
811 got = [path.relative_to(root).as_posix() for path in repo_files(root, "*.c")]
812 if got != ["new.c"]:
813 print(f"SELFTEST FAIL: worktree move inventory -> {got}")
814 return 1
815 print("selftest: worktree move drops deleted source and includes destination OK")
816 return 0
817
818
819def _sram_fixture(ns_sram_len: str) -> str:
820 """A board-shaped script whose NS_SRAM placeholder is sized `ns_sram_len`.
821
822 The other rows are load-bearing: SRAM ``1024K - 256`` exercises subtraction,
823 NOINIT sits at the top of SRAM (must stay silent), and NS_SRAM_RUN at 0x32..
824 is the non-secure alias that must fall OUTSIDE the window LD009 judges.
825 """
826 return (
827 "/*\n * Copyright (c) 2026 Brighton Sikarskie\n"
828 " * SPDX-License-Identifier: MIT\n */\n\n"
829 "ENTRY(Reset_Handler)\n\n"
830 "MEMORY\n{\n"
831 " SRAM (rwx) : ORIGIN = 0x22000000, LENGTH = 1024K - 256\n"
832 " NOINIT (rw) : ORIGIN = 0x220FFF00, LENGTH = 256\n"
833 f" NS_SRAM (rwx) : ORIGIN = 0x22100000, LENGTH = {ns_sram_len}\n"
834 " NS_SRAM_RUN (rwx) : ORIGIN = 0x32100000, LENGTH = 512K\n}\n"
835 )
836
837
838def _selftest_sram_fit() -> int:
839 """LD009 fires on a region past the SRAM end, silent when every region fits."""
840 rc = 0
841 # Anchor: a collapsed evaluator or a zeroed window constant would make every
842 # case below vacuous. Compare only named constants and eval_size results,
843 # never a bare literal, encoding the real bank arithmetic (1M + 640K = 1664K).
844 if eval_size("1664K") != SRAM_WINDOW_SIZE or eval_size("1024K") != eval_size("1M"):
845 print("SELFTEST FAIL: eval_size unit / SRAM-window anchor is wrong")
846 return 1
847 if eval_size("1M - 384K") != eval_size("640K") or eval_size("ORIGIN(SRAM)") is not None:
848 print("SELFTEST FAIL: eval_size mis-handled subtraction or a symbolic expr")
849 return 1
850
851 with tempfile.TemporaryDirectory() as td:
852 # 1024K overruns to 0x22200000 (the #544 defect); 640K lands exactly on
853 # 0x221A0000, proving the bound is inclusive.
854 for tag, ns_len, want in (("overrun.ld", "1024K", True), ("fits.ld", "640K", False)):
855 p = pathlib.Path(td) / tag
856 p.write_bytes(_sram_fixture(ns_len).encode())
857 ld009 = [f for f in check_file(p, p.read_bytes()) if f.code == "LD009"]
858 if want and (len(ld009) != 1 or "NS_SRAM" not in ld009[0].msg):
859 print(f"SELFTEST FAIL: {tag} expected one LD009 on NS_SRAM, got {ld009}")
860 rc = 1
861 elif not want and ld009:
862 print(f"SELFTEST FAIL: {tag} should have no LD009 but reported {ld009}")
863 rc = 1
864 else:
865 print(f"selftest: {tag} -> {'LD009 on NS_SRAM' if want else 'no LD009'} OK")
866 return rc
867
868
869def _selftest_body() -> int:
870 """Assert every finding code fires, and that none of them over-fires."""
871 rc = _selftest_fixtures()
872 rc |= _selftest_option_setting()
873 rc |= _selftest_option_completeness()
874 rc |= _selftest_sram_fit()
875 scan_rc, got_def, got_ref = _selftest_symbol_scan()
876 return rc | scan_rc | _selftest_closure(got_def, got_ref) | _selftest_worktree_inventory()
877
878
879def selftest() -> int:
880 """Run linker-script fixtures without inheriting the caller's repository."""
881 with isolated_git_environment():
882 return _selftest_body()
883
884
885def scan(paths: list[pathlib.Path]) -> tuple[list[Finding], int]:
886 """Run every per-file rule, and count scripts with the complete option family.
887
888 The count is what the vacuity floor is asserted against, so it is produced
889 by the same pass that applies the rules rather than by a second walk that
890 could drift from it.
891 """
892 findings: list[Finding] = []
893 complete = 0
894 for p in paths:
895 raw = p.read_bytes()
896 findings.extend(check_file(p, raw))
897 code = strip_comments(raw.decode("utf-8", errors="replace"))
898 if declared_option_words(code) == set(OPTION_SETTING_ADDR):
899 complete += 1
900 return findings, complete
901
902
903def option_floor_breached(complete: int) -> bool:
904 """Vacuity floor for LD007/LD008 -- report and fail when they match too little.
905
906 Both rules hinge on matching a ``PROVIDE`` spelling, which makes them the
907 two most able to fail OPEN: a rename would leave them matching nothing and
908 reporting a clean tree forever. Matching almost nothing is a gate failure,
909 not a pass.
910 """
911 if complete >= OPTION_SETTING_FILE_FLOOR:
912 return False
913 print(
914 f"ERROR: only {complete} script(s) declare the complete option-setting "
915 f"family, below the floor of {OPTION_SETTING_FILE_FLOOR}. Either the "
916 f"PROVIDE spelling changed (LD007/LD008 now match nothing and enforce "
917 f"nothing) or the option bytes were mass-deleted. Refusing to report success.",
918 file=sys.stderr,
919 )
920 return True
921
922
923def main() -> int:
924 """Check every tracked linker script, or run the selftest / scope listing.
925
926 Note the asymmetry: the per-file LD001-LD005 rules honour a positional
927 path list, but the LD006 symbol closure always scans the whole tree
928 because a definition and its use live in different files. Passing paths
929 therefore narrows part of this gate and not all of it.
930
931 ``--list-files`` prints the scope and exits 0 for check_lint_coverage.py.
932
933 Returns 0 when clean, 1 on any finding or a failing selftest.
934 """
935 ap = argparse.ArgumentParser(description=__doc__)
936 ap.add_argument("--selftest", action="store_true", help="assert both directions")
937 # Scope introspection for check_lint_coverage.py: print what this gate
938 # would scan, so the coverage gate can ask rather than restate the scope.
939 ap.add_argument("--list-files", action="store_true", help="print the scanned file list")
940 ap.add_argument("paths", nargs="*", help="scripts to check (default: all tracked)")
941 args = ap.parse_args()
942
943 if args.selftest:
944 return selftest()
945
946 root = pathlib.Path(
947 subprocess.run( # noqa: S603 -- fixed Git authority and constant read-only query
948 [trusted_git_executable(), "rev-parse", "--show-toplevel"],
949 capture_output=True,
950 text=True,
951 check=True,
952 ).stdout.strip()
953 )
954
955 paths = [pathlib.Path(p) for p in args.paths] or repo_files(root, "*.ld")
956 if not paths:
957 print("ERROR: no linker scripts found; refusing to report success.", file=sys.stderr)
958 return 1
959
960 if args.list_files:
961 print("\n".join(sorted(str(p.relative_to(root)) for p in paths)))
962 return 0
963
964 findings, complete = scan(paths)
965
966 # The floor is only meaningful on a whole-tree run; a positional path list
967 # legitimately narrows the scan to a handful of files.
968 if not args.paths and option_floor_breached(complete):
969 return 1
970
971 problems = check_symbol_closure(root) if not args.paths else []
972
973 for f in findings:
974 print(f)
975 for pr in problems:
976 print(pr)
977
978 total = len(findings) + len(problems)
979 if total:
980 print(f"\n{total} linker-script finding(s) in {len(paths)} file(s)")
981 return 1
982 print(f"linker scripts clean ({len(paths)} files)")
983 return 0
984
985
986if __name__ == "__main__":
987 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298