diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a1e774..a1e813c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -388,4 +388,30 @@ if(PROJECT_IS_TOP_LEVEL) ${CMAKE_BINARY_DIR}/pbusart1-work usart1) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) endif() + + # The autobaud variants driven end to end over the software-UART bridge (both + # under review — pureboot/autobaud.md): the host sends the 0xC0 calibration + # pulse, the loader times it, locks, and programs. Run on the near-flash 328P + # and the word-addressed 1284P — the two flash-addressing classes — and each + # at two clocks with the one binary, which is the clock-agnostic property + # autobaud exists for (test/pbautobaud.py). The fixture application banners + # over the same software link at the first clock's rate. + if(LIBAVR_MCU MATCHES "^atmega(328p|1284p)$" AND DEFINED PB_DEVICE) + add_executable(pbapp_autobaud test/pbapp.cpp) + target_link_libraries(pbapp_autobaud PRIVATE libavr) + target_compile_definitions(pbapp_autobaud PRIVATE PUREBOOT_CLOCK_HZ=1000000 + PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1) + add_custom_command(TARGET pbapp_autobaud POST_BUILD + COMMAND ${CMAKE_OBJCOPY} -O binary + $ $.bin) + foreach(_variant pure reg) + add_test(NAME pureboot.autobaud_${_variant} + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py + ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} + ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $.bin + 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbautobaud-${_variant}-work) + set_tests_properties(pureboot.autobaud_${_variant} PROPERTIES TIMEOUT 240) + endforeach() + endif() endif() diff --git a/pureboot/autobaud.md b/pureboot/autobaud.md index 97ac05e..3509777 100644 --- a/pureboot/autobaud.md +++ b/pureboot/autobaud.md @@ -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. diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 6a9585b..9307cd1 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -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(): diff --git a/test/pbautobaud.py b/test/pbautobaud.py new file mode 100644 index 0000000..bbfbee6 --- /dev/null +++ b/test/pbautobaud.py @@ -0,0 +1,113 @@ +#!/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 + + +The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's +default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). 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 sys + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) + + +def main(): + (device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:] + base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud) + 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="sw:B0,B1") + 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, "--autobaud", "--info", "--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}") + # 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, "--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: + device.reset() + port = pb.Port(device.pty, baud) + try: + loader = pb.Loader(port) + live = loader.connect_autobaud(15) + if live.version != pb.NEWEST_LOADER: + fail(f"{label}: loader reports pureboot {live.version}") + 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 "")) + + # 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) + print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks") + + +if __name__ == "__main__": + main()