pureboot moves to its own repo
pureboot is at git.blackmark.me/avr/pureboot now, with its own history: the files it kept in pureboot/ sit at the top level there, its CMakeLists is the merged whole of that unit and the build around it, and the v1..v8 tags moved with it - each one checks out and compiles to exactly the .text it compiled to here. The loader that repo builds is byte-identical to the one this commit removes, verified before the removal rather than after. Nothing is rewritten on this side. The history and the tags are untouched, so every commit before this one still has pureboot in it and still builds it; this is one commit that stops carrying it forward. What goes with it: the four pureboot files, the thirteen pb*.py protocol drivers and their four host-side unit tests, pbapp and the pureboot simavr runner, check_pi.py, pbhw.py, pbrig.py, sizes.py, and the Studio project. check_unit.cmake goes too - it had no caller left once the unit tests moved. What is left is the four TinySafeBoot tiers, and the build shrinks to fit them: 757 lines of CMakeLists to 137, and the preset matrix from 37 chips to one, because the tiers reimplement an ATmega328P-only protocol and every other chip in that list was there for pureboot. check.sh loses its size-table pass - the table it checked was pureboot's README - and the Studio solution loses the project that is now in the other repo. Gate green: nine tests, four tiers at their section sizes and each one's protocol suite against the simulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
#!/bin/bash
|
||||
# The port's gate: every chip's generated workflow - build, size matrix, and
|
||||
# the simulator-driven protocol suites. --full adds the reflect-spot builds
|
||||
# (libavr's rule: reflect compiles are bounded to its spot set, never the
|
||||
# full matrix) and swaps the compact size matrix for the exhaustive
|
||||
# clock x baud x backend cross product. libavr resolves from the `libavr/`
|
||||
# submodule; LIBAVR_ROOT overrides it for a working tree.
|
||||
# The port's gate: the generated workflow - build, size tests, and the
|
||||
# simulator-driven protocol suite. --full adds the reflect build, which
|
||||
# compiles the same TUs through libavr's other producer. libavr resolves from
|
||||
# the `libavr/` submodule; LIBAVR_ROOT overrides it for a working tree.
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
full=0
|
||||
[[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
|
||||
[[ "$1" == "--full" ]] && { full=1; shift; }
|
||||
|
||||
# The chip lists come from the presets rather than being spelled a second time
|
||||
# here: a chip added to make_presets.py and missed in a copy of its list would
|
||||
@@ -59,10 +57,4 @@ if ((${#red[@]})); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every tree is freshly built now - the one moment the README's size table
|
||||
# can be held to what the images measure (a per-preset ctest sees only its
|
||||
# own chip; the table needs all of them, and ungated it drifts: a
|
||||
# common-code shave moves every row at once with nothing over budget).
|
||||
python3 tools/sizes.py check-readme
|
||||
|
||||
echo "check: every chip green"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate CMakePresets.json - one uniform pipeline per chip.
|
||||
|
||||
Every chip gets generated-mode configure/build/test presets and a workflow
|
||||
running all three. Reflect-mode presets (configure + build, no tests - the
|
||||
port's TUs compile identically; the sims prove nothing new there) exist for
|
||||
libavr's reflect spot set only, mirroring its rule: the full reflect matrix
|
||||
is never built, one chip per hardware class and pack vintage is.
|
||||
The tiers reimplement the ATmega328P-only reference protocol, so that is the
|
||||
whole chip list. It stays a generated file rather than a hand-written one
|
||||
because the shape - configure, build, test, workflow, and a reflect pair
|
||||
without tests - is the shape a second chip would need too.
|
||||
|
||||
Run from the repo root: tools/make_presets.py - or with --check, which
|
||||
verifies the committed file matches this generator and edits nothing (the
|
||||
@@ -17,24 +16,12 @@ import os
|
||||
import sys
|
||||
|
||||
CHIPS = [
|
||||
"attiny13", "attiny13a", "attiny25", "attiny45", "attiny85",
|
||||
"atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a",
|
||||
"atmega48", "atmega48a", "atmega48p", "atmega48pa",
|
||||
"atmega88", "atmega88a", "atmega88p", "atmega88pa",
|
||||
"atmega168", "atmega168a", "atmega168p", "atmega168pa",
|
||||
"atmega328", "atmega328p",
|
||||
"atmega164a", "atmega164p", "atmega164pa",
|
||||
"atmega324a", "atmega324p", "atmega324pa",
|
||||
"atmega644", "atmega644a", "atmega644p", "atmega644pa",
|
||||
"atmega1284", "atmega1284p",
|
||||
"atmega328p",
|
||||
]
|
||||
|
||||
# libavr's REFLECT_SPOT (tools/check.sh): one chip per hardware class and
|
||||
# pack vintage.
|
||||
# The reflect pair: the same chip, built in libavr's other mode.
|
||||
REFLECT_SPOT = [
|
||||
"attiny13a", "attiny85", "atmega8", "atmega16a", "atmega32a",
|
||||
"atmega48pa", "atmega88", "atmega168pa", "atmega328p", "atmega164a",
|
||||
"atmega644p", "atmega1284",
|
||||
"atmega328p",
|
||||
]
|
||||
|
||||
|
||||
|
||||
377
tools/pbhw.py
377
tools/pbhw.py
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardware acceptance suite for a pureboot deployment.
|
||||
|
||||
`tools/check.sh` proves the protocol under simavr on every chip. This proves one
|
||||
*board*: that the loader actually installed on it answers, that the memories
|
||||
round-trip over the real link, that the application it flashes runs afterwards,
|
||||
and that the refusals which keep a 512-byte slot alive still fire. Run it once
|
||||
when a board is brought up, and again whenever the deployment moves - a new
|
||||
clock, a new backend, new pins.
|
||||
|
||||
Every check derives its bounds from the info block the loader itself reports, so
|
||||
nothing here is per-chip: the same run covers a 1 KiB tiny whose application
|
||||
region is 510 usable bytes and a 128 KiB mega whose flash needs a bank in the
|
||||
selector.
|
||||
|
||||
**This overwrites the board's application flash and EEPROM.** Capture them first
|
||||
with `pbrig.py backup`, which verifies what it captured.
|
||||
|
||||
tools/pbhw.py --programmer atmelice_isp --part t13 --port COM6 \
|
||||
--autobaud --loader build/ab.bin --app build/pbapp.hex \
|
||||
--marker APP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
import pbrig # noqa: E402
|
||||
|
||||
|
||||
class Suite:
|
||||
def __init__(self, rig: pbrig.Rig, work: pathlib.Path):
|
||||
self.rig = rig
|
||||
self.work = work
|
||||
self.results: list[tuple[str, bool, str]] = []
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> bool:
|
||||
self.results.append((name, ok, detail))
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" {detail}" if detail else ""))
|
||||
return ok
|
||||
|
||||
@staticmethod
|
||||
def _brief(text: str, limit: int = 78) -> str:
|
||||
return " | ".join(l.strip() for l in text.splitlines() if l.strip())[:limit]
|
||||
|
||||
# ----------------------------------------------------------------- checks
|
||||
|
||||
def identity(self) -> object | None:
|
||||
"""The info block, which every later check takes its bounds from."""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
self.rig.reset()
|
||||
port = self.rig.open_port() # wrapped for the echo where the line is shared
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
info = loader.info
|
||||
self.check("identity read", True, info.describe())
|
||||
return info
|
||||
except Exception as error: # noqa: BLE001 - a dead link is a result
|
||||
self.check("identity read", False, str(error)[:70])
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def scan(self) -> None:
|
||||
"""The --scan walk against real termios and a real oscillator: every
|
||||
probe rate must open a port (the off-nominal rates exist only through
|
||||
termios2), and one probe must answer - the nominal on a healthy board,
|
||||
a neighbor on a drifted one. The rig injects the one reset per probe
|
||||
the operator supplies in the field; this is the rate physics the
|
||||
simulator cannot arbitrate (a pty carries bytes at any rate), pinned
|
||||
on silicon."""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
found = None
|
||||
try:
|
||||
for pct in module.scan_ratios():
|
||||
rate = module.scan_rate(self.rig.d.baud, pct)
|
||||
self.rig.reset()
|
||||
try:
|
||||
# Same wrap as identity(): on a shared line an undiscarded
|
||||
# echo answers every rate a scan probes, so the walk would
|
||||
# report the first one it tried.
|
||||
port = self.rig.open_port(rate)
|
||||
except module.Error as error:
|
||||
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
|
||||
return
|
||||
try:
|
||||
module.Loader(port).connect(min(self.rig.d.wait, 6.0))
|
||||
found = pct
|
||||
break
|
||||
except module.Error:
|
||||
continue
|
||||
finally:
|
||||
port.close()
|
||||
except Exception as error: # noqa: BLE001 - a rig hiccup is a result
|
||||
self.check("scan walks the probe ladder", False, str(error)[:70])
|
||||
return
|
||||
self.check("scan finds the board's rate", found is not None,
|
||||
"no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd")
|
||||
|
||||
def eeprom(self, info) -> None:
|
||||
size = info.eeprom_size
|
||||
if not size:
|
||||
print(" skip EEPROM (this part has none)")
|
||||
return
|
||||
# A pattern no erase or partial write could produce by accident.
|
||||
pattern = bytes((i * 7 + 3) & 0xFF for i in range(size))
|
||||
image = self.work / "ee.bin"
|
||||
image.write_bytes(pattern)
|
||||
|
||||
rc, out = self.rig.pureboot("--eeprom", str(image), "--verify-eeprom", str(image))
|
||||
self.check(f"EEPROM write + verify ({size} B)", rc == 0, self._brief(out))
|
||||
|
||||
back = self.work / "ee-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-eeprom", str(back))
|
||||
got = back.read_bytes() if back.exists() else b""
|
||||
self.check("EEPROM reads back what was written", got == pattern, f"{len(got)} B")
|
||||
|
||||
self.rig.pureboot("--erase-eeprom")
|
||||
erased = self.work / "ee-erased.bin"
|
||||
self.rig.pureboot("--read-eeprom", str(erased))
|
||||
got = erased.read_bytes() if erased.exists() else b""
|
||||
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
|
||||
|
||||
def application(self, info, app: pathlib.Path, marker: str,
|
||||
marker_wait: float = 2.5) -> None:
|
||||
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
|
||||
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
|
||||
|
||||
if marker:
|
||||
# The tool hands over as it ends its session, so the application is
|
||||
# already running - but only on a board whose DTR is unwired, where
|
||||
# opening a port simply listens. Where DTR *is* wired to reset (an
|
||||
# Arduino, most USB-serial dev boards), this open resets the part
|
||||
# and the activation window comes first, so a marker emitted once at
|
||||
# startup happens on the far side of a wait this cannot know the
|
||||
# length of: the window is a compile-time constant and nothing on
|
||||
# the wire reports it. Hence --marker-wait, and a fixture that
|
||||
# repeats its banner (PUREBOOT_HEARTBEAT) rather than saying it once.
|
||||
data = self.rig.capture(seconds=marker_wait)
|
||||
seen = marker.encode() in data
|
||||
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
|
||||
self.check(f"application runs (emits {marker!r})", seen,
|
||||
f"|{sample}|" if seen or data else
|
||||
f"nothing in {marker_wait:g} s - if this board resets when its port "
|
||||
f"opens, that wait has to outlast the activation window")
|
||||
|
||||
back = self.work / "app-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-flash", str(back))
|
||||
got = back.read_bytes() if back.exists() else b""
|
||||
self.check("application flash reads back", rc == 0 and len(got) == info.base,
|
||||
f"{len(got)} B of {info.base}")
|
||||
|
||||
def _witness(self, info, slot_length: int):
|
||||
"""Read back the erased region and the loader slot: (erased, slot, how).
|
||||
|
||||
Prefers ISP, because an independent reader is the only one that can
|
||||
testify about a loader just asked to erase around itself. Where no
|
||||
programmer is attached the link answers instead - which is weaker for
|
||||
exactly the reason it is worth having, a destroyed loader being unable
|
||||
to report anything at all. The two are never printed under one word:
|
||||
an absent probe is a fact about the bench, a wrong byte is a verdict on
|
||||
the loader, and a check that conflates them stops being read.
|
||||
"""
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
whole = self.work / "whole.bin"
|
||||
if self.rig.read_memory("flash", whole, "r"):
|
||||
image = whole.read_bytes()
|
||||
image += b"\xff" * (info.flash_size - len(image))
|
||||
return image[0:limit], image[info.base:info.base + slot_length], "ISP"
|
||||
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
return (loader.read_flash(0, limit),
|
||||
loader.read_flash(info.base, slot_length),
|
||||
"the link, no probe attached - the loader's own account")
|
||||
except Exception as error: # noqa: BLE001 - a dead link is a result
|
||||
print(f" skip slot checks: no programmer, and the link did not "
|
||||
f"answer either ({str(error)[:60]})")
|
||||
return None, None, ""
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def erase_and_slot(self, info, loader_image: pathlib.Path | None) -> None:
|
||||
rc, out = self.rig.pureboot("--erase-flash")
|
||||
self.check("application region erases", rc == 0, self._brief(out))
|
||||
|
||||
want = loader_image.read_bytes() if loader_image and loader_image.exists() else b""
|
||||
erased, slot, how = self._witness(info, len(want))
|
||||
if erased is None:
|
||||
return
|
||||
|
||||
# Erased application flash, up to the trampoline word the host composes
|
||||
# on a patched-vector part.
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
self.check("erased application region is 0xff",
|
||||
set(erased) <= {0xFF}, f"0x0000..{limit:#06x} via {how}")
|
||||
|
||||
if want:
|
||||
self.check("loader slot survives the erase", slot == want,
|
||||
f"{len(want)} B at {info.base:#06x} via {how}")
|
||||
else:
|
||||
print(" skip loader slot comparison (pass --loader <image.bin>)")
|
||||
|
||||
def seal(self, info, rounds: int = 1) -> None:
|
||||
"""The seal, adversarially, over the real link.
|
||||
|
||||
pureboot 9 has no running-slot guard: what stops a mangled command from
|
||||
erasing the loader is the seal and nothing else. So this aims the worst
|
||||
command the protocol has - an SPM erase at the loader's own first page -
|
||||
and damages one header byte at a time. Every one must come back NAK with
|
||||
the slot untouched and the session still in step.
|
||||
|
||||
On a board whose link drops or mangles bytes of its own accord this is
|
||||
also the stress test: `--seal-rounds` repeats it, and a link fault
|
||||
during a round is indistinguishable to the loader from the damage being
|
||||
injected, which is the point.
|
||||
"""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
self.rig.reset()
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
if loader.info.version < module.SEALED_LOADER:
|
||||
print(f" skip seal checks (loader is pureboot {loader.info.version})")
|
||||
return
|
||||
|
||||
head_of = lambda dmg: self._sealed(module, module.OP_WRITE, module.SP_SPM,
|
||||
info.base, module.SPM_ERASE, dmg)
|
||||
refused = 0
|
||||
attempts = 0
|
||||
for _ in range(rounds):
|
||||
for index in range(6):
|
||||
attempts += 1
|
||||
port.write(head_of((index, 0x01)))
|
||||
verdict = port.read_exact(1, 5.0)
|
||||
if verdict != module.NAK:
|
||||
self.check(f"damaged byte {index} refused", False,
|
||||
f"verdict {verdict.hex()}")
|
||||
return
|
||||
if port.read_exact(1, 5.0) != module.PROMPT:
|
||||
self.check(f"re-prompt after byte {index}", False, "no prompt")
|
||||
return
|
||||
refused += 1
|
||||
self.check("damaged headers refused", refused == attempts,
|
||||
f"{refused}/{attempts}, every header byte")
|
||||
self.check("session still in step", loader.identity().raw == info.raw)
|
||||
|
||||
# And the slot itself, read back over the link: the loader is the
|
||||
# thing that would have been erased, so its own account of its
|
||||
# first bytes is a real witness - an erased page reads all 0xff.
|
||||
head = loader.read_flash(info.base, 16)
|
||||
self.check("loader slot intact", set(head) != {0xFF}, head[:8].hex())
|
||||
except Exception as error: # noqa: BLE001 - a dead link is a result
|
||||
self.check("seal checks", False, str(error)[:70])
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _sealed(module, op, space, address, count, damage=None):
|
||||
"""A sealed header, damaged after sealing - the shape a link fault has."""
|
||||
head = bytearray((op, module.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = module.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
out = bytearray(head + bytes((seal,)))
|
||||
if damage:
|
||||
out[damage[0]] ^= damage[1]
|
||||
return bytes(out)
|
||||
|
||||
def refusals(self, info) -> None:
|
||||
# One word too many: a patched-vector part spends the slot's last word
|
||||
# on the trampoline, so its application stops two bytes short.
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
oversized = self.work / "oversized.bin"
|
||||
oversized.write_bytes(bytes(limit + 2))
|
||||
rc, out = self.rig.pureboot("--flash", str(oversized))
|
||||
self.check(f"image over {limit} B refused", rc != 0, self._brief(out))
|
||||
|
||||
# ------------------------------------------------------------------- run
|
||||
|
||||
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
|
||||
marker: str, marker_wait: float = 2.5, seal_rounds: int = 1) -> int:
|
||||
print("identity")
|
||||
info = self.identity()
|
||||
if info is None:
|
||||
print("\nthe loader never answered; nothing below can be trusted")
|
||||
return 1
|
||||
|
||||
if not self.rig.d.autobaud:
|
||||
print("\nscan")
|
||||
self.scan()
|
||||
|
||||
print("\nEEPROM")
|
||||
self.eeprom(info)
|
||||
|
||||
if app:
|
||||
print("\napplication")
|
||||
self.application(info, app, marker, marker_wait)
|
||||
else:
|
||||
print("\nskip application checks (pass --app <image.hex>)")
|
||||
|
||||
print("\nerase and the slot boundary")
|
||||
self.erase_and_slot(info, loader_image)
|
||||
|
||||
print("\nthe seal")
|
||||
self.seal(info, seal_rounds)
|
||||
|
||||
print("\nrefusals")
|
||||
self.refusals(info)
|
||||
|
||||
passed = sum(1 for _, ok, _ in self.results if ok)
|
||||
print(f"\n{passed}/{len(self.results)} passed")
|
||||
return 0 if passed == len(self.results) else 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="hardware acceptance suite for one pureboot deployment",
|
||||
epilog="overwrites the board's application flash and EEPROM - back them up first")
|
||||
pbrig.Deployment.add_arguments(parser)
|
||||
parser.add_argument("--app", type=pathlib.Path,
|
||||
help="application image to flash (test/pbapp.cpp built for this deployment)")
|
||||
parser.add_argument("--loader", type=pathlib.Path,
|
||||
help="the resident loader's .bin, to prove the slot survives an erase")
|
||||
parser.add_argument("--seal-rounds", type=int, default=1,
|
||||
help="repeat the adversarial seal sweep N times (a lossy board's stress test)")
|
||||
parser.add_argument("--marker", default="",
|
||||
help="text the application emits when it runs, e.g. APP")
|
||||
parser.add_argument("--marker-wait", type=float, default=2.5,
|
||||
help="seconds to listen for it. On a board whose DTR is wired to "
|
||||
"reset, opening the port resets the part, so this must outlast "
|
||||
"the activation window (default 2.5)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
|
||||
print(f"rig: {args.part} on {args.programmer}, link {args.port} at {args.baud} Bd"
|
||||
f"{' (autobaud)' if args.autobaud else ''}")
|
||||
print("this overwrites the application flash and EEPROM\n")
|
||||
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
|
||||
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker,
|
||||
args.marker_wait, args.seal_rounds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except pbrig.Error as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
445
tools/pbrig.py
445
tools/pbrig.py
@@ -1,445 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardware rig driver for pureboot: an ISP programmer beside a serial link.
|
||||
|
||||
The simulated suites (`test/pb*.py`) prove the protocol; this drives the same
|
||||
loader on real silicon, where the things a cycle-exact simulator cannot model
|
||||
live - an RC oscillator off its nominal, a reset edge that has to come from
|
||||
somewhere, a serial bridge with its own idea of what a baud is.
|
||||
|
||||
Nothing here knows a port name, a part or a programmer. Every deployment fact
|
||||
arrives from the command line or the environment, so the same script serves any
|
||||
board: see `Deployment`. As a module it is the reset/flash/talk primitives that
|
||||
`pbhw.py` builds its acceptance suite from; as a command it is the handful of
|
||||
one-shot operations worth having on a rig - most importantly `backup`, which is
|
||||
the only thing standing between a fuse experiment and an unrecoverable part.
|
||||
|
||||
Two rig facts are encoded here because they are not guessable and cost a
|
||||
session each to learn:
|
||||
|
||||
* **An ISP access resets the part**, and it runs again the moment the programmer
|
||||
releases it. That is the only reset edge available when the serial adapter's
|
||||
DTR is not wired to reset - so a loader session begins with an ISP touch and
|
||||
knocks immediately after, which is what `Rig.pureboot()` does.
|
||||
* **avrdude splits `-U memory:op:file:format` on colons**, so a Windows path's
|
||||
drive letter breaks the spec. Every file argument is therefore passed as a
|
||||
bare filename with avrdude run in that file's own directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
DEFAULT_PUREBOOT = HERE.parent / "pureboot" / "pureboot.py"
|
||||
|
||||
# Memories worth capturing before an experiment, and the format each is read in.
|
||||
# Fuses and lock are per-part: a part without an extended fuse simply fails that
|
||||
# one read, which `backup` reports and steps over rather than aborting on.
|
||||
BACKUP_MEMORIES = (
|
||||
("flash", "i", "hex"),
|
||||
("flash", "r", "bin"),
|
||||
("eeprom", "i", "hex"),
|
||||
("eeprom", "r", "bin"),
|
||||
("lfuse", "h", "hex"),
|
||||
("hfuse", "h", "hex"),
|
||||
("efuse", "h", "hex"),
|
||||
("lock", "h", "hex"),
|
||||
("calibration", "h", "hex"),
|
||||
)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def bitclock_for(hz: int) -> str:
|
||||
"""A safe ISP bitclock for a part *currently running* at `hz`.
|
||||
|
||||
SCK must stay under a quarter of the target clock, so the bitclock follows
|
||||
the clock in force - not the one about to be fused in. Halving that ceiling
|
||||
again costs nothing on a link that moves a few hundred bytes and buys margin
|
||||
against an oscillator that is already known to be off its nominal.
|
||||
"""
|
||||
ceiling = hz // 8
|
||||
rungs = (1000, 4000, 8000, 32000, 125000, 400000)
|
||||
if ceiling < rungs[0]:
|
||||
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
|
||||
best = max(rung for rung in rungs if rung <= ceiling)
|
||||
return f"{best // 1000}kHz"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Deployment:
|
||||
"""Everything about one board. No default names a real device."""
|
||||
|
||||
port: str = "" # serial device the loader speaks on
|
||||
baud: int = 57600 # host rate; for autobaud, the rate to drive
|
||||
autobaud: bool = False # send the calibration pulse instead of p+b
|
||||
one_wire: bool = False # shared line: the host discards its own echo
|
||||
programmer: str = "" # avrdude -c
|
||||
part: str = "" # avrdude -p
|
||||
avrdude: str = "avrdude"
|
||||
bitclock: str = "125kHz" # see bitclock_for()
|
||||
pureboot: pathlib.Path = DEFAULT_PUREBOOT
|
||||
wait: int = 12 # seconds the host keeps knocking
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Deployment":
|
||||
"""Environment defaults, so a rig's facts live in one place per machine."""
|
||||
return cls(
|
||||
port=os.environ.get("PUREBOOT_PORT", ""),
|
||||
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
|
||||
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
|
||||
one_wire=os.environ.get("PUREBOOT_ONE_WIRE", "") not in ("", "0"),
|
||||
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
|
||||
part=os.environ.get("PUREBOOT_PART", ""),
|
||||
avrdude=os.environ.get("AVRDUDE", "avrdude"),
|
||||
bitclock=os.environ.get("PUREBOOT_BITCLOCK", "125kHz"),
|
||||
pureboot=pathlib.Path(os.environ.get("PUREBOOT_TOOL", str(DEFAULT_PUREBOOT))),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Deployment flags, shared by this tool and pbhw.py."""
|
||||
env = Deployment.from_env()
|
||||
parser.add_argument("--port", default=env.port, help="serial device the loader speaks on")
|
||||
parser.add_argument("--baud", type=int, default=env.baud,
|
||||
help="host rate (for autobaud, the rate to drive)")
|
||||
parser.add_argument("--autobaud", action="store_true", default=env.autobaud,
|
||||
help="send the calibration pulse instead of the p+b knock")
|
||||
parser.add_argument("--one-wire", action="store_true", default=env.one_wire,
|
||||
help="shared line: pass the tool its echo discard")
|
||||
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
|
||||
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
|
||||
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
|
||||
parser.add_argument("--bitclock", default=env.bitclock, help="ISP bitclock, e.g. 125kHz or 8kHz")
|
||||
parser.add_argument("--pureboot", type=pathlib.Path, default=env.pureboot,
|
||||
help="path to pureboot.py")
|
||||
parser.add_argument("--wait", type=int, default=env.wait, help="seconds to keep knocking")
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, args: argparse.Namespace) -> "Deployment":
|
||||
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
|
||||
one_wire=args.one_wire,
|
||||
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
|
||||
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
|
||||
|
||||
|
||||
def load_pureboot(path: pathlib.Path = DEFAULT_PUREBOOT):
|
||||
"""The host tool as a module - its Port and Loader, not a subprocess.
|
||||
|
||||
Used where a subprocess cannot express what is needed: a poke followed by a
|
||||
peek in the *same* session, or a raw read at an arbitrary baud.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("pureboot", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise Error(f"cannot load the host tool from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class Rig:
|
||||
"""One board: its programmer on one side, its serial link on the other."""
|
||||
|
||||
def __init__(self, deployment: Deployment):
|
||||
self.d = deployment
|
||||
if not deployment.programmer or not deployment.part:
|
||||
raise Error("a rig needs --programmer and --part")
|
||||
|
||||
# ------------------------------------------------------------- programmer
|
||||
|
||||
def avrdude(self, *args: str, cwd: pathlib.Path | None = None,
|
||||
bitclock: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
|
||||
command = [self.d.avrdude, "-c", self.d.programmer, "-p", self.d.part,
|
||||
"-B", bitclock or self.d.bitclock, *args]
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
cwd=None if cwd is None else str(cwd), timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _ok(result: subprocess.CompletedProcess) -> bool:
|
||||
return result.returncode == 0
|
||||
|
||||
def reset(self, bitclock: str | None = None) -> None:
|
||||
"""An ISP access, which resets the part; it runs when avrdude exits."""
|
||||
self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
|
||||
|
||||
def signature(self, bitclock: str | None = None) -> str:
|
||||
result = self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
|
||||
for line in reversed(result.stdout.splitlines()):
|
||||
if line.strip().startswith("0x"):
|
||||
return line.strip()
|
||||
raise Error(f"no signature read: {(result.stderr or result.stdout).strip()[:200]}")
|
||||
|
||||
def read_memory(self, memory: str, destination: pathlib.Path, fmt: str = "r",
|
||||
bitclock: str | None = None) -> bool:
|
||||
"""Read `memory` into `destination`, whose directory avrdude runs in."""
|
||||
destination = pathlib.Path(destination).resolve()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = self.avrdude("-U", f"{memory}:r:{destination.name}:{fmt}",
|
||||
cwd=destination.parent, bitclock=bitclock)
|
||||
# A memory the part does not have (a tiny's extended fuse) leaves avrdude
|
||||
# happy and the file empty. An empty capture is a miss, not a backup.
|
||||
return self._ok(result) and destination.exists() and destination.stat().st_size > 0
|
||||
|
||||
def write_memory(self, memory: str, source: pathlib.Path, fmt: str = "i",
|
||||
erase: bool = False, bitclock: str | None = None) -> bool:
|
||||
source = pathlib.Path(source).resolve()
|
||||
args = ["-U", f"{memory}:w:{source.name}:{fmt}"]
|
||||
if erase:
|
||||
args.insert(0, "-e")
|
||||
result = self.avrdude(*args, cwd=source.parent, bitclock=bitclock)
|
||||
return "verified" in (result.stdout + result.stderr)
|
||||
|
||||
def flash_hex(self, image: pathlib.Path, erase: bool = True,
|
||||
bitclock: str | None = None) -> bool:
|
||||
return self.write_memory("flash", image, "i", erase=erase, bitclock=bitclock)
|
||||
|
||||
def read_fuses(self, bitclock: str | None = None) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for fuse in ("lfuse", "hfuse", "efuse", "lock"):
|
||||
result = self.avrdude("-U", f"{fuse}:r:-:h", bitclock=bitclock)
|
||||
values = [l.strip() for l in result.stdout.splitlines() if l.strip().startswith("0x")]
|
||||
if values:
|
||||
out[fuse] = values[-1]
|
||||
return out
|
||||
|
||||
def write_fuses(self, bitclock: str | None = None, **fuses: str) -> bool:
|
||||
"""Write named fuses. A fuse change moves the clock the *next* access is
|
||||
timed against, so pass a bitclock safe for both sides of the change."""
|
||||
args: list[str] = []
|
||||
for name, value in fuses.items():
|
||||
args += ["-U", f"{name}:w:{value}:m"]
|
||||
if not args:
|
||||
return True
|
||||
result = self.avrdude(*args, bitclock=bitclock)
|
||||
text = result.stdout + result.stderr
|
||||
return "verified" in text or "written" in text
|
||||
|
||||
# ------------------------------------------------------------ backup
|
||||
|
||||
def backup(self, directory: pathlib.Path, prefix: str = "") -> dict[str, bool]:
|
||||
"""Capture every memory worth keeping, then prove it by a second read.
|
||||
|
||||
A backup nobody verified is a guess. Each memory is read twice and the
|
||||
two reads compared; a mismatch is reported rather than quietly stored.
|
||||
"""
|
||||
directory = pathlib.Path(directory).resolve()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
stem = prefix or self.d.part
|
||||
status: dict[str, bool] = {}
|
||||
for memory, fmt, extension in BACKUP_MEMORIES:
|
||||
name = f"{stem}-{memory}.{extension}"
|
||||
if not self.read_memory(memory, directory / name, fmt):
|
||||
status[f"{memory}.{extension}"] = False
|
||||
continue
|
||||
if extension == "bin": # only the raw form is worth comparing byte-wise
|
||||
again = directory / f".{name}.again"
|
||||
self.read_memory(memory, again, fmt)
|
||||
same = again.exists() and again.read_bytes() == (directory / name).read_bytes()
|
||||
again.unlink(missing_ok=True)
|
||||
status[f"{memory}.{extension}"] = same
|
||||
else:
|
||||
status[f"{memory}.{extension}"] = True
|
||||
return status
|
||||
|
||||
# ------------------------------------------------------------ serial link
|
||||
|
||||
def pureboot(self, *args: str, reset_first: bool = True, baud: int | None = None,
|
||||
autobaud: bool | None = None, timeout: int = 300,
|
||||
bitclock: str | None = None) -> tuple[int, str]:
|
||||
"""Reset, then knock immediately - see the module docstring.
|
||||
|
||||
Returns the host tool's exit status and its combined output, so a caller
|
||||
can assert on what it printed as well as on whether it succeeded.
|
||||
"""
|
||||
if reset_first:
|
||||
self.reset(bitclock=bitclock)
|
||||
command = [sys.executable, str(self.d.pureboot), "--port", self.d.port,
|
||||
"--baud", str(self.d.baud if baud is None else baud),
|
||||
"--wait", str(self.d.wait)]
|
||||
if self.d.autobaud if autobaud is None else autobaud:
|
||||
command.append("--autobaud")
|
||||
if self.d.one_wire:
|
||||
command.append("--one-wire")
|
||||
command += [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired as expired:
|
||||
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
|
||||
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
def open_port(self, baud: int | None = None):
|
||||
"""A port opened the way this deployment says to speak to the board.
|
||||
|
||||
Everything the rig runs as a *subprocess* gets its flags from
|
||||
`pureboot()` above; anything that drives the protocol in-process has
|
||||
to reach the same facts, and until this existed only the subprocess
|
||||
path could. A shared line is the one where that gap is fatal rather
|
||||
than untidy: the host reads back every byte it writes, so an
|
||||
undiscarded echo answers the knock before the device does. Open
|
||||
through here and a one-wire deployment cannot be silently driven as
|
||||
a two-wire one.
|
||||
"""
|
||||
module = load_pureboot(self.d.pureboot)
|
||||
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
|
||||
return module.OneWirePort(port) if self.d.one_wire else port
|
||||
|
||||
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
|
||||
"""Listen to whatever the board is saying, at an arbitrary rate.
|
||||
|
||||
Opening the port does not reset a board whose DTR is unwired, so this can
|
||||
sample a running application repeatedly without disturbing it - which is
|
||||
what makes the rate sweep below possible.
|
||||
"""
|
||||
module = load_pureboot(self.d.pureboot)
|
||||
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
|
||||
try:
|
||||
data = b""
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
chunk = port.read_available(0.2)
|
||||
if chunk:
|
||||
data += chunk
|
||||
return data
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def measure_rate(rig: Rig, marker: bytes, built_baud: int, nominal_hz: int | None = None,
|
||||
span_percent: float = 12.0, step_percent: float = 0.5,
|
||||
seconds: float = 0.75) -> dict:
|
||||
"""Find a transmitting board's true bit rate, using only the serial port.
|
||||
|
||||
The board must be emitting something recognisable at a *fixed* cycles-per-bit
|
||||
- `test/pbapp.cpp` built with PUREBOOT_HEARTBEAT does. Since its bit timing is
|
||||
a cycle count, its wire rate scales with its actual clock, so the host rates
|
||||
at which `marker` still decodes bracket that rate; the centre of the band is
|
||||
the answer, and with the clock the image was built for it gives the real one.
|
||||
|
||||
This is the measurement that turns "the loader is silent, so the wiring must
|
||||
be wrong" into a number, and it needs no instrument beyond the adapter
|
||||
already attached.
|
||||
"""
|
||||
steps = int(span_percent / step_percent)
|
||||
clean: list[int] = []
|
||||
samples: list[tuple[int, int, bool]] = []
|
||||
for index in range(-steps, steps + 1):
|
||||
baud = int(round(built_baud * (1 + index * step_percent / 100.0)))
|
||||
if baud <= 0:
|
||||
continue
|
||||
data = rig.capture(seconds=seconds, baud=baud)
|
||||
hit = marker in data
|
||||
samples.append((baud, len(data), hit))
|
||||
if hit:
|
||||
clean.append(baud)
|
||||
result: dict = {"samples": samples, "clean": clean, "built_baud": built_baud}
|
||||
if clean:
|
||||
low, high = min(clean), max(clean)
|
||||
centre = (low + high) / 2.0
|
||||
result |= {"low": low, "high": high, "centre": centre,
|
||||
"half_width_percent": (high - low) / 2.0 / centre * 100.0,
|
||||
"error_percent": (centre / built_baud - 1.0) * 100.0}
|
||||
if nominal_hz:
|
||||
result["measured_hz"] = nominal_hz * centre / built_baud
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- command
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="pureboot hardware rig: ISP reset/flash beside the serial link")
|
||||
Deployment.add_arguments(parser)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("signature", help="read the part signature over ISP")
|
||||
sub.add_parser("reset", help="reset the part (an ISP access) and let it run")
|
||||
sub.add_parser("fuses", help="read the fuse and lock bytes")
|
||||
|
||||
p = sub.add_parser("flash", help="program a hex image over ISP")
|
||||
p.add_argument("image", type=pathlib.Path)
|
||||
p.add_argument("--no-erase", action="store_true", help="do not chip-erase first")
|
||||
|
||||
p = sub.add_parser("backup", help="capture and verify every memory")
|
||||
p.add_argument("directory", type=pathlib.Path)
|
||||
p.add_argument("--prefix", default="", help="filename stem (default: the part name)")
|
||||
|
||||
p = sub.add_parser("rate", help="measure the board's true bit rate and clock")
|
||||
p.add_argument("--marker", default="APP", help="text the board emits (default: APP)")
|
||||
p.add_argument("--built-baud", type=int, required=True,
|
||||
help="the baud the running image was built for")
|
||||
p.add_argument("--nominal-hz", type=int, default=0,
|
||||
help="the clock the image was built for, to report the real one")
|
||||
p.add_argument("--span", type=float, default=12.0, help="sweep +-this many percent")
|
||||
p.add_argument("--step", type=float, default=0.5, help="sweep step in percent")
|
||||
p.add_argument("--verbose", action="store_true", help="print every step")
|
||||
|
||||
p = sub.add_parser("bitclock", help="a safe ISP bitclock for a clock in force")
|
||||
p.add_argument("hz", type=int)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "bitclock":
|
||||
print(bitclock_for(args.hz))
|
||||
return 0
|
||||
|
||||
rig = Rig(Deployment.from_args(args))
|
||||
|
||||
if args.command == "signature":
|
||||
print(rig.signature())
|
||||
elif args.command == "reset":
|
||||
rig.reset()
|
||||
print("reset")
|
||||
elif args.command == "fuses":
|
||||
for name, value in rig.read_fuses().items():
|
||||
print(f"{name:<6} {value}")
|
||||
elif args.command == "flash":
|
||||
ok = rig.flash_hex(args.image, erase=not args.no_erase)
|
||||
print(f"{args.image.name}: {'verified' if ok else 'FAILED'}")
|
||||
return 0 if ok else 1
|
||||
elif args.command == "backup":
|
||||
status = rig.backup(args.directory, args.prefix)
|
||||
for name, ok in status.items():
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
missing = [n for n, ok in status.items() if not ok]
|
||||
# Fuses a part does not have are expected misses, not failures.
|
||||
fatal = [n for n in missing if not n.startswith(("efuse", "calibration"))]
|
||||
print(f"\n{len(status) - len(missing)}/{len(status)} captured into {args.directory}")
|
||||
return 1 if fatal else 0
|
||||
elif args.command == "rate":
|
||||
result = measure_rate(rig, args.marker.encode(), args.built_baud,
|
||||
args.nominal_hz or None, args.span, args.step)
|
||||
if args.verbose:
|
||||
for baud, size, hit in result["samples"]:
|
||||
print(f" {baud:7d} Bd {size:5d} B {'MARKER' if hit else ''}")
|
||||
if not result["clean"]:
|
||||
print(f"no capture contained {args.marker!r} at any rate - is the board "
|
||||
f"transmitting, and on the pin this port is wired to?")
|
||||
return 1
|
||||
print(f"clean band {result['low']}..{result['high']} Bd")
|
||||
print(f"centre {result['centre']:.0f} Bd "
|
||||
f"(+-{result['half_width_percent']:.1f} %)")
|
||||
print(f"vs built {result['built_baud']} Bd ({result['error_percent']:+.1f} %)")
|
||||
if "measured_hz" in result:
|
||||
print(f"true clock {result['measured_hz'] / 1e6:.3f} MHz")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Error as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
194
tools/sizes.py
194
tools/sizes.py
@@ -1,194 +0,0 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user