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

@@ -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():