The reading pass over this repo found the tiers disagreeing with themselves,
and every fix here was measured.
**The turn-around guard is real code.** `tsb_asm` and `tsb_tricks` wrote
`for (std::uint8_t guard = 46; guard; --guard) ;` between taking the one-wire
line and the first UDR0 store, under a comment naming it a turn-around guard.
It has no side effect, so GCC deleted it - `sts UCSR0B` went straight to
`sts UDR0` - while the hand-written oracle spends six bytes on that wait and
libavr's own half-duplex spends them through `delay::cycles`. Two of four
tiers described a feature they did not have, which made the size gradient a
comparison between different loaders. `avr::delay::cycles<one bit time>()`
bottoms out in asm and cannot be deleted.
**The entry belongs to the library, and hand-rolling it was expensive.** Three
tiers wrote their own naked `.vectors` stub with `asm volatile("clr
__zero_reg__")` - which design.md fences to libavr and never a port, and which
`tsb_tricks` denied having in its own title line. `avr::startup::entry` also
keeps the body `noinline` for a stated reason: avr-ld must not shrink a
`.vectors` section, so a loader inlined into one forfeits call relaxation
everywhere. `tsb_pure` came out **836 -> 734** bytes for that alone.
`stack::hardware` - the reset value this part guarantees, with the write kept
where a part does not - saved another four, which is what let `tsb_asm` afford
the guard it had been four bytes short of. It fills its 512-byte section
exactly now, with the whole feature set.
**`tsb_pure` had no receive timeout.** Its `rx()` was `read_blocking()`, so a
silent host wedged the password gate and the command loop forever - the one
fix the oracle's own header lists by name, and one the other three tiers
implement. It is bounded now, and 0-on-silence falls through every compare as
theirs does.
Three gates could pass without proving anything. `sizes.py check-readme`
reported a match when every row's lookup missed; `check_size.cmake` used
`CMAKE_MATCH_1` without checking the match succeeded, which is the guard its
sibling `check_unit.cmake` has and it is the size gate; `check_pi.py` raised
IndexError instead of reporting a position-independence break that changed the
image's length. And `check.sh` spelled the 37-chip list a second time beside
make_presets.py, where a chip added to one and missed in the other is a
silently unbuilt chip - it reads the presets now, and produces the same 37 and
12.
tsbtest.py gains the scenario nothing covered: a wrong password byte must
neither activate the loader nor reach the emergency erase behind it. Red-green
on a tier with the refusal removed.
Smaller, all measured or checked: the signature is `hw::db.signature` in every
tier as the page size and EEPROM end beside it already were; `act_min` derives
from the clock; pureboot.py's `rjmp` helpers refuse a part past rjmp's
4096-word reach rather than silently folding an offset (unreachable today, the
ATtiny85 sits exactly on it); the host tool calls space 2 `data` as the wire
and the loader do; `.clangd` strips the fifth GCC-only flag the build passes;
pbrig's bitclock guard reads its own ladder; pbreloc's unexplained retry is
gone, the write being reliable on five runs without it; and the four tier
sizes live in oracle/README.md's table instead of four file headers and a
CMake comment.
`--poke` before `--peek` turned out to be right - pbtest.py round-trips a poke
through the peek behind it - so the parser order and README say so now.
Every chip green, the README size table matching every image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
9.1 KiB
Python
Executable File
195 lines
9.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""What the loader images actually measure, and whether the README still agrees.
|
|
|
|
The size matrix asserts every image fits its slot; it says nothing about the
|
|
numbers the README prints, and those drift. Every row of that table was eight
|
|
bytes stale once `startup::caller_page()` landed - common code, so every build
|
|
moved at once and no test noticed, because none of them was over budget.
|
|
|
|
Two questions, both answered from built trees:
|
|
|
|
sizes.py max the largest image per chip, and anything over budget
|
|
sizes.py check-readme the README's per-chip table against what is built
|
|
|
|
Nothing here knows a chip's geometry. The (image, budget) pairs come from each
|
|
build's own `CTestTestfile.cmake` - the same values the gate checks - so the
|
|
slot rules stay where they belong, in `pureboot/CMakeLists.txt`, and a chip
|
|
added or a budget changed needs no edit here. Only trees a configure preset
|
|
still owns are read: a stale directory keeps its last build, and a loader built
|
|
before a slot changed will happily report a size that was true once
|
|
(`tools/prune-build-trees.sh` in libavr removes them).
|
|
|
|
Sizes come from `avr-size`, and a target is only as current as its last build -
|
|
run the gate first if you want the table checked against today's source.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
# add_test(<name>.size ... -DELF=<path> ... -DLIMIT=<n> ...) - the gate's own
|
|
# pairing of an image with the budget it must fit.
|
|
# ctest writes the name as a bracket argument ([=[name.size]=]) and quotes the
|
|
# rest, so the name starts after the bracket and the path ends at the quote.
|
|
SIZE_TEST = re.compile(r'add_test\(\s*\[=\[(?P<name>[^\]]+?)\.size\]=\][^\n]*?'
|
|
r'-DELF=(?P<elf>[^"\s]+)[^\n]*?-DLIMIT=(?P<limit>\d+)')
|
|
|
|
|
|
def avr_size() -> str:
|
|
for env in (ROOT / "../../toolchain").resolve().glob("avr-gcc-*/bin/avr-size"):
|
|
if env.is_file():
|
|
return str(env)
|
|
found = shutil.which("avr-size")
|
|
if not found:
|
|
sys.exit("no avr-size found (build the toolchain, or put it on PATH)")
|
|
return found
|
|
|
|
|
|
def preset_dirs() -> list[pathlib.Path]:
|
|
"""Build trees a configure preset still owns, newest-listed first."""
|
|
listing = subprocess.run(["cmake", "--list-presets"], cwd=ROOT, capture_output=True, text=True)
|
|
names = re.findall(r'^\s*"(.+)"$', listing.stdout, re.MULTILINE)
|
|
if not names:
|
|
sys.exit("cmake --list-presets returned nothing - run from a configured checkout")
|
|
return [d for d in (ROOT / "build" / n for n in names) if (d / "CTestTestfile.cmake").is_file()]
|
|
|
|
|
|
def measure(paths: list[str], tool: str) -> dict[str, int]:
|
|
""".text per ELF, in one avr-size call per batch."""
|
|
sizes: dict[str, int] = {}
|
|
for start in range(0, len(paths), 400):
|
|
batch = [p for p in paths[start:start + 400] if pathlib.Path(p).is_file()]
|
|
if not batch:
|
|
continue
|
|
out = subprocess.run([tool, *batch], capture_output=True, text=True).stdout
|
|
for line in out.splitlines()[1:]:
|
|
fields = line.split()
|
|
if len(fields) >= 6 and fields[0].isdigit():
|
|
sizes[fields[5]] = int(fields[0])
|
|
return sizes
|
|
|
|
|
|
def collect() -> dict[str, list[tuple[str, int, int]]]:
|
|
"""chip -> [(target, text, limit)], from every owned build tree."""
|
|
tool = avr_size()
|
|
found: dict[str, list[tuple[str, str, int]]] = {}
|
|
for tree in preset_dirs():
|
|
chip = tree.name.split("-")[0]
|
|
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
|
|
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
|
|
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
|
|
# A chip's generated and reflect trees must answer with the same bytes
|
|
# (the identity invariant), so the same target measuring two sizes means
|
|
# a stale tree - or an identity breach. Either is a finding; picking one
|
|
# silently is how a gate reports another build's numbers as today's.
|
|
for chip, rows in found.items():
|
|
seen: dict[str, tuple[int, str]] = {}
|
|
for name, elf, _ in rows:
|
|
if elf not in sizes:
|
|
continue
|
|
if name in seen and seen[name][0] != sizes[elf]:
|
|
sys.exit(f"{chip} {name}: {seen[name][0]} B in {seen[name][1]} but "
|
|
f"{sizes[elf]} B in {elf} - a stale tree (rebuild or remove it) "
|
|
f"or a cross-mode identity breach")
|
|
seen.setdefault(name, (sizes[elf], elf))
|
|
measured = {
|
|
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
|
|
key=lambda row: -row[1])
|
|
for chip, rows in sorted(found.items())
|
|
}
|
|
# A configured-but-unbuilt preset registers its tests with no images behind
|
|
# them; it is not a chip with nothing to say, it is a chip not built yet.
|
|
return {chip: rows for chip, rows in measured.items() if rows}
|
|
|
|
|
|
def cmd_max(args) -> int:
|
|
measured = collect()
|
|
if not measured:
|
|
sys.exit("nothing built - configure and build a preset first")
|
|
over = []
|
|
print(f"{'chip':<13} {'largest image':<34} {'.text':>6} {'budget':>7} headroom")
|
|
for chip, rows in measured.items():
|
|
name, text, limit = rows[0]
|
|
flag = "OVER" if text > limit else f"{limit - text:>5} B"
|
|
print(f"{chip:<13} {name:<34} {text:>6} {limit:>7} {flag}")
|
|
over += [(chip, n, t, l) for n, t, l in rows if t > l]
|
|
total = sum(len(rows) for rows in measured.values())
|
|
print(f"\n{total} images across {len(measured)} chips")
|
|
if over:
|
|
print("\nOVER BUDGET:")
|
|
for chip, name, text, limit in over:
|
|
print(f" {chip} {name}: {text} > {limit}")
|
|
return 1
|
|
tightest = min(((chip, n, t, l) for chip, rows in measured.items() for n, t, l in rows),
|
|
key=lambda row: row[3] - row[2])
|
|
chip, name, text, limit = tightest
|
|
print(f"tightest fit: {chip} {name} - {text} of {limit}, {limit - text} B spare")
|
|
return 0
|
|
|
|
|
|
def cmd_check_readme(args) -> int:
|
|
"""The README's per-chip table, against the stock build and the worst
|
|
autobaud configuration (OSCCAL baked, plus the USART-pin release where
|
|
the chip has a USART; the one-wire fold of the same build is its twin
|
|
and competes for the same cell) - the config the Autobaud column
|
|
documents."""
|
|
readme = (ROOT / "pureboot" / "README.md").read_text()
|
|
measured = collect()
|
|
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
|
|
readme, re.MULTILINE)
|
|
if not rows:
|
|
sys.exit("no size table found in pureboot/README.md")
|
|
bad = skipped = 0
|
|
for chips, stock_doc, auto_doc in rows:
|
|
# "ATmega48, 48A, 48P, 48PA" plus any footnote mark - the first name
|
|
# is the family's base, and the sub strips the rest.
|
|
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
|
built = {name: text for name, text, _ in measured.get(chip, [])}
|
|
# The on-USART pair defines the column where the chip has a USART;
|
|
# the default-pin pair is the whole space elsewhere. Whichever twin
|
|
# measures larger is the number the cell must state.
|
|
candidates = [name for name in ("pureboot_autobaud_osccal_on_usart0",
|
|
"pureboot_1w_autobaud_osccal_on_usart0") if name in built]
|
|
if not candidates:
|
|
candidates = [name for name in ("pureboot_autobaud_osccal",
|
|
"pureboot_1w_autobaud_osccal") if name in built]
|
|
worst = max(candidates, key=lambda name: built[name], default="pureboot_autobaud_osccal")
|
|
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
|
|
if target not in built:
|
|
skipped += 1
|
|
continue
|
|
if built[target] != int(documented):
|
|
print(f" {chip:<12} {target:<18} README says {documented} B, built is {built[target]} B")
|
|
bad += 1
|
|
if bad:
|
|
print(f"\n{bad} row(s) stale - update pureboot/README.md")
|
|
return 1
|
|
# A row whose target was not built is only skipped, so a chip-name change
|
|
# or a build tree that holds nothing would otherwise skip every row and
|
|
# report a match over an empty comparison.
|
|
if skipped == 2 * len(rows):
|
|
sys.exit(f"none of the {len(rows)} README rows matched a built image - "
|
|
f"refusing to report a match over nothing")
|
|
print(f"README size table matches every built image ({len(rows)} rows"
|
|
+ (f", {skipped} not built" if skipped else "") + ")")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
subs = parser.add_subparsers(dest="cmd", required=True)
|
|
subs.add_parser("max", help="largest image per chip, and anything over budget")
|
|
subs.add_parser("check-readme", help="the README's size table against what is built")
|
|
args = parser.parse_args()
|
|
return {"max": cmd_max, "check-readme": cmd_check_readme}[args.cmd](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|