fix: --flash never ran the brick guard, because it never read the fuses

check_walk_region() refuses an image writing into the span reset crosses to
reach the loader, and a plain --flash never ran it: the fuses were read for
--fuses and for --update-loader, so the tool read them to protect the loader
and never to protect the reset path. It cost an ssd1306 board - an application
grown through 0x7c00 on a 328P with hfuse 0xdc, reset landing mid-function,
an ICE the only way back.

The check now fetches its own input, so the operation that asks for no fuses
cannot skip it and neither can a direct API caller: pbdirty and pbmute call
op_flash() as a library and are guarded without a line changing in them.
Fuses that cannot be read are a refusal naming --assume-fuses and --force,
because unknown is not empty.

The rig had to stop lying first. The fuse read is an LPM diverted by BLBSET,
which simavr executes straight out of flash with no hook, so a fuse read
answered flash bytes and --fuses had been printing them on every chip. The
runner models the diversion at the SPMCSR write, -f states the profile - and
stores the register itself, since a registered handler replaces simavr's store
and would otherwise swallow every SPM command on the cores where nothing else
watches it.

pureboot.walk reproduces the brick: the unfixed tool writes 249 pages through
0x7c00 in silence, the fixed one refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 03:56:20 +02:00
parent 6ff407e7bb
commit 37f9747e26
7 changed files with 272 additions and 17 deletions

View File

@@ -587,8 +587,7 @@ one; a re-run on the resident's link finds nothing at all. The state file carrie
the only bytes not recoverable from the device; losing it mid-update still
completes the update, and the staging region comes back by reflashing the
application. A boot-sectioned mega needs its fuses for the preflight — read
from the device, or supplied with `--assume-fuses` where reading is impossible
(simulators).
from the device, or supplied with `--assume-fuses` where reading is impossible.
## Host tool
@@ -611,7 +610,11 @@ verify by read-back unless `--no-verify`, and a flash page that reads back
wrong is rewritten up to three times before the run stops (see the fill above).
`--verify-flash` only reports. Images are raw binary, or Intel HEX by
extension. `--force` overrides the refusable safety checks — today, flashing
application data into a mega's reset walk region.
application data into a mega's reset walk region. That check reads the boot
fuses itself rather than waiting to be handed them, because a plain `--flash`
asks for none and is exactly the operation that must not skip it; fuses it
cannot read leave the region unknown, and unknown is refused rather than
assumed empty (`--assume-fuses` states them).
`--autobaud` opens with the calibration pulse instead of the plain knock, for a
loader built `SERIAL autobaud`; the rest of the session is identical, at
@@ -750,6 +753,13 @@ Per chip preset, `ctest` runs:
a bare verify must see the corruption and the repairing verify must fix it in
one rewrite. Hardware forbids the state here, but simavr dispatches SPM from
anywhere, which is what makes the path constructible;
- `pureboot.walk` (328P) — an application whose tail lands in the span reset
crosses to reach the loader, refused by a plain `--flash` and written only
under `--force`, with a lower image and an unprogrammed BOOTRST left alone.
The refusal is decided from a modeled fuse read: the diversion BLBSET arms
answers an LPM, which simavr executes straight out of flash with no hook, so
the runner lends those bytes the fuses for the cycles SELFPRGEN holds (`-f`
states them, unprogrammed elsewhere);
- `pureboot.update` — the full `--update-loader` flow, then every power-fail
phase: the device is killed mid-write, restarted from its flash dump, and a
re-run must complete the update with the application intact;

View File

@@ -1476,11 +1476,36 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
print(f"loader updated: pureboot {update.version}, {len(image)} B at {info.base:#06x}, staging region restored")
def walk_check_fuses(loader):
"""The fuses the walk check needs, fetched where the check is rather than
by whichever operation happened to want them printed. An ordinary --flash
asks for no fuses of its own and is exactly the caller that must not skip
the check, so the dependency is fetched here and cannot be forgotten.
None where they cannot be had, which the check answers with its refusal."""
if loader.info.patch_vector:
return None
try:
return read_fuse_bytes(loader)
except Error:
return None
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:
reset into itself. The fuses are what name that span, so arriving without
them is a refusal and not a pass: the check that did not run answers
unknown, and unknown is not empty."""
if info.patch_vector:
return
if fuse_bytes is None:
if not force:
raise Error(
f"the fuses could not be read, so the reset walk region below {info.base:#06x} "
"is unknown - an image writing into it while BOOTRST is programmed leaves "
"reset unable to reach the loader - --assume-fuses to state them, --force "
"to flash without the check"
)
return
bootrst, bls_start = mega_boot(info, fuse_bytes)
if not bootrst or bls_start >= info.base:
@@ -1520,7 +1545,7 @@ 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)
check_walk_region(pages, loader.info, fuse_bytes or walk_check_fuses(loader), force)
if erase:
op_erase_flash(loader)
order = covered(pages, loader.info, skip_blank=erase)
@@ -1648,14 +1673,21 @@ def op_poke(loader, spec):
print(f"poke: {len(data)} B at {int(address, 0):#06x}")
def op_fuses(loader):
def read_fuse_bytes(loader):
"""The device's fuses in the order this tool indexes them everywhere:
low, lock, extended, high - BOOT_FUSE's order and --assume-fuses'."""
low, lock, extended, high = loader.read_fuses()
return bytes((low, lock, extended, high))
def op_fuses(loader):
fuse_bytes = read_fuse_bytes(loader)
low, lock, extended, high = fuse_bytes
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: