pureboot 3: a 512-byte slot on every chip, the 1284s included

The word-addressed 1284s were the one family deploying in a 1 KiB slot,
because the far-flash machinery (ELPM reads, RAMPZ page commands, a
word-addressed wire) did not fit 512 B. It does now: 478 B stock, 494 B in
the heaviest configuration the build can produce. They take the 644s'
geometry, where the smallest boot section holds the resident slot and its
staging slot together. The loader's version goes to 3; the host tool did
not change, so its own version stays 2 and only the window it speaks
widens.

Most of the saving is one restructure. The info block and a flash read are
the same act, so giving all four streamed commands one address-and-count
path leaves exactly one call site for the flash streamer: it inlines into
the never-returning command loop and its 24-bit cursor stops being saved
and restored around every transmit. Around it, the ack byte moved out of
line, the wire's byte pair is bit_cast into the word it already is, the
fuse loop ends on its count, the info block's in-slot offset is taken as
the one-byte relocation it is, and -fno-expensive-optimizations gives way
to -fno-move-loop-invariants -fno-tree-ter. Every chip shrank 14-18 B.

The size matrix grew the axes it was missing: the USART1 instance across
the whole clock ladder, and the shape a slow baud gives a software UART —
past 255 delay iterations libavr takes the 16-bit delay loop, which the
ladder default never selects and which was 4 B over the 1284's slot the
first time it was built.

The protocol fixture stopped deriving the loader entry from the flash
size; on the 1284s it had been jumping a slot low and reaching the loader
only because erased flash walked it up.

Docs and comments were consolidated across the port in the same pass: the
README carries a per-chip size table instead of prose, and prose that
restated the code is gone — 190 lines, no behaviour with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:02:59 +02:00
parent 6f3eb06233
commit 8f106b636d
13 changed files with 594 additions and 752 deletions

View File

@@ -1,24 +1,13 @@
#!/usr/bin/env python3
"""pureboot host tool — the smart half of the pureboot protocol (README.md).
"""pureboot host tool — the smart half of the protocol (README.md).
The device exposes primitives; this tool composes them: image loading (raw
binary or Intel HEX), flash programming with read-back verification, erase as
writing 0xff, EEPROM programming, fuse and info readout, the hand-over jump,
and — on chips without a hardware boot section — the reset-vector surgery
that re-homes the application's entry through the trampoline word below the
loader. Page 0 and the trampoline are written first, so every interruption
point of a flash leaves the chip reset-recoverable into the loader.
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.
It also updates the loader itself (--update-loader): pureboot's image is
position-independent, so the tool installs the identical binary one 512-byte
slot below the resident loader, jumps into that staging copy, lets it rewrite
the resident slot, and restores what the staging slot held — resumable at
every phase from the flash state plus a host-side state file carrying the
saved bytes.
Python standard library only; the serial port is driven with termios on POSIX
and the Win32 serial API (through ctypes) on Windows, so any tty or COM port
works — a USB adapter as well as a simavr pty.
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
@@ -36,22 +25,19 @@ else:
PROMPT = b"+"
VERSION = 2 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks to. A pureboot version implies its wire
# protocol — the protocol carries no number of its own — so knowing which
# versions speak what is the tool's job, and this window is where it says so:
# every pureboot so far speaks this protocol, and a version that changes it
# becomes the new floor here.
# The loader versions this tool speaks. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that
# map lives: every version so far speaks the same protocol, and one that
# changes it becomes the new floor here.
OLDEST_LOADER = 1
NEWEST_LOADER = 2
SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector
NEWEST_LOADER = 3
SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
VERBOSE = False
def verbose(message):
"""Detail printed only under --verbose: decisions and derived facts, not
per-byte chatter — the progress bar carries the bulk transfers."""
if VERBOSE:
print(f" {message}")
@@ -61,11 +47,9 @@ class Error(Exception):
class Progress:
"""A transient in-place bar on stderr for the operations that take wire
time. Drawn only when stderr is a tty — logs, pipes and the test harness
see nothing — and erased once done; the summary line each operation
prints afterwards is the persistent record. A zero total (or no label)
disables it, so callers can pass one through unconditionally."""
"""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
@@ -330,18 +314,16 @@ class Info:
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)
# Large chips speak word addresses for flash (bit 1); the host keeps
# every address in bytes and converts at the wire.
# Bit 1: flash addresses are words on the wire. Every address here
# stays a byte address 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.slot = 1024 if self.word_flash else SLOT
self.flash_size = self.base + self.slot
self.stage = self.base - self.slot # where a staging copy of the loader goes
# The hand-over target, as the word address 'J' takes: the trampoline
# below the loader (tinies), or word 0 (mega — the application's own
# reset vector; BOOTRST re-vectors a reset into the loader instead).
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
def describe(self):
@@ -354,7 +336,7 @@ class Info:
)
def lines(self):
"""The info block as one fact per line — what --info prints."""
"""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:
@@ -365,7 +347,7 @@ class Info:
f"flash {self.flash_size} B, {self.page} B pages"
+ (", word-addressed wire" if self.word_flash else ""),
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
f"loader {self.base:#06x} ({self.slot} B slot)",
f"loader {self.base:#06x} ({SLOT} B slot)",
f"staging {self.stage:#06x}",
f"EEPROM {self.eeprom_size} B",
f"hand-over {hand_over}",
@@ -373,19 +355,18 @@ class Info:
class Loader:
"""A pureboot session. Between commands the loader has prompted `+` and
awaits a command byte; every method restores that invariant — except
jump(), after which the target must be knocked afresh."""
"""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
def connect(self, wait):
"""Knock until the activation window answers, then read the info
block. Also converges when the loader already sits in its command
loop: the knock bytes are ignored-or-executed there, and the drain
absorbs whatever they produced."""
"""Knock until the window answers, then read the info block. Also
converges into a live session: the knock bytes are ignored there and
the drain absorbs whatever they produced."""
self.port.flush_input()
deadline = time.monotonic() + wait
knocks = 0
@@ -471,16 +452,13 @@ class Loader:
return self._command(b"F", 4, 2.0)
def jump(self, word_address):
"""'J': the device acks, then execution continues at the word
address — a loader slot's base (whose copy must then be knocked
afresh) or the application entry."""
"""The device acks, then execution continues at the word address."""
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt()
def enter_copy(self, byte_address, wait):
"""Jump into the loader copy at `byte_address` and knock it. Ending
up in the copy addressed is guaranteed by construction: a jump to a
slot base lands in that slot's entry stub."""
"""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."""
self.jump(byte_address // 2)
return self.connect(wait)
@@ -576,15 +554,14 @@ def plan_flash(image, info):
def covered(pages, info, skip_blank):
"""Pages in programming order; optionally dropping all-0xff pages (sound
only over erased flash) — never a load-bearing one.
"""Pages in programming order, optionally dropping all-0xff ones (sound
only over erased flash, and never a load-bearing page).
With a patched vector (tinies), the patched page 0 goes first and the
trampoline page second: from the first write on, a reset lands in the
loader and the loader's own fall-through lands on the application entry,
so every interruption point of the flash is recoverable. With a hardware
boot section a reset re-vectors to the loader regardless; ascending
order, page 0 last, maximizes what an interrupted image retains."""
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]
@@ -650,10 +627,9 @@ def mega_boot(info, fuse_bytes):
def image_info(image):
"""The info block embedded in a pureboot binary, or None. Searched per
known loader version, so the magic stays three selective bytes rather than
two that code could carry by chance — and a binary this tool does not know
the version of reads as no block at all, which is what it is to the tool."""
"""The info block embedded in a pureboot binary, or None. 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 0 <= at <= len(image) - 12:
@@ -662,12 +638,10 @@ def image_info(image):
def loader_image(path):
"""A loader update image, as the slot's own content. A raw binary is that
already; an Intel HEX links the loader at its base inside an otherwise
blank flash image, and load_image() anchors every image at zero, so the
blank below the base is dropped here. The base comes from the image's own
info block rather than the device's, so an image built for somewhere else
survives intact and the preflight can say so."""
"""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:
@@ -676,18 +650,17 @@ def loader_image(path):
def staging_content(image, info):
"""The 512-byte staging-slot content: the image, padding, and — on
chips whose hand-over jumps through the word below the resident loader —
that word, which for a staging copy is the slot's own last word: an rjmp
to the resident base. The staging copy's fall-through and 'J'-free exit
both land in a loader instead of garbage."""
slot = info.slot
if len(image) > (slot - 2 if info.patch_vector else slot):
raise Error(f"loader image is {len(image)} B, the slot holds {slot - 2 if info.patch_vector else slot}")
content = bytearray(image) + bytearray([0xFF] * (slot - len(image)))
"""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
content[SLOT - 2], content[SLOT - 1] = through & 0xFF, through >> 8
return bytes(content)
@@ -713,7 +686,7 @@ def update_preflight(image, info, fuse_bytes):
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 * info.slot} B, BOOTSZ) is "
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:
@@ -755,7 +728,7 @@ class UpdateState:
self.data = {
"signature": info.signature.hex(),
"base": info.base,
"staging": loader.read_flash(info.stage, info.slot).hex(),
"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:
@@ -774,9 +747,8 @@ class UpdateState:
def write_differing(loader, base, content, order=None, label=None):
"""Program the pages of `content` at `base` that differ from flash
idempotent, so a resumed phase redoes only what an interruption left.
A label puts the compare-and-program loop on the progress bar."""
"""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
@@ -789,9 +761,8 @@ def write_differing(loader, base, content, order=None, label=None):
bar.step()
if label:
verbose(f"{label}: {written} of {len(offsets)} pages differed")
# Page-wise read-back with the same bounded repair as verify_pages: this
# is the loader-update path, where a page left wrong is a half-written
# loader slot.
# 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
@@ -813,8 +784,8 @@ def write_differing(loader, base, content, order=None, label=None):
def patch_word0(loader, page0, target_base):
"""Rewrite page 0 with its word 0 re-aimed at `target_base` — the
resume insurance around rewriting a loader slot the reset path uses."""
"""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)
@@ -824,10 +795,9 @@ def patch_word0(loader, page0, target_base):
def op_update_loader(loader, wait, path, state_path, fuse_bytes):
"""Replace the resident loader with `path`, using the loader itself as
its own staging loader. Every phase is idempotent and keyed off the
actual flash state, so a re-run after any interruption resumes; the
state file carries the bytes the staging slot held."""
"""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."""
info = loader.info
image = loader_image(path)
for warning in update_preflight(image, info, fuse_bytes):
@@ -835,7 +805,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
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] * (info.slot - len(image)))
resident = bytes(image) + bytes([0xFF] * (SLOT - len(image)))
page = info.page
state = UpdateState(state_path)
@@ -845,38 +815,29 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
verbose(f"saving the staging slot to {state_path}")
state.load_or_save(loader)
# Install the staging copy — unless a loader already sits whole in the
# staging slot (a build programmed there by hand): that copy IS the
# installed staging copy, and rewriting it would only trip its own
# running-slot guard on the composed through-word. Any pureboot with
# the device's own info block serves — the staged copy just streams
# pages, so an older build installs a newer resident all the same. Two
# checks make "already a loader" mean a *complete* one: the block must
# sit where every image carries it (within the slot's first 256 bytes
# — the build's position lint), matching the device's block byte for
# byte, and the slot must be unchanged since this update began (the
# state file's snapshot) — a resumed, half-written install differs
# from its snapshot and takes the install path below, which completes
# it page by page.
current = loader.read_flash(info.stage, info.slot)
# A loader already sitting whole in the staging slot IS the staging copy:
# rewriting it would only meet its own running-slot guard. 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)
staged_loader = image_info(current[:268])
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:
# On a chip whose staging slot starts at address 0 (the 1 KB
# tiny13s), its first page carries the reset vector: written last,
# so any earlier interruption still resets into the old resident,
# and from then on resets enter the staging copy.
order = list(range(0, info.slot, page))
# 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 slot. Where a patched reset
# vector routes through the resident (a tiny with the staging slot away
# from page 0), word 0 is re-aimed at the staging copy around the
# rewrite, so a power failure mid-rewrite still resets into a loader.
# 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)
redirect = info.patch_vector and info.stage != 0
@@ -894,7 +855,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
if redirect:
verbose("word 0 restored")
write_differing(loader, 0, state.page0)
order = list(range(0, info.slot, page))
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")
@@ -904,10 +865,9 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
def check_walk_region(pages, info, fuse_bytes, force):
"""With BOOTRST programmed but targeting below the loader, reset reaches
the loader only by walking across erased flash from the boot-section
start; application data in that span would divert reset into itself.
Only checkable when the fuses are known (--fuses or --assume-fuses)."""
"""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)
@@ -926,10 +886,9 @@ def check_walk_region(pages, info, fuse_bytes, force):
def op_erase_flash(loader):
"""0xff over the whole application area. Descending on a patched-vector
chip: page 0 — the patched reset vector — goes last, so an interrupted
erase still resets into the loader, and once it is gone the whole area
is erased and the reset walk reaches the loader anyway."""
"""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:
@@ -965,11 +924,10 @@ def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
def verify_pages(loader, pages, repair=False):
"""Read every page back and compare. With `repair`, a mismatched page is
rewritten and re-read, up to RETRIES times before it is raised: 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
after three is not that, and stops the run."""
"""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):