ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hum_regmap.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The register symbol table the Hardware User's Manual actually publishes.
4
5``cite_check.py`` answers "is this citation well-formed, and does it name a
6real chapter and a page inside that chapter?". It cannot answer "does the
7cited text describe the register being touched?", and it cannot answer "does
8this register exist at all?". Three landed defects lived in that gap, each
9carrying a citation naming a REAL chapter and a REAL page:
10
11* the whole ``ra8_rsip`` crypto family addressed registers HUM Ch 52 does not
12 publish -- that chapter is six pages long and describes no registers at all;
13* ``ra8_ptp_regs.h`` declared a thirteen-register window at ``0x403E_0100``,
14 a reserved hole in the GPTP aperture, and the demo printed ``clock PASS``
15 because a reserved aperture echoed back its own writes (#498);
16* ``ra8_etha_regs.h`` declared ``EASCR`` at ``0x0580``, which appears nowhere
17 in HUM Ch 32, and ~26 ETHA citations pointed at real pages describing other
18 registers (#539).
19
20Nothing mechanically connected a register SYMBOL in our headers to the symbol
21table the manual publishes. This module is that connection: it parses the
22per-register description subsections out of the committed manual PDF, which
23have a rigidly regular shape --
24
25 32.3.2.6 EATMFSCq : Transmission Maximum Frame Size Configuration ...
26 Base address: ETHAm = 0x403C_A000 + 0x2000 x m (m = 0, 1)
27 Offset address: 0x0040 + 0x4 x q
28
29-- yielding ``(chapter, section, symbol, offset, page, name)`` for every
30register in the manual. That is a strictly stronger source than the chapter
31register-list tables (Table X.Y), because the subsection heading carries the
32page number the register is actually described on, which is exactly what the
33"plausible but false citation" defects got wrong.
34
35Two properties this module is built around, both distilled by the #190 audit
36and both easy to get wrong here:
37
38* **No check may compare a constant to itself.** A committed snapshot of the
39 extracted names, diffed against a committed header, proves nothing -- both
40 sides are ours to edit. So the authority is always the PDF; the committed
41 CSV is a reviewable convenience whose freshness is separately proven by
42 regenerating.
43* **Every scan needs a vacuity floor.** A PDF text extraction that silently
44 yielded zero rows would report every register in the tree clean, forever.
45 That is the single most likely failure mode here -- a poppler upgrade, a
46 re-typeset manual revision, a changed heading style -- so
47 :func:`assert_not_vacuous` is a hard, absolute floor that runs before any
48 caller is allowed to use the result.
49"""
50
51from __future__ import annotations
52
53import csv
54import io
55import re
56import shutil
57import subprocess
58from dataclasses import dataclass, replace
59from pathlib import Path
60
61REPO_ROOT = Path(__file__).resolve().parents[2]
62
63# The committed Renesas RA8D2 Hardware User's Manual (R01UH1065EJ, Rev.1.30).
64HUM_PDF = REPO_ROOT / "docs" / "reference" / "ra8d2-hardware-user-manual.pdf"
65
66# The generated, committed symbol table.
67HUM_CSV = REPO_ROOT / "docs" / "reference" / "HUM_REGISTERS.csv"
68
69CSV_FIELDS = (
70 "chapter",
71 "section",
72 "symbol",
73 "offset",
74 "offset_expr",
75 "page",
76 "page_end",
77 "name",
78)
79
80# A register description subsection heading:
81# "32.3.2.6 EATMFSCq : Transmission Maximum Frame Size ... Register q"
82# The chapter and section number are separated so a citation naming chapter X
83# can be resolved against exactly chapter X's symbols.
84#
85# Three irregularities in this manual that a tighter pattern silently drops,
86# each of which cost real registers before it was handled:
87# * one subsection can define several forms of one register under
88# SLASH-separated symbols -- "38.2.3 TDR/TDRLL/TDRLH : Transmit Data
89# Register", "48.2.4 CRCDOR/CRCDOR_HA/CRCDOR_BY : CRC Data Output
90# Register". Each alternate is a symbol our headers may legitimately
91# declare, so each becomes its own row.
92# * or under COMMA-separated symbols -- "60.2.13 CDAYR, CDAYR_x : Capture
93# Data Address Y Register (x = B, M)", "7.2.4 OFS1, OFS1_SEC : ...". 43
94# headings use this form, and rejecting it made the whole CEU capture-
95# address family read as invented.
96# * the space after the colon is not always typeset -- "19.2.3
97# ELSRn:Event Link Setting Register n". Requiring it lost the whole ELC
98# chapter's ELSRn.
99HEADING_RE = re.compile(
100 r"^\s*(?P<chapter>\d{1,2})\.(?P<section>\d{1,3}(?:\.\d{1,3})*)"
101 r"\s{2,}(?P<symbol>[A-Za-z][A-Za-z0-9_]{1,23}"
102 r"(?:\s*[/,]\s*[A-Za-z][A-Za-z0-9_]{1,23})*)"
103 r"\s*:\s*(?P<name>[A-Za-z(]\S*(?:\s+\S+)*?)\s*$"
104)
105
106# Splits a heading's symbol group into its individual alternates.
107ALTERNATE_SEP_RE = re.compile(r"\s*[/,]\s*")
108
109# The leading hex literal of an offset expression. Taking everything up to the
110# first "+" was not enough: the manual qualifies some offsets with a
111# parenthetical -- "0x003C (CDAYR)", "0x014 (CFIFO/CFIFOL/CFIFOLL)" -- and that
112# text has to be dropped before the value can be compared as a number.
113BASE_OFFSET_RE = re.compile(r"^(0[xX][0-9A-Fa-f_]+)")
114
115# "Offset address: 0x0040 + 0x4 x q". Only the offset form is taken: registers
116# documented by absolute address instead carry no offset, and the offset check
117# skips them rather than inventing one.
118OFFSET_RE = re.compile(r"^\s*Offset address\s*:\s*(?P<expr>\S.*?)\s*$")
119
120# Any numbered section heading, register-bearing or not ("32.4 ETHA Operation
121# Modes"). These bound how far a register's description runs: the text for one
122# register ends where the next heading begins. Without that, a citation naming
123# the second page of a two-page register description would read as wrong, which
124# is most of what a naive page check would shout about.
125#
126# The two-space minimum is load-bearing: ``-layout`` preserves the manual's
127# wide heading gutter, so a real heading always has one, while a mid-paragraph
128# cross-reference ("see 38.26 for details.") has a single space. Accepting the
129# latter would plant a spurious boundary inside a register's description and
130# shrink its page range, manufacturing exactly the false "wrong page" reports
131# this range model exists to prevent.
132SECTION_RE = re.compile(r"^\s*(?P<chapter>\d{1,2})\.(?P<section>\d{1,3}(?:\.\d{1,3})*)\s{2,}\S")
133
134# A register abbreviation is uppercase letters and digits, optionally carrying
135# lowercase array-index letters (EATMFSCq, BCNTnAER, PDCFCH00RCHn). Requiring
136# uppercase to dominate rejects prose headings that happen to share the
137# "<number> <Word> : <text>" shape -- an electrical-characteristics table
138# yielded "1.012 Conditions : AVCC: 2.7 to 3.63 V" without this.
139MIN_UPPERCASE_IN_SYMBOL = 2
140
141# Highest code point the repository's 7-bit ASCII rule permits.
142MAX_ASCII_CODEPOINT = 127
143
144
145def _looks_like_register_symbol(symbol: str) -> bool:
146 """True when `symbol` has the shape of a HUM register abbreviation."""
147 upper = sum(1 for ch in symbol if ch.isupper())
148 lower = sum(1 for ch in symbol if ch.islower())
149 return upper >= MIN_UPPERCASE_IN_SYMBOL and upper > lower
150
151
152# The manual's table of contents repeats every subsection heading with a dot
153# leader and a page number ("... EATMFSCq : Transmission ....... 1635"). Those
154# lines match HEADING_RE exactly, so they are rejected explicitly -- left in,
155# they would triple the row count and attribute every register to the TOC page.
156TOC_LEADER_RE = re.compile(r"\.{4,}\s*\d{1,5}\s*$")
157
158# The printed page footer, e.g. "R01UH1065EJ0130 Rev.1.30 Page 1630 of 4291".
159# In this revision the printed number equals the PDF page index, but the footer
160# is read rather than assumed so a re-paginated revision cannot shift silently.
161FOOTER_RE = re.compile(r"Page\s+(?P<page>\d{1,5})\s+of\s+\d{4,5}")
162
163# How far below a heading the offset line may sit before the heading is taken
164# to have none. The intervening lines are the base-address block.
165OFFSET_LOOKAHEAD_LINES = 14
166
167# Transliterations for the only two non-ASCII characters the manual's register
168# headings and offset expressions contain: U+00D7 MULTIPLICATION SIGN (the
169# array-stride operator, "0x0040 + 0x4 x q") and U+00B5 MICRO SIGN. Spelled as
170# escapes because this file is itself held to the repository's 7-bit ASCII
171# rule. Anything outside this set is a hard error rather than a silent
172# mangling -- a dropped character would corrupt a symbol or an offset.
173ASCII_FOLD = {"\u00d7": "*", "\u00b5": "u"}
174
175# --- Vacuity floors --------------------------------------------------------
176# Absolute, hand-verified lower bounds on what a working extraction produces
177# from this manual. They are deliberately well below the true counts (2000+
178# rows across 62 chapters at the time of writing) so a normal revision bump
179# does not trip them, and deliberately far above zero so a broken extraction
180# cannot pass. A floor of "whatever we got last time" would be the
181# compare-a-constant-to-itself mistake these guard against.
182MIN_TOTAL_ROWS = 1500
183MIN_CHAPTERS_WITH_REGISTERS = 50
184MIN_ROWS_WITH_OFFSET = 1200
185
186
187class HumExtractionError(RuntimeError):
188 """The manual could not be turned into a usable register symbol table."""
189
190
191@dataclass(frozen=True)
192class HumRegister:
193 """One register as the Hardware User's Manual describes it.
194
195 Attributes:
196 chapter: HUM chapter number the register is described in.
197 section: Full subsection number, e.g. ``3.2.6`` within that chapter.
198 symbol: Register abbreviation exactly as printed, index letter and
199 all (``EATMFSCq``, ``PTPTIVCt``, ``BCNTnAER``).
200 offset: Base byte offset as printed, e.g. ``0x0040``; empty when the
201 register is documented by absolute address rather than offset.
202 offset_expr: The full offset expression including any array stride,
203 e.g. ``0x0040 + 0x4 * q``; empty alongside an empty ``offset``.
204 page: Printed page the register description begins on.
205 page_end: Last page the description can still run onto -- the page of
206 the next section heading. Bit-field tables routinely spill onto
207 the following page, so a citation anywhere in ``page..page_end``
208 is pointing at this register's description.
209 name: The register's full name as printed.
210 """
211
212 chapter: int
213 section: str
214 symbol: str
215 offset: str
216 offset_expr: str
217 page: int
218 page_end: int
219 name: str
220
221 def covers(self, first: int, last: int) -> bool:
222 """True when the cited page span overlaps this description's pages."""
223 return first <= self.page_end and last >= self.page
224
225 @property
226 def canonical(self) -> str:
227 """The symbol with index letters removed, for matching against C code."""
228 return canonical_symbol(self.symbol)
229
230
231def canonical_symbol(symbol: str) -> str:
232 """Reduce a register abbreviation to the form a C header would spell.
233
234 The manual writes array registers with a lowercase index letter wherever
235 it reads best -- ``EATMFSCq``, ``IPCSEMn``, ``BCNTnAER``, ``PDCFCH00RCHn``
236 -- while a C header names the array once (``EATMFSC[8]``). Since every
237 genuine abbreviation is uppercase and digits, dropping every lowercase
238 letter normalises both spellings onto one key. Across the whole manual
239 this collides for exactly five symbols, and in each case the colliding
240 pair is the same register written with two different index letters
241 (``PVDmCR0`` / ``PVDnCR0``), so no distinct register is masked.
242
243 The strip happens BEFORE the upper-case fold, not after: folding first
244 would leave no lowercase letters to remove and quietly turn this into the
245 identity function, so ``EATMFSCq`` and ``EATMFSC`` would stop matching.
246 Callers holding a name in some other case (an offset enum suffix, say)
247 must upper-case it themselves before calling.
248 """
249 return re.sub(r"[a-z]", "", symbol).upper()
250
251
252def _fold_ascii(text: str, context: str) -> str:
253 """Return `text` as 7-bit ASCII, or raise if it holds an unknown character."""
254 out = "".join(ASCII_FOLD.get(ch, ch) for ch in text)
255 bad = sorted({ch for ch in out if ord(ch) > MAX_ASCII_CODEPOINT})
256 if bad:
257 codes = ", ".join(f"U+{ord(ch):04X}" for ch in bad)
258 msg = (
259 f"non-ASCII character(s) {codes} in {context!r}; add a transliteration "
260 f"to hum_regmap.ASCII_FOLD rather than dropping the character"
261 )
262 raise HumExtractionError(msg)
263 return out
264
265
266def _pdftotext(pdf: Path) -> str:
267 """Run ``pdftotext -layout`` over the manual and return its text.
268
269 Raises:
270 HumExtractionError: pdftotext is missing, the PDF is absent, or the
271 extraction failed. A gate that skipped here would report every
272 register clean.
273 """
274 if shutil.which("pdftotext") is None:
275 msg = "pdftotext not found on PATH (install poppler-utils)"
276 raise HumExtractionError(msg)
277 if not pdf.is_file():
278 msg = f"manual PDF not found: {pdf}"
279 raise HumExtractionError(msg)
280 result = subprocess.run( # noqa: S603 -- fixed argv, no shell
281 ["pdftotext", "-layout", str(pdf), "-"], # noqa: S607 -- poppler from PATH is intended
282 capture_output=True,
283 check=False,
284 )
285 if result.returncode != 0:
286 detail = result.stderr.decode("utf-8", "replace").strip()
287 msg = f"pdftotext failed on {pdf.name}: {detail}"
288 raise HumExtractionError(msg)
289 return result.stdout.decode("utf-8", "replace")
290
291
292def _find_offset(lines: list[str], start: int) -> str:
293 """Return the offset expression printed below the heading at `start`."""
294 limit = min(start + OFFSET_LOOKAHEAD_LINES, len(lines))
295 for index in range(start + 1, limit):
296 match = OFFSET_RE.match(lines[index])
297 if match is not None:
298 return match.group("expr")
299 if HEADING_RE.match(lines[index]) is not None:
300 break
301 return ""
302
303
304def _join_wrapped(lines: list[str], index: int) -> str:
305 """Return the heading's register name, re-joining a typeset line wrap.
306
307 A long heading wraps mid-name -- "... Configuration Register q (q = 0"
308 then "to 7)" on the next line. An unbalanced parenthesis is the reliable
309 tell, and pulling in the continuation keeps the committed name readable
310 instead of truncated.
311 """
312 match = HEADING_RE.match(lines[index])
313 name = "" if match is None else match.group("name")
314 if name.count("(") <= name.count(")") or index + 1 >= len(lines):
315 return name
316 continuation = lines[index + 1].strip()
317 if not continuation or HEADING_RE.match(lines[index + 1]) is not None:
318 return name
319 return f"{name} {continuation}"
320
321
322def _heading_rows(lines: list[str], index: int, page_number: int) -> list[HumRegister]:
323 """Build the register rows a single heading line declares, if any.
324
325 A heading may name several access widths of one register at once
326 (``TDR/TDRLL/TDRLH``); each alternate becomes its own row sharing the
327 section, offset and page. ``page_end`` is filled in later, once the next
328 section boundary is known.
329 """
330 match = HEADING_RE.match(lines[index])
331 if match is None or TOC_LEADER_RE.search(match.group("name")):
332 return []
333 if int(match.group("chapter")) < 1:
334 return []
335 parts = ALTERNATE_SEP_RE.split(match.group("symbol"))
336 alternates = [s for s in parts if _looks_like_register_symbol(s)]
337 if not alternates:
338 return []
339 expr = _fold_ascii(_find_offset(lines, index), f"offset of {alternates[0]}")
340 base_match = BASE_OFFSET_RE.match(expr)
341 base = base_match.group(1) if base_match else ""
342 name = _fold_ascii(_join_wrapped(lines, index), f"name of {alternates[0]}")
343 return [
344 HumRegister(
345 chapter=int(match.group("chapter")),
346 section=match.group("section"),
347 symbol=symbol,
348 offset=base,
349 offset_expr=expr,
350 page=page_number,
351 page_end=page_number,
352 name=name,
353 )
354 for symbol in alternates
355 ]
356
357
358def _scan_text(text: str) -> list[HumRegister]:
359 """Walk the extracted manual and resolve each register's page range.
360
361 Every numbered section heading -- register-bearing or not -- is recorded in
362 document order. A register's description runs from its own heading page up
363 to the page of the next heading, which is how a bit-field table spilling
364 onto the following page stays inside the register it belongs to.
365 """
366 boundaries: list[tuple[int, list[HumRegister]]] = []
367 for page_text in text.split("\f"):
368 footer = FOOTER_RE.search(page_text)
369 if footer is None:
370 continue
371 page_number = int(footer.group("page"))
372 lines = page_text.split("\n")
373 for index, line in enumerate(lines):
374 if SECTION_RE.match(line) is None:
375 continue
376 boundaries.append((page_number, _heading_rows(lines, index, page_number)))
377
378 rows: list[HumRegister] = []
379 for position, (page_number, here) in enumerate(boundaries):
380 following = boundaries[position + 1][0] if position + 1 < len(boundaries) else page_number
381 end = max(page_number, following)
382 rows.extend(replace(row, page_end=end) for row in here)
383 rows.sort(key=lambda row: (row.chapter, row.page, row.section, row.symbol))
384 return rows
385
386
387def extract_from_pdf(pdf: Path = HUM_PDF) -> list[HumRegister]:
388 """Parse every register description subsection out of the manual.
389
390 Raises:
391 HumExtractionError: the extraction failed or produced a vacuous result.
392 """
393 rows = _scan_text(_pdftotext(pdf))
394 assert_not_vacuous(rows)
395 return rows
396
397
398def assert_not_vacuous(rows: list[HumRegister]) -> None:
399 """Fail loudly when an extraction is too thin to have worked.
400
401 An empty or near-empty symbol table makes every downstream check pass
402 unconditionally, which reads as a clean tree. This is the guard that turns
403 that failure mode into a red gate.
404
405 Raises:
406 HumExtractionError: any floor in this module was not met.
407 """
408 chapters = {row.chapter for row in rows}
409 with_offset = sum(1 for row in rows if row.offset)
410 problems = []
411 if len(rows) < MIN_TOTAL_ROWS:
412 problems.append(f"{len(rows)} register rows < floor {MIN_TOTAL_ROWS}")
413 if len(chapters) < MIN_CHAPTERS_WITH_REGISTERS:
414 problems.append(
415 f"{len(chapters)} chapters with registers < floor {MIN_CHAPTERS_WITH_REGISTERS}"
416 )
417 if with_offset < MIN_ROWS_WITH_OFFSET:
418 problems.append(f"{with_offset} rows carry an offset < floor {MIN_ROWS_WITH_OFFSET}")
419 if problems:
420 msg = (
421 "HUM register extraction is vacuous -- every downstream check would "
422 "pass unconditionally: " + "; ".join(problems)
423 )
424 raise HumExtractionError(msg)
425
426
427def to_csv(rows: list[HumRegister]) -> str:
428 """Serialise the symbol table to the committed CSV form."""
429 buffer = io.StringIO(newline="")
430 writer = csv.writer(buffer, lineterminator="\n")
431 writer.writerow(CSV_FIELDS)
432 for row in rows:
433 writer.writerow(
434 [
435 row.chapter,
436 row.section,
437 row.symbol,
438 row.offset,
439 row.offset_expr,
440 row.page,
441 row.page_end,
442 row.name,
443 ]
444 )
445 return buffer.getvalue()
446
447
448def from_csv(text: str) -> list[HumRegister]:
449 """Parse the committed CSV form back into rows.
450
451 Raises:
452 HumExtractionError: the CSV header does not match :data:`CSV_FIELDS`,
453 or the result is vacuous.
454 """
455 reader = csv.reader(io.StringIO(text, newline=""))
456 try:
457 header = next(reader)
458 except StopIteration as exc:
459 msg = "register map CSV is empty"
460 raise HumExtractionError(msg) from exc
461 if tuple(header) != CSV_FIELDS:
462 msg = f"register map CSV header {header} != {list(CSV_FIELDS)}"
463 raise HumExtractionError(msg)
464 rows = [
465 HumRegister(
466 chapter=int(record[0]),
467 section=record[1],
468 symbol=record[2],
469 offset=record[3],
470 offset_expr=record[4],
471 page=int(record[5]),
472 page_end=int(record[6]),
473 name=record[7],
474 )
475 for record in reader
476 if record
477 ]
478 assert_not_vacuous(rows)
479 return rows
480
481
482class RegisterMap:
483 """Chapter-indexed lookup over the manual's register symbol table."""
484
485 def __init__(self, rows: list[HumRegister]) -> None:
486 """Index `rows` by chapter and canonical symbol."""
487 assert_not_vacuous(rows)
488 self.rows = rows
489 self._by_chapter: dict[int, dict[str, list[HumRegister]]] = {}
490 for row in rows:
491 self._by_chapter.setdefault(row.chapter, {}).setdefault(row.canonical, []).append(row)
492
493 @property
494 def chapters(self) -> set[int]:
495 """Every chapter that publishes at least one register."""
496 return set(self._by_chapter)
497
498 def lookup(self, chapter: int, symbol: str) -> list[HumRegister]:
499 """Every description of `symbol` inside `chapter`; empty when absent."""
500 return self._by_chapter.get(chapter, {}).get(canonical_symbol(symbol), [])
501
502 def find_anywhere(self, symbol: str) -> list[HumRegister]:
503 """Every description of `symbol` in any chapter.
504
505 Used only to sharpen a diagnostic: a symbol that is real but cited
506 against the wrong chapter is a different (and much smaller) mistake
507 than one the manual never mentions.
508 """
509 key = canonical_symbol(symbol)
510 return [
511 row for chapter in self._by_chapter for row in self._by_chapter[chapter].get(key, [])
512 ]
513
514
515def load(csv_path: Path = HUM_CSV) -> RegisterMap:
516 """Load the committed register map CSV.
517
518 Raises:
519 HumExtractionError: the CSV is missing, malformed or vacuous.
520 """
521 if not csv_path.is_file():
522 msg = f"register map not found: {csv_path} (run scripts/gen/gen_hum_register_map.py)"
523 raise HumExtractionError(msg)
524 return RegisterMap(from_csv(csv_path.read_text(encoding="utf-8")))
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157