3 Commits

Author SHA1 Message Date
f6598b0511 pureboot: PI lint, tightened gates, and the self-update test suite
check_pi.py asserts the two link-time facts position independence rests on
(no absolute jmp/call; the info block within the image's first 256 bytes);
the size gates drop to 510 on the tinies for the trampoline word.

New per-chip tests beside the reworked protocol test: the planner units
(programming orders and their recovery properties, the surgery, staging
composition, boot-fuse decode, and the update preflight's error/warning
matrix over synthetic fuse bytes), the relocated-copy sweep (the identical
image installed one slot lower serves the full command set — the PI
acceptance test, and the one that caught the temporary-buffer trap), and
the self-update end-to-end: --update-loader to a re-timed build
(pureboot9, byte-different by PUREBOOT_TIMEOUT alone), then every
power-fail phase killed mid-write, restarted from the runner's flash dump,
and completed by a re-run with the application intact throughout. The mega
rounds run the BOOTRST-unprogrammed profile: the fixture application's 'L'
jump is the application-owned loader entry that profile relies on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:57 +02:00
0604d3a0ad pureboot: one position-independent binary — its own staging loader
The image now runs from any 512-byte slot with every command intact:
control flow stays PC-relative, the write guard keys on the running slot
(the return-address anchor, computed once), the info block is addressed
from that same anchor as a byte pair (no absolute 16-bit address in the
image), and the application jump is an indirect call through a noipa-
laundered pointer to the absolute entry. 'J' — jump to a wire word
address, the one transfer primitive — replaces 'G': the host knows the
application entry from the info block, and moving between loader copies
needs arbitrary targets. The activation window is a compile-time 8 s
(PUREBOOT_TIMEOUT overrides), counted as a single calibrated poll loop.

A refused page no longer poisons the write-once temporary buffer (a real
silicon trap: the next write would program the drained data): every page
write discards the buffer first — CTPB on the tinies, on the mega the same
RWWSRE store that re-enables RWW after programming. The tinies' post-op
busy-waits go with it: their CPU halts through page erase and write.

488 / 502 / 504 B on t13a / t85 / mega — under the tinies' 510-byte budget,
whose last slot word is the host-managed trampoline: the resident's holds
the application entry, a staging copy's the jump through which an abandoned
update still times out into a loader.

The host tool updates the loader with itself: --update-loader installs the
identical image one slot below the resident, jumps into it, lets it rewrite
the resident, and restores the staging region from a state file — each
phase idempotent off the flash state, resumable after any interruption
(t13a: the staging slot carries the reset vector, written last in and
first out; t85: word 0 redirected around the resident rewrite; mega:
fuse-matrix preflight with a hard BOOTSZ gate and --assume-fuses for
simulators). Application flashing recovers by reset from any interruption:
patched page 0 and trampoline first, erase descending, and a walk-region
refusal behind --force on BOOTRST-below-loader megas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:43 +02:00
62a548cc09 pureboot: harden the sim device runner
Cancel the GPIO bridge's cycle timers with the state they drive: avr_reset
drops the TX latch, whose falling edge starts a spurious decode before
bridge_reset runs, and the stale sampler then interleaves with the loader's
first real answer through the shared shift state — the first post-reset
replies came back corrupted and the knock retries burned the activation
window into the application.

Wrap the mega's registered flash ioctl to re-dispatch page erases with Z
masked to the page boundary: simavr's PGERS handler erases spm_pagesize
bytes from Z & ~1 (its PGWRT path masks correctly), wiping the neighbouring
page when Z sits past the page start, which hardware permits (§26.8.1).
Model the write-once temporary buffer in the tiny NVM module — silicon
refuses a second load per word until the buffer clears, and a last-write-
wins model masks real firmware bugs.

Optional arguments select the reset vector (the mega's fuse profiles) and a
raw flash image to resume from (power-fail tests re-enter a dumped state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:10 +02:00
12 changed files with 1330 additions and 198 deletions

View File

@@ -118,6 +118,15 @@ endif()
# the trampoline word just below the loader on the tinies (host-side vector # the trampoline word just below the loader on the tinies (host-side vector
# surgery points it at the application). --pmem-wrap-around models AVR's # surgery points it at the application). --pmem-wrap-around models AVR's
# modulo-flash PC where the flash is big enough to need it. # modulo-flash PC where the flash is big enough to need it.
#
# The image is position-independent (check_pi.py asserts the two link-time
# facts that make it so), and on the tinies its budget is 510, not 512: the
# slot's last word is the trampoline the host composes — the resident slot's
# holds the application entry, and a staging copy's holds the jump through
# which it reaches the loader it installed. The activation window is a
# compile-time constant; a different PUREBOOT_TIMEOUT builds the re-timed
# binary a self-update then installs.
set(PUREBOOT_TIMEOUT 8 CACHE STRING "pureboot activation window, seconds")
if(LIBAVR_MCU STREQUAL "attiny13a") if(LIBAVR_MCU STREQUAL "attiny13a")
set(_pb_flash 1024) set(_pb_flash 1024)
set(_pb_wrap "") set(_pb_wrap "")
@@ -125,6 +134,7 @@ if(LIBAVR_MCU STREQUAL "attiny13a")
set(_pb_hz 9600000) set(_pb_hz 9600000)
set(_pb_baud 57600) set(_pb_baud 57600)
set(_pb_eeprom 64) set(_pb_eeprom 64)
set(_pb_limit 510)
elseif(LIBAVR_MCU STREQUAL "attiny85") elseif(LIBAVR_MCU STREQUAL "attiny85")
set(_pb_flash 8192) set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k) set(_pb_wrap -Wl,--pmem-wrap-around=8k)
@@ -132,6 +142,7 @@ elseif(LIBAVR_MCU STREQUAL "attiny85")
set(_pb_hz 8000000) set(_pb_hz 8000000)
set(_pb_baud 57600) set(_pb_baud 57600)
set(_pb_eeprom 512) set(_pb_eeprom 512)
set(_pb_limit 510)
else() else()
set(_pb_flash 32768) set(_pb_flash 32768)
set(_pb_wrap -Wl,--pmem-wrap-around=32k) set(_pb_wrap -Wl,--pmem-wrap-around=32k)
@@ -139,6 +150,7 @@ else()
set(_pb_hz 16000000) set(_pb_hz 16000000)
set(_pb_baud 115200) set(_pb_baud 115200)
set(_pb_eeprom 1024) set(_pb_eeprom 1024)
set(_pb_limit 512)
endif() endif()
math(EXPR _pb_base "${_pb_flash} - 512") math(EXPR _pb_base "${_pb_flash} - 512")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL) math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
@@ -150,13 +162,22 @@ endif()
add_executable(pureboot pureboot/pureboot.cpp) add_executable(pureboot pureboot/pureboot.cpp)
target_link_libraries(pureboot PRIVATE libavr) target_link_libraries(pureboot PRIVATE libavr)
target_compile_definitions(pureboot PRIVATE PUREBOOT_TIMEOUT=${PUREBOOT_TIMEOUT})
target_link_options(pureboot PRIVATE -nostartfiles -Wl,--section-start=.text=${_pb_base_hex} target_link_options(pureboot PRIVATE -nostartfiles -Wl,--section-start=.text=${_pb_base_hex}
-Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap}) -Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap})
add_custom_command(TARGET pureboot POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:pureboot>) add_custom_command(TARGET pureboot POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:pureboot>)
if(PROJECT_IS_TOP_LEVEL) if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.size add_test(NAME pureboot.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot> COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot>
-DLIMIT=512 -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -DLIMIT=${_pb_limit} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
if(Python3_FOUND)
add_test(NAME pureboot.pi
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${_pb_base_hex})
add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
endif()
# The protocol test flashes this fixture through the loader with the real # The protocol test flashes this fixture through the loader with the real
# host tool and expects its banner after the hand-over; a normally linked # host tool and expects its banner after the hand-over; a normally linked
@@ -173,5 +194,33 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbtest-work) ${CMAKE_BINARY_DIR}/pbtest-work)
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
# The position-independence acceptance test: the identical image,
# installed one slot lower, must serve the full command set.
add_test(NAME pureboot.reloc
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbreloc.py
${PB_DEVICE} $<TARGET_FILE:pureboot> ${LIBAVR_MCU} ${_pb_hz} ${_pb_base_hex}
${_pb_page} ${_pb_baud} ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbreloc-work)
set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180
ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}")
# 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
# rehearsed from the runner's flash dumps.
add_executable(pureboot9 pureboot/pureboot.cpp)
target_link_libraries(pureboot9 PRIVATE libavr)
target_compile_definitions(pureboot9 PRIVATE PUREBOOT_TIMEOUT=9)
target_link_options(pureboot9 PRIVATE -nostartfiles -Wl,--section-start=.text=${_pb_base_hex}
-Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap})
add_test(NAME pureboot.update
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbupdate.py
${PB_DEVICE} $<TARGET_FILE:pureboot> $<TARGET_FILE:pureboot9> ${LIBAVR_MCU}
${_pb_hz} ${_pb_base_hex} ${_pb_page} ${_pb_baud} $<TARGET_FILE:pbapp>.bin
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbupdate-work)
set_tests_properties(pureboot.update PROPERTIES TIMEOUT 600
ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}")
endif() endif()
endif() endif()

View File

@@ -3,11 +3,22 @@
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables constraint: one C++ source, no inline assembly, no global register variables
(attributes allowed), built for every chip libavr targets, **512 bytes on (attributes allowed), built for every chip libavr targets, **512 bytes on
each** — 490 B on the ATtiny13A, 510 B on the ATtiny85, 484 B on the each** — 488 B on the ATtiny13A, 502 B on the ATtiny85, 504 B on the
ATmega328P. The device speaks primitives; every composite — verify, erase, ATmega328P. The device speaks primitives; every composite — verify, erase,
reset-vector surgery, timeout configuration — lives in the host tool reset-vector surgery, updating the loader itself — lives in the host tool
(`pureboot.py`). (`pureboot.py`).
The image is **position-independent**: control flow is PC-relative, the
read/write paths take wire addresses, the write guard protects the 512-byte
slot the code is *running* in (from the runtime return address), the info
block is addressed from that same anchor, and the application jump is an
indirect call to an absolute entry. The identical binary therefore runs from
any 512-byte slot with every command intact — which makes pureboot **its own
staging loader**: the host installs the same binary one slot below the
resident, jumps into it, and lets it rewrite the resident. On the tinies the
budget is 510, not 512: a slot's last word belongs to the host-managed
trampoline (below).
## Link ## Link
| Chip | Serial | Baud | Clock assumed | | Chip | Serial | Baud | Clock assumed |
@@ -31,18 +42,18 @@ The host then has one activation window per awaited byte to knock: `p` then
awaited again (line noise cannot lock the loader, only delay it). A window awaited again (line noise cannot lock the loader, only delay it). A window
expiring with an idle line boots the application. expiring with an idle line boots the application.
The window length in seconds is the **last EEPROM cell** (address The window length is a compile-time constant — 8 s by default, another value
`eeprom_size - 1`); `0x00` and the erased `0xff` both mean the 4 s default, via the `PUREBOOT_TIMEOUT` CMake cache variable — so the whole EEPROM belongs
so a full EEPROM erase resets the timeout rather than maxing it. The host to the application; pureboot never uses it for its own state. Re-timing a
changes it with the ordinary EEPROM-write command. deployed loader is a self-update with a re-timed build (below).
## Session ## Session
After the knock the loader stays in its command loop until `G` or a reset. After the knock the loader stays in its command loop until `J` jumps away or
Before reading each command it waits for any pending EEPROM write to finish the chip resets. Before reading each command it waits for any pending EEPROM
and sends the prompt `+` (0x2b) — the prompt is therefore also the completion write to finish and sends the prompt `+` (0x2b) — the prompt is therefore
ack of the previous command. A session is: await `+`, send a command, read also the completion ack of the previous command. A session is: await `+`,
its reply, repeat. send a command, read its reply, repeat.
| Cmd | Arguments | Reply | | Cmd | Arguments | Reply |
|---|---|---| |---|---|---|
@@ -52,17 +63,24 @@ its reply, repeat.
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) | | `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun | | `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse | | `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
| `G` | — | `+`, then the application runs | | `J` | word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) | | other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`W` streams exactly one SPM page (size from the info block) into the buffer, `W` streams exactly one SPM page (size from the info block) into the buffer,
then erases and programs; the address must be page-aligned. Pages inside the then erases and programs; the address must be page-aligned. Pages inside the
loader's own 512 bytes are drained but never programmed — a broken host 512-byte slot the loader is *running* in are drained but never programmed — a
cannot brick the chip. `w` is host-paced: send the next byte only after the broken host cannot brick the running copy, and a staged copy may rewrite the
previous byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip without an resident slot. `w` is host-paced: send the next byte only after the previous
extended fuse byte (the ATtiny13A) that slot carries no meaning. Fuse *writing* does not byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip
exist: SPM reaches flash (and, on the mega, lock bits) only — fuse bytes are without an extended fuse byte (the ATtiny13A) that slot carries no meaning.
external-programming territory by hardware. Fuse *writing* does not exist: SPM reaches flash (and, on the mega, lock
bits) only — fuse bytes are external-programming territory by hardware.
`J` is the one control-transfer primitive: the host uses it to run the
application (word 0 on the mega, the trampoline word on the tinies — both
known from the info block) and to move between loader copies during a
self-update. A jump to a loader slot's base re-enters that copy's own
startup; it must then be knocked afresh.
The info block (`b`): The info block (`b`):
@@ -76,24 +94,59 @@ The info block (`b`):
| 11 | bit 0 set: host must patch the reset vector (no hardware boot section) | | 11 | bit 0 set: host must patch the reset vector (no hardware boot section) |
Composites are the host's job: verify = read back and compare, erase = Composites are the host's job: verify = read back and compare, erase =
write `0xff` (per page for flash, per byte for EEPROM), timeout = EEPROM write `0xff` (per page for flash, per byte for EEPROM).
write to the last cell.
## Deployment ## Deployment
**ATmega328P**: program the loader at 0x7e00 with an external programmer; **ATmega328P**: program the loader at 0x7e00 with an external programmer.
fuses BOOTSZ = 11 (256 words) and BOOTRST programmed. Applications are Two fuse profiles, same binary:
flashed unmodified — reset re-vectors to the loader in hardware, word 0
stays the application's own reset vector, and `G` jumps to 0.
**Tinies** (no boot section): program the loader at `flash - 512`; erased | BOOTSZ | BOOTRST | Behavior |
|---|---|---|
| 256 words (512 B) | programmed | *Standalone*: reset always enters the loader; **self-update impossible** (the staging slot lies outside the boot section, where SPM is disabled). |
| 512 words (1 KB) | unprogrammed | *Self-update, app-first*: reset always boots the application, which owns all 31.5 KB and must offer its own jump to 0x7e00 to reach the loader (a virgin chip reaches it by reset across erased flash). Updates are power-fail-safe except mid-rewrite of the resident slot itself (no reset path leads to the staging copy then). |
| 512 words (1 KB) | programmed | *Self-update, loader-first*: reset lands at 0x7c00 — the staging slot, normally erased, so execution walks up into the loader; during an update it is the staging copy itself, so a mid-rewrite power loss recovers by reset. The loss windows move to the staging install/retire page writes instead (page-write scale). The host keeps `[0x7c00, 0x7e00)` clear of application data (`--force` overrides). |
Applications are flashed unmodified — word 0 stays the application's own
reset vector, and the hand-over jumps to 0.
**Tinies** (no boot section): program the loader at `flash 512`; erased
flash below it walks up into the loader, so a virgin chip activates. When flash below it walks up into the loader, so a virgin chip activates. When
flashing an application the host performs reset-vector surgery: the flashing an application the host performs reset-vector surgery: word 0 is
application's own `rjmp` target is re-encoded as a trampoline `rjmp` in the rewritten to `rjmp` to the loader base, and the application's own entry is
word just below the loader (`base - 2`, where `G` jumps), and word 0 is re-encoded as a trampoline `rjmp` in the word just below the loader
rewritten to `rjmp` to the loader base. Every other vector stays the (`base 2`, where the hand-over jumps). Every other vector stays the
application's. Page 0 is written last, so an interrupted flash leaves word 0 application's. The patched page 0 and the trampoline page are written
erased and the chip still falls through to the loader on the next reset. *first*, so from the first write on an interrupted flash still resets into
the loader; an erase runs top-down for the same reason.
## Updating the loader
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
with any pureboot build — a re-timed window, a newer protocol — using the
loader itself as its own staging loader:
1. The staging slot `[base512, base)` is saved to a host-side state file
(on the 1 KB tiny13A that is the whole application, vectors included).
2. The resident installs the identical update image there. On the tinies the
host composes the slot's last word — the same address as the resident's
trampoline — as a jump to the resident base, so even an abandoned staging
copy times out into a loader, never into garbage.
3. `J` enters the staging copy, which rewrites the resident slot. On the
t85 the host first re-aims word 0 at the staging copy, so a power loss
mid-rewrite still resets into a loader; on the t13a the staging slot
carries the reset vector itself.
4. `J` enters the new resident, which restores the staging slot's saved
content (word 0 and the trampoline with it) and the state file is
discarded.
Every phase is idempotent and keyed off the actual flash state: re-running
the same command after any interruption resumes and completes. The state
file carries the only bytes not recoverable from the device; if it is lost
mid-update the update still completes, and the staging region is restored by
reflashing the application. The mega needs its fuses for the preflight
(BOOTSZ gate, profile notes) — read from the device, or supplied with
`--assume-fuses` where reading is impossible (simulators).
## Host tool ## Host tool
@@ -101,23 +154,41 @@ erased and the chip still falls through to the loader on the next reset.
a USB adapter as well as a simavr pty): a USB adapter as well as a simavr pty):
pureboot.py --port /dev/ttyUSB0 --baud 57600 \ pureboot.py --port /dev/ttyUSB0 --baud 57600 \
--info --fuses --flash app.hex --timeout 10 --info --fuses --flash app.hex
Operations run in a fixed order within one session: info, fuses, flash Operations run in a fixed order within one session: info, fuses, loader
(erase / program / read / verify), EEPROM (erase / program / read / verify), update, flash (erase / program / read / verify), EEPROM (erase / program /
timeout — then the loader hands over to the application; `--stay` keeps the read / verify) — then the loader hands over to the application; `--stay`
session alive instead, and a later invocation reconnects into it (the knock keeps the session alive instead, and a later invocation reconnects into it
converges there too). `--flash` and `--eeprom` verify by read-back unless (the knock converges there too). `--flash` and `--eeprom` verify by
`--no-verify`; images are raw binary, or Intel HEX by extension. read-back unless `--no-verify`; 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).
## Tests ## Tests
Per chip preset, `ctest` runs the 512-byte size gate and the end-to-end Per chip preset, `ctest` runs:
protocol test: a simavr device (`test/pureboot_device.c` — the mega's USART
as a pty; on the tinies a cycle-timed GPIO⇄pty bridge for the software UART, - `pureboot.size` — the 510-byte (tinies) / 512-byte (mega) budget;
plus the SPM/NVM module simavr's tiny cores lack) driven by the real host - `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`
tool through knock-from-reset, program + verify of both memories, timeout in the image, the info block within its first 256 bytes;
configuration, session reconnect, an external reset through the patched - `pureboot.planner` — the host tool's pure logic: programming orders and
vector, and the hand-over to a fixture application whose banner proves the their recovery properties, the surgery, the staging composition, the
launch — cross-checked against the simulator's ground-truth memory dumps and boot-fuse decode, and the update preflight's error/warning matrix over
an independent decode of the surgery's rjmp words. synthetic fuse bytes;
- `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c` — the mega's USART as a pty; on the tinies a
cycle-timed GPIO⇄pty bridge for the software UART, plus the SPM/NVM module
simavr's tiny cores lack) driven by the real host tool through
knock-from-reset, program + verify of both memories, session reconnect, an
external reset through the patched vector, and the hand-over to a fixture
application whose banner proves the launch — cross-checked against the
simulator's ground-truth memory dumps and an independent decode of the
surgery's rjmp words;
- `pureboot.reloc` — the identical image installed one slot below the
resident serves the complete command set from there (the
position-independence acceptance test);
- `pureboot.update` — the full `--update-loader` flow to a re-timed build,
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 throughout.

View File

@@ -1,17 +1,27 @@
// pureboot — a serial bootloader on libavr, pure by constraint: one C++ // pureboot — a serial bootloader on libavr, pure by constraint: one C++
// source with no inline assembly and no global register variables, built for // source with no inline assembly and no global register variables, built for
// every chip libavr targets, 512 bytes on each. The device speaks primitives // every chip libavr targets, 512 bytes on each. The device speaks primitives
// — read/program flash, read/write EEPROM, fuse bytes, an info block, run — // — read/program flash, read/write EEPROM, fuse bytes, an info block, a jump
// and everything composite (verify, erase, reset-vector surgery on the // and everything composite (verify, erase, reset-vector surgery, updating
// tinies, timeout configuration) lives in the host tool. Protocol reference: // the loader itself) lives in the host tool. Protocol reference: README.md
// README.md next to this file. // next to this file.
//
// The image is position-independent: control flow is PC-relative, the write
// and read paths take wire addresses, the write guard refuses the 512-byte
// slot the code is *running* in (taken from the runtime return address), the
// info block is read relative to that same anchor, and the application jump
// is an indirect call to an absolute entry. The identical binary therefore
// runs from any 512-byte slot with every command intact: flashed one slot
// below the resident loader it becomes the staging loader that rewrites the
// resident — how pureboot updates itself, host-driven, with no other
// firmware involved.
// //
// Entry: reset lands in avr::startup::entry below (BOOTRST on the mega; the // Entry: reset lands in avr::startup::entry below (BOOTRST on the mega; the
// patched reset vector — or erased flash walking up into the loader — on the // patched reset vector — or erased flash walking up into the loader — on the
// tinies). A watchdog reset hands straight to the application. Otherwise the // tinies). A watchdog reset hands straight to the application. Otherwise the
// host has one activation window — EEPROM's last cell, in seconds — to knock // host has one activation window per awaited knock byte ("pb"); an idle line
// ("pb"); an idle line boots the application. A session then stays in the // boots the application. A session then stays in the command loop until 'J'
// command loop until 'G' hands over or the chip resets. // jumps away or the chip resets.
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
@@ -51,22 +61,22 @@ consteval std::array<std::uint8_t, 3> signature()
using dev = avr::device<{.clock = clock()}>; using dev = avr::device<{.clock = clock()}>;
// Geometry: the loader owns the top 512 bytes of flash; the byte below it is // Geometry: the resident loader owns the top 512 bytes of flash; the word
// the trampoline word (the application's relocated reset vector) on chips // below it is the trampoline (the application's relocated reset vector) on
// without a hardware boot section. The RWWSRE bit marks a separate boot // chips without a hardware boot section. The RWWSRE bit marks a separate
// section — on classic AVR the two capabilities coincide. // boot section — on classic AVR the two capabilities coincide.
constexpr std::uint16_t boot_bytes = 512; constexpr std::uint16_t boot_bytes = 512;
constexpr std::uint16_t base = static_cast<std::uint16_t>(spm::flash_bytes - boot_bytes); constexpr std::uint16_t base = static_cast<std::uint16_t>(spm::flash_bytes - boot_bytes);
constexpr std::uint16_t page = spm::page_bytes; constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::db.field_index("SPMCSR", "RWWSRE") >= 0; constexpr bool boot_section = avr::hw::db.field_index("SPMCSR", "RWWSRE") >= 0;
// The activation timeout lives in EEPROM's last cell, in seconds; the host // The activation window, in seconds, is a compile-time constant (the build
// rewrites it with the ordinary EEPROM-write command. An unprogrammed cell — // may override it): the whole EEPROM belongs to the application, and
// 0x00 or the erased 0xff — means the 4 s default: a stray value can never // re-timing the loader is a bootloader self-update with a re-timed binary.
// floor the window to nothing and lock the loader out, and erasing the whole #if !defined(PUREBOOT_TIMEOUT)
// EEPROM resets the timeout instead of maxing it to 255 s. #define PUREBOOT_TIMEOUT 8
constexpr std::uint16_t timeout_cell = avr::hw::db.mem.eeprom_size - 1; #endif
constexpr std::uint8_t default_seconds = 4; constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
// The 12-byte info block the host reads with the 'b' command; flash-resident // The 12-byte info block the host reads with the 'b' command; flash-resident
// (there is no crt to copy a .data image). // (there is no crt to copy a .data image).
@@ -79,7 +89,7 @@ inline constexpr std::array<std::uint8_t, 12> info_data = {
signature()[2], signature()[2],
static_cast<std::uint8_t>(page), static_cast<std::uint8_t>(page),
base & 0xff, base & 0xff,
base >> 8, // app flash ends here; loader base base >> 8, // app flash ends here; resident loader base
avr::hw::db.mem.eeprom_size & 0xff, avr::hw::db.mem.eeprom_size & 0xff,
avr::hw::db.mem.eeprom_size >> 8, avr::hw::db.mem.eeprom_size >> 8,
boot_section ? 0 : 1, // bit 0: host must patch the reset vector (no hardware boot section) boot_section ? 0 : 1, // bit 0: host must patch the reset vector (no hardware boot section)
@@ -90,17 +100,35 @@ using info = avr::flash_table<info_data>;
// software UART (no vector — the table belongs to the application) on PB0/PB1 // software UART (no vector — the table belongs to the application) on PB0/PB1
// elsewhere. Both are class templates on the clock so only the selected // elsewhere. Both are class templates on the clock so only the selected
// backend is ever instantiated. pending() is the cheap line test the // backend is ever instantiated. pending() is the cheap line test the
// activation window polls; rx() then picks the byte up. // activation window polls; rx() then picks the byte up; drain() holds until
// the last transmitted frame is fully on the wire (the jump hand-over must
// not let the target's re-init clip the ack).
template <avr::hertz_t C> template <avr::hertz_t C>
consteval std::int16_t rxc_field() consteval std::int16_t rxc_field()
{ {
return avr::hw::db.field_index("UCSR0A", "RXC0"); return avr::hw::db.field_index("UCSR0A", "RXC0");
} }
template <avr::hertz_t C>
consteval std::int16_t txc_field()
{
return avr::hw::db.field_index("UCSR0A", "TXC0");
}
template <avr::hertz_t C>
consteval std::int16_t status_reg()
{
return avr::hw::db.reg_index("UCSR0A");
}
template <avr::hertz_t C> template <avr::hertz_t C>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart0<C, {.baud = 115200_Bd, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart0<C, {.baud = 115200_Bd, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6).
static constexpr std::uint8_t poll_cycles = 10;
static void init() static void init()
{ {
avr::init<uart>(); avr::init<uart>();
@@ -120,6 +148,19 @@ struct hardware_link {
{ {
uart::write(byte); uart::write(byte);
} }
static void drain()
{
// write() leaves the byte draining behind it. Clear a stale TXC0
// first (W1C by writing the sampled status back — the store a hand
// assembler writes, keeping U2X0), then wait for the fresh
// completion; with a byte still ahead in the shifter TXC0 cannot
// re-set until the last pending byte has fully left.
using status = avr::hw::reg_impl<status_reg<C>()>;
status::write(status::read());
while (!avr::hw::field_impl<txc_field<C>()>::test()) {
}
}
}; };
template <avr::hertz_t C> template <avr::hertz_t C>
@@ -127,6 +168,10 @@ struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::pb0, 57600_Bd>; using rx_t = avr::uart::software_rx_polled<C, avr::pb0, 57600_Bd>;
using tx_t = avr::uart::software_tx<C, avr::pb1, 57600_Bd>; using tx_t = avr::uart::software_tx<C, avr::pb1, 57600_Bd>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6).
static constexpr std::uint8_t poll_cycles = 8;
static void init() static void init()
{ {
avr::init<rx_t, tx_t>(); avr::init<rx_t, tx_t>();
@@ -146,58 +191,65 @@ struct software_link {
{ {
tx_t::template write<off>(byte); tx_t::template write<off>(byte);
} }
static void drain()
{
// The software transmitter returns only after the stop bit.
}
}; };
using link = std::conditional_t<avr::hw::db.has_reg("UDR0"), hardware_link<dev::clock>, software_link<dev::clock>>; using link = std::conditional_t<avr::hw::db.has_reg("UDR0"), hardware_link<dev::clock>, software_link<dev::clock>>;
// The application's entry: the linker pins pureboot_app to 0x0000 on the // The application's entry, an absolute address the linker pins (--defsym in
// mega (reset re-vectors here through BOOTRST, so address 0 stays the // CMakeLists.txt): 0x0000 on the mega (word 0 stays the application's own
// application's own vector) and to the trampoline word at base - 2 on the // vector — BOOTRST re-vectors a reset into the loader in hardware) and the
// tinies (--defsym in CMakeLists.txt). // trampoline word at base - 2 on the tinies. Reaching it must not depend on
// where this copy runs, so the jump goes through a pointer: [[gnu::noipa]]
// keeps the constant from folding back into a PC-relative call.
extern "C" [[noreturn]] void pureboot_app(); extern "C" [[noreturn]] void pureboot_app();
[[noreturn]] void run_app() [[gnu::noipa, noreturn]] void jump(void (*target)())
{ {
pureboot_app(); target();
__builtin_unreachable();
} }
// One activation tick is 65536 pending() polls — a pin (or flag) test plus a [[gnu::noinline, noreturn]] void run_app()
// 16-bit countdown, about 8 cycles. Whole-second precision is all the
// timeout cell promises; the seconds count stays a loop bound (a runtime
// multiply would drag libgcc's __mulhi3 into the MUL-less tinies).
consteval std::uint16_t ticks_per_second()
{ {
return static_cast<std::uint16_t>(dev::clock.hz / (65536ull * 8u)); jump(pureboot_app);
} }
static_assert(ticks_per_second() >= 1);
bool pending_before(std::uint8_t seconds) // One activation window is a single 32-bit poll countdown. The divisor is
// the backend's counted poll-loop cycles (its own comment reads them off the
// compiled loop); whole-second precision is all the window promises, so the
// nearest cycle count is plenty.
consteval std::uint32_t window_polls()
{ {
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles);
}
bool pending_before_deadline()
{
std::uint32_t polls = window_polls();
do { do {
std::uint16_t ticks = ticks_per_second(); if (link::pending())
do { return true;
std::uint16_t spins = 0; // wraps first, so 65536 polls per tick } while (--polls);
do {
if (link::pending())
return true;
} while (--spins);
} while (--ticks);
} while (--seconds);
return false; return false;
} }
// A knock byte under the activation deadline: an idle line means no host is // A knock byte under the activation deadline: an idle line means no host is
// there, and the application runs. // there, and the application runs.
std::uint8_t rx_deadline(std::uint8_t seconds) std::uint8_t rx_deadline()
{ {
if (!pending_before(seconds)) if (!pending_before_deadline())
run_app(); run_app();
return link::rx(); return link::rx();
} }
std::uint16_t rx16() std::uint16_t rx16()
{ {
std::uint8_t low = link::rx(); std::uint16_t low = link::rx();
return static_cast<std::uint16_t>(low | (link::rx() << 8)); return static_cast<std::uint16_t>(low | (link::rx() << 8));
} }
@@ -207,7 +259,9 @@ const std::uint8_t *flash_ptr(std::uint16_t address)
} }
// The streamers take the count in the wire's 8-bit form: 0 means 256. // The streamers take the count in the wire's 8-bit form: 0 means 256.
void send_flash(std::uint16_t address, std::uint8_t count) // send_flash stays out of line: its two callers ('b' and 'R') otherwise each
// inline a private copy of the loop.
[[gnu::noinline]] void send_flash(std::uint16_t address, std::uint8_t count)
{ {
do do
link::tx(avr::flash_load(flash_ptr(address++))); link::tx(avr::flash_load(flash_ptr(address++)));
@@ -234,23 +288,44 @@ void store_eeprom(std::uint16_t address, std::uint8_t count)
} }
// One flash page: stream the bytes into the SPM buffer as little-endian // One flash page: stream the bytes into the SPM buffer as little-endian
// words, then erase and program. Addresses in the loader's own 512 bytes // words, then erase and program — except the 512-byte slot this code runs
// are drained but never programmed — a broken host cannot brick the chip. // in, which is drained but never programmed, so a copy can never erase
// On the mega the RWW section is re-enabled so reads work immediately. // itself. `slot_high` is the high byte of that running slot's base (run()
void program_flash(std::uint16_t address) // derives it); a broken host thus cannot brick the running loader, and a
// copy flashed one slot lower may rewrite the slot above it — how pureboot
// updates itself. On the mega the RWW section is re-enabled so reads work
// immediately.
void program_flash(std::uint16_t address, std::uint8_t slot_high)
{ {
for (std::uint16_t i = 0; i < page; i += 2) { // A buffer word cannot be loaded twice without an erase (§26.2.1), so a
// refused page's drained data must not linger for the next write:
// discard the buffer up front — CTPB on the tinies; on the mega writing
// RWWSRE aborts a pending load (§26.2.2).
if constexpr (boot_section)
spm::rww_enable<off>();
else
spm::clear_buffer<off>();
// The address is the loop's only state: pages are aligned, so the walk
// ends when the offset bits wrap back to zero.
do {
std::uint8_t low = link::rx(); std::uint8_t low = link::rx();
std::uint8_t high = link::rx(); std::uint8_t high = link::rx();
spm::fill<off>(address + i, static_cast<std::uint16_t>(low | (high << 8))); spm::fill<off>(address, static_cast<std::uint16_t>(low | (high << 8)));
} address += 2;
if (address < base) { } while (static_cast<std::uint8_t>(address) & (page - 1));
address -= 2; // back inside the page — erase and write ignore the word bits
const std::uint8_t page_high = static_cast<std::uint8_t>(address >> 8) & 0xfe;
if (page_high != slot_high) {
// The tinies halt the CPU through the erase and the write, so only
// the mega — running on while its RWW section programs — waits.
spm::erase_page<off>(address); spm::erase_page<off>(address);
spm::wait();
spm::write_page<off>(address);
spm::wait();
if constexpr (boot_section) if constexpr (boot_section)
spm::wait();
spm::write_page<off>(address);
if constexpr (boot_section) {
spm::wait();
spm::rww_enable<off>(); spm::rww_enable<off>();
}
} }
} }
@@ -259,8 +334,10 @@ void program_flash(std::uint16_t address)
// AVR — SPM reaches flash (and boot lock bits) only. // AVR — SPM reaches flash (and boot lock bits) only.
void send_fuses() void send_fuses()
{ {
for (std::uint8_t which = 0; which < 4; ++which) std::uint8_t which = 0;
do
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which))); link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
while (++which & 3);
} }
[[noreturn]] void run() [[noreturn]] void run()
@@ -272,13 +349,17 @@ void send_fuses()
link::init(); link::init();
std::uint8_t seconds = ee::read(timeout_cell); // The high byte of the 512-byte-aligned base this copy runs at: the word
if (seconds == 0 || seconds == 0xff) // return address's high byte is the byte address >> 9 (the slot index),
seconds = default_seconds; // doubled back into address terms. program_flash refuses this one slot
// and the info block is addressed from it, so both follow wherever the
// code was flashed.
const std::uint8_t slot_high =
static_cast<std::uint8_t>((reinterpret_cast<std::uint16_t>(__builtin_return_address(0)) >> 8) << 1);
// The knock: 'p' then 'b', each under a fresh window; any other byte is // The knock: 'p' then 'b', each under a fresh window; any other byte is
// line noise and waits again. Falling out of a window runs the app. // line noise and waits again. Falling out of a window runs the app.
while (rx_deadline(seconds) != 'p' || rx_deadline(seconds) != 'b') { while (rx_deadline() != 'p' || rx_deadline() != 'b') {
} }
for (;;) { for (;;) {
@@ -286,34 +367,43 @@ void send_fuses()
// and fuse reads (§26.2.1), and the ack tells the host all is done. // and fuse reads (§26.2.1), and the ack tells the host all is done.
ee::wait(); ee::wait();
link::tx(ack); link::tx(ack);
switch (link::rx()) { const std::uint8_t command = link::rx();
case 'b': // info block switch (command) {
send_flash(reinterpret_cast<std::uint16_t>(info::storage.data()), info::size()); case 'b': { // info block, read relative to the running slot
// The block sits in the image's first 256 bytes (the build lint
// asserts it), and slots are 512-aligned — so the low byte of its
// link address is its offset in any slot, and the high byte of
// its runtime address is the running slot's. Built as a byte
// pair so no absolute 16-bit address is ever materialized.
const std::uint8_t low = static_cast<std::uint8_t>(reinterpret_cast<std::uint16_t>(info::storage.data()));
send_flash(std::bit_cast<std::uint16_t>(std::array{low, slot_high}), info::size());
break; break;
case 'R': { // read flash: addr16, n8 (0 = 256) }
case 'J': { // jump to a wire word address: hand-over and staging transfer
auto target = reinterpret_cast<void (*)()>(rx16());
link::tx(ack);
link::drain();
jump(target);
}
case 'R': // read flash: addr16, n8 (0 = 256)
case 'r': // read EEPROM: addr16, n8
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
std::uint16_t address = rx16(); std::uint16_t address = rx16();
send_flash(address, link::rx()); std::uint8_t count = link::rx();
if (command == 'R')
send_flash(address, count);
else if (command == 'r')
send_eeprom(address, count);
else
store_eeprom(address, count);
break; break;
} }
case 'W': // program one flash page: addr16, page bytes case 'W': // program one flash page: addr16, page bytes
program_flash(rx16()); program_flash(rx16(), slot_high);
break; break;
case 'r': { // read EEPROM: addr16, n8
std::uint16_t address = rx16();
send_eeprom(address, link::rx());
break;
}
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
std::uint16_t address = rx16();
store_eeprom(address, link::rx());
break;
}
case 'F': // fuse and lock bytes case 'F': // fuse and lock bytes
send_fuses(); send_fuses();
break; break;
case 'G': // hand over to the application
link::tx(ack);
run_app();
default: // unknown bytes are ignored; the loop re-acks default: // unknown bytes are ignored; the loop re-acks
break; break;
} }

View File

@@ -3,17 +3,25 @@
The device exposes primitives; this tool composes them: image loading (raw The device exposes primitives; this tool composes them: image loading (raw
binary or Intel HEX), flash programming with read-back verification, erase as binary or Intel HEX), flash programming with read-back verification, erase as
writing 0xff, EEPROM programming, fuse and info readout, activation-timeout writing 0xff, EEPROM programming, fuse and info readout, the hand-over jump,
configuration, and — on chips without a hardware boot section — the and — on chips without a hardware boot section — the reset-vector surgery
reset-vector surgery that re-homes the application's entry through the that re-homes the application's entry through the trampoline word below the
trampoline word below the loader, writing page 0 last so an interrupted loader. Page 0 and the trampoline are written first, so every interruption
flash still falls through to the loader. point of a flash leaves the chip reset-recoverable into the loader.
It also updates the loader itself (--update-loader): pureboot's image is
position-independent, so the tool installs the identical binary one 512-byte
slot below the resident loader, jumps into that staging copy, lets it rewrite
the resident slot, and restores what the staging slot held — resumable at
every phase from the flash state plus a host-side state file carrying the
saved bytes.
Python standard library only; the serial port is driven with termios, so any Python standard library only; the serial port is driven with termios, so any
tty works — a USB adapter as well as a simavr pty. tty works — a USB adapter as well as a simavr pty.
""" """
import argparse import argparse
import json
import os import os
import select import select
import sys import sys
@@ -22,6 +30,7 @@ import time
PROMPT = b"+" PROMPT = b"+"
PROTOCOL_VERSION = 1 PROTOCOL_VERSION = 1
SLOT = 512 # the loader slot size; also the self-update staging distance
class Error(Exception): class Error(Exception):
@@ -88,12 +97,18 @@ class Info:
raise Error(f"bad info block: {raw.hex()}") raise Error(f"bad info block: {raw.hex()}")
if raw[2] != PROTOCOL_VERSION: if raw[2] != PROTOCOL_VERSION:
raise Error(f"protocol version {raw[2]}, tool speaks {PROTOCOL_VERSION}") raise Error(f"protocol version {raw[2]}, tool speaks {PROTOCOL_VERSION}")
self.raw = bytes(raw)
self.signature = raw[3:6] self.signature = raw[3:6]
self.page = raw[6] self.page = raw[6]
self.base = raw[7] | (raw[8] << 8) self.base = raw[7] | (raw[8] << 8)
self.eeprom_size = raw[9] | (raw[10] << 8) self.eeprom_size = raw[9] | (raw[10] << 8)
self.patch_vector = bool(raw[11] & 1) self.patch_vector = bool(raw[11] & 1)
self.flash_size = self.base + 512 self.flash_size = self.base + SLOT
self.stage = self.base - SLOT # where a staging copy of the loader goes
# The hand-over target, as the word address 'J' takes: the trampoline
# below the loader (tinies), or word 0 (mega — the application's own
# reset vector; BOOTRST re-vectors a reset into the loader instead).
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
def describe(self): def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature) sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -107,7 +122,8 @@ class Info:
class Loader: class Loader:
"""A pureboot session. Between commands the loader has prompted `+` and """A pureboot session. Between commands the loader has prompted `+` and
awaits a command byte; every method restores that invariant.""" awaits a command byte; every method restores that invariant — except
jump(), after which the target must be knocked afresh."""
def __init__(self, port): def __init__(self, port):
self.port = port self.port = port
@@ -181,10 +197,23 @@ class Loader:
def read_fuses(self): def read_fuses(self):
return self._command(b"F", 4, 2.0) return self._command(b"F", 4, 2.0)
def run_application(self): def jump(self, word_address):
self.port.write(b"G") """'J': the device acks, then execution continues at the word
address — a loader slot's base (whose copy must then be knocked
afresh) or the application entry."""
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt() self._expect_prompt()
def enter_copy(self, byte_address, wait):
"""Jump into the loader copy at `byte_address` and knock it. Ending
up in the copy addressed is guaranteed by construction: a jump to a
slot base lands in that slot's entry stub."""
self.jump(byte_address // 2)
return self.connect(wait)
def run_application(self):
self.jump(self.info.app_entry_word)
# ---------------------------------------------------------------- images --- # ---------------------------------------------------------------- images ---
@@ -237,8 +266,7 @@ def rjmp_to(word_address, destination, flash_words):
def plan_flash(image, info): def plan_flash(image, info):
"""The pages to program, as {page_address: bytes}, already carrying the """The pages to program, as {page_address: bytes}, already carrying the
reset-vector surgery where the chip needs it. Page 0 must go last — reset-vector surgery where the chip needs it."""
callers get it separated."""
page = info.page page = info.page
limit = info.base - (2 if info.patch_vector else 0) limit = info.base - (2 if info.patch_vector else 0)
if len(image) > limit: if len(image) > limit:
@@ -272,25 +300,254 @@ def plan_flash(image, info):
return pages return pages
def covered(pages, skip_blank): def covered(pages, info, skip_blank):
"""Pages in programming order: ascending, page 0 last; optionally """Pages in programming order; optionally dropping all-0xff pages (sound
dropping all-0xff pages (sound only over erased flash) — never the only over erased flash) — never a load-bearing one.
load-bearing page 0."""
rest = [a for a in sorted(pages) if a != 0] With a patched vector (tinies), the patched page 0 goes first and the
trampoline page second: from the first write on, a reset lands in the
loader and the loader's own fall-through lands on the application entry,
so every interruption point of the flash is recoverable. With a hardware
boot section a reset re-vectors to the loader regardless; ascending
order, page 0 last, maximizes what an interrupted image retains."""
trampoline_page = info.base - info.page if info.patch_vector else None
first = [0, trampoline_page] if info.patch_vector else []
rest = [a for a in sorted(pages) if a not in first]
if skip_blank: if skip_blank:
rest = [a for a in rest if pages[a].count(0xFF) != len(pages[a])] rest = [a for a in rest if pages[a].count(0xFF) != len(pages[a])]
return rest + [0] order = [a for a in first if a in pages] + rest
if not info.patch_vector:
order = [a for a in order if a != 0] + ([0] if 0 in pages else [])
return order
# ----------------------------------------------------------------- fuses ---
def mega_boot(high_fuse):
"""Decode the ATmega328P high fuse's boot configuration (DS40002061B
§27.3, Table 27-13/27-16): BOOTSZ1:0 in bits 2:1 select the boot-section
words, BOOTRST in bit 0 (programmed = 0) re-vectors reset to its start.
Returns (bootrst_programmed, boot_section_start_byte)."""
bootsz = (high_fuse >> 1) & 0x03
words = {0b11: 256, 0b10: 512, 0b01: 1024, 0b00: 2048}[bootsz]
return (high_fuse & 1) == 0, 0x8000 - words * 2
# ---------------------------------------------------------- loader update ---
def image_info(image):
"""The info block embedded in a pureboot binary, or None."""
at = image.find(b"PB" + bytes((PROTOCOL_VERSION,)))
return Info(image[at : at + 12]) if 0 <= at <= len(image) - 12 else None
def staging_content(image, info):
"""The 512-byte staging-slot content: the image, padding, and — on
chips whose hand-over jumps through the word below the resident loader —
that word, which for a staging copy is the slot's own last word: an rjmp
to the resident base. The staging copy's fall-through and 'J'-free exit
both land in a loader instead of garbage."""
if len(image) > (SLOT - 2 if info.patch_vector else SLOT):
raise Error(f"loader image is {len(image)} B, the slot holds {SLOT - 2 if info.patch_vector else SLOT}")
content = bytearray(image) + bytearray([0xFF] * (SLOT - len(image)))
if info.patch_vector:
through = rjmp_to((info.base - 2) // 2, info.base // 2, info.flash_size // 2)
content[SLOT - 2], content[SLOT - 1] = through & 0xFF, through >> 8
return bytes(content)
def update_preflight(image, info, fuse_bytes):
"""Errors and warnings before any flash is touched. Returns warnings."""
embedded = image_info(image)
if embedded is None:
raise Error("no pureboot info block in the update image — not a pureboot binary?")
if embedded.raw[3:] != info.raw[3:]:
raise Error(
f"update image is for another target: it declares "
f"[{embedded.describe()}], the device says [{info.describe()}]"
)
warnings = []
if not info.patch_vector:
if fuse_bytes is None:
raise Error("a loader update on this chip needs its fuses — unreadable? pass --assume-fuses")
high = fuse_bytes[3]
bootrst, bls_start = mega_boot(high)
if info.stage < bls_start:
raise Error(
f"cannot self-update: the staging slot {info.stage:#06x} lies below the "
f"boot section ({bls_start:#06x}, high fuse {high:#04x}) where SPM is disabled "
f"— a boot section of at least 1 KB (BOOTSZ) is required, and only an "
f"external programmer can change fuses"
)
if not bootrst:
warnings.append(
"BOOTRST unprogrammed: reset boots the application throughout the update; "
"an interruption is recovered by re-running this update"
)
elif bls_start == info.stage:
warnings.append(
"BOOTRST targets the staging slot: brief unrecoverable windows exist while "
"the staging copy itself is being installed or retired (page-write scale)"
)
else:
warnings.append(
f"BOOTRST targets {bls_start:#06x}, inside application flash: reset reaches a "
f"loader only across erased flash from there"
)
return warnings
class UpdateState:
"""The host-side memory of an update in flight: what the staging slot
held (and page 0, where the update repoints it). Losing this file after
the staging slot was overwritten loses those saved bytes — the update
still completes, but the staging region can then only be restored by
reflashing the application."""
def __init__(self, path):
self.path = path
self.data = None
def load_or_save(self, loader):
info = loader.info
if os.path.exists(self.path):
self.data = json.load(open(self.path))
if bytes.fromhex(self.data["signature"]) != info.signature or self.data["base"] != info.base:
raise Error(f"{self.path} belongs to a different device — remove it to start over")
return
self.data = {
"signature": info.signature.hex(),
"base": info.base,
"staging": loader.read_flash(info.stage, SLOT).hex(),
"page0": loader.read_flash(0, info.page).hex() if info.patch_vector else "",
}
with open(self.path, "w") as f:
json.dump(self.data, f)
@property
def staging(self):
return bytes.fromhex(self.data["staging"])
@property
def page0(self):
return bytes.fromhex(self.data["page0"])
def discard(self):
os.unlink(self.path)
def write_differing(loader, base, content, order=None):
"""Program the pages of `content` at `base` that differ from flash —
idempotent, so a resumed phase redoes only what an interruption left."""
page = loader.info.page
offsets = order if order is not None else range(0, len(content), page)
written = 0
for offset in offsets:
want = content[offset : offset + page]
if loader.read_flash(base + offset, page) != want:
loader.write_page(base + offset, want)
written += 1
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")
return written
def patch_word0(loader, page0, target_base):
"""Rewrite page 0 with its word 0 re-aimed at `target_base` — the
resume insurance around rewriting a loader slot the reset path uses."""
info = loader.info
patched = bytearray(page0)
word = rjmp_to(0, target_base // 2, info.flash_size // 2)
patched[0], patched[1] = word & 0xFF, word >> 8
write_differing(loader, 0, bytes(patched))
return bytes(patched)
def op_update_loader(loader, wait, path, state_path, fuse_bytes):
"""Replace the resident loader with `path`, using the loader itself as
its own staging loader. Every phase is idempotent and keyed off the
actual flash state, so a re-run after any interruption resumes; the
state file carries the bytes the staging slot held."""
info = loader.info
image = load_image(path)
for warning in update_preflight(image, info, fuse_bytes):
print(f"note: {warning}")
staged = staging_content(image, info)
resident = bytes(image) + bytes([0xFF] * (SLOT - len(image)))
page = info.page
state = UpdateState(state_path)
state.load_or_save(loader)
# Install the staging copy. On a chip whose staging slot starts at
# address 0 (the 1 KB tiny13A), its first page carries the reset vector:
# written last, so any earlier interruption still resets into the old
# resident, and from then on resets enter the staging copy.
order = list(range(0, SLOT, page))
if info.stage == 0:
order = order[1:] + [0]
if write_differing(loader, info.stage, staged, order):
print(f"staging copy installed at {info.stage:#06x}")
# Enter it and let it rewrite the resident slot. Where a patched reset
# vector routes through the resident (a tiny with the staging slot away
# from page 0), word 0 is re-aimed at the staging copy around the
# rewrite, so a power failure mid-rewrite still resets into a loader.
loader.enter_copy(info.stage, wait)
redirect = info.patch_vector and info.stage != 0
if redirect:
patch_word0(loader, state.page0, info.stage)
if write_differing(loader, info.base, resident):
print(f"resident loader rewritten at {info.base:#06x}")
# Enter the new resident and put the staging region back: page 0 first
# where it lives in that region (word 0 then points at the new resident
# for the rest of the restore), the saved trampoline with the rest.
loader.enter_copy(info.base, wait)
if redirect:
write_differing(loader, 0, state.page0)
order = list(range(0, SLOT, page))
if info.stage == 0:
order = [0] + order[1:]
write_differing(loader, info.stage, state.staging, order)
state.discard()
print(f"loader updated: {len(image)} B at {info.base:#06x}, staging region restored")
def check_walk_region(pages, info, fuse_bytes, force):
"""With BOOTRST programmed but targeting below the loader, reset reaches
the loader only by walking across erased flash from the boot-section
start; application data in that span would divert reset into itself.
Only checkable when the fuses are known (--fuses or --assume-fuses)."""
if info.patch_vector or fuse_bytes is None:
return
bootrst, bls_start = mega_boot(fuse_bytes[3])
if not bootrst or bls_start >= info.base:
return
overlap = [a for a in sorted(pages) if a >= bls_start and pages[a].count(0xFF) != len(pages[a])]
if overlap and not force:
raise Error(
f"the image writes {overlap[0]:#06x}.. inside the reset walk region "
f"[{bls_start:#06x}, {info.base:#06x}) (BOOTRST programmed): reset could no "
f"longer reach the loader — --force to flash it anyway"
)
# ------------------------------------------------------------ operations --- # ------------------------------------------------------------ operations ---
def op_erase_flash(loader): def op_erase_flash(loader):
"""0xff over the whole application area. Order does not matter here — """0xff over the whole application area. Descending on a patched-vector
every target byte is the same value — and a blank word 0 still falls chip: page 0 — the patched reset vector — goes last, so an interrupted
through to the loader, so an interruption is harmless.""" erase still resets into the loader, and once it is gone the whole area
is erased and the reset walk reaches the loader anyway."""
blank = bytes([0xFF] * loader.info.page) blank = bytes([0xFF] * loader.info.page)
for address in range(0, loader.info.base, loader.info.page): addresses = range(0, loader.info.base, loader.info.page)
for address in reversed(addresses) if loader.info.patch_vector else addresses:
loader.write_page(address, blank) loader.write_page(address, blank)
print(f"erase: {loader.info.base // loader.info.page} pages") print(f"erase: {loader.info.base // loader.info.page} pages")
@@ -300,12 +557,13 @@ def op_erase_eeprom(loader):
print(f"erase: {loader.info.eeprom_size} B of EEPROM") print(f"erase: {loader.info.eeprom_size} B of EEPROM")
def op_flash(loader, path, erase, verify): def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
image = load_image(path) image = load_image(path)
pages = plan_flash(image, loader.info) pages = plan_flash(image, loader.info)
check_walk_region(pages, loader.info, fuse_bytes, force)
if erase: if erase:
op_erase_flash(loader) op_erase_flash(loader)
order = covered(pages, skip_blank=erase) order = covered(pages, loader.info, skip_blank=erase)
for address in order: for address in order:
loader.write_page(address, pages[address]) loader.write_page(address, pages[address])
print(f"flash: {path}: {len(order)} pages") print(f"flash: {path}: {len(order)} pages")
@@ -366,15 +624,10 @@ def op_read_eeprom(loader, path):
print(f"read EEPROM: {len(data)} B -> {path}") print(f"read EEPROM: {len(data)} B -> {path}")
def op_timeout(loader, seconds):
loader.write_eeprom(loader.info.eeprom_size - 1, bytes((seconds,)))
label = f"{seconds} s" if seconds else "the device default"
print(f"activation timeout: {label}")
def op_fuses(loader): def op_fuses(loader):
low, lock, extended, high = loader.read_fuses() low, lock, extended, high = loader.read_fuses()
print(f"fuses: low {low:02x} high {high:02x} extended {extended:02x} lock {lock:02x}") print(f"fuses: low {low:02x} high {high:02x} extended {extended:02x} lock {lock:02x}")
return bytes((low, lock, extended, high))
# -------------------------------------------------------------------- cli --- # -------------------------------------------------------------------- cli ---
@@ -389,6 +642,10 @@ def main():
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking") parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--info", action="store_true", help="print the device info block") parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes") 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)")
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") parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash")
parser.add_argument("--flash", metavar="FILE", help="program an application (bin or ihex)") parser.add_argument("--flash", metavar="FILE", help="program an application (bin or ihex)")
parser.add_argument("--no-verify", action="store_true", help="skip read-back after writes") parser.add_argument("--no-verify", action="store_true", help="skip read-back after writes")
@@ -398,12 +655,19 @@ def main():
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)") parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM") parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image") parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
parser.add_argument("--timeout", type=int, metavar="S", help="activation window, 1-254 s (0: default)") parser.add_argument("--force", action="store_true", help="override refusable safety checks")
parser.add_argument("--stay", action="store_true", help="leave the loader in its session") parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
args = parser.parse_args() args = parser.parse_args()
if args.timeout is not None and not 0 <= args.timeout <= 254: if args.update_loader and (args.flash or args.erase_flash):
parser.error("--timeout must be 0..254 (255 is the erased cell)") parser.error("--update-loader does not combine with application flash operations")
fuse_override = None
if args.assume_fuses:
try:
fuse_override = bytes.fromhex(args.assume_fuses)
assert len(fuse_override) == 4
except (ValueError, AssertionError):
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
port = Port(args.port, args.baud) port = Port(args.port, args.baud)
try: try:
@@ -411,10 +675,16 @@ def main():
info = loader.connect(args.wait) info = loader.connect(args.wait)
if args.info: if args.info:
print(f"device: {info.describe()}") print(f"device: {info.describe()}")
if args.fuses: fuse_bytes = fuse_override
op_fuses(loader) if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader)
if fuse_bytes is None:
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)
if args.flash: if args.flash:
op_flash(loader, args.flash, args.erase_flash, not args.no_verify) op_flash(loader, args.flash, args.erase_flash, not args.no_verify, fuse_bytes, args.force)
elif args.erase_flash: elif args.erase_flash:
op_erase_flash(loader) op_erase_flash(loader)
if args.read_flash: if args.read_flash:
@@ -429,8 +699,6 @@ def main():
op_read_eeprom(loader, args.read_eeprom) op_read_eeprom(loader, args.read_eeprom)
if args.verify_eeprom: if args.verify_eeprom:
op_verify_eeprom(loader, args.verify_eeprom) op_verify_eeprom(loader, args.verify_eeprom)
if args.timeout is not None:
op_timeout(loader, args.timeout)
if args.stay: if args.stay:
print("loader stays in its session (reset to leave)") print("loader stays in its session (reset to leave)")
else: else:

53
test/check_pi.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Position-independence lint for the pureboot image.
The self-staging design lets the identical binary run from any 512-byte
slot, which holds only if nothing in the image addresses itself absolutely.
Two link-time facts guarantee it, both asserted here from the built ELF:
1. No absolute jmp/call opcodes — all control flow is PC-relative
(rjmp/rcall/ijmp/icall). -mrelax normally guarantees this; a code
change that grows a branch out of relaxation range would break it
silently.
2. The info block sits within the image's first 256 bytes: the 'b'
command rebuilds its address as (running slot high byte : low byte of
the link address), which needs the offset to fit that low byte.
Usage: check_pi.py <objdump> <nm> <elf> <text_start_hex>
"""
import re
import subprocess
import sys
def main():
objdump, nm, elf, text_start = sys.argv[1:]
text_start = int(text_start, 0)
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
absolute = [
line
for line in listing.splitlines()
if re.search(r"\t(jmp|call)\t", line)
]
if absolute:
print("FAIL: absolute control flow in the image:")
print("\n".join(absolute))
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 "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
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)
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}")
if __name__ == "__main__":
main()

View File

@@ -1,8 +1,14 @@
// Test-fixture application for the pureboot protocol test: prints "APP" on // Test-fixture application for the pureboot protocol tests: prints "APP" on
// the chip's serial link (the same link the loader uses) and idles — the // the chip's serial link (the same link the loader uses) — the proof that
// proof that the loader's hand-over, and on the tinies the host's // the loader's hand-over, and on the tinies the host's reset-vector
// reset-vector surgery, actually launched it. Linked normally (crt, vectors // surgery, actually launched it. Linked normally (crt, vectors at 0); on
// at 0); on the tinies its reset vector is the rjmp the host re-homes. // the tinies its reset vector is the rjmp the host re-homes.
//
// On the mega it then listens, and an 'L' makes it jump into the resident
// loader — the application-owned loader entry a BOOTRST-unprogrammed mega
// relies on (reset always boots the application there), exercised by the
// self-update tests. The tinies idle: reset reaches their loader through
// the patched vector, so the application owes it nothing.
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
using namespace avr::literals; using namespace avr::literals;
@@ -27,6 +33,12 @@ struct link {
{ {
tx_t::write(static_cast<std::uint8_t>(c)); tx_t::write(static_cast<std::uint8_t>(c));
} }
[[noreturn]] static void idle()
{
for (;;)
if (tx_t::read_blocking() == 'L')
reinterpret_cast<void (*)()>((avr::hw::db.mem.flash_size - 512) / 2)();
}
}; };
template <avr::hertz_t C> template <avr::hertz_t C>
@@ -36,6 +48,11 @@ struct link<C, false> {
{ {
tx_t::write(static_cast<std::uint8_t>(c)); tx_t::write(static_cast<std::uint8_t>(c));
} }
[[noreturn]] static void idle()
{
while (true) {
}
}
}; };
} // namespace } // namespace
@@ -46,6 +63,5 @@ int main()
link<dev::clock>::tx('A'); link<dev::clock>::tx('A');
link<dev::clock>::tx('P'); link<dev::clock>::tx('P');
link<dev::clock>::tx('P'); link<dev::clock>::tx('P');
while (true) { link<dev::clock>::idle();
}
} }

93
test/pbreloc.py Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Position-independence acceptance test: the identical pureboot binary,
flashed one slot below the resident loader, must serve the complete command
set from there. The resident installs it (through-word composed by the host
layer), 'J' transfers control, and every command is exercised against the
staged copy — the info block must come back byte-identical, the write guard
must protect the staged copy's own slot and permit the resident's, and the
staged copy must be able to rewrite the resident slot verbatim.
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
"""
import os
import subprocess
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
base, page, baud = int(base_hex, 0), int(page), int(baud)
stage = base - 512
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)
objcopy = os.environ.get("PB_OBJCOPY", "avr-objcopy")
image_path = os.path.join(workdir, "pureboot.bin")
subprocess.run([objcopy, "-O", "binary", elf, image_path], check=True)
image = open(image_path, "rb").read()
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, os.path.join(workdir, "dump.bin"))
try:
port = pb.Port(device.pty, baud)
loader = pb.Loader(port)
info = loader.connect(25)
if info.base != base:
fail(f"info reports base {info.base:#06x}")
resident_info = info.raw
# Install the staging copy exactly as the update flow would.
staged = pb.staging_content(image, info)
pb.write_differing(loader, stage, staged)
# Enter it; from here on, every command runs in the relocated copy.
staged_info = loader.enter_copy(stage, 25)
if staged_info.raw != resident_info:
fail(f"staged info {staged_info.raw.hex()} != resident info {resident_info.hex()}")
# 'R' from the staged copy already proved itself in the install
# verify; 'F' must answer 4 bytes (values are unmodeled in simavr).
if len(loader.read_fuses()) != 4:
fail("fuse read from the staged copy")
# EEPROM round-trip through the staged copy.
pattern = bytes(range(0x50, 0x60))
loader.write_eeprom(0, pattern)
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.
before = loader.read_flash(stage, page)
loader.write_page(stage, bytes(page))
if loader.read_flash(stage, page) != before:
fail("the staged copy's guard let its own slot change")
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")
# Restore the resident image through the staged copy, then 'J' back
# into it and prove it lives.
resident = image + b"\xff" * (512 - len(image))
pb.write_differing(loader, base, resident)
back_info = loader.enter_copy(base, 25)
if back_info.raw != resident_info:
fail("the restored resident does not serve its info block")
port.close()
finally:
device.stop()
print("pbreloc: the relocated copy serves the full command set")
if __name__ == "__main__":
main()

61
test/pbsim.py Normal file
View File

@@ -0,0 +1,61 @@
"""Shared simavr harness for the pureboot tests: spawn the device runner,
hand out its pty, restart it from a flash dump (the power-fail path), and
keep its chatter out of undrained pipes."""
import os
import signal
import subprocess
class Device:
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None):
cmd = [binary, elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None:
cmd.append(reset_hex if reset_hex is not None else ("0" if mcu != "atmega328p" else base_hex))
if resume is not None:
cmd.append(resume)
self.log = open(dump + ".log", "a")
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=self.log, text=True)
self.dump = dump
self.pty = None
for _ in range(50):
line = self.proc.stdout.readline()
if not line:
break
if line.startswith("PB_PTY"):
self.pty = line.split()[1]
break
if not self.pty:
self.stop()
raise RuntimeError("device did not report a pty")
def reset(self):
"""The external reset line: SIGUSR1 re-enters at the reset vector."""
self.proc.send_signal(signal.SIGUSR1)
def power_fail(self):
"""SIGTERM: the runner dumps its flash and exits — the image a
restart resumes from."""
self.stop()
return self.dump
def stop(self):
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.log.close()
def run_tool(tool, pty, baud, *args, timeout=180):
result = subprocess.run(
[os.environ.get("PYTHON", "python3"), tool, "--port", pty, "--baud", str(baud), "--wait", "25", *args],
capture_output=True,
text=True,
timeout=timeout,
)
print(result.stdout, end="")
if result.returncode != 0:
raise RuntimeError(f"tool exited {result.returncode}: {result.stderr.strip()}")
return result.stdout

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""End-to-end pureboot protocol test: spawn the simavr device, then drive it """End-to-end pureboot protocol test: spawn the simavr device, then drive it
with the real host tool (pureboot.py, as a subprocess over the device's pty) with the real host tool (pureboot.py, as a subprocess over the device's pty)
through flash + EEPROM + timeout + fuse + hand-over scenarios, and cross-check through flash + EEPROM + fuse + hand-over scenarios, and cross-check
the tool's view against the simulator's ground-truth memory dumps. the tool's view against the simulator's ground-truth memory dumps.
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page> Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
@@ -101,8 +101,8 @@ def main():
try: try:
# Session 1: knock from reset, identify, program everything, stay. # Session 1: knock from reset, identify, program everything, stay.
out = run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin, out = run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
"--eeprom", ee_path, "--timeout", "8", "--stay") "--eeprom", ee_path, "--stay")
for needed in ("device: signature", "fuses:", "verify:", "activation timeout: 8 s", "stays"): for needed in ("device: signature", "fuses:", "verify:", "stays"):
if needed not in out: if needed not in out:
fail(f"session 1 output lacks {needed!r}") fail(f"session 1 output lacks {needed!r}")
@@ -116,8 +116,6 @@ def main():
eeprom_back = open(read_eeprom, "rb").read() eeprom_back = open(read_eeprom, "rb").read()
if eeprom_back[: len(ee_image)] != ee_image: if eeprom_back[: len(ee_image)] != ee_image:
fail("EEPROM read-back mismatch") fail("EEPROM read-back mismatch")
if eeprom_back[-1] != 8:
fail(f"timeout cell reads {eeprom_back[-1]}, expected 8")
# The expected post-surgery flash, straight from the tool's planner. # The expected post-surgery flash, straight from the tool's planner.
pages = pb.plan_flash(open(app_bin, "rb").read(), info) pages = pb.plan_flash(open(app_bin, "rb").read(), info)
@@ -128,16 +126,14 @@ def main():
# An external reset re-enters through the patched word 0 (tinies; the # An external reset re-enters through the patched word 0 (tinies; the
# runner resets them to address 0 like silicon) or BOOTRST (mega). # runner resets them to address 0 like silicon) or BOOTRST (mega).
# The loader must answer a fresh knock, and 'G' must land in the # The loader must answer a fresh knock, and the 'J' hand-over must
# application, which banners on the same link. # land in the application, which banners on the same link.
device.proc.send_signal(signal.SIGUSR1) device.proc.send_signal(signal.SIGUSR1)
port = pb.Port(device.pty, baud) port = pb.Port(device.pty, baud)
try: try:
loader = pb.Loader(port) loader = pb.Loader(port)
loader.connect(15) loader.connect(15)
port.write(b"G") loader.run_application()
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("no ack for G")
banner = port.read_exact(3, 5.0) banner = port.read_exact(3, 5.0)
if banner != b"APP": if banner != b"APP":
fail(f"application banner was {banner!r}") fail(f"application banner was {banner!r}")
@@ -168,7 +164,7 @@ def main():
ee_true_path = dump + ".eeprom" ee_true_path = dump + ".eeprom"
if os.path.exists(ee_true_path): if os.path.exists(ee_true_path):
ee_true = open(ee_true_path, "rb").read() ee_true = open(ee_true_path, "rb").read()
if ee_true[: len(ee_image)] != ee_image or ee_true[-1] != 8: if ee_true[: len(ee_image)] != ee_image:
fail("ground-truth EEPROM does not match what was programmed") fail("ground-truth EEPROM does not match what was programmed")
print("pbtest: all scenarios pass") print("pbtest: all scenarios pass")

211
test/pbupdate.py Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Self-update end-to-end: an application is flashed, then the loader
replaces itself with a re-timed build through the host tool's
--update-loader — and the power-fail phases of that update are rehearsed by
killing the simulated device mid-write, restarting it from its flash dump,
and letting a re-run complete the update.
The mega runs the BOOTRST-unprogrammed profile (reset boots the application;
the fixture application's 'L' jump is the application-owned loader entry),
with --assume-fuses standing in for the fuse read simavr cannot model. The
tinies reset into a loader at every phase by construction — the t13a because
its staging slot carries the reset vector itself, the t85 through the word-0
redirect the tool plants around the resident rewrite.
Usage: pbupdate.py <device_bin> <pureboot_elf> <update_elf> <mcu> <hz>
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
"""
import os
import subprocess
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def rjmp_decode(word, at, flash_words):
"""Written against the instruction-set definition, not with the tool's
encoder, so an encoding bug cannot verify itself."""
if word & 0xF000 != 0xC000:
fail(f"word at {at * 2:#06x} is {word:#06x}, not an rjmp")
offset = word & 0x0FFF
if offset >= 0x800:
offset -= 0x1000
return (at + 1 + offset) % flash_words
class PowerFail(Exception):
pass
MEGA_FUSES = "ffffffdd" # high 0xdd: BOOTSZ = 1 KB, BOOTRST unprogrammed
def make_fault_loader(pb, base, kill_region, kill_hits, device):
"""A Loader whose write_page kills the device (or, with device=None,
just the host) at the Nth write into a region; the sequence
stage->resident->stage distinguishes the install from the restore."""
class FaultLoader(pb.Loader):
def __init__(self, port):
super().__init__(port)
self.seen_resident = False
self.hits = 0
def write_page(self, address, data):
if address >= base:
phase = "resident"
self.seen_resident = True
elif address >= base - 512:
phase = "stage_restore" if self.seen_resident else "stage"
else:
phase = "app"
if phase == kill_region:
self.hits += 1
if self.hits == kill_hits:
if device is not None:
device.power_fail()
raise PowerFail(f"{kill_region} write {kill_hits}")
super().write_page(address, data)
return FaultLoader
def main():
(device_bin, elf, update_elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir) = sys.argv[1:]
base, page, baud = int(base_hex, 0), int(page), int(baud)
mega = mcu == "atmega328p"
reset_hex = "0" if mega else None # the mega runs BOOTRST-unprogrammed here
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)
objcopy = os.environ.get("PB_OBJCOPY", "avr-objcopy")
images = {}
for name, source in (("v0", elf), ("v9", update_elf)):
path = os.path.join(workdir, name + ".bin")
subprocess.run([objcopy, "-O", "binary", source, path], check=True)
images[name] = open(path, "rb").read()
if images["v0"] == images["v9"]:
fail("the update image is byte-identical to the resident build")
dump = os.path.join(workdir, "dump.bin")
state = os.path.join(workdir, "update.pbstate")
fuses = bytes.fromhex(MEGA_FUSES) if mega else None
def connect(device):
port = pb.Port(device.pty, baud)
if mega:
# Reset boots the application here; its 'L' is the loader entry.
# To a live loader the same byte is an ignored command.
port.read_available(0.5)
port.write(b"L")
loader = pb.Loader(port)
loader.connect(25)
return port, loader
def padded(image):
return image + b"\xff" * (512 - len(image))
def resident_bytes(loader):
return loader.read_flash(base, 256) + loader.read_flash(base + 256, 256)
def assert_state(loader, image, app_pages):
if resident_bytes(loader) != padded(image):
fail("resident loader does not match the update image")
stage = base - 512
got = loader.read_flash(stage, 256) + loader.read_flash(stage + 256, 256)
for address, data in app_pages.items():
if stage <= address < base:
if got[address - stage : address - stage + page] != data:
fail(f"staging region page {address:#06x} not restored")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=reset_hex)
final = "v0"
try:
# The application first — its planner output is the restore truth.
pbsim.run_tool(tool, device.pty, baud, "--flash", app_bin, "--stay")
port, loader = connect(device)
app_pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info)
port.close()
# A clean CLI update, resident -> v9.
args = ["--update-loader", os.path.join(workdir, "v9.bin"), "--state", state, "--stay"]
if mega:
args += ["--assume-fuses", MEGA_FUSES]
out = pbsim.run_tool(tool, device.pty, baud, *args)
if "loader updated" not in out:
fail("update did not report success")
if os.path.exists(state):
fail("state file survived a completed update")
port, loader = connect(device)
assert_state(loader, images["v9"], app_pages)
loader.run_application()
if port.read_exact(3, 5.0) != b"APP":
fail("application does not banner after the update")
port.close()
final = "v9"
print("clean update: resident replaced, staging restored, application intact")
# Power-fail rehearsal: kill mid-phase, restart from the dump,
# re-run, and the update must still complete. Each round flips the
# direction so the flash is never already at its target. The mega's
# mid-resident-rewrite loss is exercised as a host crash instead:
# with BOOTRST unprogrammed and the resident mid-erase, a power loss
# there has no reset path into the staging copy — the documented
# cost of that profile (README).
for kill_region, kill_hits, kill_device in (
("stage", 2, True),
("resident", 1, not mega),
("stage_restore", 2, True),
):
device.reset() # the previous round left the application running
port, loader = connect(device)
target = "v9" if resident_bytes(loader) == padded(images["v0"]) else "v0"
image_path = os.path.join(workdir, target + ".bin")
injected = make_fault_loader(pb, base, kill_region, kill_hits, device if kill_device else None)(port)
injected.info = loader.info
try:
pb.op_update_loader(injected, 25, image_path, state, fuses)
fail(f"{kill_region}: fault never triggered")
except PowerFail as event:
print(f"power fail injected: {event}")
port.close()
if kill_device:
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump,
reset_hex=reset_hex, resume=dump)
port, loader = connect(device)
pb.op_update_loader(loader, 25, image_path, state, fuses)
assert_state(loader, images[target], app_pages)
loader.run_application()
if port.read_exact(3, 5.0) != b"APP":
fail(f"{kill_region}: application lost after the resumed update")
port.close()
final = target
print(f"resumed after {kill_region} loss: update completed, application intact")
finally:
device.stop()
# Ground truth: the simulator's own flash against the final state, and
# on the tinies an independent decode of the reset routing.
flash = open(dump, "rb").read()
if flash[base : base + 512] != padded(images[final]):
fail("ground-truth resident region does not match the final image")
if not mega:
flash_words = (base + 512) // 2
word0 = flash[0] | (flash[1] << 8)
if rjmp_decode(word0, 0, flash_words) != base // 2:
fail("ground-truth reset vector does not land on the loader")
app = open(app_bin, "rb").read()
trampoline = flash[base - 2] | (flash[base - 1] << 8)
if rjmp_decode(trampoline, (base - 2) // 2, flash_words) != rjmp_decode(app[0] | (app[1] << 8), 0, flash_words):
fail("ground-truth trampoline does not land on the application entry")
print("pbupdate: clean update + all power-fail phases recovered")
if __name__ == "__main__":
main()

View File

@@ -43,6 +43,43 @@ static const char *dump_path;
static uint32_t reset_pc; static uint32_t reset_pc;
static volatile sig_atomic_t reset_requested; static volatile sig_atomic_t reset_requested;
// simavr 1.6's avr_flash PGERS handler erases spm_pagesize bytes starting at
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly) —
// hardware ignores the in-page bits (§26.8.1), so an erase issued with Z
// anywhere inside the page wipes half the neighbouring page in simulation
// only. Wrap the mega's registered flash ioctl and re-dispatch page erases
// with Z forced to the page boundary; everything else passes through.
static avr_flash_t *mega_flash;
static int (*mega_flash_ioctl)(avr_io_t *io, uint32_t ctl, void *param);
static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param)
{
if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) {
uint16_t z = (uint16_t)(io->avr->data[30] | (io->avr->data[31] << 8));
uint16_t masked = (uint16_t)(z & ~(mega_flash->spm_pagesize - 1));
io->avr->data[30] = (uint8_t)masked;
io->avr->data[31] = (uint8_t)(masked >> 8);
int result = mega_flash_ioctl(io, ctl, param);
io->avr->data[30] = (uint8_t)z;
io->avr->data[31] = (uint8_t)(z >> 8);
return result;
}
return mega_flash_ioctl(io, ctl, param);
}
static void fix_mega_flash_erase(void)
{
for (avr_io_t *io = avr->io_port; io; io = io->next) {
if (io->kind && strcmp(io->kind, "flash") == 0) {
mega_flash = (avr_flash_t *)io;
mega_flash_ioctl = io->ioctl;
io->ioctl = fixed_flash_ioctl;
return;
}
}
fprintf(stderr, "device: no flash module to fix — SPM page erases may misalign\n");
}
static void request_reset(int sig) static void request_reset(int sig)
{ {
(void)sig; (void)sig;
@@ -54,6 +91,7 @@ static void request_reset(int sig)
typedef struct { typedef struct {
avr_io_t io; avr_io_t io;
uint8_t buffer[128]; uint8_t buffer[128];
uint8_t used[128]; // a buffer word loads once until erased — like silicon
unsigned page; unsigned page;
} tiny_nvm_t; } tiny_nvm_t;
@@ -71,16 +109,21 @@ static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1); uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1);
if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0 if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0
unsigned offset = z & (n->page - 1) & ~1u; unsigned offset = z & (n->page - 1) & ~1u;
n->buffer[offset] = mcu->data[0]; if (!n->used[offset]) { // first write wins until the buffer clears
n->buffer[offset + 1] = mcu->data[1]; n->buffer[offset] = mcu->data[0];
n->buffer[offset + 1] = mcu->data[1];
n->used[offset] = 1;
}
} else if (command == 0x03) { // PGERS } else if (command == 0x03) { // PGERS
memset(mcu->flash + page_base, 0xff, n->page); memset(mcu->flash + page_base, 0xff, n->page);
} else if (command == 0x05) { // PGWRT: programming only clears bits } else if (command == 0x05) { // PGWRT: programming only clears bits
for (unsigned i = 0; i < n->page; i++) for (unsigned i = 0; i < n->page; i++)
mcu->flash[page_base + i] &= n->buffer[i]; mcu->flash[page_base + i] &= n->buffer[i];
memset(n->buffer, 0xff, n->page); memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page);
} else if (command == 0x11) { // CTPB } else if (command == 0x11) { // CTPB
memset(n->buffer, 0xff, n->page); memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page);
} }
mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly
return 0; return 0;
@@ -162,8 +205,14 @@ static void rx_start_next(void)
// A reset abandons whatever the bridge was mid-transfer: bytes still queued // A reset abandons whatever the bridge was mid-transfer: bytes still queued
// for a chip that no longer has the context to receive them meaningfully, // for a chip that no longer has the context to receive them meaningfully,
// and a decode in progress on a TX line the reset may have already changed. // and a decode in progress on a TX line the reset may have already changed.
// The pending cycle timers must go with the state: avr_reset drops the TX
// output latch, whose falling edge starts a spurious decode before this
// runs, and a stale tx_sample would then interleave with the loader's first
// real answer through the shared shift state, corrupting it.
static void bridge_reset(void) static void bridge_reset(void)
{ {
avr_cycle_timer_cancel(avr, tx_sample, NULL);
avr_cycle_timer_cancel(avr, rx_step, NULL);
rx_head = rx_tail = 0; rx_head = rx_tail = 0;
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
@@ -215,8 +264,14 @@ static void finish(int sig)
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
if (argc != 8) { if (argc < 8 || argc > 10) {
fprintf(stderr, "usage: %s <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>\n", argv[0]); fprintf(stderr,
"usage: %s <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
" [reset_hex] [resume_flash]\n"
" reset_hex: reset vector (default: base on the mega, 0 on the tinies)\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" run's dump, for power-fail resume tests\n",
argv[0]);
return 2; return 2;
} }
const char *mcu_name = argv[2]; const char *mcu_name = argv[2];
@@ -235,16 +290,27 @@ int main(int argc, char *argv[])
avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0); avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0);
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
elf_firmware_t fw = {0}; if (argc > 9) {
if (elf_read_firmware(argv[1], &fw) != 0) { // Resume: the full flash image of an interrupted prior run.
fprintf(stderr, "device: cannot read %s\n", argv[1]); FILE *f = fopen(argv[9], "rb");
return 1; if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
fprintf(stderr, "device: cannot read %s\n", argv[9]);
return 1;
}
fclose(f);
} else {
elf_firmware_t fw = {0};
if (elf_read_firmware(argv[1], &fw) != 0) {
fprintf(stderr, "device: cannot read %s\n", argv[1]);
return 1;
}
memcpy(avr->flash + base, fw.flash, fw.flashsize);
} }
memcpy(avr->flash + base, fw.flash, fw.flashsize); // The mega enters the loader in hardware (BOOTRST, not modeled — the
// The mega enters the loader in hardware (BOOTRST, not modeled); the // argument picks the modeled fuse's target); the tinies reset to word 0
// tinies reset to word 0 like silicon — erased flash walks up into the // like silicon — erased flash walks up into the loader, and after the
// loader, and after the host's surgery the patched vector routes there. // host's surgery the patched vector routes there.
reset_pc = use_uart_pty ? base : 0; reset_pc = argc > 8 ? (uint32_t)strtoul(argv[8], NULL, 0) : (use_uart_pty ? base : 0);
avr->pc = reset_pc; avr->pc = reset_pc;
avr->codeend = avr->flashend; avr->codeend = avr->flashend;
@@ -258,6 +324,7 @@ int main(int argc, char *argv[])
} }
if (use_uart_pty) { if (use_uart_pty) {
fix_mega_flash_erase();
// POLL_SLEEP paces an idle-polling loader in host real time (a // POLL_SLEEP paces an idle-polling loader in host real time (a
// no-hardware CPU-saving hack); clear it so cycles run free. // no-hardware CPU-saving hack); clear it so cycles run free.
uint32_t flags = 0; uint32_t flags = 0;

157
test/test_planner.py Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Host-tool unit tests — the pure planning and policy logic, no simulator:
the flash-programming orders and their recovery properties, the reset-vector
surgery, the staging-slot composition, the mega boot-fuse decode, and the
update preflight's error/warning matrix (fuse combinations simavr cannot
model reach it here as synthetic bytes).
Usage: test_planner.py <tool_py>
"""
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def expect_error(what, fn, *needles):
try:
fn()
except Exception as error:
for needle in needles:
if needle not in str(error):
fail(f"{what}: error lacks {needle!r}: {error}")
return
fail(f"{what}: no error raised")
def info_of(pb, base, page, patch, flash):
raw = bytes((0x50, 0x42, 1, 0x1E, 0x93, 0x0B, page, base & 0xFF, base >> 8,
0, 2, 1 if patch else 0))
info = pb.Info(raw)
assert info.flash_size == flash
return info
def rjmp_decode(word, at, flash_words):
if word & 0xF000 != 0xC000:
fail(f"not an rjmp: {word:#06x}")
offset = word & 0x0FFF
if offset >= 0x800:
offset -= 0x1000
return (at + 1 + offset) % flash_words
def main():
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
import pureboot as pb
tiny = info_of(pb, 0x1E00, 64, True, 0x2000)
mega = info_of(pb, 0x7E00, 128, False, 0x8000)
# mega_boot: BOOTSZ words and the BOOTRST sense, DS40002061B §27.
for bits, start in ((0b11, 0x7E00), (0b10, 0x7C00), (0b01, 0x7800), (0b00, 0x7000)):
prog, at = pb.mega_boot((0xF8 | (bits << 1)) & ~1)
if not prog or at != start:
fail(f"mega_boot BOOTSZ={bits:02b} programmed: {prog} {at:#06x}")
prog, at = pb.mega_boot(0xF8 | (bits << 1) | 1)
if prog or at != start:
fail(f"mega_boot BOOTSZ={bits:02b} unprogrammed: {prog} {at:#06x}")
# Surgery: word 0 lands on the loader, the trampoline on the original
# entry — checked with an independent decoder.
app = bytes((0xC0 | 0x00, 0xC0)) + bytes((0x12,)) * 300 # rjmp .+0x00C0... entry word 0xC0C0
entry = rjmp_decode(app[0] | (app[1] << 8), 0, tiny.flash_size // 2)
pages = pb.plan_flash(app, tiny)
word0 = pages[0][0] | (pages[0][1] << 8)
if rjmp_decode(word0, 0, tiny.flash_size // 2) != tiny.base // 2:
fail("surgery: patched word 0 misses the loader")
tp = pages[tiny.base - 64]
tramp = tp[62] | (tp[63] << 8)
if rjmp_decode(tramp, (tiny.base - 2) // 2, tiny.flash_size // 2) != entry:
fail("surgery: trampoline misses the original entry")
expect_error("non-rjmp vector", lambda: pb.plan_flash(bytes((0x0C, 0x94)) + app[2:], tiny), "not an rjmp")
looped = bytearray(app)
word = pb.rjmp_to(0, tiny.base // 2, tiny.flash_size // 2)
looped[0], looped[1] = word & 0xFF, word >> 8
expect_error("read-back image", lambda: pb.plan_flash(bytes(looped), tiny), "read-back")
expect_error("oversize image", lambda: pb.plan_flash(bytes(0x1DFF), tiny), "application flash ends")
# Ordering: patched vector puts page 0 first and the trampoline second;
# a boot section puts page 0 last. Blank pages drop only when erased.
order = pb.covered(pages, tiny, skip_blank=False)
if order[0] != 0 or order[1] != tiny.base - 64:
fail(f"tiny order starts {order[:2]}, want page 0 then trampoline page")
if sorted(order[2:]) != order[2:]:
fail("tiny order tail not ascending")
mega_pages = pb.plan_flash(bytes((0xFF,)) * 600, mega)
morder = pb.covered(mega_pages, mega, skip_blank=False)
if morder[-1] != 0 or sorted(morder[:-1]) != morder[:-1]:
fail(f"mega order {morder}, want ascending with page 0 last")
blanky = {0: pages[0], 64: bytes((0xFF,)) * 64, 128: pages[128], tiny.base - 64: tp}
slim = pb.covered(blanky, tiny, skip_blank=True)
if 64 in slim or 0 not in slim or tiny.base - 64 not in slim:
fail(f"skip_blank order wrong: {slim}")
# Staging content: the identical image plus the through-word on a
# patched-vector chip; hard size clamps either way.
image = bytes(range(256)) * 2 # 512 B — too big for a tiny slot
expect_error("tiny staging size", lambda: pb.staging_content(image, tiny), "510")
staged = pb.staging_content(image[:508], tiny)
through = staged[510] | (staged[511] << 8)
if rjmp_decode(through, (tiny.base - 2) // 2, tiny.flash_size // 2) != tiny.base // 2:
fail("through-word misses the resident base")
if pb.staging_content(image, mega) != image:
fail("mega staging content should be the bare image")
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
# The embedded info block: found in a synthetic binary, absent in noise.
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10
found = pb.image_info(binary)
if found is None or found.raw != tiny.raw:
fail("image_info misses the embedded block")
if pb.image_info(bytes((0xAA,)) * 40) is not None:
fail("image_info invents a block")
# 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")
expect_error("mega needs fuses", lambda: pb.update_preflight(bytes((0xAA,)) * 8 + mega.raw, mega, None),
"--assume-fuses")
mega_image = bytes((0xAA,)) * 8 + mega.raw
def fuses(high):
return bytes((0xFF, 0xFF, 0xFF, high))
expect_error("BOOTSZ 512 B", lambda: pb.update_preflight(mega_image, mega, fuses(0xFE)),
"cannot self-update", "BOOTSZ")
notes = pb.update_preflight(mega_image, mega, fuses(0xFD)) # 1 KB, BOOTRST unprogrammed
if not any("BOOTRST unprogrammed" in n for n in notes):
fail(f"1K/unprogrammed notes: {notes}")
notes = pb.update_preflight(mega_image, mega, fuses(0xFC)) # 1 KB, BOOTRST programmed
if not any("staging slot" in n for n in notes):
fail(f"1K/programmed notes: {notes}")
notes = pb.update_preflight(mega_image, mega, fuses(0xFA)) # 2 KB, BOOTRST programmed
if not any("application flash" in n for n in notes):
fail(f"2K/programmed notes: {notes}")
if pb.update_preflight(bytes((0xAA,)) * 8 + tiny.raw, tiny, None) != []:
fail("tiny preflight should pass without fuses")
# The walk-region refusal: BOOTRST aimed below the loader plus app data
# in the walk span errors without --force; erased spans and unprogrammed
# BOOTRST pass.
deep = {0x7800: bytes((1,)) * 128}
expect_error("walk region", lambda: pb.check_walk_region(deep, mega, fuses(0xFA), False), "--force")
pb.check_walk_region(deep, mega, fuses(0xFA), True)
pb.check_walk_region(deep, mega, fuses(0xFB), False) # BOOTRST unprogrammed
pb.check_walk_region({0x7800: bytes((0xFF,)) * 128}, mega, fuses(0xFA), False)
pb.check_walk_region(deep, mega, None, False) # fuses unknown: no check
print("test_planner: all planner and policy checks pass")
if __name__ == "__main__":
main()