From 5f9e736d6680b421d22858ccebddfff9aa64905d Mon Sep 17 00:00:00 2001 From: BlackMark Date: Tue, 21 Jul 2026 21:18:10 +0200 Subject: [PATCH] pureboot: artifact roles, wrong-chip refusal, and misplaced-loader re-homing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's deployment section now says what each build artifact is for: the .hex is the programmer artifact (self-addressed into the top slot), the .bin the self-update image — bare slot bytes a programmer would put at address 0, where a boot-sectioned mega cannot even heal itself (SPM only runs from the boot section) but a patched-vector chip runs the position-independent copy and re-homes a build through the ordinary --update-loader flow: the staging install and the word-0 redirect both execute outside page 0's slot, so the running-slot guard never blocks it. pbrehome.py is the acceptance test (misplaced at 0, guard intact, re-home, app flash over the stale copy, banner); the staging slot is the one position that cannot re-home itself, documented. The preflight's wrong-chip refusal and loader_image's handling of padded images (peeled to the slot content by the embedded base) are documented and the padded case pinned in the planner. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 14 ++++++++ pureboot/README.md | 26 +++++++++++++++ test/pbrehome.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ test/test_planner.py | 15 +++++++++ 4 files changed, 133 insertions(+) create mode 100644 test/pbrehome.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d021ee7..bec2e91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -305,6 +305,20 @@ if(PROJECT_IS_TOP_LEVEL) set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180 ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}") + # Re-homing: a loader mistakenly programmed at address 0 (a raw .bin + # handed to a programmer) must heal into the canonical slot through + # the ordinary --update-loader flow. Patched-vector behavior, so one + # representative chip carries it. + if(LIBAVR_MCU STREQUAL "attiny85") + add_test(NAME pureboot.rehome + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbrehome.py + ${PB_DEVICE} $ $.bin ${_pb_sim_mcu} + ${_pb_hz} ${_pb_base_hex} ${_pb_page} ${_pb_baud} $.bin + ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbrehome-work) + set_tests_properties(pureboot.rehome PROPERTIES TIMEOUT 180) + endif() + # The self-update end-to-end: the re-timed build (same source, only # PUREBOOT_TIMEOUT differs — a byte-different image) replaces the # resident through --update-loader, with every power-fail phase diff --git a/pureboot/README.md b/pureboot/README.md index 9a3e16d..49d85ae 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -111,6 +111,24 @@ write `0xff` (per page for flash, per byte for EEPROM). ## Deployment +The build leaves three artifacts per chip. The ELF is a container for the +tests and objcopy — never flashed. The **.hex is the programmer artifact**: +it carries its own addresses and lands the loader in its top slot, +touching nothing else. The **.bin is the self-update image** — the slot's +bare bytes with no addressing, which a programmer would put at address 0. +On a boot-sectioned mega a copy at 0 is dead weight (SPM only executes +from the boot section, so it cannot even heal itself — reflash the .hex); +on the patched-vector chips it *runs* (the image is position-independent +and reset enters word 0), reports its canonical geometry, and the ordinary +`--update-loader` flow re-homes a build into the top slot — the staging +install and the word-0 redirect both execute from copies outside page 0's +slot, so the running-slot guard never blocks the flow (`pureboot.rehome` +is the acceptance test). Flashing the application afterwards overwrites +the stale copy, vector surgery included. The one position that cannot +re-home itself is the staging slot: installing the staging copy would hit +the running copy's own guard and the update stops at its verify — flash +the .hex instead. + **Boot-sectioned megas**: program the loader at `flash − slot` with an external programmer. Every such mega has a BOOTSZ step whose boot section is exactly the loader slot — 512 B, the second-smallest step on the 8 KiB @@ -161,6 +179,14 @@ loader itself as its own staging loader. The image is the loader's own 512 bytes as a raw binary, or the Intel HEX the build emits beside it, which links the loader at its base inside an otherwise blank flash image: +The preflight refuses an image built for another chip: the info block +embedded in every pureboot binary (signature, page size, loader base, +EEPROM size, flags) must match the device's own, and the error names both. +Die revisions share their base signature and geometry, so their images are +interchangeable — as the silicon is. `loader_image()` also accepts a +padded image (a raw .bin padded from 0, or a whole-flash read-back with +the loader resident) and peels it to the slot content by the embedded base. + 1. The staging slot `[base−slot, base)` is saved to a host-side state file (on the 1 KB tiny13s that is the whole application, vectors included). 2. The resident installs the identical update image there. On the diff --git a/test/pbrehome.py b/test/pbrehome.py new file mode 100644 index 0000000..2ffb43f --- /dev/null +++ b/test/pbrehome.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Re-homing acceptance test: a pureboot image mistakenly programmed at +address 0 of a patched-vector chip (a raw .bin handed to a programmer, which +defaults to offset 0) must still be a working loader — position-independent, +guarding its accidental slot — and the ordinary --update-loader flow must +re-home a build into the canonical top slot: the staging install and the +word-0 redirect both run from copies whose slots are not page 0's, so the +running-slot guard never blocks the flow. Flashing an application through +the healed resident then overwrites the stale copy, vector surgery included, +and the banner proves the launch. + +Usage: pbrehome.py + +""" + +import os +import sys + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) + + +def main(): + (device_bin, elf, update_bin, mcu, hz, base_hex, page, baud, app_bin, tool, workdir) = sys.argv[1:] + base, baud = int(base_hex, 0), 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") + state = os.path.join(workdir, "rehome.pbstate") + if os.path.exists(state): + os.unlink(state) + + # The runner's base argument places the firmware: 0 is the misplaced + # flash, and the patched-vector reset at word 0 lands in it directly. + device = pbsim.Device(device_bin, elf, mcu, hz, "0x0", int(page), baud, dump, reset_hex="0") + try: + port = pb.Port(device.pty, baud) + loader = pb.Loader(port) + info = loader.connect(25) + if info.base != base: + fail(f"the misplaced copy reports base {info.base:#06x} — the info block must stay canonical") + + # The accidental slot still guards itself; the canonical base slot + # must be writable from it (that is what re-homing rides on). + before = loader.read_flash(0, info.page) + loader.write_page(0, bytes(info.page)) + if loader.read_flash(0, info.page) != before: + fail("the misplaced copy's guard let its own slot change") + + # The ordinary update flow re-homes the build into the top slot. + pb.op_update_loader(loader, 25, update_bin, state, None) + update = open(update_bin, "rb").read() + if loader.read_flash(base, len(update)) != update: + fail("the canonical slot does not hold the update image") + + # An application flashed through the healed resident overwrites the + # stale copy (surgery included) and launches. + pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info) + for address in pb.covered(pages, loader.info, skip_blank=False): + loader.write_page(address, pages[address]) + pb.verify_pages(loader, pages) + loader.run_application() + if port.read_exact(3, 5.0) != b"APP": + fail("application does not banner after the re-home") + port.close() + finally: + device.stop() + print("pbrehome: a misplaced loader re-homes through the ordinary update flow") + + +if __name__ == "__main__": + main() diff --git a/test/test_planner.py b/test/test_planner.py index e600829..e7abd41 100644 --- a/test/test_planner.py +++ b/test/test_planner.py @@ -155,6 +155,21 @@ def main(): if pb.image_info(bytes((0xAA,)) * 40) is not None: fail("image_info invents a block") + # loader_image must peel a padded image down to the slot content: a raw + # .bin padded from address 0 (or a whole-flash read-back with the loader + # resident at base) yields the same bytes as the bare slot image. + import tempfile + slot_image = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xCC,)) * 40 + padded = bytes((0xFF,)) * tiny.base + slot_image + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + f.write(padded) + padded_path = f.name + try: + if pb.loader_image(padded_path) != slot_image: + fail("loader_image does not peel a padded image to the slot content") + finally: + os.unlink(padded_path) + # Update preflight: the full fuse matrix, plus target mismatch. other = info_of(pb, 0x1E00, 32, True, 0x2000) expect_error("wrong-target image", lambda: pb.update_preflight(binary, other, None), "another target")