ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_reserved_addresses.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"""Reject first-party address constants that point into a Reserved memory window.
5
6WHY THIS EXISTS
7===============
8Three times now a driver has named an address that describes hardware which is
9not there, compiled clean, and failed only on silicon:
10
11 * the `ra8_rsip` crypto family -- invented registers, whole driver rewritten;
12 * `ra8_ptp` -- addressed a reserved window, and its `gptp: clock PASS` was a
13 reserved aperture echoing back a write (#498);
14 * `ra8_wdt_regs.h` -- read OFS0/OFS3 at 0x03001E04 / 0x03001E20, which appear
15 nowhere in either Hardware User's Manual and land in a Reserved area, and
16 dereferenced them at runtime (#545).
17
18Nothing catches this class. `check_hum_register_map.py` (#540) cross-checks
19register SYMBOLS and struct/window OFFSETS against the manual's tables, but an
20absolute-address enumerator like `k_ra8_wdt_ofs0_addr` is neither, so it falls
21outside that gate's rules. `check_linker_scripts.py` rule LD007 guards the
22phantom data-flash base on the LINKER side only -- the C side was unguarded,
23which is exactly how #545 survived.
24
25WHAT IT CHECKS
26==============
27 RA001 an enumerator in a `uintptr_t`-typed enum resolves into a window the
28 HUM memory map marks Reserved.
29
30The rule is deliberately narrow. `uintptr_t` enums are, by the CLAUDE.md
31constants hierarchy, THE way this tree spells a hardware address ("Use
32`uintptr_t` for any enum whose values are hardware memory-mapped addresses"),
33so the scan surface is precisely the set of things that claim to be addresses.
34A value that is not an address does not belong in one of these enums, and an
35address that lands in a Reserved window is wrong by construction -- reading it
36is undefined: it may fault, or return garbage the caller then trusts.
37
38WHAT IT DELIBERATELY DOES NOT CHECK
39===================================
40It does not try to validate that a non-reserved address is CORRECT -- that
41needs the manual's register tables, which is #540's job. This gate answers the
42much cheaper question that #540 cannot: "is this address inside a hole?"
43"""
44
45from __future__ import annotations
46
47import argparse
48import contextlib
49import io
50import pathlib
51import re
52import subprocess
53import sys
54from dataclasses import dataclass
55
56# Reserved windows of the RA8 address map. Both supported parts agree
57# line-for-line: RA8D2 HUM R01UH1065EJ0130 and RA8P1 HUM R01UH1064EJ0130,
58# Ch 3 "Address Space" memory map.
59#
60# Keep this table SMALL and primary-sourced. A window listed here must be
61# reserved on every supported part, because a hit is a hard failure.
62RESERVED_WINDOWS: list[tuple[int, int, str]] = [
63 # Between the Extra MRAM option-setting region and the SiP-Flash area.
64 # This is the window #545's invented OFS addresses landed in.
65 (0x03000000, 0x07FFFFFF, "Reserved area between Extra MRAM and SiP-Flash (HUM Ch 3 map)"),
66 # The same window through the non-secure alias (BASE_MC 0x1200_0000). The
67 # option-setting words have a full non-secure mirror -- OFS3 is genuinely
68 # read at 0x12C9_F4C4 -- so the hole above it is just as reachable by a
69 # wrong constant, and omitting it would leave the alias half unguarded.
70 (0x13000000, 0x17FFFFFF, "Reserved area, non-secure alias of 0x0300_0000 (HUM Ch 3 map)"),
71 # The conventional RA-family data-flash base. The RA8 has no such array;
72 # it faults on this silicon (#397). LD007 guards the linker side.
73 (0x27000000, 0x27FFFFFF, "phantom data-flash -- the RA8 has no data-flash array (#397)"),
74]
75
76# Directories holding hand-written first-party code. `libs/third_party` is SOUP
77# and exempt per CLAUDE.md; it is filtered below rather than listed here.
78SCAN_ROOTS = ("libs", "examples", "port", "tools", "apps", "tests")
79
80# Vendored SOUP is governed by its upstream boundary, never by this checker.
81THIRD_PARTY_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/")
82
83# Below this many `uintptr_t` enumerators the scan has plainly stopped
84# matching -- a syntax change in how the tree spells address enums would
85# otherwise leave this gate quietly enforcing nothing. Measured 2026-07-28:
86# 151 enum blocks across 136 files, well over a thousand enumerators. The
87# floor sits far under a healthy tree but far above a collapsed scan.
88ENUMERATOR_FLOOR = 300
89
90ENUM_OPEN = re.compile(r"\benum\s*:\s*uintptr_t\s*\{", re.MULTILINE)
91ENUMERATOR = re.compile(r"(\bk_[A-Za-z0-9_]+)\s*=\s*(0[xX][0-9A-Fa-f_]+)")
92
93
94@dataclass(frozen=True)
95class Finding:
96 """One RA001 hit: an address enumerator inside a Reserved window."""
97
98 path: pathlib.Path
99 line: int
100 symbol: str
101 value: int
102 why: str
103
104 def render(self) -> str:
105 """Format the finding as a single `path:line: [RA001] ...` line."""
106 return (
107 f"{self.path}:{self.line}: [RA001] {self.symbol} = 0x{self.value:08X} "
108 f"lands in a Reserved window -- {self.why}"
109 )
110
111
112def strip_comments(text: str) -> str:
113 """Blank out block and line comments, preserving line structure.
114
115 Newlines are kept so reported line numbers stay true to the source. A
116 commented-out address is documentation, not a dereference, so it must not
117 be flagged -- both remediated headers cite the old bad values in prose.
118 """
119 text = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), text, flags=re.DOTALL)
120 return re.sub(r"//[^\n]*", "", text)
121
122
123def enum_bodies(code: str) -> list[tuple[int, str]]:
124 """Yield `(offset, body)` for every `enum : uintptr_t { ... }` block.
125
126 Brace-matched rather than regex-terminated so a nested initialiser cannot
127 truncate the body and silently drop the enumerators after it.
128 """
129 out: list[tuple[int, str]] = []
130 for m in ENUM_OPEN.finditer(code):
131 start = m.end()
132 depth = 1
133 i = start
134 while i < len(code) and depth > 0:
135 if code[i] == "{":
136 depth += 1
137 elif code[i] == "}":
138 depth -= 1
139 i += 1
140 out.append((start, code[start : i - 1]))
141 return out
142
143
144def reserved_hit(value: int) -> str | None:
145 """Return the description of the Reserved window containing `value`, if any."""
146 for lo, hi, why in RESERVED_WINDOWS:
147 if lo <= value <= hi:
148 return why
149 return None
150
151
152def check_text(path: pathlib.Path, text: str) -> tuple[list[Finding], int]:
153 """Apply RA001 to one file; also return how many enumerators were seen.
154
155 The enumerator count feeds the vacuity floor, and is produced by the same
156 pass that applies the rule so the two cannot drift.
157 """
158 code = strip_comments(text)
159 findings: list[Finding] = []
160 seen = 0
161 for offset, body in enum_bodies(code):
162 for m in ENUMERATOR.finditer(body):
163 seen += 1
164 value = int(m.group(2).replace("_", ""), 16)
165 why = reserved_hit(value)
166 if why is not None:
167 line = code[: offset + m.start()].count("\n") + 1
168 findings.append(Finding(path, line, m.group(1), value, why))
169 return findings, seen
170
171
172def repo_root() -> pathlib.Path:
173 """Resolve the repository root via git."""
174 return pathlib.Path(
175 subprocess.run(
176 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 # git from PATH is intended
177 capture_output=True,
178 text=True,
179 check=True,
180 ).stdout.strip()
181 )
182
183
184def tracked_sources(root: pathlib.Path) -> list[pathlib.Path]:
185 """List tracked first-party C sources and headers, excluding SOUP."""
186 out = subprocess.run( # noqa: S603 # fixed argv, no shell
187 ["git", "ls-files", "--", *(f"{d}/**/*.[ch]" for d in SCAN_ROOTS)], # noqa: S607 -- fixed repository Git census
188 capture_output=True,
189 text=True,
190 check=True,
191 cwd=root,
192 ).stdout.split()
193 return [root / p for p in out if not p.startswith(THIRD_PARTY_PREFIXES)]
194
195
196def scan(paths: list[pathlib.Path]) -> tuple[list[Finding], int]:
197 """Run RA001 over every path, returning findings and the enumerator count."""
198 findings: list[Finding] = []
199 total = 0
200 for p in paths:
201 try:
202 text = p.read_text(encoding="utf-8", errors="replace")
203 except OSError:
204 continue
205 f, seen = check_text(p, text)
206 findings.extend(f)
207 total += seen
208 return findings, total
209
210
211def floor_breached(seen: int) -> bool:
212 """Report and fail when the scan matched too few enumerators to mean anything."""
213 if seen >= ENUMERATOR_FLOOR:
214 return False
215 print(
216 f"ERROR: matched only {seen} uintptr_t enumerator(s), below the floor of "
217 f"{ENUMERATOR_FLOOR}. Either the tree stopped spelling addresses as "
218 f"`enum : uintptr_t` (RA001 now enforces nothing) or the scan scope "
219 f"collapsed. Refusing to report success.",
220 file=sys.stderr,
221 )
222 return True
223
224
225# Values the selftest fixtures below encode, named so the assertions do not
226# repeat bare literals.
227_FIXTURE_BAD_OFS0 = 0x03001E04
228_FIXTURE_BAD_DATAFLASH = 0x27000000
229_FIXTURE_QUIET_ENUMERATORS = 3
230
231_MUST_FIRE = """
232typedef enum : uintptr_t {
233 k_ra8_bad_addr = 0x03001E04UL,
234} bad_t;
235"""
236
237_MUST_FIRE_DATAFLASH = """
238typedef enum : uintptr_t {
239 k_ra8_df_addr = 0x27000000UL,
240} df_t;
241"""
242
243_MUST_STAY_QUIET = """
244/* A commented-out 0x03001E04 is prose, not a dereference. */
245typedef enum : uintptr_t {
246 k_ra8_ofs0_addr = 0x02C9F040UL,
247 k_ra8_ofs3_addr = 0x12C9F4C4UL,
248 k_ra8_wdt_base_addr = 0x40202600UL,
249} good_t;
250
251/* Not a uintptr_t enum: a mask that happens to look like a reserved address. */
252typedef enum : uint32_t {
253 k_ra8_mask_thing = 0x03001E04UL,
254} mask_t;
255"""
256
257
258def selftest() -> int:
259 """Assert RA001 fires on a reserved address and stays quiet on a real one.
260
261 Both directions, driving `check_text` -- the same entry point `scan` uses.
262 A checker that only proves it can fire is indistinguishable from one whose
263 scope has collapsed to nothing.
264 """
265 rc = 0
266 fake = pathlib.Path("selftest.h")
267
268 hits, seen = check_text(fake, _MUST_FIRE)
269 if len(hits) != 1 or hits[0].value != _FIXTURE_BAD_OFS0:
270 print("SELFTEST FAIL: RA001 did not fire on the #545 reserved address", file=sys.stderr)
271 rc = 1
272 if seen != 1:
273 print(f"SELFTEST FAIL: expected 1 enumerator, counted {seen}", file=sys.stderr)
274 rc = 1
275
276 hits, _ = check_text(fake, _MUST_FIRE_DATAFLASH)
277 if len(hits) != 1 or hits[0].value != _FIXTURE_BAD_DATAFLASH:
278 print("SELFTEST FAIL: RA001 did not fire on phantom data-flash", file=sys.stderr)
279 rc = 1
280
281 hits, seen = check_text(fake, _MUST_STAY_QUIET)
282 if hits:
283 print(
284 f"SELFTEST FAIL: RA001 over-fired on legitimate addresses: "
285 f"{[h.render() for h in hits]}",
286 file=sys.stderr,
287 )
288 rc = 1
289 if seen != _FIXTURE_QUIET_ENUMERATORS:
290 print(
291 f"SELFTEST FAIL: expected {_FIXTURE_QUIET_ENUMERATORS} uintptr_t "
292 f"enumerators in the quiet fixture, counted {seen} -- the uint32_t "
293 f"enum must not be scanned",
294 file=sys.stderr,
295 )
296 rc = 1
297
298 # The floor must itself be able to fail, or it is decoration. Its diagnostic
299 # is swallowed here so a PASSING selftest never prints a line starting
300 # "ERROR:" -- a gate whose success output reads like a failure costs the
301 # next reader real time.
302 sink = io.StringIO()
303 with contextlib.redirect_stderr(sink):
304 floor_bites = floor_breached(0)
305 if not floor_bites:
306 print("SELFTEST FAIL: vacuity floor accepted an empty scan", file=sys.stderr)
307 rc = 1
308
309 if rc == 0:
310 print("check_reserved_addresses selftest: OK (fires, stays quiet, floor bites)")
311 return rc
312
313
314def main() -> int:
315 """Scan first-party C for address enumerators inside Reserved windows."""
316 ap = argparse.ArgumentParser(description=__doc__)
317 ap.add_argument("--selftest", action="store_true", help="assert both directions")
318 ap.add_argument("--list-files", action="store_true", help="print the scanned file list")
319 ap.add_argument("paths", nargs="*", help="files to check (default: all tracked)")
320 args = ap.parse_args()
321
322 if args.selftest:
323 return selftest()
324
325 root = repo_root()
326 paths = [pathlib.Path(p) for p in args.paths] if args.paths else tracked_sources(root)
327
328 if args.list_files:
329 for p in paths:
330 print(p)
331 return 0
332
333 findings, seen = scan(paths)
334 for f in findings:
335 print(f.render(), file=sys.stderr)
336
337 # Only floor a whole-tree run; a positional subset legitimately sees few.
338 if not args.paths and floor_breached(seen):
339 return 1
340
341 if findings:
342 print(
343 f"\n{len(findings)} address constant(s) point into a Reserved window. "
344 f"An address the manual does not define is not a citation problem -- "
345 f"reading it is undefined behaviour on silicon.",
346 file=sys.stderr,
347 )
348 return 1
349 return 0
350
351
352if __name__ == "__main__":
353 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298