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:
@@ -37,11 +37,12 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
|
||||
info = [line for line in symbols.splitlines() if "info_data" in line]
|
||||
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
|
||||
if len(info) != 1:
|
||||
print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
|
||||
sys.exit(1)
|
||||
offset = int(info[0].split()[0], 16) - text_start
|
||||
address = int(info[0].split()[0], 16)
|
||||
offset = address - text_start
|
||||
if not 0 <= offset < 256:
|
||||
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -69,9 +69,18 @@ struct link {
|
||||
// 'L' hands back to the loader at the top slot — 512 bytes, or the
|
||||
// 1 KiB the >64 KiB chips use.
|
||||
constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 512;
|
||||
for (;;)
|
||||
if (tx_t::read_blocking() == 'L')
|
||||
for (;;) {
|
||||
auto command = tx_t::read_blocking();
|
||||
if (command == 'L')
|
||||
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
|
||||
// 'D' leaves every word of the SPM page buffer dirty, so that a
|
||||
// following 'L' enters the loader with the buffer it never clears.
|
||||
if (command == 'D') {
|
||||
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2)
|
||||
avr::spm::fill(at, 0xdead);
|
||||
tx('D');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
90
test/pbdirty.py
Normal file
90
test/pbdirty.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dirty-page-buffer acceptance test: the loader carries no buffer discard,
|
||||
so a page filled over words an earlier writer left behind programs those
|
||||
instead. This asserts the whole contract — the corruption is real and a bare
|
||||
verify sees it, the repairing verify fixes it in one rewrite (the write that
|
||||
took the stale words auto-erased the buffer), and it stays fixed.
|
||||
|
||||
The state is reached the way the loader cannot prevent: an application
|
||||
dirties the buffer and jumps in with no reset between. Real boot-sectioned
|
||||
megas forbid that outright — SPM executes only from the boot section
|
||||
(Atmel-8271 §26.2) — but simavr dispatches SPM from anywhere, which is what
|
||||
makes the path constructible at all.
|
||||
|
||||
Usage: pbdirty.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <app_bin> <tool_py> <workdir>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir = sys.argv[1:]
|
||||
page, baud = int(page), int(baud)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
import pureboot as pb
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
|
||||
# Reset boots the application on a BOOTRST-unprogrammed mega; its 'L' is
|
||||
# the loader entry this test needs, reached without a reset.
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, reset_hex="0")
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(25)
|
||||
|
||||
# Install the application and hand over to it.
|
||||
pb.op_flash(loader, app_bin, erase=False, verify=True)
|
||||
loader.run_application()
|
||||
if port.read_exact(3, 5.0) != b"APP":
|
||||
fail("the application did not start")
|
||||
|
||||
port.write(b"D")
|
||||
if port.read_exact(1, 5.0) != b"D":
|
||||
fail("the application did not acknowledge dirtying the page buffer")
|
||||
port.write(b"L")
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(25)
|
||||
|
||||
# Program by hand, so the corruption is observable before anything
|
||||
# repairs it.
|
||||
pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info)
|
||||
for address in sorted(pages):
|
||||
loader.write_page(address, pages[address])
|
||||
try:
|
||||
pb.verify_pages(loader, pages)
|
||||
except pb.Error as error:
|
||||
if "verify failed" not in str(error):
|
||||
fail(f"the read-back failed, but not at verify: {error}")
|
||||
else:
|
||||
# Either the fixture no longer dirties the buffer, or the loader
|
||||
# clears it again — in which case this test's premise is gone.
|
||||
fail("programming over a dirty page buffer came back clean")
|
||||
|
||||
# What the programming path uses: one rewrite settles it, and it stays
|
||||
# settled.
|
||||
pb.verify_pages(loader, pages, repair=True)
|
||||
pb.verify_pages(loader, pages)
|
||||
|
||||
# Ground truth beyond the loader's own read-back.
|
||||
loader.run_application()
|
||||
if port.read_exact(3, 5.0) != b"APP":
|
||||
fail("the application did not start after the recovered write")
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
print("pbdirty: a dirty page buffer is caught by verify and cleared by the retry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,8 +66,10 @@ def main():
|
||||
if loader.read_eeprom(0, len(pattern)) != pattern:
|
||||
fail("EEPROM round-trip through the staged copy")
|
||||
|
||||
# The guard, both ways: its own slot refused (drained, unchanged),
|
||||
# the resident slot writable.
|
||||
# The guard, both ways: its own slot refused (drained, unchanged), the
|
||||
# resident slot writable. The refusal leaves its drained words in the
|
||||
# SPM buffer, so the write that follows may take them — and clears
|
||||
# them by writing, so the retry must not.
|
||||
before = loader.read_flash(stage, page)
|
||||
loader.write_page(stage, bytes(page))
|
||||
if loader.read_flash(stage, page) != before:
|
||||
@@ -75,7 +77,9 @@ def main():
|
||||
marker = bytes((i * 3) & 0xFF for i in range(page))
|
||||
loader.write_page(base, marker)
|
||||
if loader.read_flash(base, page) != marker:
|
||||
fail("the staged copy could not write the resident slot")
|
||||
loader.write_page(base, marker)
|
||||
if loader.read_flash(base, page) != marker:
|
||||
fail("the staged copy could not write the resident slot, even on retry")
|
||||
|
||||
# Restore the resident image through the staged copy, then 'J' back
|
||||
# into it and prove it lives.
|
||||
|
||||
@@ -204,6 +204,52 @@ def main():
|
||||
pb.check_walk_region({0x7800: bytes((0xFF,)) * 128}, mega, fuses(0xFA), False)
|
||||
pb.check_walk_region(deep, mega, None, False) # fuses unknown: no check
|
||||
|
||||
# The repairing verify: a mismatched page is rewritten rather than raised,
|
||||
# bounded so a fault that is not self-clearing cannot spin.
|
||||
class FakeLoader:
|
||||
"""A device whose first `bad` writes of any page land wrong."""
|
||||
|
||||
def __init__(self, info, bad):
|
||||
self.info = info
|
||||
self.bad = bad
|
||||
self.flash = {}
|
||||
self.writes = 0
|
||||
|
||||
def write_page(self, address, data):
|
||||
self.writes += 1
|
||||
self.flash[address] = bytes(len(data)) if self.bad > 0 else bytes(data)
|
||||
self.bad -= 1
|
||||
|
||||
def read_flash(self, address, count):
|
||||
return self.flash.get(address, bytes(count))
|
||||
|
||||
want = {0: bytes((i * 5) & 0xFF for i in range(128))}
|
||||
|
||||
# One bad write, then good: repaired in place, and the caller never sees
|
||||
# an error. The rewrite is counted, so a silent no-op cannot pass.
|
||||
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=1)
|
||||
device.write_page(0, want[0])
|
||||
pb.verify_pages(device, want, repair=True)
|
||||
if device.writes != 2:
|
||||
fail(f"repairing verify made {device.writes} writes, expected 2")
|
||||
|
||||
# Without repair the same state raises, so the repair is what fixed it.
|
||||
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=1)
|
||||
device.write_page(0, want[0])
|
||||
expect_error("verify without repair", lambda: pb.verify_pages(device, want), "verify failed")
|
||||
|
||||
# A page that never comes good stops after RETRIES rewrites, and says so.
|
||||
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=99)
|
||||
device.write_page(0, want[0])
|
||||
expect_error(
|
||||
"unrepairable page",
|
||||
lambda: pb.verify_pages(device, want, repair=True),
|
||||
"verify failed",
|
||||
f"after {pb.RETRIES} retries",
|
||||
)
|
||||
if device.writes != pb.RETRIES + 1:
|
||||
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
|
||||
|
||||
print("test_planner: all planner and policy checks pass")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user