build: the libavr pin advances past phase 6, at byte parity everywhere

The pin crosses libavr's phase 6 - the renamed system surface, the named
serial configs, the receiver-tolerance table, the paged SPM receipts -
and every loader image comes out size-identical: the full matrix on six
representative chips (the exhaustive cross product on three of them),
the stock and autobaud columns untouched, the four tsb tiers back on
their recorded floors at 510/526/638/836.

Byte parity was not free, and the two libavr defects it surfaced were
fixed there rather than absorbed here. The EEPROM write procedure's
step 2 - the SPMEN spin - had landed unconditionally and cost every
build six bytes for a wait a polled loader can never take; it is scoped
now, and the loaders state the datasheet's own omission clause
(spm_interlock::omitted, DS40002061B 8.6.3). The blocking page
erase/write grew an internal wait the tiers' settle() already provides,
so the tiers issue the command form and pureboot keeps its host-driven
sp_spm path.

What the port states rather than inherits: the stock 115200 at 16 MHz
sits +2.1 % past the receiver-tolerance table libavr now holds rates
to, so the hardware links say .allow_baud_error = true - the same
2.5 % envelope pureboot_baud_feasible() has always enforced, proven on
silicon across the fleet. rx_ready() reads readable() now.

Alongside the pin: rule 33's ASCII sweep over every source (docs keep
their typography), rule 34's InsertBraces in .clang-format with the
tree reformatted, std::array over the simavr runners' raw buffers, and
the stale Studio size in ide/README.md replaced by the claim its
check-flags gate actually holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:43:44 +02:00
parent 0cb83ff36f
commit e4390d2ba8
38 changed files with 791 additions and 625 deletions

View File

@@ -1,9 +1,9 @@
#!/bin/bash
# The port's gate: every chip's generated workflow build, size matrix, and
# 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 × baud × backend cross product. libavr resolves from the `libavr/`
# clock x baud x backend cross product. libavr resolves from the `libavr/`
# submodule; LIBAVR_ROOT overrides it for a working tree.
set -e
cd "$(dirname "$0")/.."
@@ -36,7 +36,7 @@ if ((full)); then
done
fi
# Every tree is freshly built now the one moment the README's size table
# 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).

View File

@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""Regenerate CMakePresets.json one uniform pipeline per chip.
"""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
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.
Run from the repo root: tools/make_presets.py or with --check, which
Run from the repo root: tools/make_presets.py - or with --check, which
verifies the committed file matches this generator and edits nothing (the
ctest entry `presets.generated` runs that, so drift reds the gate).
"""
@@ -90,7 +90,7 @@ def main():
if "--check" in sys.argv[1:]:
current = open(path).read() if os.path.exists(path) else ""
if current != rendered:
print("CMakePresets.json does not match its generator run tools/make_presets.py")
print("CMakePresets.json does not match its generator - run tools/make_presets.py")
return 1
return 0
with open(path, "w") as f:

View File

@@ -5,7 +5,7 @@
*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
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
@@ -63,7 +63,7 @@ class Suite:
info = loader.info
self.check("identity read", True, info.describe())
return info
except Exception as error: # noqa: BLE001 a dead link is a result
except Exception as error: # noqa: BLE001 - a dead link is a result
self.check("identity read", False, str(error)[:70])
return None
finally:
@@ -75,7 +75,7 @@ class Suite:
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,
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
@@ -102,7 +102,7 @@ class Suite:
continue
finally:
port.close()
except Exception as error: # noqa: BLE001 a rig hiccup is a result
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,
@@ -139,7 +139,7 @@ class Suite:
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
# 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
@@ -152,7 +152,7 @@ class Suite:
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"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"
@@ -166,7 +166,7 @@ class Suite:
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
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
@@ -189,8 +189,8 @@ class Suite:
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
"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, ""
@@ -226,7 +226,7 @@ class Suite:
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
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.
@@ -271,10 +271,10 @@ class Suite:
# 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.
# 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
except Exception as error: # noqa: BLE001 - a dead link is a result
self.check("seal checks", False, str(error)[:70])
finally:
try:
@@ -284,7 +284,7 @@ class Suite:
@staticmethod
def _sealed(module, op, space, address, count, damage=None):
"""A sealed header, damaged after sealing the shape a link fault has."""
"""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
@@ -344,7 +344,7 @@ class Suite:
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")
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)")

View File

@@ -3,14 +3,14 @@
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
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
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
@@ -18,7 +18,7 @@ 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
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
@@ -63,7 +63,7 @@ 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
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.
"""
@@ -138,7 +138,7 @@ class Deployment:
def load_pureboot(path: pathlib.Path = DEFAULT_PUREBOOT):
"""The host tool as a module its Port and Loader, not a subprocess.
"""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.
@@ -260,7 +260,7 @@ class Rig:
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.
"""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.
@@ -301,7 +301,7 @@ class Rig:
"""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
sample a running application repeatedly without disturbing it - which is
what makes the rate sweep below possible.
"""
module = load_pureboot(self.d.pureboot)
@@ -327,7 +327,7 @@ def measure_rate(rig: Rig, marker: bytes, built_baud: int, nominal_hz: int | Non
"""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
- `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.
@@ -430,7 +430,7 @@ def main(argv: list[str] | None = None) -> int:
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 "
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")

View File

@@ -3,7 +3,7 @@
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
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:
@@ -12,14 +12,14 @@ Two questions, both answered from built trees:
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
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
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.
"""
@@ -33,7 +33,7 @@ import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
# add_test(<name>.size ... -DELF=<path> ... -DLIMIT=<n> ...) the gate's own
# 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.
@@ -56,7 +56,7 @@ def preset_dirs() -> list[pathlib.Path]:
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")
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()]
@@ -86,7 +86,7 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
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
# 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]] = {}
@@ -95,7 +95,7 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
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"{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 = {
@@ -111,7 +111,7 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
def cmd_max(args) -> int:
measured = collect()
if not measured:
sys.exit("nothing built configure and build a preset first")
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():
@@ -129,7 +129,7 @@ def cmd_max(args) -> int:
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")
print(f"tightest fit: {chip} {name} - {text} of {limit}, {limit - text} B spare")
return 0
@@ -137,7 +137,7 @@ 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
and competes for the same cell) - the config the Autobaud column
documents."""
readme = (ROOT / "pureboot" / "README.md").read_text()
measured = collect()
@@ -147,7 +147,8 @@ def cmd_check_readme(args) -> int:
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.
# "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;
@@ -167,7 +168,7 @@ def cmd_check_readme(args) -> int:
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")
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 "") + ")")