pureboot.py: --scan walks a silent loader's rate; --info decodes the clock

The RC-oscillator answer's host half. --scan probes ±10 % around the built
rate in 2 % steps, nearest first, one activation window (one reset) per
probe: a fixed-baud loader whose oscillator drifted answers at the ratio,
and the report gives the session workaround (--baud), the offset, the
OSCCAL direction at ~1 %/step, and the autobaud way out. The walk and the
advice are logic-tested (test_scan.py, red-proven on the trim direction) —
a pty carries bytes at any rate, so the wire cannot arbitrate them.

On an autobaud session --info now reads the measured bit period from
ram_start — the geometry table gains that column — and undoes the unit's
encoding ((cycles − 8) / 4, floored: libavr's spin granule and per-bit
overhead), so the printed clock is the true one within a granule; --clock
turns it into a stated drift. The autobaud end-to-end asserts the figure
inside exactly that envelope at both clock points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 01:01:35 +02:00
parent 69f089e53a
commit 52c4cdab32
4 changed files with 188 additions and 28 deletions

View File

@@ -167,6 +167,9 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.planner add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.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 add_test(NAME pureboot.handshake
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
add_test(NAME pureboot.updatelink add_test(NAME pureboot.updatelink

View File

@@ -43,6 +43,13 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
UNIFIED_LOADER = 5 UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 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 # 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 # 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 # 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 # from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon. # share this row, as they share the silicon.
CHIP_GEOMETRY = { CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector) # signature : (flash, page, eeprom, patch_vector, ram_start)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A # ram_start is where SRAM begins in data space: the classic megas and the
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25 # tinies keep it right after the plain I/O registers (0x60), the x8/x4
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45 # generations past their extended I/O file (0x100). An autobaud loader's
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85 # measured bit period lives at exactly ram_start (its only RAM object;
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A # the loader's own build pins the layout), which is what --info reads.
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA (0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A (0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A (0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA (0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A (0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A (0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA (0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA (0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A (0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A (0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P (0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328 (0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P (0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA (0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A (0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A (0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA (0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P (0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284 (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 VERBOSE = False
@@ -412,7 +424,7 @@ class Info:
if geometry is None: if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature) sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it") 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 base = flash - SLOT
word_flash = flash > 0x10000 word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base 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 # The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware. # 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 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 (v14)
# permits where from_identity refuses.
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
self.ram = geometry[4] if geometry else None
def describe(self): def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature) sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -1353,6 +1370,58 @@ def op_fuses(loader):
return fuse_bytes 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 --- # -------------------------------------------------------------------- cli ---
@@ -1368,6 +1437,12 @@ def main():
parser.add_argument("--autobaud", action="store_true", parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single " 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)") "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("--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("--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") parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1414,6 +1489,12 @@ def main():
except (ValueError, AssertionError): except (ValueError, AssertionError):
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") 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) port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted") verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try: try:
@@ -1423,6 +1504,16 @@ def main():
print("device:") print("device:")
for line in info.lines(): for line in info.lines():
print(f" {line}") 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 fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader) read = op_fuses(loader)

View File

@@ -16,6 +16,7 @@ baked in.
""" """
import os import os
import re
import sys import sys
import time import time
@@ -58,11 +59,23 @@ def main():
try: try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse # The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it. # and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses", out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
"--flash", app_bin, "--eeprom", ee_path, "--stay") "--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"): for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out: if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{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 both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin") read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin") read_eeprom = os.path.join(workdir, f"re_{label}.bin")

53
test/test_scan.py Normal file
View File

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