diff --git a/CMakeLists.txt b/CMakeLists.txt index d579684..0e42fa9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -167,6 +167,9 @@ if(PROJECT_IS_TOP_LEVEL) add_test(NAME pureboot.planner COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) + add_test(NAME pureboot.scan + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py + ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) add_test(NAME pureboot.handshake COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) add_test(NAME pureboot.updatelink diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index c741959..863aed3 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -43,6 +43,13 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops UNIFIED_LOADER = 5 SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 +# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded +# as delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES, +# floored — the spin granule and per-bit overhead of libavr's software UART. +# --info undoes the encoding to report the true clock, which therefore sits +# within one granule below it. +UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8 + # A selector's high nibble is the flash bank — the address bits above the 16-bit # wire address — so a transfer names a byte address within one 64 KiB bank and # no command has to speak word addresses. No single transfer may cross a bank @@ -68,31 +75,36 @@ CALIBRATE = 0xC0 # from its chip database at build time). Die revisions that share a signature # share this row, as they share the silicon. CHIP_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 + # signature : (flash, page, eeprom, patch_vector, ram_start) + # ram_start is where SRAM begins in data space: the classic megas and the + # tinies keep it right after the plain I/O registers (0x60), the x8/x4 + # generations past their extended I/O file (0x100). An autobaud loader's + # measured bit period lives at exactly ram_start (its only RAM object; + # the loader's own build pins the layout), which is what --info reads. + (0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A + (0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25 + (0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45 + (0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85 + (0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A + (0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA + (0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A + (0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A + (0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA + (0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A + (0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A + (0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA + (0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA + (0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A + (0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A + (0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P + (0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328 + (0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P + (0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA + (0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A + (0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A + (0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA + (0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P + (0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284 } VERBOSE = False @@ -412,7 +424,7 @@ class Info: if geometry is None: sig = " ".join(f"{b:02x}" for b in signature) raise Error(f"unknown signature {sig} — this tool has no geometry for it") - flash, page, eeprom, patch = geometry + flash, page, eeprom, patch, _ = geometry base = flash - SLOT word_flash = flash > 0x10000 wire_base = base // 2 if word_flash else base @@ -448,6 +460,11 @@ class Info: # The hand-over target as 'J' takes it: the trampoline below the # loader, or word 0 where BOOTRST re-vectors reset in hardware. self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0 + # Where SRAM begins, from the signature — None only for a chip this + # tool has no geometry row for, which the wire-block path (v1–4) + # permits where from_identity refuses. + geometry = CHIP_GEOMETRY.get(tuple(self.signature)) + self.ram = geometry[4] if geometry else None def describe(self): sig = " ".join(f"{b:02x}" for b in self.signature) @@ -1353,6 +1370,58 @@ def op_fuses(loader): return fuse_bytes +def scan_ratios(): + """The probe walk, in percent of the built rate: the built rate itself + first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near + its trim is the common case, and each probe costs a reset.""" + return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)] + + +def scan_rate(baud, pct): + return round(baud * (100 + pct) / 100) + + +def scan_report(baud, pct, version, clock=None): + """The findings, one per line: the found rate is the session workaround, + its ratio to the built rate is the oscillator's offset, and the fixes are + the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build.""" + rate = scan_rate(baud, pct) + lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}", + f" session --baud {rate}"] + if clock: + lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})") + if pct: + direction = "lower" if pct > 0 else "higher" + lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), " + "or the autobaud build") + else: + lines.append(" fix none — the built rate answers; check the earlier wiring instead") + return lines + + +def op_scan(port_path, baud, wait, clock=None): + """A fixed-baud loader whose oscillator drifted still answers — at the + drifted ratio, since its rate scales with its clock. One probe per + activation window, and with an application resident the window opens + exactly once per reset, so each probe announces itself and expects a + fresh reset before knocking.""" + for pct in scan_ratios(): + rate = scan_rate(baud, pct) + print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True) + port = Port(port_path, rate) + try: + info = Loader(port).connect(wait) + except Error: + continue + finally: + port.close() + for line in scan_report(baud, pct, info.version, clock): + print(line) + return + raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the " + "autobaud build, which has no rate to miss (README.md)") + + # -------------------------------------------------------------------- cli --- @@ -1368,6 +1437,12 @@ def main(): 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("--scan", action="store_true", + help="walk ±10%% around --baud for a fixed-baud loader gone silent — one " + "reset per probe, standalone (README.md: deployment)") + parser.add_argument("--clock", type=int, metavar="HZ", + help="the clock the loader was built for — lets --scan and an autobaud " + "--info state drift in absolute terms") 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") @@ -1414,6 +1489,12 @@ def main(): except (ValueError, AssertionError): parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") + if args.scan: + if args.autobaud: + parser.error("--scan probes fixed rates; an autobaud loader has none to miss") + op_scan(args.port, args.baud, args.wait, args.clock) + return + port = Port(args.port, args.baud) verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted") try: @@ -1423,6 +1504,16 @@ def main(): print("device:") for line in info.lines(): print(f" {line}") + if args.autobaud: + # The whole of the loader's RAM is the measured bit period at + # ram_start; decoded and times the rate this session drives, + # that is the true clock — the number to hold an OSCCAL bake + # or a fixed-baud build against (README.md: deployment). + unit = int.from_bytes(loader.read_ram(info.ram, 2), "little") + cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT + clock = cycles * args.baud + offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else "" + print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})") fuse_bytes = fuse_override if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): read = op_fuses(loader) diff --git a/test/pbautobaud.py b/test/pbautobaud.py index 8875241..5e7831b 100644 --- a/test/pbautobaud.py +++ b/test/pbautobaud.py @@ -16,6 +16,7 @@ baked in. """ import os +import re import sys import time @@ -58,11 +59,23 @@ def main(): 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") + out = pbsim.run_tool(tool, device.pty, baud, "--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 ram_start. 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") diff --git a/test/test_scan.py b/test/test_scan.py new file mode 100644 index 0000000..6f275ac --- /dev/null +++ b/test/test_scan.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""--scan's walk and report logic, no simulator: the probe order, the rate +arithmetic, and the advice's direction. The rate physics itself is not +sim-testable — a pty carries bytes at any termios rate — so what the wire +would arbitrate is pinned here as logic instead. + +Usage: test_scan.py +""" + +import os +import sys + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) + + +def main(): + sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1]))) + import pureboot as pb + + walk = pb.scan_ratios() + if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]: + fail(f"probe walk is not built-rate-first, nearest-out: {walk}") + + if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216: + fail("probe rate arithmetic") + if pb.scan_rate(115200, 0) != 115200: + fail("the built rate must probe unchanged") + + # A loader answering fast means a fast oscillator: the trim goes down. + report = "\n".join(pb.scan_report(9600, 4, 6)) + for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"): + if needle not in report: + fail(f"+4 % report lacks {needle!r}:\n{report}") + report = "\n".join(pb.scan_report(9600, -6, 6)) + if "6 steps higher" not in report: + fail(f"-6 % report advises the wrong direction:\n{report}") + + report = "\n".join(pb.scan_report(9600, 0, 6)) + if "none" not in report or "steps" in report: + fail(f"an on-rate answer must advise no trim:\n{report}") + + report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000)) + if "9984000" not in report: + fail(f"the absolute clock must scale with the found ratio:\n{report}") + + print("OK") + + +if __name__ == "__main__": + main()