The reading pass over this repo found the tiers disagreeing with themselves,
and every fix here was measured.
**The turn-around guard is real code.** `tsb_asm` and `tsb_tricks` wrote
`for (std::uint8_t guard = 46; guard; --guard) ;` between taking the one-wire
line and the first UDR0 store, under a comment naming it a turn-around guard.
It has no side effect, so GCC deleted it - `sts UCSR0B` went straight to
`sts UDR0` - while the hand-written oracle spends six bytes on that wait and
libavr's own half-duplex spends them through `delay::cycles`. Two of four
tiers described a feature they did not have, which made the size gradient a
comparison between different loaders. `avr::delay::cycles<one bit time>()`
bottoms out in asm and cannot be deleted.
**The entry belongs to the library, and hand-rolling it was expensive.** Three
tiers wrote their own naked `.vectors` stub with `asm volatile("clr
__zero_reg__")` - which design.md fences to libavr and never a port, and which
`tsb_tricks` denied having in its own title line. `avr::startup::entry` also
keeps the body `noinline` for a stated reason: avr-ld must not shrink a
`.vectors` section, so a loader inlined into one forfeits call relaxation
everywhere. `tsb_pure` came out **836 -> 734** bytes for that alone.
`stack::hardware` - the reset value this part guarantees, with the write kept
where a part does not - saved another four, which is what let `tsb_asm` afford
the guard it had been four bytes short of. It fills its 512-byte section
exactly now, with the whole feature set.
**`tsb_pure` had no receive timeout.** Its `rx()` was `read_blocking()`, so a
silent host wedged the password gate and the command loop forever - the one
fix the oracle's own header lists by name, and one the other three tiers
implement. It is bounded now, and 0-on-silence falls through every compare as
theirs does.
Three gates could pass without proving anything. `sizes.py check-readme`
reported a match when every row's lookup missed; `check_size.cmake` used
`CMAKE_MATCH_1` without checking the match succeeded, which is the guard its
sibling `check_unit.cmake` has and it is the size gate; `check_pi.py` raised
IndexError instead of reporting a position-independence break that changed the
image's length. And `check.sh` spelled the 37-chip list a second time beside
make_presets.py, where a chip added to one and missed in the other is a
silently unbuilt chip - it reads the presets now, and produces the same 37 and
12.
tsbtest.py gains the scenario nothing covered: a wrong password byte must
neither activate the loader nor reach the emergency erase behind it. Red-green
on a tier with the refusal removed.
Smaller, all measured or checked: the signature is `hw::db.signature` in every
tier as the page size and EEPROM end beside it already were; `act_min` derives
from the clock; pureboot.py's `rjmp` helpers refuse a part past rjmp's
4096-word reach rather than silently folding an offset (unreachable today, the
ATtiny85 sits exactly on it); the host tool calls space 2 `data` as the wire
and the loader do; `.clangd` strips the fifth GCC-only flag the build passes;
pbrig's bitclock guard reads its own ladder; pbreloc's unexplained retry is
gone, the write being reliable on five runs without it; and the four tier
sizes live in oracle/README.md's table instead of four file headers and a
CMake comment.
`--poke` before `--peek` turned out to be right - pbtest.py round-trips a poke
through the peek behind it - so the parser order and README say so now.
Every chip green, the README size table matching every image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1883 lines
87 KiB
Python
1883 lines
87 KiB
Python
#!/usr/bin/env python3
|
|
"""pureboot host tool - the smart half of the protocol (README.md).
|
|
|
|
The device exposes primitives; everything composite is here: HEX/raw images,
|
|
programming with repairing read-back verification, the reset-vector surgery
|
|
the boot-section-less chips need, and the self-update that stages the loader
|
|
one slot lower and lets it rewrite the resident.
|
|
|
|
Standard library only. The port is termios on POSIX and the Win32 serial API
|
|
through ctypes on Windows, so any tty or COM port works.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
if os.name == "nt":
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
else:
|
|
import array
|
|
import fcntl
|
|
import select
|
|
import termios
|
|
|
|
PROMPT = b"+"
|
|
# The refusal, from pureboot 9: the prompt inverted, so no single flipped bit
|
|
# turns "nothing happened" into "it did".
|
|
NAK = bytes((~PROMPT[0] & 0xFF,))
|
|
VERSION = 10 # this tool's own version - free to drift from a loader's
|
|
# The loader versions this tool can drive. A pureboot version implies its wire
|
|
# protocol, which carries no number of its own, so this window is where that
|
|
# map lives: the tool keeps a decoder for every generation in it (1-4 speak
|
|
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
|
|
# builds and changes nothing on the wire; 8 the one-wire deployments, whose
|
|
# only host-side trace is the --one-wire echo discard; 9 seals every command
|
|
# and answers each seal before acting), and a version it has no decoder for
|
|
# moves the floor.
|
|
OLDEST_LOADER = 1
|
|
NEWEST_LOADER = 9
|
|
SLOT = 512 # the loader slot, on every chip
|
|
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
|
|
|
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
|
|
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
|
|
# the spaces below. The loader carries one transfer loop instead of four bodies
|
|
# - which is what buys the data space and the host-issued SPM operations.
|
|
# 6 marks the builds that may carry a baked OSCCAL trim, nothing on the wire;
|
|
# 7 gives 'J' a selector byte (older loaders take the bare address - jump()
|
|
# sends each form to the version that speaks it) and re-homes the autobaud
|
|
# unit into the GPIOR pair where the chip has one; 8 marks the builds whose
|
|
# deployment may be one-wire (hardware half-duplex, or a software link folded
|
|
# onto one pin) - nothing on the wire either, but a shared line makes the
|
|
# host read its own bytes back, which is what --one-wire consumes.
|
|
UNIFIED_LOADER = 5
|
|
SP_FLASH, SP_EEPROM, SP_DATA, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
|
|
|
# pureboot 9 replaces the command letters with bits and seals every command.
|
|
# The header is one shape for all of them - opcode, selector, address, count,
|
|
# seal - and the loader answers the seal *before* it acts: PROMPT accepts, NAK
|
|
# refuses and nothing happened. That verdict is what lets a refusal stay local
|
|
# to its own command: the payload of a fill or a write burst only goes out
|
|
# after the header has been accepted, so a rejected header never leaves the
|
|
# host pushing bytes into a loader that has gone back to reading commands.
|
|
#
|
|
# A transfer is the absence of the other three opcodes, and its direction is
|
|
# the low bit. Identify is bit 5 because both knock bytes carry it: 'b' has to
|
|
# still ask the version (the host cannot know which generation it is talking to
|
|
# until something answers), and 'p' has to stay harmless against a loader
|
|
# already in session - pureboot 9 reserves no invalid opcode, so a knock that
|
|
# meant nothing before would otherwise eat the five bytes behind it.
|
|
SEALED_LOADER = 9
|
|
OP_WRITE, OP_FILL, OP_JUMP, OP_IDENTIFY = 1, 4, 8, 0x20
|
|
# What a sealed header's fields must fold to. Non-zero, so a run of one
|
|
# repeated byte - a stuck line, a page of erased flash read as a header -
|
|
# cannot satisfy it.
|
|
SEAL = 0x5A
|
|
# An SPM command carries its SPMCSR byte in the count field, where the seal
|
|
# covers it. There is no data phase: a byte after the header would arrive after
|
|
# the seal had already been checked, which is the hole the seal exists to close.
|
|
|
|
# An autobaud loader keeps its measured bit period readable, 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.
|
|
# v5/v6 keep it at ram_start; v7 moves it into GPIOR2:GPIOR1 on the chips
|
|
# that have the pair (their data addresses are in the geometry) and keeps
|
|
# ram_start only where they do not exist. --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
|
|
# boundary; the host chunks to keep that true.
|
|
def selector(space, address):
|
|
return space | ((address >> 16) << 4)
|
|
|
|
|
|
# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its
|
|
# byte to SPMCSR and fires the instruction at the selected flash address. Every
|
|
# part pureboot targets agrees on these encodings.
|
|
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
|
|
|
|
# Calibration byte for an 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
|
|
|
|
# pureboot 5 answers 'b' with its version and the chip signature; the host
|
|
# derives the rest of the geometry from the signature rather than reading a
|
|
# table off the device. 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.
|
|
CHIP_GEOMETRY = {
|
|
# signature : (flash, page, eeprom, patch_vector, ram_start, gpior1)
|
|
# 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). gpior1 is GPIOR1's
|
|
# data address - 0x32 on the t25/45/85, 0x4A from the x8 generation on,
|
|
# None where the chip has no pair (t13, classic megas). A v7 autobaud
|
|
# loader's measured bit period lives in GPIOR2:GPIOR1 where they exist
|
|
# and at exactly ram_start elsewhere (its only RAM object; the loader's
|
|
# own build pins the layout); v5/v6 always used ram_start. --info reads
|
|
# whichever home the answering version implies.
|
|
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60, None), # ATtiny13/13A
|
|
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60, 0x32), # ATtiny25
|
|
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60, 0x32), # ATtiny45
|
|
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60, 0x32), # ATtiny85
|
|
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48/48A
|
|
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48P/48PA
|
|
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60, None), # ATmega8/8A
|
|
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88/88A
|
|
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88P/88PA
|
|
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60, None), # ATmega16/16A
|
|
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168/168A
|
|
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168P/168PA
|
|
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164P/164PA
|
|
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164A
|
|
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60, None), # ATmega32/32A
|
|
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328P
|
|
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328
|
|
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324P
|
|
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324PA
|
|
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324A
|
|
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644/644A
|
|
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644P/644PA
|
|
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284P
|
|
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284
|
|
}
|
|
|
|
VERBOSE = False
|
|
|
|
|
|
def verbose(message):
|
|
if VERBOSE:
|
|
print(f" {message}")
|
|
|
|
|
|
class Error(Exception):
|
|
pass
|
|
|
|
|
|
class Progress:
|
|
"""A transient bar on stderr, drawn only for a tty and erased when done -
|
|
logs and pipes see only the summary line each operation prints. No label
|
|
or a zero total disables it, so callers can pass one unconditionally."""
|
|
|
|
def __init__(self, label, total, unit="pages"):
|
|
self.label, self.total, self.unit = label, total, unit
|
|
self.done = 0
|
|
self.width = 0
|
|
self.live = bool(label) and total > 0 and sys.stderr.isatty()
|
|
self._draw()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
if self.live:
|
|
sys.stderr.write("\r" + " " * self.width + "\r")
|
|
sys.stderr.flush()
|
|
|
|
def step(self, n=1):
|
|
self.done += n
|
|
self._draw()
|
|
|
|
def _draw(self):
|
|
if not self.live:
|
|
return
|
|
bar = 24 * self.done // self.total
|
|
line = (f"{self.label:<16} [{'#' * bar}{'-' * (24 - bar)}] "
|
|
f"{100 * self.done // self.total:3d}% {self.done}/{self.total} {self.unit}")
|
|
self.width = max(self.width, len(line))
|
|
sys.stderr.write("\r" + line)
|
|
sys.stderr.flush()
|
|
|
|
|
|
# ---------------------------------------------------------------- serial ---
|
|
|
|
|
|
class PosixPort:
|
|
"""A raw serial port with deadline-based reads, over termios. A rate with
|
|
no B-constant - the off-nominal probes `--scan` walks - goes through
|
|
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
|
|
by name."""
|
|
|
|
# The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed
|
|
# word offsets: four flag words, then a line-discipline byte and 19
|
|
# control chars padded to word 9 (include/uapi/asm-generic/termbits.h).
|
|
_TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B
|
|
_BOTHER, _CBAUD = 0o010000, 0o010017
|
|
_ISPEED, _OSPEED = 9, 10
|
|
|
|
@staticmethod
|
|
def _speed(baud):
|
|
return getattr(termios, f"B{baud}", None)
|
|
|
|
def _set_arbitrary(self, baud):
|
|
buf = array.array("i", [0] * (self._OSPEED + 1))
|
|
try:
|
|
fcntl.ioctl(self.fd, self._TCGETS2, buf, True)
|
|
buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER
|
|
buf[self._ISPEED] = buf[self._OSPEED] = baud
|
|
fcntl.ioctl(self.fd, self._TCSETS2, buf)
|
|
except OSError:
|
|
raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None
|
|
|
|
def _apply_baud(self, attrs, baud):
|
|
speed = self._speed(baud)
|
|
attrs[4] = attrs[5] = speed if speed is not None else termios.B38400
|
|
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
|
|
if speed is None:
|
|
self._set_arbitrary(baud)
|
|
self.baud = baud
|
|
|
|
def __init__(self, path, baud):
|
|
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
|
|
try:
|
|
attrs = termios.tcgetattr(self.fd)
|
|
attrs[0] = 0 # iflag
|
|
attrs[1] = 0 # oflag
|
|
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
|
|
attrs[3] = 0 # lflag
|
|
attrs[6][termios.VMIN] = 0
|
|
attrs[6][termios.VTIME] = 0
|
|
self._apply_baud(attrs, baud)
|
|
except BaseException:
|
|
os.close(self.fd)
|
|
raise
|
|
|
|
def set_baud(self, baud):
|
|
"""Retune the port without closing it - the fd stays open, so no DTR
|
|
pulse and no reset. That matters: the only caller is mid-session with a
|
|
loader copy that a reset would throw away."""
|
|
self._apply_baud(termios.tcgetattr(self.fd), baud)
|
|
|
|
def close(self):
|
|
os.close(self.fd)
|
|
|
|
def write(self, data):
|
|
os.write(self.fd, data)
|
|
|
|
def flush_input(self):
|
|
termios.tcflush(self.fd, termios.TCIFLUSH)
|
|
|
|
def read_available(self, wait):
|
|
"""Everything that arrives within `wait` seconds of quiet start."""
|
|
ready, _, _ = select.select([self.fd], [], [], wait)
|
|
return os.read(self.fd, 4096) if ready else b""
|
|
|
|
def read_exact(self, count, timeout):
|
|
data = b""
|
|
deadline = time.monotonic() + timeout
|
|
while len(data) < count:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise Error(f"timeout: got {len(data)} of {count} bytes")
|
|
ready, _, _ = select.select([self.fd], [], [], remaining)
|
|
if ready:
|
|
data += os.read(self.fd, count - len(data))
|
|
return data
|
|
|
|
|
|
if os.name == "nt":
|
|
# The same port, over the Win32 serial API - kernel32 through ctypes, so
|
|
# the tool stays standard-library only. Timeouts live in the driver
|
|
# (COMMTIMEOUTS) rather than in a readiness call: Windows has no select()
|
|
# for a COM handle, so each read asks the driver for its own deadline.
|
|
|
|
_GENERIC_READ, _GENERIC_WRITE = 0x80000000, 0x40000000
|
|
_OPEN_EXISTING, _PURGE_RXCLEAR = 3, 0x0008
|
|
_INVALID_HANDLE = wintypes.HANDLE(-1).value
|
|
# A gap this long ends a read_available(): longer than the coalescing a
|
|
# USB-serial adapter's latency timer imposes (16 ms on FTDI parts), so a
|
|
# burst is not split, short enough to stay responsive.
|
|
_GAP_MS = 30
|
|
|
|
class _DCB(ctypes.Structure):
|
|
_fields_ = [
|
|
("DCBlength", wintypes.DWORD),
|
|
("BaudRate", wintypes.DWORD),
|
|
("fBits", wintypes.DWORD), # the packed flag bitfield, set below
|
|
("wReserved", wintypes.WORD),
|
|
("XonLim", wintypes.WORD),
|
|
("XoffLim", wintypes.WORD),
|
|
("ByteSize", wintypes.BYTE),
|
|
("Parity", wintypes.BYTE),
|
|
("StopBits", wintypes.BYTE),
|
|
("XonChar", ctypes.c_char),
|
|
("XoffChar", ctypes.c_char),
|
|
("ErrorChar", ctypes.c_char),
|
|
("EofChar", ctypes.c_char),
|
|
("EvtChar", ctypes.c_char),
|
|
("wReserved1", wintypes.WORD),
|
|
]
|
|
|
|
class _COMMTIMEOUTS(ctypes.Structure):
|
|
_fields_ = [
|
|
("ReadIntervalTimeout", wintypes.DWORD),
|
|
("ReadTotalTimeoutMultiplier", wintypes.DWORD),
|
|
("ReadTotalTimeoutConstant", wintypes.DWORD),
|
|
("WriteTotalTimeoutMultiplier", wintypes.DWORD),
|
|
("WriteTotalTimeoutConstant", wintypes.DWORD),
|
|
]
|
|
|
|
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
_LPDWORD = ctypes.POINTER(wintypes.DWORD)
|
|
# Declared, not inferred: a HANDLE is a pointer, and a defaulted int
|
|
# return would truncate it on 64-bit.
|
|
_k32.CreateFileW.restype = wintypes.HANDLE
|
|
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
_k32.ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD, _LPDWORD, wintypes.LPVOID]
|
|
_k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD, _LPDWORD, wintypes.LPVOID]
|
|
_k32.GetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(_DCB)]
|
|
_k32.SetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(_DCB)]
|
|
_k32.SetCommTimeouts.argtypes = [wintypes.HANDLE, ctypes.POINTER(_COMMTIMEOUTS)]
|
|
_k32.PurgeComm.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
|
|
def _fail(what):
|
|
code = ctypes.get_last_error()
|
|
raise Error(f"{what}: {ctypes.FormatError(code).strip()} (Windows error {code})")
|
|
|
|
class WindowsPort:
|
|
"""A raw serial port with deadline-based reads, over Win32."""
|
|
|
|
def __init__(self, path, baud):
|
|
# Win32 takes the rate as a plain integer, so unlike termios any
|
|
# rate the hardware can divide down to is available - but a driver
|
|
# may also accept one it cannot produce (an FT232R takes a baud of
|
|
# 3, reports it back, and goes on using the previous divisor).
|
|
# Only obvious nonsense is refusable; the rest is the driver's word.
|
|
if baud < 50:
|
|
raise Error(f"unsupported baud rate {baud}")
|
|
# \\.\COM6: the device-namespace form. A bare COMn resolves only
|
|
# for n < 10, and double-digit ports are routine on Windows.
|
|
if path.lower().startswith("com") and path[3:].isdigit():
|
|
path = rf"\\.\{path}"
|
|
self.handle = _k32.CreateFileW(
|
|
path, _GENERIC_READ | _GENERIC_WRITE, 0, None, _OPEN_EXISTING, 0, None
|
|
)
|
|
if self.handle == _INVALID_HANDLE:
|
|
_fail(f"cannot open {path}")
|
|
self.timeouts = None
|
|
try:
|
|
dcb = _DCB()
|
|
dcb.DCBlength = ctypes.sizeof(_DCB)
|
|
if not _k32.GetCommState(self.handle, ctypes.byref(dcb)):
|
|
_fail(f"cannot read the state of {path}")
|
|
dcb.BaudRate, dcb.ByteSize, dcb.Parity, dcb.StopBits = baud, 8, 0, 0 # 8N1
|
|
# fBinary, and DTR/RTS asserted (fDtrControl and fRtsControl,
|
|
# two bits each, = _ENABLE); every other flag clear, so no
|
|
# parity and no flow control. Raising both matches what opening
|
|
# a POSIX tty does - including the reset pulse on the boards
|
|
# that wire DTR to it.
|
|
dcb.fBits = 0x1 | (1 << 4) | (1 << 12)
|
|
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
|
|
_fail(f"cannot configure {path} for {baud} baud 8N1")
|
|
# Arm them once here too: reads re-arm per call, but the write
|
|
# timeout would otherwise stay at the driver's default - which
|
|
# may be "wait forever" - until the first read.
|
|
self._deadline(_GAP_MS, 1000)
|
|
self.baud = baud
|
|
except Error:
|
|
# An open port outlives the exception otherwise, and a COM
|
|
# handle is exclusive: the next attempt would meet its own
|
|
# leftover as "Access is denied".
|
|
self.close()
|
|
raise
|
|
|
|
def set_baud(self, baud):
|
|
"""Retune the port on its live handle - SetCommState only, so the
|
|
handle is never reopened and DTR never drops. That matters: the only
|
|
caller is mid-session with a loader copy a reset would throw away."""
|
|
if baud < 50:
|
|
raise Error(f"unsupported baud rate {baud}")
|
|
dcb = _DCB()
|
|
dcb.DCBlength = ctypes.sizeof(_DCB)
|
|
if not _k32.GetCommState(self.handle, ctypes.byref(dcb)):
|
|
_fail("cannot read the port state")
|
|
dcb.BaudRate = baud
|
|
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
|
|
_fail(f"cannot retune the port to {baud} baud")
|
|
self.baud = baud
|
|
|
|
def close(self):
|
|
_k32.CloseHandle(self.handle)
|
|
|
|
def _deadline(self, interval, total):
|
|
"""Arm the driver's read timeouts: `interval` ms of quiet ends a
|
|
read once bytes have arrived, `total` ms ends it regardless."""
|
|
if self.timeouts == (interval, total):
|
|
return
|
|
spec = _COMMTIMEOUTS()
|
|
spec.ReadIntervalTimeout = interval
|
|
spec.ReadTotalTimeoutConstant = total
|
|
spec.WriteTotalTimeoutConstant = 5000
|
|
if not _k32.SetCommTimeouts(self.handle, ctypes.byref(spec)):
|
|
_fail("cannot set the port timeouts")
|
|
self.timeouts = (interval, total)
|
|
|
|
def _read(self, count):
|
|
buffer = ctypes.create_string_buffer(count)
|
|
got = wintypes.DWORD()
|
|
if not _k32.ReadFile(self.handle, buffer, count, ctypes.byref(got), None):
|
|
_fail("read failed")
|
|
return buffer.raw[: got.value]
|
|
|
|
def write(self, data):
|
|
written = wintypes.DWORD()
|
|
if not _k32.WriteFile(self.handle, data, len(data), ctypes.byref(written), None):
|
|
_fail("write failed")
|
|
if written.value != len(data):
|
|
raise Error(f"short write: {written.value} of {len(data)} bytes")
|
|
|
|
def flush_input(self):
|
|
if not _k32.PurgeComm(self.handle, _PURGE_RXCLEAR):
|
|
_fail("cannot flush the input buffer")
|
|
|
|
def read_available(self, wait):
|
|
"""Everything that arrives within `wait` seconds of quiet start."""
|
|
# A zero total means *no* timeout to the driver, so never round
|
|
# down to it - the same trap on the deadline below.
|
|
self._deadline(_GAP_MS, max(1, round(wait * 1000)))
|
|
return self._read(4096)
|
|
|
|
def read_exact(self, count, timeout):
|
|
data = b""
|
|
deadline = time.monotonic() + timeout
|
|
while len(data) < count:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise Error(f"timeout: got {len(data)} of {count} bytes")
|
|
# No interval timeout here: only the count or the deadline
|
|
# ends the read, so a gap mid-reply is simply waited out.
|
|
self._deadline(0, max(1, round(remaining * 1000)))
|
|
data += self._read(count - len(data))
|
|
return data
|
|
|
|
|
|
Port = WindowsPort if os.name == "nt" else PosixPort
|
|
|
|
|
|
class OneWirePort:
|
|
"""The host side of a shared line (--one-wire): an FTDI-style adapter on
|
|
a one-wire link reads back every byte it transmits - its RX is tied to
|
|
its own TX through the line. Consume that echo at each write and verify
|
|
it, which doubles as a wiring check: an echo that never comes is an RX
|
|
not on the line, and is reported as itself instead of decoding as a
|
|
device reply.
|
|
|
|
The device's reply may interleave with the echo of a multi-byte write -
|
|
a loader already in session re-prompts after the knock's first byte
|
|
while the second is still queued behind that reply - so the echo is
|
|
matched byte for byte and anything else arriving in between is device
|
|
traffic, held for the next read."""
|
|
|
|
def __init__(self, port):
|
|
self._port = port
|
|
self._pending = b""
|
|
self.lost_echoes = 0
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._port, name)
|
|
|
|
def write(self, data, blind=False):
|
|
"""Put `data` on the line and consume its echo.
|
|
|
|
`blind` marks the protocol's one multi-byte write with no ack between
|
|
its bytes - the knock. Aimed at a loader already in session, its first
|
|
byte draws a prompt while the second is still going out, and on real
|
|
wiring the device's push-pull ack **wins the line** against the host's
|
|
1 k series resistor: that second byte is *destroyed, not delayed*, and
|
|
its echo never comes. Measured on an ATtiny13A at 57600 - the loader
|
|
answers a single byte perfectly and loses the knock's second every
|
|
time. So on a blind write a missing echo is a property of the wiring
|
|
rather than a fault in it, and the caller's retry is what deals with
|
|
it. Every other write is ack-paced and cannot collide, so a missing
|
|
echo there really is an RX that is not on the line.
|
|
"""
|
|
data = bytes(data)
|
|
self._port.write(data)
|
|
# The echo arrives at line rate - 10 bits a byte - plus adapter
|
|
# latency; a generous floor keeps slow rates and USB scheduling out
|
|
# of the error path.
|
|
deadline = time.monotonic() + 10 * len(data) / self._port.baud + 0.5
|
|
remaining = data
|
|
while remaining and time.monotonic() < deadline:
|
|
# Speculative, so it cannot be read_exact, whose contract is to
|
|
# raise: doing that made the diagnosis below unreachable on every
|
|
# quiet line and surfaced a bare "timeout: got 0 of 1 bytes" in
|
|
# its place - the one message this class exists to replace.
|
|
for byte in self._port.read_available(0.02):
|
|
if remaining and byte == remaining[0]:
|
|
remaining = remaining[1:]
|
|
else:
|
|
self._pending += bytes((byte,))
|
|
if not remaining:
|
|
return
|
|
if not blind:
|
|
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
|
|
f"{len(data)} byte(s) - is the adapter's RX tied to the line?")
|
|
self.lost_echoes += len(remaining)
|
|
# Which loss this is matters, and the count says it. *Some* bytes lost is
|
|
# the device's ack winning the line against the host's series resistor -
|
|
# ordinary, and what the retry absorbs. *Every* byte lost is nothing
|
|
# coming back at all, which is a line that is not free: an application
|
|
# holding the shared pin low (this rig's LED demo ends that way), a
|
|
# wedge, or an RX that is not on the line. Same retry either way, but
|
|
# blaming an ack that never happened sends the reader to the wrong place.
|
|
if len(remaining) == len(data):
|
|
verbose(f"one-wire: none of {len(data)} byte(s) echoed - the line is not "
|
|
f"coming back. Held low by something? (a pin driven low, a wedge, "
|
|
f"or an RX not on the line)")
|
|
else:
|
|
verbose(f"one-wire: {len(remaining)} of {len(data)} knock byte(s) lost to the "
|
|
f"device's ack; retrying")
|
|
|
|
def write_blind(self, data):
|
|
self.write(data, blind=True)
|
|
|
|
def read_exact(self, count, timeout):
|
|
taken, self._pending = self._pending[:count], self._pending[count:]
|
|
if len(taken) == count:
|
|
return taken
|
|
return taken + self._port.read_exact(count - len(taken), timeout)
|
|
|
|
def read_available(self, wait):
|
|
taken, self._pending = self._pending, b""
|
|
return taken + self._port.read_available(0 if taken else wait)
|
|
|
|
def flush_input(self):
|
|
self._pending = b""
|
|
self._port.flush_input()
|
|
|
|
|
|
# -------------------------------------------------------------- protocol ---
|
|
|
|
|
|
class Info:
|
|
"""The 12-byte info block."""
|
|
|
|
@classmethod
|
|
def from_identity(cls, raw):
|
|
"""pureboot 5's reply: the version and the chip signature. The rest of
|
|
the geometry is looked up from the signature - the loader derived the
|
|
same facts from its chip database at build time, so nothing is guessed,
|
|
it is simply not sent. Reconstructs a block in the older layout, so
|
|
every derived attribute below is shared with the loaders that do send
|
|
one.
|
|
|
|
The base is where application flash ends, which is a property of the
|
|
chip and not of the copy answering: a loader staged one slot lower
|
|
reports the same geometry the resident one does, exactly as the loaders
|
|
that send a block do. Which slot a copy runs in is not something the
|
|
identity reports, and from pureboot 9 nothing on the device cares."""
|
|
if len(raw) != 4:
|
|
raise Error(f"bad identity reply: {raw.hex()}")
|
|
version, signature = raw[0], tuple(raw[1:4])
|
|
geometry = CHIP_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 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()}")
|
|
self.version = raw[2]
|
|
if not OLDEST_LOADER <= self.version <= NEWEST_LOADER:
|
|
raise Error(
|
|
f"pureboot {self.version}: this tool (version {VERSION}) speaks pureboot "
|
|
f"{OLDEST_LOADER}..{NEWEST_LOADER} - a newer loader needs a newer tool"
|
|
)
|
|
self.raw = bytes(raw)
|
|
self.signature = raw[3:6]
|
|
self.page = raw[6] or 256 # the wire count convention: 0 means 256
|
|
self.patch_vector = bool(raw[11] & 1)
|
|
# Bit 1: the flash runs past what one 16-bit address covers. Through
|
|
# pureboot 4 that made flash addresses words on the wire; pureboot 5
|
|
# keeps them bytes and carries the bank in the selector instead. Every
|
|
# address in this tool stays a byte address either way and converts at
|
|
# the wire.
|
|
self.word_flash = bool(raw[11] & 2)
|
|
scale = 2 if self.word_flash else 1
|
|
self.base = (raw[7] | (raw[8] << 8)) * scale
|
|
self.eeprom_size = raw[9] | (raw[10] << 8)
|
|
self.flash_size = self.base + SLOT
|
|
self.stage = self.base - SLOT # where a staging copy of the loader goes
|
|
# 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
|
|
# Where this loader keeps the measured bit period (None when a fixed
|
|
# signature row is missing): the GPIOR pair from v7 where the chip
|
|
# has one, ram_start before that and everywhere without the pair.
|
|
gpior1 = geometry[5] if geometry else None
|
|
self.unit_home = gpior1 if self.version >= 7 and gpior1 is not None else self.ram
|
|
|
|
def describe(self):
|
|
sig = " ".join(f"{b:02x}" for b in self.signature)
|
|
vector = "host-patched reset vector" if self.patch_vector else "hardware boot section"
|
|
return (
|
|
f"signature {sig}, page {self.page} B, "
|
|
f"app flash {self.base} B (loader at {self.base:#06x}), "
|
|
f"EEPROM {self.eeprom_size} B, {vector}"
|
|
)
|
|
|
|
def lines(self):
|
|
"""One fact per line - what --info prints."""
|
|
if self.patch_vector:
|
|
hand_over = f"host-patched reset vector, trampoline at {self.base - 2:#06x}"
|
|
else:
|
|
hand_over = "hardware boot section, jump to word 0"
|
|
return (
|
|
f"version pureboot {self.version}",
|
|
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
|
|
f"flash {self.flash_size} B, {self.page} B pages"
|
|
+ (", past one 16-bit bank" if self.word_flash else ""),
|
|
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
|
|
f"loader {self.base:#06x} ({SLOT} B slot)",
|
|
f"staging {self.stage:#06x}",
|
|
f"EEPROM {self.eeprom_size} B",
|
|
f"hand-over {hand_over}",
|
|
)
|
|
|
|
|
|
class Loader:
|
|
"""A session. Between commands the loader has prompted and awaits a
|
|
command byte; every method restores that, except jump() - after which the
|
|
target must be knocked afresh."""
|
|
|
|
def __init__(self, port):
|
|
self.port = port
|
|
self.info = None
|
|
# Set once a session is established over an autobaud link, so a
|
|
# re-entry after 'J' repeats the handshake that worked.
|
|
self.autobaud = False
|
|
# The pre-knock drain runs once per port: the bytes it exists for are
|
|
# leftovers from before this process opened the port. Re-knocks later
|
|
# in the same session must not pay it - a fresh activation window is
|
|
# already burning while they wait.
|
|
self._line_drained = False
|
|
# The link this session is speaking. It moves when the host follows a
|
|
# staging copy built for another one (enter_copy).
|
|
self.baud = getattr(port, "baud", None)
|
|
self._link_declared = False
|
|
|
|
def _read_identity(self):
|
|
"""The 'b' reply, in either of the two layouts a loader may send.
|
|
pureboot 5 answers with its version and the signature; older loaders
|
|
answer with a 12-byte block. The version byte cannot be mistaken for
|
|
the older block's 'P', so four bytes are enough to tell them apart.
|
|
|
|
The timeout is short on purpose: a real answer follows the prompt
|
|
within a frame time or two, so half a second is dozens of times the
|
|
worst case - while a *false* prompt match (a stale byte, reset
|
|
garbage) makes this read collect noise, and every second spent on it
|
|
comes out of the activation window the retry needs."""
|
|
head = self.port.read_exact(4, 0.5)
|
|
if head[0:2] == b"PB":
|
|
return Info(head + self.port.read_exact(8, 0.5))
|
|
return Info.from_identity(head)
|
|
|
|
def identity(self):
|
|
"""Ask a live session who it is.
|
|
|
|
The identity command takes no argument and changes nothing, which makes
|
|
it the one question whose answer is known in advance - so it doubles as
|
|
the host's check that the stream is still in step, and as the byte a
|
|
lost host resynchronises on."""
|
|
self.port.write(b"b")
|
|
answer = self._read_identity()
|
|
self._expect_prompt()
|
|
return answer
|
|
|
|
def _handshake(self, wait, knock, what):
|
|
"""One activation, retried until the loader answers or the window
|
|
closes. The identity reply is what proves the loader is listening - a
|
|
prompt byte alone does not, since one left over from a previous session
|
|
can still be in the pipeline while the port opening resets the device
|
|
into a fresh window, where a command without its knock is discarded.
|
|
Each attempt is therefore the whole handshake. This also converges into
|
|
an already-live session: the knock bytes are ignored there and the
|
|
drain absorbs whatever they produced.
|
|
|
|
Before the port's first knock ever, the line is drained until quiet: a
|
|
prompt from a previous session (`--stay`) can still be in the USB
|
|
pipeline when the port opens, where a flush cannot clear what has not
|
|
arrived yet - and on a board that resets when its port opens, trusting
|
|
that stale byte would spend the fresh activation window reading noise
|
|
from a device that never heard the knock. Once only, and bounded:
|
|
later re-knocks in this session face no foreign leftovers, and their
|
|
own window is already burning."""
|
|
deadline = time.monotonic() + wait
|
|
if not self._line_drained:
|
|
self._line_drained = True
|
|
drain = time.monotonic() + 0.25
|
|
while self.port.read_available(0.05):
|
|
if time.monotonic() > drain:
|
|
break
|
|
knocks = 0
|
|
refusal = None
|
|
# The knock is the only write in the protocol with no ack between its
|
|
# bytes, so on a shared line it is the only one whose echo may
|
|
# legitimately not come back - the device's ack collides with it and
|
|
# wins (OneWirePort.write). Losing a byte here is what the retry below
|
|
# is for; raising instead aborted the loop before it ever ran, which on
|
|
# real wiring made every reconnect into a live session fail.
|
|
knock_out = getattr(self.port, "write_blind", self.port.write)
|
|
while True:
|
|
self.port.flush_input()
|
|
knock_out(knock)
|
|
knocks += 1
|
|
if PROMPT in self.port.read_available(0.4):
|
|
# Settle: absorb a real loader's trailing bytes before asking
|
|
# for the identity. Bounded by the deadline so a target that
|
|
# never falls quiet - a board stuck in a reset loop, whose
|
|
# garbage carries a stray prompt - cannot spin here forever.
|
|
while self.port.read_available(0.3):
|
|
if time.monotonic() > deadline:
|
|
break
|
|
self.port.write(b"b")
|
|
try:
|
|
# A version the tool cannot speak is the loader's own
|
|
# answer, not a failed knock: Info reports it rather than
|
|
# sending the tool round the loop again.
|
|
self.info = self._read_identity()
|
|
except Error as failed:
|
|
if "pureboot" in str(failed):
|
|
raise
|
|
# A malformed or unknown identity is retried as noise, but
|
|
# it was an answer: if nothing better ever arrives, naming
|
|
# it beats reporting silence.
|
|
refusal = failed
|
|
self.info = None
|
|
if self.info is not None:
|
|
self._expect_prompt()
|
|
verbose(f"loader answered {what} {knocks}; identity read")
|
|
return self.info
|
|
if time.monotonic() > deadline:
|
|
if refusal is not None:
|
|
raise Error(f"no usable answer - the last identity reply failed: {refusal}")
|
|
raise Error("no answer - reset the device within its activation window")
|
|
|
|
def connect(self, wait):
|
|
"""Knock 'p' then 'b' and read the identity."""
|
|
return self._handshake(wait, b"pb", "knock")
|
|
|
|
def connect_autobaud(self, wait):
|
|
"""The autobaud handshake. In place of the p+b knock the host sends the
|
|
calibration pulse - one seven-bit-time low pulse at the host's chosen
|
|
baud, which the loader times into its per-bit unit - then a single 'p'
|
|
the loader decodes at the rate it just measured. A lost pulse, or a
|
|
knock landing while the loader is mid-frame, simply fails to answer and
|
|
leaves the measurement loop waiting for the next pulse, so the retry in
|
|
_handshake covers it."""
|
|
self.autobaud = True
|
|
return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration")
|
|
|
|
def _expect_prompt(self, timeout=2.0):
|
|
byte = self.port.read_exact(1, timeout)
|
|
if byte != PROMPT:
|
|
raise Error(f"expected prompt, got {byte.hex()}")
|
|
|
|
@property
|
|
def sealed(self):
|
|
"""pureboot 9 and later: bit opcodes, a sealed header, and a verdict
|
|
on that seal before the command runs."""
|
|
return self.info is not None and self.info.version >= SEALED_LOADER
|
|
|
|
def _header(self, op, space, address, count, timeout=2.0):
|
|
"""Send a sealed header and take the loader's verdict on it.
|
|
|
|
Returning normally means the loader has accepted the command and not
|
|
yet done it - which is the whole point of the verdict, and why the
|
|
payload of a fill or a write burst is sent only after this returns."""
|
|
head = bytes((op, selector(space, address), address & 0xFF, (address >> 8) & 0xFF, count & 0xFF))
|
|
seal = SEAL
|
|
for byte in head:
|
|
seal ^= byte
|
|
self.port.write(head + bytes((seal,)))
|
|
answer = self.port.read_exact(1, timeout)
|
|
if answer == NAK:
|
|
raise Error(
|
|
f"the loader refused the command (opcode {op:#04x}, {address:#06x}): the "
|
|
f"seal did not match, so nothing was done - the link mangled the header"
|
|
)
|
|
if answer != PROMPT:
|
|
raise Error(f"expected a verdict on the seal, got {answer.hex()}")
|
|
|
|
def _command(self, tx, reply_len=0, timeout=2.0):
|
|
self.port.write(tx)
|
|
reply = self.port.read_exact(reply_len, timeout) if reply_len else b""
|
|
self._expect_prompt(timeout)
|
|
return reply
|
|
|
|
def _stream_read(self, command, address, count, address_scale=1):
|
|
data = b""
|
|
while count:
|
|
chunk = min(count, 256)
|
|
wire = address // address_scale
|
|
head = bytes((ord(command), wire & 0xFF, wire >> 8, chunk & 0xFF))
|
|
data += self._command(head, chunk, 5.0)
|
|
address += chunk
|
|
count -= chunk
|
|
return data
|
|
|
|
@property
|
|
def unified(self):
|
|
"""pureboot 5 and later: one 'G'/'g' pair over selector-named spaces."""
|
|
return self.info is not None and self.info.version >= UNIFIED_LOADER
|
|
|
|
def _read_space(self, space, address, count):
|
|
"""A run out of any space, chunked to 256 bytes and to bank bounds."""
|
|
data = b""
|
|
while count:
|
|
chunk = min(count, 256, 0x10000 - (address & 0xFFFF))
|
|
if self.sealed:
|
|
self._header(0, space, address, chunk, 5.0)
|
|
data += self.port.read_exact(chunk, 5.0)
|
|
self._expect_prompt(5.0)
|
|
else:
|
|
head = bytes((ord("G"), selector(space, address), address & 0xFF,
|
|
(address >> 8) & 0xFF, chunk & 0xFF))
|
|
data += self._command(head, chunk, 5.0)
|
|
address += chunk
|
|
count -= chunk
|
|
return data
|
|
|
|
def _write_space(self, space, address, data, progress=None):
|
|
"""A run into any space. Each byte is acked as its write begins - an
|
|
EEPROM cell and an SPM operation both need that pacing, and the ack is
|
|
what the loader sends in place of a completion status."""
|
|
offset = 0
|
|
while offset < len(data):
|
|
chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))]
|
|
if self.sealed:
|
|
self._header(OP_WRITE, space, address, len(chunk))
|
|
else:
|
|
self.port.write(bytes((ord("g"), selector(space, address), address & 0xFF,
|
|
(address >> 8) & 0xFF, len(chunk) & 0xFF)))
|
|
for byte in chunk:
|
|
self.port.write(bytes((byte,)))
|
|
self._expect_prompt()
|
|
if progress:
|
|
progress.step()
|
|
self._expect_prompt() # the next command prompt
|
|
address += len(chunk)
|
|
offset += len(chunk)
|
|
|
|
def spm(self, operation, address):
|
|
"""One SPM operation at a flash address - the erase, write and RWW
|
|
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here.
|
|
|
|
From pureboot 9 the operation rides the header's count field instead of
|
|
arriving as data behind it, which is what puts it inside the seal: the
|
|
loader will not hand a byte to SPMCSR that the host did not seal, and
|
|
the verdict says whether it did."""
|
|
if self.sealed:
|
|
self._header(OP_WRITE, SP_SPM, address, operation, 5.0)
|
|
self._expect_prompt(5.0)
|
|
return
|
|
self._write_space(SP_SPM, address, bytes((operation,)))
|
|
|
|
def read_data(self, address, count):
|
|
"""Data space: SRAM, and with it the register file and every I/O
|
|
register, which share the address space on AVR. New in pureboot 5."""
|
|
return self._read_space(SP_DATA, address, count)
|
|
|
|
def write_data(self, address, data):
|
|
self._write_space(SP_DATA, address, data)
|
|
|
|
def read_flash(self, address, count):
|
|
if self.unified:
|
|
return self._read_space(SP_FLASH, address, count)
|
|
if not self.info.word_flash:
|
|
return self._stream_read("R", address, count)
|
|
# Word-addressed wire: widen to even bounds and never let one read
|
|
# cross a 64 KiB boundary (the device holds RAMPZ for a whole run).
|
|
start = address & ~1
|
|
span = (address + count + 1 & ~1) - start
|
|
data = b""
|
|
at = start
|
|
remaining = span
|
|
while remaining:
|
|
chunk = min(remaining, 0x10000 - (at & 0xFFFF))
|
|
data += self._stream_read("R", at, chunk, address_scale=2)
|
|
at += chunk
|
|
remaining -= chunk
|
|
return data[address - start : address - start + count]
|
|
|
|
def read_eeprom(self, address, count):
|
|
if self.unified:
|
|
return self._read_space(SP_EEPROM, address, count)
|
|
return self._stream_read("r", address, count)
|
|
|
|
def write_page(self, address, data):
|
|
assert len(data) == self.info.page and address % self.info.page == 0
|
|
if self.unified:
|
|
# The fill loads the page buffer and stops there; the erase and the
|
|
# write are host-issued SPM operations. Only a chip with a boot
|
|
# section has RWW to re-enable - on the others bit 4 of SPMCSR
|
|
# means something else entirely, so it must not be sent.
|
|
if self.sealed:
|
|
# The page goes out only once the header is accepted. It is the
|
|
# protocol's one unacked burst, so a header refused after the
|
|
# host had already started sending it would leave the page
|
|
# being read as commands - which is exactly what the verdict
|
|
# is placed here to prevent.
|
|
self._header(OP_FILL, SP_FLASH, address, len(data) & 0xFF)
|
|
self.port.write(data)
|
|
self._expect_prompt(2.0)
|
|
else:
|
|
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
|
|
self._command(head + data, 0, 2.0)
|
|
self.spm(SPM_ERASE, address)
|
|
self.spm(SPM_WRITE, address)
|
|
if not self.info.patch_vector:
|
|
self.spm(SPM_RWWSRE, address)
|
|
return
|
|
wire = address // (2 if self.info.word_flash else 1)
|
|
head = bytes((ord("W"), wire & 0xFF, wire >> 8))
|
|
self._command(head + data, 0, 2.0)
|
|
|
|
def write_eeprom(self, address, data, progress=None):
|
|
if self.unified:
|
|
self._write_space(SP_EEPROM, address, data, progress)
|
|
return
|
|
offset = 0
|
|
while offset < len(data):
|
|
chunk = data[offset : offset + 256]
|
|
head = bytes((ord("w"), address & 0xFF, address >> 8, len(chunk) & 0xFF))
|
|
self.port.write(head)
|
|
for byte in chunk:
|
|
self.port.write(bytes((byte,)))
|
|
self._expect_prompt() # per-byte ack: the write has begun
|
|
if progress:
|
|
progress.step()
|
|
self._expect_prompt() # the next command prompt
|
|
address += len(chunk)
|
|
offset += len(chunk)
|
|
|
|
def read_fuses(self):
|
|
if self.unified:
|
|
return self._read_space(SP_FUSE, 0, 4)
|
|
return self._command(b"F", 4, 2.0)
|
|
|
|
def jump(self, word_address):
|
|
"""The device acks, then execution continues at the word address.
|
|
From v7 the jump rides the unified decode, so it carries a selector
|
|
byte the loader ignores; older loaders take the bare address. From v9
|
|
the ack is the verdict on its seal - a jump to a mangled address is a
|
|
jump into arbitrary code, so it is sealed like everything else."""
|
|
if self.sealed:
|
|
self._header(OP_JUMP, 0, word_address, 0)
|
|
return
|
|
if self.info.version >= 7:
|
|
self.port.write(bytes((ord("J"), 0, word_address & 0xFF, word_address >> 8)))
|
|
else:
|
|
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
|
|
self._expect_prompt()
|
|
|
|
def enter_copy(self, byte_address, wait, link=None):
|
|
"""Jump into the loader copy at `byte_address` and knock it - a slot
|
|
base is that copy's entry stub, so it can only land there.
|
|
|
|
`link` is that copy's own `(baud, autobaud)`, for when it is not this
|
|
session's. A staging copy *is* the new image, so it speaks the rate and
|
|
backend it was built for; the host has to be told which, because 512
|
|
bytes of position-independent code carry no header to read it from.
|
|
Retuning goes through the open port, so no DTR pulse resets the copy that
|
|
is now running - and the session keeps the new link afterwards, since
|
|
every later jump lands in the same image.
|
|
"""
|
|
baud, autobaud = link if link is not None else (self.baud, self.autobaud)
|
|
if link is not None:
|
|
self._link_declared = True
|
|
self.jump(byte_address // 2)
|
|
if baud is not None and baud != self.baud:
|
|
self.port.set_baud(baud)
|
|
self.baud = baud
|
|
self.autobaud = autobaud
|
|
try:
|
|
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
|
|
except Error as unheard:
|
|
if self._link_declared:
|
|
raise
|
|
# The bare activation timeout sends the operator to look at wiring,
|
|
# while on a patched-vector part the application region is already
|
|
# gone. Name the one cause that fits: the copy answers on its own
|
|
# link, not the resident's.
|
|
raise Error(
|
|
f"the copy at {byte_address:#06x} did not answer on this session's "
|
|
f"link ({baud} Bd, {'autobaud' if autobaud else 'fixed baud'}). An "
|
|
f"image built for another baud or backend speaks that one instead - "
|
|
f"say which with --staged-baud / --staged-autobaud"
|
|
) from unheard
|
|
|
|
def run_application(self):
|
|
self.jump(self.info.app_entry_word)
|
|
|
|
|
|
# ---------------------------------------------------------------- images ---
|
|
|
|
|
|
def load_image(path):
|
|
"""Raw binary, or Intel HEX by extension (.hex/.ihx/.ihex)."""
|
|
data = open(path, "rb").read()
|
|
if not path.lower().endswith((".hex", ".ihx", ".ihex")):
|
|
if not data:
|
|
raise Error(f"{path}: empty image")
|
|
return data
|
|
memory = {}
|
|
for number, line in enumerate(data.decode("ascii", "replace").splitlines(), 1):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if not line.startswith(":"):
|
|
raise Error(f"{path}:{number}: not an Intel HEX record")
|
|
record = bytes.fromhex(line[1:])
|
|
if sum(record) & 0xFF:
|
|
raise Error(f"{path}:{number}: checksum mismatch")
|
|
count, address, kind = record[0], (record[1] << 8) | record[2], record[3]
|
|
payload = record[4 : 4 + count]
|
|
if kind == 0:
|
|
for i, byte in enumerate(payload):
|
|
memory[address + i] = byte
|
|
elif kind == 1:
|
|
break
|
|
elif kind in (2, 4) and not any(payload):
|
|
continue # a zero base extends nothing
|
|
elif kind in (3, 5):
|
|
continue # start address: irrelevant, reset is the entry
|
|
else:
|
|
raise Error(f"{path}:{number}: record type {kind} reaches beyond the 16-bit space")
|
|
if not memory:
|
|
raise Error(f"{path}: empty image")
|
|
return bytes(memory.get(i, 0xFF) for i in range(max(memory) + 1))
|
|
|
|
|
|
# --------------------------------------------------------------- surgery ---
|
|
|
|
|
|
# rjmp's displacement is 12 bits, so a part whose flash is wider than 4096
|
|
# words cannot be walked this way: an offset the hardware wraps modulo the
|
|
# flash size and one it wraps modulo 4096 are then different addresses, and
|
|
# nothing in the opcode says which was meant. Every chip that needs the
|
|
# reset-vector surgery is at or under that today - the ATtiny85 sits exactly on
|
|
# it - and this is what fails loudly if one is ever added that is not.
|
|
RJMP_REACH_WORDS = 1 << 12
|
|
|
|
|
|
def rjmp_wraps_cleanly(flash_words):
|
|
return flash_words <= RJMP_REACH_WORDS
|
|
|
|
|
|
def rjmp_target(word_address, opcode, flash_words):
|
|
if not rjmp_wraps_cleanly(flash_words):
|
|
raise Error(f"{flash_words} words of flash is past rjmp's {RJMP_REACH_WORDS}-word reach - "
|
|
f"a relocated reset vector cannot be read back from the opcode alone")
|
|
return (word_address + 1 + (opcode & 0x0FFF)) % flash_words
|
|
|
|
|
|
def rjmp_to(word_address, destination, flash_words):
|
|
if not rjmp_wraps_cleanly(flash_words):
|
|
raise Error(f"{flash_words} words of flash is past rjmp's {RJMP_REACH_WORDS}-word reach - "
|
|
f"a relocated reset vector cannot be spelled as one rjmp")
|
|
return 0xC000 | ((destination - word_address - 1) % flash_words % 0x1000)
|
|
|
|
|
|
def plan_flash(image, info):
|
|
"""The pages to program, as {page_address: bytes}, already carrying the
|
|
reset-vector surgery where the chip needs it."""
|
|
page = info.page
|
|
limit = info.base - (2 if info.patch_vector else 0)
|
|
if len(image) > limit:
|
|
raise Error(f"image is {len(image)} B, application flash ends at {limit}")
|
|
final = bytearray(image) + bytearray([0xFF] * (-len(image) % page))
|
|
|
|
if info.patch_vector:
|
|
flash_words = info.flash_size // 2
|
|
word0 = final[0] | (final[1] << 8)
|
|
if word0 & 0xF000 != 0xC000:
|
|
raise Error(
|
|
"the image's reset vector is not an rjmp - pureboot's vector "
|
|
"surgery cannot re-home it (crt-less entry at address 0?)"
|
|
)
|
|
entry = rjmp_target(0, word0, flash_words)
|
|
if entry >= info.base // 2:
|
|
raise Error(
|
|
"the image's reset vector already targets the loader - this "
|
|
"is a read-back of a patched image; flash the original"
|
|
)
|
|
trampoline_word = (info.base - 2) // 2
|
|
patch = rjmp_to(0, info.base // 2, flash_words)
|
|
final[0], final[1] = patch & 0xFF, patch >> 8
|
|
trampoline_page = info.base - page
|
|
if len(final) < trampoline_page + page:
|
|
final += bytearray([0xFF] * (trampoline_page + page - len(final)))
|
|
jump = rjmp_to(trampoline_word, entry, flash_words)
|
|
final[info.base - 2], final[info.base - 1] = jump & 0xFF, jump >> 8
|
|
verbose(f"vector surgery: word 0 -> loader {info.base:#06x}, "
|
|
f"trampoline {info.base - 2:#06x} -> entry word {entry:#06x}")
|
|
|
|
pages = {a: bytes(final[a : a + page]) for a in range(0, len(final), page)}
|
|
return pages
|
|
|
|
|
|
def covered(pages, info, skip_blank):
|
|
"""Pages in programming order, optionally dropping all-0xff ones (sound
|
|
only over erased flash, and never a load-bearing page).
|
|
|
|
A patched vector puts page 0 first and the trampoline page second, so from
|
|
the first write on a reset lands in the loader and its fall-through on the
|
|
application entry - every interruption point recoverable. A hardware boot
|
|
section re-vectors reset regardless; page 0 goes last there, which
|
|
maximizes what an interrupted image retains."""
|
|
trampoline_page = info.base - info.page if info.patch_vector else None
|
|
first = [0, trampoline_page] if info.patch_vector else []
|
|
rest = [a for a in sorted(pages) if a not in first]
|
|
if skip_blank:
|
|
rest = [a for a in rest if pages[a].count(0xFF) != len(pages[a])]
|
|
order = [a for a in first if a in pages] + rest
|
|
if not info.patch_vector:
|
|
order = [a for a in order if a != 0] + ([0] if 0 in pages else [])
|
|
return order
|
|
|
|
|
|
# ----------------------------------------------------------------- fuses ---
|
|
|
|
|
|
# Per-chip boot fuse geometry, keyed by the signature's family/part bytes:
|
|
# which byte of the 'F' reply (low, lock, extended, high) carries BOOTSZ/
|
|
# BOOTRST, and the BOOTSZ->words ladder. A die revision shares its base
|
|
# signature, so one row covers it. The m48s have no boot section and no
|
|
# row - their info block says patch-vector and this table is never
|
|
# consulted. Sources: Atmel-2486/2466/2503 (HIGH fuse), Atmel-2545/8271/
|
|
# DS40002065 (x8: EXTENDED, except the m328s' HIGH), Atmel-8272/8011/2593/
|
|
# 42719 (x4: HIGH).
|
|
_LADDER_128 = {0b11: 128, 0b10: 256, 0b01: 512, 0b00: 1024}
|
|
_LADDER_256 = {0b11: 256, 0b10: 512, 0b01: 1024, 0b00: 2048}
|
|
_LADDER_512 = {0b11: 512, 0b10: 1024, 0b01: 2048, 0b00: 4096}
|
|
BOOT_FUSE = {
|
|
bytes((0x93, 0x07)): (3, _LADDER_128), # m8/8A
|
|
bytes((0x94, 0x03)): (3, _LADDER_128), # m16/16A
|
|
bytes((0x95, 0x02)): (3, _LADDER_256), # m32/32A
|
|
bytes((0x93, 0x0A)): (2, _LADDER_128), # m88/88A
|
|
bytes((0x93, 0x0F)): (2, _LADDER_128), # m88P/88PA
|
|
bytes((0x94, 0x06)): (2, _LADDER_128), # m168/168A
|
|
bytes((0x94, 0x0B)): (2, _LADDER_128), # m168P/168PA
|
|
bytes((0x95, 0x14)): (3, _LADDER_256), # m328
|
|
bytes((0x95, 0x0F)): (3, _LADDER_256), # m328P
|
|
bytes((0x94, 0x0F)): (3, _LADDER_128), # m164A
|
|
bytes((0x94, 0x0A)): (3, _LADDER_128), # m164P/164PA
|
|
bytes((0x95, 0x15)): (3, _LADDER_256), # m324A
|
|
bytes((0x95, 0x08)): (3, _LADDER_256), # m324P
|
|
bytes((0x95, 0x11)): (3, _LADDER_256), # m324PA
|
|
bytes((0x96, 0x09)): (3, _LADDER_512), # m644/644A
|
|
bytes((0x96, 0x0A)): (3, _LADDER_512), # m644P/644PA
|
|
bytes((0x97, 0x06)): (3, _LADDER_512), # m1284
|
|
bytes((0x97, 0x05)): (3, _LADDER_512), # m1284P
|
|
}
|
|
|
|
|
|
def mega_boot(info, fuse_bytes):
|
|
"""Decode a mega's boot configuration from its fuses (the byte and the
|
|
BOOTSZ ladder are per chip): BOOTSZ1:0 in bits 2:1 select the
|
|
boot-section words, BOOTRST in bit 0 (programmed = 0) re-vectors reset
|
|
to its start. Returns (bootrst_programmed, boot_section_start_byte)."""
|
|
entry = BOOT_FUSE.get(bytes(info.signature[1:3]))
|
|
if entry is None:
|
|
raise Error(f"unknown mega signature {info.signature.hex()} - no boot fuse map")
|
|
which, ladder = entry
|
|
fuse = fuse_bytes[which]
|
|
words = ladder[(fuse >> 1) & 0x03]
|
|
return (fuse & 1) == 0, info.flash_size - words * 2
|
|
|
|
|
|
# ---------------------------------------------------------- loader update ---
|
|
|
|
|
|
def image_info(image):
|
|
"""What a pureboot binary says about itself, or None.
|
|
|
|
An update image is a bare slot: nothing about it names the chip it was
|
|
built for, and installing a foreign one bricks the target - so every
|
|
loader carries a stamp for this. Through pureboot 4 the stamp is the
|
|
12-byte info block the device also serves; pureboot 5 serves its identity
|
|
from immediates and carries a 6-byte stamp (magic, version, signature)
|
|
that only this exists for, from which the geometry is looked up exactly as
|
|
it is for a live device.
|
|
|
|
Searched once per known version, so the magic stays three selective bytes
|
|
rather than two that code could carry by chance."""
|
|
for version in range(OLDEST_LOADER, NEWEST_LOADER + 1):
|
|
at = image.find(b"PB" + bytes((version,)))
|
|
if at < 0:
|
|
continue
|
|
if version >= UNIFIED_LOADER:
|
|
if at <= len(image) - 6:
|
|
return Info.from_identity(image[at + 2 : at + 6])
|
|
elif at <= len(image) - 12:
|
|
return Info(image[at : at + 12])
|
|
return None
|
|
|
|
|
|
def loader_image(path):
|
|
"""An update image as the slot's own content: a raw binary already is,
|
|
while a HEX carries the blank below the loader's base, which is peeled off
|
|
here. The base comes from the image's own block, not the device's, so a
|
|
foreign image survives intact for the preflight to reject by name."""
|
|
image = load_image(path)
|
|
embedded = image_info(image)
|
|
if embedded and len(image) > embedded.base:
|
|
image = image[embedded.base :]
|
|
return image
|
|
|
|
|
|
def staging_content(image, info):
|
|
"""The staging slot's content: the image, padding, and - where the
|
|
hand-over jumps through the word below the resident - that word, which for
|
|
a staging copy is its own last one. Composed as an rjmp to the resident,
|
|
so an abandoned staging copy still falls through into a loader."""
|
|
budget = SLOT - 2 if info.patch_vector else SLOT
|
|
if len(image) > budget:
|
|
raise Error(f"loader image is {len(image)} B, the slot holds {budget}")
|
|
content = bytearray(image) + bytearray([0xFF] * (SLOT - len(image)))
|
|
if info.patch_vector:
|
|
through = rjmp_to((info.base - 2) // 2, info.base // 2, info.flash_size // 2)
|
|
content[SLOT - 2], content[SLOT - 1] = through & 0xFF, through >> 8
|
|
return bytes(content)
|
|
|
|
|
|
def update_preflight(image, info, fuse_bytes):
|
|
"""Errors and warnings before any flash is touched. Returns warnings."""
|
|
embedded = image_info(image)
|
|
if embedded is None:
|
|
raise Error(
|
|
"no pureboot info block in the update image - not a pureboot binary, "
|
|
f"or a version this tool ({VERSION}) does not know"
|
|
)
|
|
if embedded.raw[3:] != info.raw[3:]:
|
|
raise Error(
|
|
f"update image is for another target: it declares "
|
|
f"[{embedded.describe()}], the device says [{info.describe()}]"
|
|
)
|
|
warnings = []
|
|
if not info.patch_vector:
|
|
if fuse_bytes is None:
|
|
raise Error("a loader update on this chip needs its fuses - unreadable? pass --assume-fuses")
|
|
bootrst, bls_start = mega_boot(info, fuse_bytes)
|
|
if info.stage < bls_start:
|
|
raise Error(
|
|
f"cannot self-update: the staging slot {info.stage:#06x} lies below the "
|
|
f"boot section ({bls_start:#06x}) where SPM is disabled "
|
|
f" - a boot section of at least two slots ({2 * SLOT} B, BOOTSZ) is "
|
|
f"required, and only an external programmer can change fuses"
|
|
)
|
|
if not bootrst:
|
|
warnings.append(
|
|
"BOOTRST unprogrammed: reset boots the application throughout the update; "
|
|
"an interruption is recovered by re-running this update"
|
|
)
|
|
elif bls_start == info.stage:
|
|
warnings.append(
|
|
"BOOTRST targets the staging slot: brief unrecoverable windows exist while "
|
|
"the staging copy itself is being installed or retired (page-write scale)"
|
|
)
|
|
else:
|
|
warnings.append(
|
|
f"BOOTRST targets {bls_start:#06x}, inside application flash: reset reaches a "
|
|
f"loader only across erased flash from there"
|
|
)
|
|
return warnings
|
|
|
|
|
|
class UpdateState:
|
|
"""The host-side memory of an update in flight: what the staging slot
|
|
held (and page 0, where the update repoints it). Losing this file after
|
|
the staging slot was overwritten loses those saved bytes - the update
|
|
still completes, but the staging region can then only be restored by
|
|
reflashing the application."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.data = None
|
|
|
|
def load_or_save(self, loader):
|
|
info = loader.info
|
|
if os.path.exists(self.path):
|
|
self.data = json.load(open(self.path))
|
|
if bytes.fromhex(self.data["signature"]) != info.signature or self.data["base"] != info.base:
|
|
raise Error(f"{self.path} belongs to a different device - remove it to start over")
|
|
return
|
|
self.data = {
|
|
"signature": info.signature.hex(),
|
|
"base": info.base,
|
|
"staging": loader.read_flash(info.stage, SLOT).hex(),
|
|
"page0": loader.read_flash(0, info.page).hex() if info.patch_vector else "",
|
|
}
|
|
with open(self.path, "w") as f:
|
|
json.dump(self.data, f)
|
|
|
|
@property
|
|
def staging(self):
|
|
return bytes.fromhex(self.data["staging"])
|
|
|
|
@property
|
|
def page0(self):
|
|
return bytes.fromhex(self.data["page0"])
|
|
|
|
def discard(self):
|
|
os.unlink(self.path)
|
|
|
|
|
|
def write_differing(loader, base, content, order=None, label=None):
|
|
"""Program the pages of `content` at `base` that differ from flash, so a
|
|
resumed phase redoes only what an interruption left."""
|
|
page = loader.info.page
|
|
offsets = list(order) if order is not None else list(range(0, len(content), page))
|
|
written = 0
|
|
with Progress(label, len(offsets)) as bar:
|
|
for offset in offsets:
|
|
want = content[offset : offset + page]
|
|
if loader.read_flash(base + offset, page) != want:
|
|
loader.write_page(base + offset, want)
|
|
written += 1
|
|
bar.step()
|
|
if label:
|
|
verbose(f"{label}: {written} of {len(offsets)} pages differed")
|
|
# The same bounded repair as verify_pages: here a page left wrong is a
|
|
# half-written loader slot.
|
|
for retry in range(RETRIES + 1):
|
|
bad = [
|
|
offset
|
|
for offset in range(0, len(content), page)
|
|
if loader.read_flash(base + offset, len(content[offset : offset + page])) != content[offset : offset + page]
|
|
]
|
|
if not bad:
|
|
break
|
|
if retry == RETRIES:
|
|
raise Error(
|
|
f"verify failed at {base + bad[0]:#06x} after programming "
|
|
f"(still wrong after {RETRIES} retries)"
|
|
)
|
|
for offset in bad:
|
|
verbose(f"rewriting page {base + offset:#06x} (retry {retry + 1})")
|
|
loader.write_page(base + offset, content[offset : offset + page])
|
|
written += 1
|
|
return written
|
|
|
|
|
|
def patch_word0(loader, page0, target_base):
|
|
"""Re-aim word 0 at `target_base` - the resume insurance around
|
|
rewriting a loader slot the reset path goes through."""
|
|
info = loader.info
|
|
patched = bytearray(page0)
|
|
word = rjmp_to(0, target_base // 2, info.flash_size // 2)
|
|
patched[0], patched[1] = word & 0xFF, word >> 8
|
|
write_differing(loader, 0, bytes(patched))
|
|
return bytes(patched)
|
|
|
|
|
|
def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=None):
|
|
"""Replace the resident loader with `path`, using the loader as its own
|
|
staging loader. Every phase is idempotent and keyed off the flash state,
|
|
so a re-run resumes; the state file carries what the staging slot held.
|
|
|
|
`staged_link` is the new image's own `(baud, autobaud)` where it differs from
|
|
this session's - the copies the host enters *are* that image, so they answer
|
|
on its link and not the resident's. Note what this does to the idempotence
|
|
above: once the staging copy is installed, the resumable state is only
|
|
reachable on the new link, so a re-run has to name it too."""
|
|
info = loader.info
|
|
image = loader_image(path)
|
|
for warning in update_preflight(image, info, fuse_bytes):
|
|
print(f"note: {warning}")
|
|
update = image_info(image) # the preflight proved it is there
|
|
verbose(f"installing pureboot {update.version} over pureboot {info.version}")
|
|
staged = staging_content(image, info)
|
|
resident = bytes(image) + bytes([0xFF] * (SLOT - len(image)))
|
|
page = info.page
|
|
|
|
state = UpdateState(state_path)
|
|
if os.path.exists(state_path):
|
|
verbose(f"resuming the update recorded in {state_path}")
|
|
else:
|
|
verbose(f"saving the staging slot to {state_path}")
|
|
state.load_or_save(loader)
|
|
|
|
# A loader already sitting whole in the staging slot IS the staging copy:
|
|
# rewriting it in place would be a copy overwriting itself as it runs. Any pureboot
|
|
# with the device's info block serves, since a staged copy only streams
|
|
# pages. "Whole" needs both checks - the block where every image carries
|
|
# it and matching byte for byte, and the slot unchanged since this update
|
|
# began, so a half-written install takes the path below instead.
|
|
current = loader.read_flash(info.stage, SLOT)
|
|
# The whole slot is searched: a loader's stamp sits wherever its image put
|
|
# it, which is the end of the code on pureboot 5 and the front of it
|
|
# before that.
|
|
staged_loader = image_info(current)
|
|
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
|
|
print("staging slot already holds a loader - left in place")
|
|
else:
|
|
# Where the staging slot starts at address 0 (the 1 KB tiny13s) its
|
|
# first page carries the reset vector, so it goes last: until then a
|
|
# reset still reaches the old resident.
|
|
order = list(range(0, SLOT, page))
|
|
if info.stage == 0:
|
|
order = order[1:] + [0]
|
|
if write_differing(loader, info.stage, staged, order, label="staging copy"):
|
|
print(f"staging copy installed at {info.stage:#06x}")
|
|
|
|
# Enter it and let it rewrite the resident. Where a patched reset vector
|
|
# routes through the resident, word 0 is re-aimed at the staging copy for
|
|
# the rewrite, so a power loss mid-rewrite still resets into a loader.
|
|
verbose(f"entering the staging copy at {info.stage:#06x}")
|
|
loader.enter_copy(info.stage, wait, link=staged_link)
|
|
redirect = info.patch_vector and info.stage != 0
|
|
if redirect:
|
|
verbose("word 0 re-aimed at the staging copy for the rewrite")
|
|
patch_word0(loader, state.page0, info.stage)
|
|
if write_differing(loader, info.base, resident, label="resident"):
|
|
print(f"resident loader rewritten at {info.base:#06x}")
|
|
|
|
# Enter the new resident and put the staging region back: page 0 first
|
|
# where it lives in that region (word 0 then points at the new resident
|
|
# for the rest of the restore), the saved trampoline with the rest.
|
|
verbose(f"entering the new resident at {info.base:#06x}")
|
|
loader.enter_copy(info.base, wait)
|
|
if redirect:
|
|
verbose("word 0 restored")
|
|
write_differing(loader, 0, state.page0)
|
|
order = list(range(0, SLOT, page))
|
|
if info.stage == 0:
|
|
order = [0] + order[1:]
|
|
write_differing(loader, info.stage, state.staging, order, label="staging restore")
|
|
|
|
state.discard()
|
|
print(f"loader updated: pureboot {update.version}, {len(image)} B at {info.base:#06x}, staging region restored")
|
|
|
|
|
|
def check_walk_region(pages, info, fuse_bytes, force):
|
|
"""BOOTRST programmed below the loader means reset reaches it only by
|
|
walking across erased flash; application data in that span would divert
|
|
reset into itself. Needs the fuses (--fuses or --assume-fuses)."""
|
|
if info.patch_vector or fuse_bytes is None:
|
|
return
|
|
bootrst, bls_start = mega_boot(info, fuse_bytes)
|
|
if not bootrst or bls_start >= info.base:
|
|
return
|
|
overlap = [a for a in sorted(pages) if a >= bls_start and pages[a].count(0xFF) != len(pages[a])]
|
|
if overlap and not force:
|
|
raise Error(
|
|
f"the image writes {overlap[0]:#06x}.. inside the reset walk region "
|
|
f"[{bls_start:#06x}, {info.base:#06x}) (BOOTRST programmed): reset could no "
|
|
f"longer reach the loader - --force to flash it anyway"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------ operations ---
|
|
|
|
|
|
def op_erase_flash(loader):
|
|
"""0xff over the application area, descending where the reset vector is
|
|
patched: page 0 goes last, so an interrupted erase still resets into the
|
|
loader - and once it is gone, the erased walk reaches it anyway."""
|
|
blank = bytes([0xFF] * loader.info.page)
|
|
addresses = range(0, loader.info.base, loader.info.page)
|
|
with Progress("erase", len(addresses)) as bar:
|
|
for address in reversed(addresses) if loader.info.patch_vector else addresses:
|
|
loader.write_page(address, blank)
|
|
bar.step()
|
|
print(f"erase: {loader.info.base // loader.info.page} pages")
|
|
|
|
|
|
def op_erase_eeprom(loader):
|
|
with Progress("erase EEPROM", loader.info.eeprom_size, "B") as bar:
|
|
loader.write_eeprom(0, bytes([0xFF] * loader.info.eeprom_size), progress=bar)
|
|
print(f"erase: {loader.info.eeprom_size} B of EEPROM")
|
|
|
|
|
|
def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
|
|
image = load_image(path)
|
|
verbose(f"{path}: {len(image)} B image")
|
|
pages = plan_flash(image, loader.info)
|
|
check_walk_region(pages, loader.info, fuse_bytes, force)
|
|
if erase:
|
|
op_erase_flash(loader)
|
|
order = covered(pages, loader.info, skip_blank=erase)
|
|
if len(order) != len(pages):
|
|
verbose(f"{len(pages) - len(order)} blank pages skipped (erased flash underneath)")
|
|
with Progress("flash", len(order)) as bar:
|
|
for address in order:
|
|
loader.write_page(address, pages[address])
|
|
bar.step()
|
|
print(f"flash: {path}: {len(order)} pages")
|
|
if verify:
|
|
verify_pages(loader, pages, repair=True)
|
|
|
|
|
|
def verify_pages(loader, pages, repair=False):
|
|
"""Read every page back and compare. With `repair`, a mismatch is
|
|
rewritten and re-read up to RETRIES times first: a page filled over a
|
|
dirty SPM buffer takes stale words, and the write that took them cleared
|
|
the buffer, so one rewrite settles it. Anything still wrong is not that."""
|
|
repaired = 0
|
|
with Progress("verify", len(pages)) as bar:
|
|
for address in sorted(pages):
|
|
for retry in range(RETRIES + 1):
|
|
got = loader.read_flash(address, loader.info.page)
|
|
if got == pages[address]:
|
|
break
|
|
first = next(i for i in range(len(got)) if got[i] != pages[address][i])
|
|
detail = (
|
|
f"verify failed at {address + first:#06x}: "
|
|
f"wrote {pages[address][first]:02x}, read {got[first]:02x}"
|
|
)
|
|
if not repair:
|
|
raise Error(detail)
|
|
if retry == RETRIES:
|
|
raise Error(f"{detail} (still wrong after {RETRIES} retries)")
|
|
verbose(f"{detail} - rewriting page {address:#06x} (retry {retry + 1})")
|
|
loader.write_page(address, pages[address])
|
|
repaired += 1
|
|
bar.step()
|
|
note = f", {repaired} page rewrite(s)" if repaired else ""
|
|
print(f"verify: {len(pages)} pages ok{note}")
|
|
|
|
|
|
def op_verify_flash(loader, path):
|
|
verify_pages(loader, plan_flash(load_image(path), loader.info))
|
|
|
|
|
|
def read_progress(reader, total, label):
|
|
"""A bulk read in 256-byte wire chunks under a progress bar."""
|
|
data = b""
|
|
with Progress(label, total, "B") as bar:
|
|
while len(data) < total:
|
|
chunk = min(256, total - len(data))
|
|
data += reader(len(data), chunk)
|
|
bar.step(chunk)
|
|
return data
|
|
|
|
|
|
def op_read_flash(loader, path):
|
|
data = read_progress(loader.read_flash, loader.info.base, "read flash")
|
|
open(path, "wb").write(data)
|
|
print(f"read flash: {len(data)} B -> {path}")
|
|
|
|
|
|
def op_eeprom(loader, path, erase, verify):
|
|
image = load_image(path)
|
|
if len(image) > loader.info.eeprom_size:
|
|
raise Error(f"EEPROM image is {len(image)} B, device has {loader.info.eeprom_size}")
|
|
if erase:
|
|
op_erase_eeprom(loader)
|
|
with Progress("eeprom", len(image), "B") as bar:
|
|
loader.write_eeprom(0, image, progress=bar)
|
|
print(f"eeprom: {path}: {len(image)} B")
|
|
if verify:
|
|
got = read_progress(loader.read_eeprom, len(image), "verify EEPROM")
|
|
if got != image:
|
|
first = next(i for i in range(len(got)) if got[i] != image[i])
|
|
raise Error(f"verify failed at EEPROM {first:#06x}: wrote {image[first]:02x}, read {got[first]:02x}")
|
|
print(f"verify: {len(image)} B ok")
|
|
|
|
|
|
def op_verify_eeprom(loader, path):
|
|
image = load_image(path)
|
|
got = read_progress(loader.read_eeprom, len(image), "verify EEPROM")
|
|
if got != image:
|
|
first = next(i for i in range(len(got)) if got[i] != image[i])
|
|
raise Error(f"verify failed at EEPROM {first:#06x}: expected {image[first]:02x}, read {got[first]:02x}")
|
|
print(f"verify: {len(image)} B of EEPROM ok")
|
|
|
|
|
|
def op_read_eeprom(loader, path):
|
|
data = read_progress(loader.read_eeprom, loader.info.eeprom_size, "read EEPROM")
|
|
open(path, "wb").write(data)
|
|
print(f"read EEPROM: {len(data)} B -> {path}")
|
|
|
|
|
|
def _require_unified(loader, what):
|
|
if not loader.unified:
|
|
raise Error(f"{what} needs pureboot {UNIFIED_LOADER} or later; this loader is {loader.info.version}")
|
|
|
|
|
|
def _peek_spec(spec):
|
|
"""ADDR[:N] - addresses and counts in any Python integer base."""
|
|
address, _, count = spec.partition(":")
|
|
return int(address, 0), int(count, 0) if count else 1
|
|
|
|
|
|
def op_peek(loader, spec):
|
|
_require_unified(loader, "--peek")
|
|
address, count = _peek_spec(spec)
|
|
data = loader.read_data(address, count)
|
|
for offset in range(0, len(data), 16):
|
|
row = data[offset : offset + 16]
|
|
text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
|
|
print(f"{address + offset:#06x} {row.hex(' '):<47} {text}")
|
|
|
|
|
|
def op_poke(loader, spec):
|
|
_require_unified(loader, "--poke")
|
|
address, _, payload = spec.partition(":")
|
|
if not payload:
|
|
raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef")
|
|
data = bytes.fromhex(payload.replace(" ", ""))
|
|
loader.write_data(int(address, 0), data)
|
|
print(f"poke: {len(data)} B at {int(address, 0):#06x}")
|
|
|
|
|
|
def op_fuses(loader):
|
|
low, lock, extended, high = loader.read_fuses()
|
|
print("fuses:")
|
|
print(f" low 0x{low:02x}")
|
|
print(f" high 0x{high:02x}")
|
|
print(f" extended 0x{extended:02x}")
|
|
print(f" lock 0x{lock:02x}")
|
|
fuse_bytes = bytes((low, lock, extended, high))
|
|
# On a boot-sectioned mega the BOOTSZ/BOOTRST decode is the fuse fact the
|
|
# loader's whole deployment hangs on - say it in words.
|
|
if not loader.info.patch_vector:
|
|
try:
|
|
bootrst, bls_start = mega_boot(loader.info, fuse_bytes)
|
|
reset = "reset enters it" if bootrst else "reset boots the application"
|
|
print(f" boot section at {bls_start:#06x} ({loader.info.flash_size - bls_start} B), "
|
|
f"BOOTRST {'programmed' if bootrst else 'unprogrammed'} - {reset}")
|
|
except Error:
|
|
pass # unknown signature: the raw bytes above still stand
|
|
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, one_wire=False):
|
|
"""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. On a shared line the probes echo back like
|
|
everything else; undiscarded they would answer every rate."""
|
|
for pct in scan_ratios():
|
|
rate = scan_rate(baud, pct)
|
|
print(f"scan: {rate} Bd ({pct:+d} %) - reset the target", flush=True)
|
|
try:
|
|
port = Port(port_path, rate)
|
|
except Error as unmakeable:
|
|
print(f"scan: {rate} Bd skipped - {unmakeable}")
|
|
continue
|
|
if one_wire:
|
|
port = OneWirePort(port)
|
|
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 ---
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="pureboot host tool", epilog="operations run in the order listed above"
|
|
)
|
|
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION} "
|
|
f"(speaks pureboot {OLDEST_LOADER}..{NEWEST_LOADER})")
|
|
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("--one-wire", action="store_true",
|
|
help="the link is a shared line: read back and discard this tool's own "
|
|
"echoed bytes (any backend of a one-wire deployment)")
|
|
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")
|
|
parser.add_argument("--state", metavar="FILE", help="update state file (default: FILE.pbstate)")
|
|
# The update enters the staging copy, which is the new image and so speaks
|
|
# the link *it* was built for. Nothing in the image says which, so where it
|
|
# differs from this session's these name it and the host follows.
|
|
parser.add_argument("--staged-baud", metavar="BD", type=int,
|
|
help="the baud the --update-loader image was built for, where it "
|
|
"differs from --baud")
|
|
parser.add_argument("--staged-autobaud", action=argparse.BooleanOptionalAction, default=None,
|
|
help="whether that image is an autobaud build, where it differs "
|
|
"from --autobaud")
|
|
parser.add_argument("--assume-fuses", metavar="HEX8", help="fuse bytes low,lock,ext,high as 8 hex digits "
|
|
"(overrides reading them - e.g. under a simulator that cannot)")
|
|
parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash")
|
|
parser.add_argument("--flash", metavar="FILE", help="program an application (bin or ihex)")
|
|
parser.add_argument("--no-verify", action="store_true", help="skip read-back after writes")
|
|
parser.add_argument("--read-flash", metavar="FILE", help="dump the application flash")
|
|
parser.add_argument("--verify-flash", metavar="FILE", help="compare flash against an image")
|
|
parser.add_argument("--erase-eeprom", action="store_true", help="0xff over the EEPROM")
|
|
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
|
|
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
|
|
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
|
|
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space - "
|
|
"pureboot 5 and later")
|
|
parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, "
|
|
"I/O) - pureboot 5 and later")
|
|
parser.add_argument("--force", action="store_true", help="override refusable safety checks")
|
|
parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
|
|
parser.add_argument("-v", "--verbose", action="store_true",
|
|
help="print decisions and derived facts as operations run")
|
|
args = parser.parse_args()
|
|
global VERBOSE
|
|
VERBOSE = args.verbose
|
|
|
|
if args.update_loader and (args.flash or args.erase_flash):
|
|
parser.error("--update-loader does not combine with application flash operations")
|
|
fuse_override = None
|
|
if args.assume_fuses:
|
|
try:
|
|
fuse_override = bytes.fromhex(args.assume_fuses)
|
|
assert len(fuse_override) == 4
|
|
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, args.one_wire)
|
|
return
|
|
|
|
port = Port(args.port, args.baud)
|
|
if args.one_wire:
|
|
port = OneWirePort(port)
|
|
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted"
|
|
+ (", one-wire echo discarded" if args.one_wire else ""))
|
|
try:
|
|
loader = Loader(port)
|
|
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
|
|
if args.info:
|
|
print("device:")
|
|
for line in info.lines():
|
|
print(f" {line}")
|
|
if args.autobaud and info.unit_home is not None:
|
|
# The measured bit period, from wherever this version keeps it
|
|
# (unit_home); 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). The
|
|
# autobaud identity path refuses unknown signatures, so the
|
|
# home is always known here; the guard states that dependency.
|
|
unit = int.from_bytes(loader.read_data(info.unit_home, 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 x {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)
|
|
if fuse_bytes is None:
|
|
fuse_bytes = read
|
|
if args.update_loader:
|
|
state = args.state or args.update_loader + ".pbstate"
|
|
staged_link = None
|
|
if args.staged_baud is not None or args.staged_autobaud is not None:
|
|
staged_link = (
|
|
args.staged_baud if args.staged_baud is not None else args.baud,
|
|
args.staged_autobaud if args.staged_autobaud is not None else args.autobaud,
|
|
)
|
|
op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes,
|
|
staged_link)
|
|
if args.flash:
|
|
op_flash(loader, args.flash, args.erase_flash, not args.no_verify, fuse_bytes, args.force)
|
|
elif args.erase_flash:
|
|
op_erase_flash(loader)
|
|
if args.read_flash:
|
|
op_read_flash(loader, args.read_flash)
|
|
if args.verify_flash:
|
|
op_verify_flash(loader, args.verify_flash)
|
|
if args.eeprom:
|
|
op_eeprom(loader, args.eeprom, args.erase_eeprom, not args.no_verify)
|
|
elif args.erase_eeprom:
|
|
op_erase_eeprom(loader)
|
|
if args.read_eeprom:
|
|
op_read_eeprom(loader, args.read_eeprom)
|
|
if args.verify_eeprom:
|
|
op_verify_eeprom(loader, args.verify_eeprom)
|
|
if args.poke:
|
|
op_poke(loader, args.poke)
|
|
if args.peek:
|
|
op_peek(loader, args.peek)
|
|
if args.stay:
|
|
print("loader stays in its session (reset to leave)")
|
|
else:
|
|
loader.run_application()
|
|
print("application running")
|
|
finally:
|
|
port.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (Error, OSError) as error:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|