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>
211 lines
11 KiB
Python
211 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""End-to-end autobaud test: drive an autobaud loader in simavr through the
|
|
calibration handshake and a flash + EEPROM + fuse round-trip, cross-checked
|
|
against the simulator's ground-truth memory - then repeat at a second F_CPU with
|
|
the *same* loader binary, which is the property autobaud exists for: one
|
|
clock-agnostic image that locks onto whatever rate the host sends.
|
|
|
|
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
|
|
<app_bin> <app_hz> <app_baud> <tool_py> <workdir> [link]
|
|
|
|
The loader is a software-serial build, driven over the GPIO<->pty bridge; the
|
|
optional link overrides the default -l sw:B0,B1 - RX == TX in it is the
|
|
one-wire deployment, and every session then runs with the host's echo
|
|
discard on. The app fixture is built for (app_hz, app_baud); the hand-over
|
|
is checked at that point, and a second point at half the clock proves the
|
|
lock is measured, not baked in.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
|
|
def fail(message):
|
|
print(f"FAIL: {message}")
|
|
sys.exit(1)
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
link = args.pop() if len(args) == 11 else "sw:B0,B1"
|
|
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = args
|
|
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
|
|
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
|
|
extra = ("--one-wire",) if one_wire else ()
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import pbsim
|
|
import pureboot as pb
|
|
|
|
os.makedirs(workdir, exist_ok=True)
|
|
ee_image = bytes(range(0xA0, 0xB0))
|
|
ee_path = os.path.join(workdir, "ee.bin")
|
|
open(ee_path, "wb").write(ee_image)
|
|
|
|
# The geometry the surgery planner needs, from the chip class the runner is
|
|
# told - the same derivation pbtest.py makes: the boot-sectioned megas need
|
|
# no vector surgery, the tinies and the boot-section-less m48s do, and the
|
|
# large chips speak word addresses.
|
|
mega = mcu.startswith("atmega")
|
|
patch = not mega or mcu.startswith("atmega48")
|
|
word_flash = base + pb.SLOT > 0x10000
|
|
wire_base = base // 2 if word_flash else base
|
|
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
|
ground_truth = pb.Info(bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF,
|
|
wire_base & 0xFF, wire_base >> 8, 0, 0, flags]))
|
|
|
|
def round_trip(hz, baud, label, hand_over):
|
|
"""One clock point: reset, calibrate + knock, program, verify against the
|
|
simulator's own flash, and (at the app's point) hand over to the fixture."""
|
|
dump = os.path.join(workdir, f"flash_{label}.bin")
|
|
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link=link)
|
|
try:
|
|
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
|
# and a single knock at `baud`; the loader locks to it.
|
|
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--info", "--clock", str(hz),
|
|
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
|
|
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
|
if needed not in out:
|
|
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
|
# The measured clock, decoded from the unit at whichever home this
|
|
# version keeps it in. The runner's clock is exact, so the figure
|
|
# must land inside the
|
|
# encoding's own envelope: the loader floors the bit period to
|
|
# 4-cycle spin granules after an 8-cycle discount, and the edge
|
|
# poll can shave a few cycles more - one granule of slack below
|
|
# the true clock, none above (in cycles per bit, times the rate).
|
|
measured = re.search(r"measured\s+(\d+) Hz", out)
|
|
if not measured:
|
|
fail(f"{label}: --info lacks the measured clock\n{out}")
|
|
measured = int(measured.group(1))
|
|
if not hz - 19 * baud <= measured <= hz + 4 * baud:
|
|
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
|
|
# Read both memories back over the locked link and check them.
|
|
read_flash = os.path.join(workdir, f"rf_{label}.bin")
|
|
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
|
|
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--verify-flash", app_bin,
|
|
"--verify-eeprom", ee_path, "--read-flash", read_flash,
|
|
"--read-eeprom", read_eeprom, "--stay")
|
|
if out.count("verify:") != 2:
|
|
fail(f"{label}: did not verify both memories\n{out}")
|
|
if open(read_eeprom, "rb").read()[: len(ee_image)] != ee_image:
|
|
fail(f"{label}: EEPROM read-back mismatch")
|
|
|
|
if hand_over:
|
|
# A calibration pulse with no knock behind it must not wedge
|
|
# the loader: the whole activation is bounded, including the
|
|
# knock's edge wait, so one stray low pulse - EMI, or a host
|
|
# that opens the port and never knocks - closes the window and
|
|
# boots the application. The banner is the proof.
|
|
# (The pause lets the loader reach its measurement loop, so the
|
|
# pulse is genuinely seen and the test cannot pass vacuously.)
|
|
device.reset()
|
|
port = pb.Port(device.pty, baud)
|
|
if one_wire:
|
|
port = pb.OneWirePort(port)
|
|
try:
|
|
time.sleep(0.2)
|
|
port.write(bytes((pb.CALIBRATE,)))
|
|
# Accumulate rather than match exactly: the reset leaves the
|
|
# idle line a framing artefact ahead of the banner, which is
|
|
# noise here - the question is only whether the app ran.
|
|
seen = b""
|
|
deadline = time.monotonic() + 180.0
|
|
while b"APP" not in seen and time.monotonic() < deadline:
|
|
seen += port.read_available(1.0)
|
|
if b"APP" not in seen:
|
|
fail(f"{label}: lone calibration pulse wedged the loader - app never bannered, saw {seen!r}")
|
|
print(f" {label}: lone calibration pulse does not wedge the loader")
|
|
finally:
|
|
port.close()
|
|
|
|
device.reset()
|
|
port = pb.Port(device.pty, baud)
|
|
if one_wire:
|
|
port = pb.OneWirePort(port)
|
|
try:
|
|
loader = pb.Loader(port)
|
|
live = loader.connect_autobaud(15)
|
|
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
|
|
fail(f"{label}: loader reports pureboot {live.version}")
|
|
if loader.unified:
|
|
# pureboot 5's data space. 0x0200 is clear of the
|
|
# loader's own .noinit unit at the bottom of SRAM and of
|
|
# the stack at the top. Reading it back over the same
|
|
# locked link proves both directions of the new space.
|
|
probe = bytes(range(0x30, 0x40))
|
|
loader.write_data(0x0200, probe)
|
|
if loader.read_data(0x0200, len(probe)) != probe:
|
|
fail(f"{label}: RAM round-trip mismatch")
|
|
# The register file and the I/O space share the data
|
|
# address space on AVR, so the same command reaches a
|
|
# peripheral register. SPMCSR reads back as idle here.
|
|
verbose_ram = loader.read_data(0x0200, 4)
|
|
print(f" {label}: RAM read/write ok ({verbose_ram.hex()})")
|
|
loader.run_application()
|
|
banner = port.read_exact(3, 5.0)
|
|
if banner != b"APP":
|
|
fail(f"{label}: application banner was {banner!r}")
|
|
finally:
|
|
port.close()
|
|
finally:
|
|
device.stop()
|
|
|
|
# Ground truth (read after the runner exits and writes its dump): what
|
|
# the tool programmed must be what the simulator actually holds.
|
|
pages = pb.plan_flash(open(app_bin, "rb").read(), ground_truth)
|
|
flash_true = open(dump, "rb").read()
|
|
for address, data in pages.items():
|
|
if flash_true[address : address + page] != data:
|
|
fail(f"{label}: simulator flash differs from the programmed image at {address:#06x}")
|
|
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
|
|
+ (", hand-over ok" if hand_over else ""))
|
|
|
|
def must_lock(hz, baud, label):
|
|
"""The calibration alone, at a tight bit period. Nothing is programmed -
|
|
the question is only whether the loader can still measure the pulse."""
|
|
dump = os.path.join(workdir, f"flash_{label}.bin")
|
|
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump,
|
|
link=link)
|
|
try:
|
|
port = pb.Port(device.pty, baud)
|
|
if one_wire:
|
|
port = pb.OneWirePort(port)
|
|
try:
|
|
live = pb.Loader(port).connect_autobaud(15)
|
|
if live.version != pb.NEWEST_LOADER:
|
|
fail(f"{label}: loader reports pureboot {live.version}")
|
|
finally:
|
|
port.close()
|
|
finally:
|
|
device.stop()
|
|
print(f" {label}: locked at {hz} Hz / {baud} Bd ({hz / baud:.0f} cycles a bit)")
|
|
|
|
# The app fixture is built for one clock; the hand-over banners there. A
|
|
# second point at double that clock, same loader binary, proves the lock is
|
|
# measured, not baked in - the whole point of autobaud. (Doubling keeps the
|
|
# bit period healthy; halving would drop it below the software UART's floor.)
|
|
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
|
|
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
|
|
|
|
# Both points above sit near 100 cycles a bit, which is comfortable. The
|
|
# calibration's real floor is far tighter, and it is worth a gate: measured
|
|
# here, the lock is solid down to ~36 cycles a bit and fails outright by ~31
|
|
# - a sharp edge, not a fraying one. This pins the tightest standard rate the
|
|
# fixture's clock reaches, so a change that raises the floor is caught.
|
|
#
|
|
# It does *not* bound what a real deployment can use. On silicon the
|
|
# oscillator's own jitter costs roughly a factor of two: an ATtiny13A on its
|
|
# factory RC trim was reliable at ~118 cycles a bit and already locking only
|
|
# 1 attempt in 5 by ~59, which no exact-clock simulation can show. The
|
|
# deployable envelope is a README matter; this is the logic's floor.
|
|
must_lock(app_hz, app_baud * 2, "tight-bit")
|
|
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks, "
|
|
"and the tight bit period still locks")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|