The window's per-poll cycle counts were hand-counted for a uint32_t countdown, but every default window fits uint24_t, whose decrement chain is one sbci shorter — so deployed loaders ran 9/10ths of their stated seconds (a 328P's 8 s was 7.2 s on the wire). No golden-asm pin can hold this: the loops compile in consumer context. pbwindow.py measures the behavior instead: it installs a real application beside the loader through the host tool's own plan_flash (surgery included), starts the simulator with the line idle, and reads the cycle of the first transmit — the application's banner, so that cycle is the window. Held at plus or minus 2 percent per chip (pureboot.window), red at -10.0 percent against the old constants, green with poll_cycles now counted for the narrow countdown (hardware 9, software 7; window_polls() solves narrow-first and adds the wide loop's cycle where the count forces uint32_t — a count narrow only at the wide cost stays wide, so the choice cannot oscillate). The autobaud window is its poll budget at the measured ten cycles a poll, gated the same way (pureboot.window.autobaud), and the README carries that arithmetic now. No version bump: timing-window precision is not meaningful behavior, v7 stays. The gate flushed out two runner gaps. The software bridge accepted any falling edge as a start bit, so the device's own TX-init glitch decoded as a stray byte; it re-samples mid-bit now and abandons a false start, as silicon does. And after avr_reset, the idle-line re-raise was silently dropped: ioport pin irqs are IRQ_FLAG_FILTERED and the irq's cached value survives the reset the port latch does not, so the device read the line stuck low, calibrate() measured reset-to-first-edge as one wrapping pulse, and the first knock after a reset could boot the application instead of locking — the intermittent autobaud failure. bridge_reset forces a real transition (0 then 1, no cycles between). The README's Autobaud column now carries each chip's worst configuration — autobaud with OSCCAL baked, on a USART's own pins where the chip has one (tinies: autobaud + OSCCAL) — the numbers the existing pureboot_autobaud_osccal[_on_usart0] matrix points already gate; sizes.py checks the column against exactly those targets. Tool sizes and window prose updated with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
7.3 KiB
Python
Executable File
165 lines
7.3 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)
|
|
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 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 †" — the first name is the family's base.
|
|
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
|
built = {name: text for name, text, _ in measured.get(chip, [])}
|
|
worst = ("pureboot_autobaud_osccal_on_usart0"
|
|
if "pureboot_autobaud_osccal_on_usart0" in built else "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
|
|
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())
|