4"""Structural and formatting checker for GNU ld linker scripts.
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.
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:
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
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,
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.
65LD006 is whole-tree, so it is reported once rather than per file.
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
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.
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.
95Run with --selftest to prove the rules fire on a deliberately malformed script
96and stay quiet on a legal-but-tricky one.
99from __future__
import annotations
108sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
109sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] /
"dev"))
111from git_environment
import isolated_git_environment, trusted_git_executable
112from linker_script_fixtures
import MALFORMED, OFS_BAD, OFS_GOOD, TRICKY
114SYMBOL_PREFIX =
"g_ra8_ls_"
115EXCLUDED_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/",
"libs/ra8_fonts/")
118_COMMENT = re.compile(
r"/\*.*?\*/", re.DOTALL)
121def strip_comments(text: str) -> str:
122 """Blank out comment bodies, preserving newlines so line numbers hold."""
124 def blank(m: re.Match[str]) -> str:
125 return re.sub(
r"[^\n]",
" ", m.group(0))
127 return _COMMENT.sub(blank, text)
130def repo_files(root: pathlib.Path, pattern: str) -> list[pathlib.Path]:
131 """Live candidate files matching a git pathspec, minus vendored prefixes.
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.
145 "--exclude-standard",
149 out = subprocess.run(
154 ).stdout.splitlines()
155 return [root / p
for p
in out
if not p.startswith(EXCLUDED_PREFIXES)
and (root / p).is_file()]
159 """One linker-script rule violation, identified by its LDxxx code.
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
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
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}"
175def _check_formatting(path: pathlib.Path, raw: bytes) -> tuple[list[Finding], str]:
176 """LD005 -- encoding, line endings, indentation, trailing whitespace.
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.
181 findings: list[Finding] = []
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")
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"))
195 for i, ln
in enumerate(text.splitlines(), 1):
196 if ln.startswith(
"\t")
or re.match(
r"^ *\t", ln):
199 path, i,
"LD005",
"tab indentation (this tree indents ld scripts with spaces)"
202 if ln != ln.rstrip():
203 findings.append(Finding(path, i,
"LD005",
"trailing whitespace"))
204 return findings, text
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:
213 Finding(path, 1,
"LD001",
"no SPDX-License-Identifier in the first 60 lines")
215 if not re.search(
r"Copyright \(c\) \d{4}", head):
217 Finding(path, 1,
"LD001",
"no 'Copyright (c) <year>' line in the first 60 lines")
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)
226 return [Finding(path, 1,
"LD002",
"no ENTRY(symbol) declaration")]
228 return [Finding(path, 1,
"LD002", f
"{len(entries)} ENTRY declarations, expected exactly 1")]
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.
235 Also returns the declared region names, which LD004 needs to decide
236 whether an output section lands somewhere that exists.
238 findings: list[Finding] = []
239 regions: set[str] = set()
240 mem_blocks = re.findall(
r"\bMEMORY\s*\{(.*?)\n\}", code, re.DOTALL)
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))
247 findings.append(Finding(path, 1,
"LD003",
"MEMORY block declares no regions"))
248 for name
in sorted(regions):
250 rf
"^\s*{re.escape(name)}\s*(\([rwxail!]+\))?\s*:([^\n]*)$",
254 if decl
and not (
"ORIGIN" in decl.group(2)
and "LENGTH" in decl.group(2)):
255 line = code[: decl.start()].count(
"\n") + 1
257 Finding(path, line,
"LD003", f
"region '{name}' lacks ORIGIN and/or LENGTH")
259 return findings, regions
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):
269 line = code[: m.start()].count(
"\n") + 1
271 Finding(path, line,
"LD004", f
"output section placed in undeclared region '{name}'")
279SRAM_WINDOW_BASE = 0x22000000
280SRAM_WINDOW_SIZE = 0x001A0000
281SRAM_WINDOW_END = SRAM_WINDOW_BASE + SRAM_WINDOW_SIZE
283_SIZE_UNIT = {
"": 1,
"K": 1024,
"M": 1024 * 1024}
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]?)*")
290def eval_size(expr: str) -> int |
None:
291 """Evaluate an ORIGIN/LENGTH literal, or None when it is not static.
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.
298 if not text
or not _SIZE_WHOLE.fullmatch(text):
301 for sign, num, unit
in _SIZE_TERM.findall(text):
302 total += (-1
if sign ==
"-" else 1) * int(num.replace(
"_",
""), 0) * _SIZE_UNIT[unit]
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.
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.
314 findings: list[Finding] = []
315 for name
in sorted(regions):
317 rf
"^\s*{re.escape(name)}\s*(\([rwxail!]+\))?\s*:([^\n]*)$",
323 om = re.search(
r"ORIGIN\s*=\s*([^,\n]+)", decl.group(2))
324 lm = re.search(
r"LENGTH\s*=\s*([^,\n]+)", decl.group(2))
327 origin = eval_size(om.group(1))
328 length = eval_size(lm.group(1))
329 if origin
is None or length
is None:
331 if not (SRAM_WINDOW_BASE <= origin < SRAM_WINDOW_END):
333 end = origin + length
334 if end > SRAM_WINDOW_END:
335 line = code[: decl.start()].count(
"\n") + 1
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)",
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,
385def _check_option_setting(path: pathlib.Path, code: str) -> list[Finding]:
386 """LD007 -- no phantom data-flash, and option-setting addresses match the HUM.
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.
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
402 "phantom data-flash 0x27000000 -- the RA8D2 has no such region (#397)",
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)
409 got = int(m.group(1).replace(
"_",
""), 16)
411 line = code[: m.start()].count(
"\n") + 1
417 f
"{name} = {m.group(1)}, expected 0x{expect:08X} (HUM Ch 7 Figure 7.1 p 279)",
428OPTION_SETTING_FILE_FLOOR = 40
431def option_section(name: str) -> str:
432 """Map a PROVIDE symbol to the output section that word lands in.
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.
438 return ".option_setting_" + name.removesuffix(
"_ADDR").lower()
441def declared_option_words(code: str) -> set[str]:
442 """The option-setting PROVIDE names this script declares (comment-blanked)."""
445 for name
in OPTION_SETTING_ADDR
446 if re.search(
r"PROVIDE\(\s*" + re.escape(name) +
r"\s*=", code)
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))
455def _check_option_completeness(path: pathlib.Path, code: str) -> list[Finding]:
456 """LD008 -- the option-setting family is all-or-nothing, and closed.
458 See the module docstring for why this is structural rather than per-device.
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}
467 for stray
in sorted(placed - known_sections):
469 (i
for i, ln
in enumerate(code.splitlines(), 1)
if stray
in ln),
477 f
"unknown option-setting section '{stray}' -- not a word the HUM lists",
481 if not declared
and not (placed & known_sections):
484 missing_provides = sorted(set(OPTION_SETTING_ADDR) - declared)
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)}",
496 missing_sections = sorted(known_sections - placed)
503 f
"places {len(placed & known_sections)}/{len(known_sections)} option-setting "
504 f
"sections; missing: {', '.join(missing_sections)}",
510def check_file(path: pathlib.Path, raw: bytes) -> list[Finding]:
511 """Every linker-script rule, one function per finding code.
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.
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)
530def defined_symbols(text: str) -> set[str]:
531 """Linker symbols this script DEFINES, in any of the three spellings.
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.
537 Runs on the comment-blanked view, so a symbol named only in a comment is
538 not counted as defined.
540 code = strip_comments(text)
541 found: set[str] = set()
543 for m
in re.finditer(rf
"\b({SYMBOL_PREFIX}\w+)\s*=", code):
544 found.add(m.group(1))
548def referenced_symbols(text: str) -> set[str]:
549 """Linker symbols a C/C++ file REFERENCES, by prefix match.
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.
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))
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:
567 f
"[LD006] '{sym}' is referenced by C but no linker script "
568 f
"defines it (first use: {users[0]})"
573def check_symbol_closure(root: pathlib.Path) -> list[str]:
574 """LD006 -- cross-check symbols defined in .ld files against their uses in C.
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.
580 Returns one message per problem; an empty list means the closure holds.
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)))
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:
593 for s
in referenced_symbols(text):
594 referenced.setdefault(s, []).append(str(p.relative_to(root)))
596 return closure_problems(defined, referenced)
602def _selftest_option_setting() -> int:
603 """LD007 fires on the phantom region and a wrong OFS0 address, quiet on the twin."""
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")
613 print(
"selftest: ofs_bad.ld -> LD007 (phantom + wrong OFS0) OK")
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"]
619 print(
"SELFTEST FAIL: ofs_good.ld should have no LD007 but reported:")
624 print(
"selftest: ofs_good.ld -> no LD007 OK")
628def _synth_option_script(omit: tuple[str, ...] = (), stray: str =
"") -> str:
629 """Build a syntactically real .ld declaring the option family minus `omit`.
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.
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
641 sections += f
"\n {stray} 0x02C9F800 : {{ KEEP(*({stray})) }} > OFS_CFG"
643 "/*\n * Copyright (c) 2026 Brighton Sikarskie\n"
644 " * SPDX-License-Identifier: MIT\n */\n\n"
645 "ENTRY(Reset_Handler)\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"
652 " .text : { *(.text) } > MRAM\n"
660OFS3_FAMILY = (
"OFS3_ADDR",
"OFS3_SEC_ADDR",
"OFS3_SEL_ADDR")
667def _selftest_option_completeness() -> int:
668 """LD008 fires on a partial family and on a stray section, silent when complete."""
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")
676 print(f
"selftest: OPTION_SETTING_ADDR has {n_words} words incl. the OFS3 family OK")
678 with tempfile.TemporaryDirectory()
as td:
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"),
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")
691 elif not want_fire
and ld008:
692 print(f
"SELFTEST FAIL: {fname} ({label}) should have no LD008 but reported:")
697 verdict =
"LD008" if want_fire
else "no LD008"
698 print(f
"selftest: {fname} -> {verdict} ({label}) OK")
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")
708 print(
"selftest: none.ld -> no LD008 (owns no option bytes) OK")
712def _selftest_fixtures() -> int:
713 """The two whole-file fixtures: every code must fire, nothing may over-fire."""
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
723 print(f
"SELFTEST FAIL: malformed.ld did not report {sorted(missing)}")
728 print(f
"selftest: malformed.ld -> {len(got)} findings {sorted(codes)} OK")
730 good = pathlib.Path(td) /
"tricky.ld"
731 good.write_bytes(TRICKY.encode())
732 got = check_file(good, good.read_bytes())
734 print(
"SELFTEST FAIL: tricky.ld should be clean but reported:")
739 print(
"selftest: tricky.ld -> 0 findings OK")
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."""
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"
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"
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)}")
762 print(
"selftest: defined_symbols ignores comment mentions OK")
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)}")
769 print(
"selftest: referenced_symbols ignores comment mentions OK")
770 return rc, got_def, got_ref
773def _selftest_closure(got_def: set[str], got_ref: set[str]) -> int:
774 """LD006 closure, both directions: fires on a gap, silent when resolved."""
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}")
783 print(
"selftest: closure fires on an undefined symbol OK")
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")
789 print(
"selftest: closure quiet when every symbol resolves OK")
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)
798 [trusted_git_executable(),
"init",
"-q", str(root)],
802 old.write_text(
"int old_symbol;\n", encoding=
"utf-8")
804 [trusted_git_executable(),
"-C", str(root),
"add",
"old.c"],
809 new.write_text(
"int new_symbol;\n", encoding=
"utf-8")
811 got = [path.relative_to(root).as_posix()
for path
in repo_files(root,
"*.c")]
813 print(f
"SELFTEST FAIL: worktree move inventory -> {got}")
815 print(
"selftest: worktree move drops deleted source and includes destination OK")
819def _sram_fixture(ns_sram_len: str) -> str:
820 """A board-shaped script whose NS_SRAM placeholder is sized `ns_sram_len`.
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.
827 "/*\n * Copyright (c) 2026 Brighton Sikarskie\n"
828 " * SPDX-License-Identifier: MIT\n */\n\n"
829 "ENTRY(Reset_Handler)\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"
838def _selftest_sram_fit() -> int:
839 """LD009 fires on a region past the SRAM end, silent when every region fits."""
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")
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")
851 with tempfile.TemporaryDirectory()
as td:
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}")
861 elif not want
and ld009:
862 print(f
"SELFTEST FAIL: {tag} should have no LD009 but reported {ld009}")
865 print(f
"selftest: {tag} -> {'LD009 on NS_SRAM' if want else 'no LD009'} OK")
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()
879def selftest() -> int:
880 """Run linker-script fixtures without inheriting the caller's repository."""
881 with isolated_git_environment():
882 return _selftest_body()
885def scan(paths: list[pathlib.Path]) -> tuple[list[Finding], int]:
886 """Run every per-file rule, and count scripts with the complete option family.
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
892 findings: list[Finding] = []
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):
900 return findings, complete
903def option_floor_breached(complete: int) -> bool:
904 """Vacuity floor for LD007/LD008 -- report and fail when they match too little.
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,
911 if complete >= OPTION_SETTING_FILE_FLOOR:
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.",
924 """Check every tracked linker script, or run the selftest / scope listing.
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.
931 ``--list-files`` prints the scope and exits 0 for check_lint_coverage.py.
933 Returns 0 when clean, 1 on any finding or a failing selftest.
935 ap = argparse.ArgumentParser(description=__doc__)
936 ap.add_argument(
"--selftest", action=
"store_true", help=
"assert both directions")
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()
948 [trusted_git_executable(),
"rev-parse",
"--show-toplevel"],
955 paths = [pathlib.Path(p)
for p
in args.paths]
or repo_files(root,
"*.ld")
957 print(
"ERROR: no linker scripts found; refusing to report success.", file=sys.stderr)
961 print(
"\n".join(sorted(str(p.relative_to(root))
for p
in paths)))
964 findings, complete = scan(paths)
968 if not args.paths
and option_floor_breached(complete):
971 problems = check_symbol_closure(root)
if not args.paths
else []
978 total = len(findings) + len(problems)
980 print(f
"\n{total} linker-script finding(s) in {len(paths)} file(s)")
982 print(f
"linker scripts clean ({len(paths)} files)")
986if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.