pureboot: autobaud host support and simavr end-to-end for both variants

pureboot.py --autobaud sends the 0xC0 calibration pulse and a single knock at
the host's chosen baud, reads the slimmed info block, and derives the full
geometry from the signature (AUTOBAUD_GEOMETRY, a table over every pureboot
chip). Everything downstream — flash, EEPROM, fuses, hand-over, verify — is the
fixed-baud path unchanged; the dropped write guard is host-transparent.

test/pbautobaud.py drives each variant over the GPIO⇄pty software-UART bridge
through the calibration handshake and a flash + EEPROM + fuse round-trip
cross-checked against the simulator's ground-truth memory, then repeats at
double the F_CPU with the same binary — the clock-agnostic property autobaud
exists for. Wired as pureboot.autobaud_pure/reg on the near-flash 328P and the
word-addressed 1284P. A wrong measured unit fails the flash/verify, so the test
also pins the codegen-coupled calibration constant against a toolchain bump.

Both variants green in sim on both chips at two clocks each; the fixed-baud
suite is unaffected. Only real-hardware acceptance on an RC part remains
(pureboot/autobaud.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:45:22 +02:00
parent eecf9673b9
commit 14efef96cc
4 changed files with 271 additions and 19 deletions

View File

@@ -164,30 +164,49 @@ port's build.
guarantees safety" (README.md). Self-update still works — it is the safety net
against host bugs that is lost, not a functional path.
## What remains (either version)
## Host tool and simulation — done
The decider — does pure C++ fit 512 on every chip — is answered (yes). Not yet
done, and required before shipping:
Both are wired and green, for **both** variants.
1. **Sim-validate the lock.** Run the chosen variant over the GPIO⇄pty bridge at
≥3 exact F_CPU (1/8/16 MHz, the tiny13's 9.6 MHz): confirm it measures the
right unit and completes a flash + verify. This test is also what pins the
codegen-coupled calibration constant (0xC0 vs the 7-cycle loop) against a
toolchain bump.
2. **Host tool + protocol.** `pureboot.py` learns the calibration handshake
(send the 0xC0 lead, then knock at the locked rate), the slim info format
(derive geometry from the signature), and — for the pure version — that the
loader carries no write guard. README protocol section updated.
3. **Real-hardware acceptance.** Autobaud exists for what a cycle-exact simulator
cannot produce: a real RC oscillator at ±10 % with drift and jitter. Drive an
internal-oscillator ATtiny from the host at a fixed baud; confirm lock plus a
full flash + verify (Windows environment, hardware present — confirm first).
- **Host tool.** `pureboot.py --autobaud` sends the 0xC0 calibration pulse and a
single knock at the host's chosen baud, then reads the slimmed info block and
**derives the full geometry from the signature** (`AUTOBAUD_GEOMETRY`, a table
over every chip pureboot targets). The rest of the tool — flash, EEPROM, fuses,
hand-over, verify — is unchanged: once connected, the derived `Info` presents
the same geometry the fixed-baud path reads off the wire. The pure version's
dropped write guard is host-transparent (the tool already never targets the
running slot).
- **Sim test** (`test/pbautobaud.py`, `pureboot.autobaud_pure` /
`pureboot.autobaud_reg`). Drives each variant over the GPIO⇄pty software-UART
bridge through the calibration handshake, a flash + EEPROM + fuse round-trip
cross-checked against the simulator's ground-truth memory, and a hand-over to
the fixture application — then **repeats at double the F_CPU with the same
binary**, which is the clock-agnostic property autobaud exists for. Run on the
near-flash 328P and the word-addressed 1284P (both flash-addressing classes).
Because the lock fails a flash/verify if the measured unit is wrong, this test
also **pins the codegen-coupled calibration constant** (0xC0 against the
7-cycle poll loop): a toolchain bump that reshaped the loop would fail it.
## What remains
The decider is answered (pure C++ fits 512 on every chip) and both variants pass
in simulation. One item remains before shipping the chosen variant:
- **Real-hardware acceptance.** Autobaud exists for what a cycle-exact simulator
cannot produce: a real RC oscillator at ±10 % with drift and jitter. simavr
proves the arithmetic and the fit at *exact* clocks; only silicon proves the
feature does its job. Drive an internal-oscillator ATtiny from the host at a
fixed baud; confirm lock plus a full flash + verify (Windows environment,
hardware present — confirm first). Deferred until the variant is chosen.
## Files
- `pureboot_autobaud_pure.cpp` — the pure version (GPIOR/RAM unit, no guard).
- `pureboot_autobaud_reg.cpp` — the register version (GRV unit, guard kept).
- `CMakeLists.txt``pureboot_add_autobaud()` builds either; the port's build
size-tests both on every chip.
- `pureboot.py``--autobaud`: the calibration handshake, slim info, and the
signature→geometry table both variants rely on.
- `test/pbautobaud.py` — the end-to-end sim test; `CMakeLists.txt` runs it for
both variants on the 328P and 1284P, and `pureboot_add_autobaud()` size-tests
both on every chip.
- `local/scratch/autobaud/floor_1284.S` (libavr checkout) — the hand-asm floor
probe, off-tree and gitignored; not sim-verified, a size reference only.

View File

@@ -34,6 +34,44 @@ NEWEST_LOADER = 4
SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# Calibration byte for the autobaud loader: 0xC0 is a start bit plus six zero
# data bits — one low pulse of seven bit-times, which the loader times into its
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
CALIBRATE = 0xC0
# The autobaud loader slims its info block to the version and signature; the host
# derives the rest of the geometry from the signature. flash, page, eeprom,
# patch-vector per distinct signature, over every chip pureboot targets (the
# loader computes the same from its chip database at build time). Die revisions
# that share a signature share this row, as they share the silicon.
AUTOBAUD_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284
}
VERBOSE = False
@@ -301,6 +339,28 @@ Port = WindowsPort if os.name == "nt" else PosixPort
class Info:
"""The 12-byte info block."""
@classmethod
def from_slim(cls, raw):
"""The autobaud loader's slimmed reply — version and signature only —
with the rest of the geometry looked up from the signature (the loader
derived it from the same chip facts at build time). Reconstructs the full
block so every derived attribute matches the fixed-baud path exactly."""
if len(raw) != 4:
raise Error(f"bad slim info block: {raw.hex()}")
version, signature = raw[0], tuple(raw[1:4])
geometry = AUTOBAUD_GEOMETRY.get(signature)
if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no autobaud geometry for it")
flash, page, eeprom, patch = geometry
base = flash - SLOT
word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
raw12 = bytes((ord("P"), ord("B"), version, *signature, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, eeprom & 0xFF, eeprom >> 8, flags))
return cls(raw12)
def __init__(self, raw):
if len(raw) != 12 or raw[0:2] != b"PB":
raise Error(f"bad info block: {raw.hex()}")
@@ -396,6 +456,37 @@ class Loader:
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
def connect_autobaud(self, wait):
"""The autobaud handshake. Instead of the p+b knock, the host sends the
0xC0 calibration pulse — a single seven-bit-time low pulse at the host's
chosen baud — which the loader times into its per-bit unit, then a single
'p' knock the loader decodes at the rate it just measured. As with
connect(), each attempt is the whole handshake, retried until the slim
info block comes back or the window closes: a lost pulse or a knock that
lands while the loader is mid-frame simply fails to answer, and the
loader's measurement loop is back waiting for the next pulse."""
deadline = time.monotonic() + wait
knocks = 0
while True:
self.port.flush_input()
self.port.write(bytes((CALIBRATE, ord("p"))))
knocks += 1
if PROMPT in self.port.read_available(0.4):
while self.port.read_available(0.3):
pass
self.port.write(b"b")
try:
block = self.port.read_exact(4, 2.0)
except Error:
block = b""
if len(block) == 4:
self.info = Info.from_slim(block)
self._expect_prompt()
verbose(f"loader locked on knock {knocks}; slim info read")
return self.info
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
def _expect_prompt(self, timeout=2.0):
byte = self.port.read_exact(1, timeout)
if byte != PROMPT:
@@ -1049,6 +1140,9 @@ def main():
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1086,7 +1180,7 @@ def main():
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try:
loader = Loader(port)
info = loader.connect(args.wait)
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
if args.info:
print("device:")
for line in info.lines():