pureboot.py: an update follows the staging copy onto its own link

--update-loader works by entering copies of the *new* image and letting them
rewrite the resident. Those copies speak the link they were built for, but the
host went on knocking with the session's baud and backend — the resident's. Where
the image changed either, the staging copy was installed and then never answered:
resident untouched, and on a 1 KiB tiny the staging slot is the whole application
region, so the application was already gone.

The wire cannot be probed for it. 512 bytes of position-independent code carry no
header saying what rate they were built for, so the operator declares it:
--staged-baud and --staged-autobaud, applied from the jump into the staging copy
onward. Retuning goes through the open port — SetCommState or tcsetattr on the
live handle, never a reopen — because a DTR pulse would reset the copy being
talked to. Undeclared against a changed link it still cannot work, but the error
now names that as the cause instead of reporting the bare activation timeout that
sent the operator looking at wiring.

The README's idempotence claim needed the same qualification: from step 2 a
re-run must reach the new image, and after step 3 word 0 points at the staging
copy, so on a patched-vector part the resident's link reaches nothing at all.

Found on an ATtiny13A, where two controls differing only in the activation window
updated cleanly and so isolated the link as the variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:31:27 +02:00
committed by BlackMark
parent 433bec3e58
commit 77dd45aeca
4 changed files with 297 additions and 17 deletions

View File

@@ -146,6 +146,13 @@ class Progress:
class PosixPort:
"""A raw serial port with deadline-based reads, over termios."""
@staticmethod
def _speed(baud):
try:
return getattr(termios, f"B{baud}")
except AttributeError:
raise Error(f"unsupported baud rate {baud}") from None
def __init__(self, path, baud):
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
attrs = termios.tcgetattr(self.fd)
@@ -153,14 +160,20 @@ class PosixPort:
attrs[1] = 0 # oflag
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
attrs[3] = 0 # lflag
try:
speed = getattr(termios, f"B{baud}")
except AttributeError:
raise Error(f"unsupported baud rate {baud}") from None
attrs[4] = attrs[5] = speed
attrs[4] = attrs[5] = self._speed(baud)
attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
self.baud = baud
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."""
attrs = termios.tcgetattr(self.fd)
attrs[4] = attrs[5] = self._speed(baud)
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
self.baud = baud
def close(self):
os.close(self.fd)
@@ -289,6 +302,7 @@ if os.name == "nt":
# 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
@@ -296,6 +310,21 @@ if os.name == "nt":
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)
@@ -457,6 +486,10 @@ class Loader:
# 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 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.
@@ -664,12 +697,41 @@ class Loader:
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt()
def enter_copy(self, byte_address, wait):
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."""
autobaud = self.autobaud
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)
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
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)
@@ -1017,10 +1079,16 @@ def patch_word0(loader, page0, target_base):
return bytes(patched)
def op_update_loader(loader, wait, path, state_path, fuse_bytes):
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."""
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):
@@ -1065,7 +1133,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
# 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)
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")
@@ -1302,6 +1370,15 @@ def main():
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")
@@ -1351,7 +1428,14 @@ def main():
fuse_bytes = read
if args.update_loader:
state = args.state or args.update_loader + ".pbstate"
op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes)
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: