pureboot: no SPM buffer discard, the host repairs instead

The temporary page buffer is write-once per word, so a page filled over
one an earlier writer left dirty programs the stale words. The same
datasheet clause carries the cure: the buffer auto-erases after a page
write (§26.2.1; §19.2 on the tinies), so the corruption clears itself by
happening, and rewriting the page programs correctly.

The loader therefore clears the buffer nowhere. The tinies' CTPB and the
m48s' RWWSRE discard are gone; the boot-sectioned megas keep only the
trailing RWWSRE they need anyway to re-enable the RWW section for
read-back, which discards the buffer as a side effect and keeps them off
the path entirely. 434 B on the tiny13s, 438-442 on the tiny25/45/85,
430 on the m48s; the megas are unchanged, the 1284s still 506.

The host takes over the guarantee: a flash page that reads back wrong is
rewritten up to RETRIES times before the run stops. Both read-back paths
repair — verify_pages for programming, and write_differing, which is the
loader-update path where a page left wrong is a half-written loader slot.
That one is not hypothetical: deleting the discard made attiny85
pureboot.rehome fail deterministically there, the only flow still
assuming the old contract.

Protocol-visible, so README's W command says it: one W may program the
wrong bytes after a refused page, or after an application that
self-programmed entered without a reset, and a host that programs without
reading back cannot trust it.

Tests: pureboot.dirty drives the case the loader declines to guard — the
fixture application dirties every buffer word and jumps in with no reset
(hardware forbids that on a boot-sectioned mega, but simavr dispatches SPM
from anywhere, which is what makes it constructible) — and asserts a bare
verify sees the corruption, the repairing verify fixes it in one rewrite,
and it stays fixed. pbreloc asserts the same shape after a refusal.
test_planner covers the bound against a fake device: one bad write
repaired in a single rewrite, a page that never comes good stopping after
exactly three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 16:29:27 +02:00
parent 52c3b6ca7f
commit 862bc14e1e
10 changed files with 335 additions and 93 deletions

View File

@@ -37,6 +37,7 @@ else:
PROMPT = b"+"
PROTOCOL_VERSION = 1
SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
VERBOSE = False
@@ -767,9 +768,26 @@ def write_differing(loader, base, content, order=None, label=None):
bar.step()
if label:
verbose(f"{label}: {written} of {len(offsets)} pages differed")
for at in range(0, len(content), 256):
if loader.read_flash(base + at, min(256, len(content) - at)) != content[at : at + 256]:
raise Error(f"verify failed at {base + at:#06x} after programming")
# 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.
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
@@ -920,21 +938,37 @@ def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
bar.step()
print(f"flash: {path}: {len(order)} pages")
if verify:
verify_pages(loader, pages)
verify_pages(loader, pages, repair=True)
def verify_pages(loader, pages):
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."""
repaired = 0
with Progress("verify", len(pages)) as bar:
for address in sorted(pages):
got = loader.read_flash(address, loader.info.page)
if got != pages[address]:
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])
raise Error(
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()
print(f"verify: {len(pages)} pages ok")
note = f", {repaired} page rewrite(s)" if repaired else ""
print(f"verify: {len(pages)} pages ok{note}")
def op_verify_flash(loader, path):