Compare commits
3 Commits
7d103ca957
...
f6598b0511
| Author | SHA1 | Date | |
|---|---|---|---|
| f6598b0511 | |||
| 0604d3a0ad | |||
| 62a548cc09 |
@@ -118,6 +118,15 @@ endif()
|
||||
# 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
|
||||
# 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")
|
||||
set(_pb_flash 1024)
|
||||
set(_pb_wrap "")
|
||||
@@ -125,6 +134,7 @@ if(LIBAVR_MCU STREQUAL "attiny13a")
|
||||
set(_pb_hz 9600000)
|
||||
set(_pb_baud 57600)
|
||||
set(_pb_eeprom 64)
|
||||
set(_pb_limit 510)
|
||||
elseif(LIBAVR_MCU STREQUAL "attiny85")
|
||||
set(_pb_flash 8192)
|
||||
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
|
||||
@@ -132,6 +142,7 @@ elseif(LIBAVR_MCU STREQUAL "attiny85")
|
||||
set(_pb_hz 8000000)
|
||||
set(_pb_baud 57600)
|
||||
set(_pb_eeprom 512)
|
||||
set(_pb_limit 510)
|
||||
else()
|
||||
set(_pb_flash 32768)
|
||||
set(_pb_wrap -Wl,--pmem-wrap-around=32k)
|
||||
@@ -139,6 +150,7 @@ else()
|
||||
set(_pb_hz 16000000)
|
||||
set(_pb_baud 115200)
|
||||
set(_pb_eeprom 1024)
|
||||
set(_pb_limit 512)
|
||||
endif()
|
||||
math(EXPR _pb_base "${_pb_flash} - 512")
|
||||
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
|
||||
@@ -150,13 +162,22 @@ endif()
|
||||
|
||||
add_executable(pureboot pureboot/pureboot.cpp)
|
||||
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}
|
||||
-Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap})
|
||||
add_custom_command(TARGET pureboot POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:pureboot>)
|
||||
if(PROJECT_IS_TOP_LEVEL)
|
||||
add_test(NAME pureboot.size
|
||||
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
|
||||
# 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_BINARY_DIR}/pbtest-work)
|
||||
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()
|
||||
|
||||
@@ -3,11 +3,22 @@
|
||||
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
|
||||
constraint: one C++ source, no inline assembly, no global register variables
|
||||
(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,
|
||||
reset-vector surgery, timeout configuration — lives in the host tool
|
||||
reset-vector surgery, updating the loader itself — lives in the host tool
|
||||
(`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
|
||||
|
||||
| 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
|
||||
expiring with an idle line boots the application.
|
||||
|
||||
The window length in seconds is the **last EEPROM cell** (address
|
||||
`eeprom_size - 1`); `0x00` and the erased `0xff` both mean the 4 s default,
|
||||
so a full EEPROM erase resets the timeout rather than maxing it. The host
|
||||
changes it with the ordinary EEPROM-write command.
|
||||
The window length is a compile-time constant — 8 s by default, another value
|
||||
via the `PUREBOOT_TIMEOUT` CMake cache variable — so the whole EEPROM belongs
|
||||
to the application; pureboot never uses it for its own state. Re-timing a
|
||||
deployed loader is a self-update with a re-timed build (below).
|
||||
|
||||
## Session
|
||||
|
||||
After the knock the loader stays in its command loop until `G` or a reset.
|
||||
Before reading each command it waits for any pending EEPROM write to finish
|
||||
and sends the prompt `+` (0x2b) — the prompt is therefore also the completion
|
||||
ack of the previous command. A session is: await `+`, send a command, read
|
||||
its reply, repeat.
|
||||
After the knock the loader stays in its command loop until `J` jumps away or
|
||||
the chip resets. Before reading each command it waits for any pending EEPROM
|
||||
write to finish and sends the prompt `+` (0x2b) — the prompt is therefore
|
||||
also the completion ack of the previous command. A session is: await `+`,
|
||||
send a command, read its reply, repeat.
|
||||
|
||||
| Cmd | Arguments | Reply |
|
||||
|---|---|---|
|
||||
@@ -52,17 +63,24 @@ its reply, repeat.
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
`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
|
||||
loader's own 512 bytes are drained but never programmed — a broken host
|
||||
cannot brick the chip. `w` is host-paced: send the next byte only after the
|
||||
previous byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip without an
|
||||
extended fuse byte (the ATtiny13A) that slot carries no meaning. Fuse *writing* does not
|
||||
exist: SPM reaches flash (and, on the mega, lock bits) only — fuse bytes are
|
||||
external-programming territory by hardware.
|
||||
512-byte slot the loader is *running* in are drained but never programmed — a
|
||||
broken host cannot brick the running copy, and a staged copy may rewrite the
|
||||
resident slot. `w` is host-paced: send the next byte only after the previous
|
||||
byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip
|
||||
without an extended fuse byte (the ATtiny13A) that slot carries no meaning.
|
||||
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`):
|
||||
|
||||
@@ -76,24 +94,59 @@ The info block (`b`):
|
||||
| 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 =
|
||||
write `0xff` (per page for flash, per byte for EEPROM), timeout = EEPROM
|
||||
write to the last cell.
|
||||
write `0xff` (per page for flash, per byte for EEPROM).
|
||||
|
||||
## Deployment
|
||||
|
||||
**ATmega328P**: program the loader at 0x7e00 with an external programmer;
|
||||
fuses BOOTSZ = 11 (256 words) and BOOTRST programmed. Applications are
|
||||
flashed unmodified — reset re-vectors to the loader in hardware, word 0
|
||||
stays the application's own reset vector, and `G` jumps to 0.
|
||||
**ATmega328P**: program the loader at 0x7e00 with an external programmer.
|
||||
Two fuse profiles, same binary:
|
||||
|
||||
**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
|
||||
flashing an application the host performs reset-vector surgery: the
|
||||
application's own `rjmp` target is re-encoded as a trampoline `rjmp` in the
|
||||
word just below the loader (`base - 2`, where `G` jumps), and word 0 is
|
||||
rewritten to `rjmp` to the loader base. Every other vector stays the
|
||||
application's. Page 0 is written last, so an interrupted flash leaves word 0
|
||||
erased and the chip still falls through to the loader on the next reset.
|
||||
flashing an application the host performs reset-vector surgery: word 0 is
|
||||
rewritten to `rjmp` to the loader base, and the application's own entry is
|
||||
re-encoded as a trampoline `rjmp` in the word just below the loader
|
||||
(`base − 2`, where the hand-over jumps). Every other vector stays the
|
||||
application's. The patched page 0 and the trampoline page are written
|
||||
*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 `[base−512, 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
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
|
||||
(erase / program / read / verify), EEPROM (erase / program / read / verify),
|
||||
timeout — then the loader hands over to the application; `--stay` keeps the
|
||||
session alive instead, and a later invocation reconnects into it (the knock
|
||||
converges there too). `--flash` and `--eeprom` verify by read-back unless
|
||||
`--no-verify`; images are raw binary, or Intel HEX by extension.
|
||||
Operations run in a fixed order within one session: info, fuses, loader
|
||||
update, flash (erase / program / read / verify), EEPROM (erase / program /
|
||||
read / verify) — then the loader hands over to the application; `--stay`
|
||||
keeps the session alive instead, and a later invocation reconnects into it
|
||||
(the knock converges there too). `--flash` and `--eeprom` verify by
|
||||
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
|
||||
|
||||
Per chip preset, `ctest` runs the 512-byte size gate and the end-to-end
|
||||
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,
|
||||
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, timeout
|
||||
configuration, 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.
|
||||
Per chip preset, `ctest` runs:
|
||||
|
||||
- `pureboot.size` — the 510-byte (tinies) / 512-byte (mega) budget;
|
||||
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`
|
||||
in the image, the info block within its first 256 bytes;
|
||||
- `pureboot.planner` — the host tool's pure logic: programming orders and
|
||||
their recovery properties, the surgery, the staging composition, the
|
||||
boot-fuse decode, and the update preflight's error/warning matrix over
|
||||
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.
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
// pureboot — a serial bootloader on libavr, pure by constraint: one C++
|
||||
// source with no inline assembly and no global register variables, built for
|
||||
// every chip libavr targets, 512 bytes on each. The device speaks primitives
|
||||
// — read/program flash, read/write EEPROM, fuse bytes, an info block, run —
|
||||
// and everything composite (verify, erase, reset-vector surgery on the
|
||||
// tinies, timeout configuration) lives in the host tool. Protocol reference:
|
||||
// README.md next to this file.
|
||||
// — read/program flash, read/write EEPROM, fuse bytes, an info block, a jump
|
||||
// — and everything composite (verify, erase, reset-vector surgery, updating
|
||||
// the loader itself) lives in the host tool. Protocol reference: README.md
|
||||
// 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
|
||||
// patched reset vector — or erased flash walking up into the loader — on 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
|
||||
// ("pb"); an idle line boots the application. A session then stays in the
|
||||
// command loop until 'G' hands over or the chip resets.
|
||||
// host has one activation window per awaited knock byte ("pb"); an idle line
|
||||
// boots the application. A session then stays in the command loop until 'J'
|
||||
// jumps away or the chip resets.
|
||||
|
||||
#include <libavr/libavr.hpp>
|
||||
|
||||
@@ -51,22 +61,22 @@ consteval std::array<std::uint8_t, 3> signature()
|
||||
|
||||
using dev = avr::device<{.clock = clock()}>;
|
||||
|
||||
// Geometry: the loader owns the top 512 bytes of flash; the byte below it is
|
||||
// the trampoline word (the application's relocated reset vector) on chips
|
||||
// without a hardware boot section. The RWWSRE bit marks a separate boot
|
||||
// section — on classic AVR the two capabilities coincide.
|
||||
// Geometry: the resident loader owns the top 512 bytes of flash; the word
|
||||
// below it is the trampoline (the application's relocated reset vector) on
|
||||
// chips without a hardware boot section. The RWWSRE bit marks a separate
|
||||
// boot section — on classic AVR the two capabilities coincide.
|
||||
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 page = spm::page_bytes;
|
||||
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
|
||||
// rewrites it with the ordinary EEPROM-write command. An unprogrammed cell —
|
||||
// 0x00 or the erased 0xff — means the 4 s default: a stray value can never
|
||||
// floor the window to nothing and lock the loader out, and erasing the whole
|
||||
// EEPROM resets the timeout instead of maxing it to 255 s.
|
||||
constexpr std::uint16_t timeout_cell = avr::hw::db.mem.eeprom_size - 1;
|
||||
constexpr std::uint8_t default_seconds = 4;
|
||||
// The activation window, in seconds, is a compile-time constant (the build
|
||||
// may override it): the whole EEPROM belongs to the application, and
|
||||
// re-timing the loader is a bootloader self-update with a re-timed binary.
|
||||
#if !defined(PUREBOOT_TIMEOUT)
|
||||
#define PUREBOOT_TIMEOUT 8
|
||||
#endif
|
||||
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
|
||||
|
||||
// The 12-byte info block the host reads with the 'b' command; flash-resident
|
||||
// (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],
|
||||
static_cast<std::uint8_t>(page),
|
||||
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 >> 8,
|
||||
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
|
||||
// elsewhere. Both are class templates on the clock so only the selected
|
||||
// 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>
|
||||
consteval std::int16_t rxc_field()
|
||||
{
|
||||
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>
|
||||
struct hardware_link {
|
||||
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()
|
||||
{
|
||||
avr::init<uart>();
|
||||
@@ -120,6 +148,19 @@ struct hardware_link {
|
||||
{
|
||||
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>
|
||||
@@ -127,6 +168,10 @@ struct software_link {
|
||||
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>;
|
||||
|
||||
// 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()
|
||||
{
|
||||
avr::init<rx_t, tx_t>();
|
||||
@@ -146,58 +191,65 @@ struct software_link {
|
||||
{
|
||||
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>>;
|
||||
|
||||
// The application's entry: the linker pins pureboot_app to 0x0000 on the
|
||||
// mega (reset re-vectors here through BOOTRST, so address 0 stays the
|
||||
// application's own vector) and to the trampoline word at base - 2 on the
|
||||
// tinies (--defsym in CMakeLists.txt).
|
||||
// The application's entry, an absolute address the linker pins (--defsym in
|
||||
// CMakeLists.txt): 0x0000 on the mega (word 0 stays the application's own
|
||||
// vector — BOOTRST re-vectors a reset into the loader in hardware) and the
|
||||
// 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();
|
||||
|
||||
[[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
|
||||
// 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()
|
||||
[[gnu::noinline, noreturn]] void run_app()
|
||||
{
|
||||
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 {
|
||||
std::uint16_t ticks = ticks_per_second();
|
||||
do {
|
||||
std::uint16_t spins = 0; // wraps first, so 65536 polls per tick
|
||||
do {
|
||||
if (link::pending())
|
||||
return true;
|
||||
} while (--spins);
|
||||
} while (--ticks);
|
||||
} while (--seconds);
|
||||
if (link::pending())
|
||||
return true;
|
||||
} while (--polls);
|
||||
return false;
|
||||
}
|
||||
|
||||
// A knock byte under the activation deadline: an idle line means no host is
|
||||
// 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();
|
||||
return link::rx();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
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
|
||||
// words, then erase and program. Addresses in the loader's own 512 bytes
|
||||
// are drained but never programmed — a broken host cannot brick the chip.
|
||||
// On the mega the RWW section is re-enabled so reads work immediately.
|
||||
void program_flash(std::uint16_t address)
|
||||
// words, then erase and program — except the 512-byte slot this code runs
|
||||
// in, which is drained but never programmed, so a copy can never erase
|
||||
// itself. `slot_high` is the high byte of that running slot's base (run()
|
||||
// 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 high = link::rx();
|
||||
spm::fill<off>(address + i, static_cast<std::uint16_t>(low | (high << 8)));
|
||||
}
|
||||
if (address < base) {
|
||||
spm::fill<off>(address, static_cast<std::uint16_t>(low | (high << 8)));
|
||||
address += 2;
|
||||
} 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::wait();
|
||||
spm::write_page<off>(address);
|
||||
spm::wait();
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
spm::write_page<off>(address);
|
||||
if constexpr (boot_section) {
|
||||
spm::wait();
|
||||
spm::rww_enable<off>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,8 +334,10 @@ void program_flash(std::uint16_t address)
|
||||
// AVR — SPM reaches flash (and boot lock bits) only.
|
||||
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)));
|
||||
while (++which & 3);
|
||||
}
|
||||
|
||||
[[noreturn]] void run()
|
||||
@@ -272,13 +349,17 @@ void send_fuses()
|
||||
|
||||
link::init();
|
||||
|
||||
std::uint8_t seconds = ee::read(timeout_cell);
|
||||
if (seconds == 0 || seconds == 0xff)
|
||||
seconds = default_seconds;
|
||||
// The high byte of the 512-byte-aligned base this copy runs at: the word
|
||||
// return address's high byte is the byte address >> 9 (the slot index),
|
||||
// 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
|
||||
// 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 (;;) {
|
||||
@@ -286,34 +367,43 @@ void send_fuses()
|
||||
// and fuse reads (§26.2.1), and the ack tells the host all is done.
|
||||
ee::wait();
|
||||
link::tx(ack);
|
||||
switch (link::rx()) {
|
||||
case 'b': // info block
|
||||
send_flash(reinterpret_cast<std::uint16_t>(info::storage.data()), info::size());
|
||||
const std::uint8_t command = link::rx();
|
||||
switch (command) {
|
||||
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;
|
||||
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();
|
||||
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;
|
||||
}
|
||||
case 'W': // program one flash page: addr16, page bytes
|
||||
program_flash(rx16());
|
||||
program_flash(rx16(), slot_high);
|
||||
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
|
||||
send_fuses();
|
||||
break;
|
||||
case 'G': // hand over to the application
|
||||
link::tx(ack);
|
||||
run_app();
|
||||
default: // unknown bytes are ignored; the loop re-acks
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
|
||||
The device exposes primitives; this tool composes them: image loading (raw
|
||||
binary or Intel HEX), flash programming with read-back verification, erase as
|
||||
writing 0xff, EEPROM programming, fuse and info readout, activation-timeout
|
||||
configuration, and — on chips without a hardware boot section — the
|
||||
reset-vector surgery that re-homes the application's entry through the
|
||||
trampoline word below the loader, writing page 0 last so an interrupted
|
||||
flash still falls through to the loader.
|
||||
writing 0xff, EEPROM programming, fuse and info readout, the hand-over jump,
|
||||
and — on chips without a hardware boot section — the reset-vector surgery
|
||||
that re-homes the application's entry through the trampoline word below the
|
||||
loader. Page 0 and the trampoline are written first, so every interruption
|
||||
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
|
||||
tty works — a USB adapter as well as a simavr pty.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
@@ -22,6 +30,7 @@ import time
|
||||
|
||||
PROMPT = b"+"
|
||||
PROTOCOL_VERSION = 1
|
||||
SLOT = 512 # the loader slot size; also the self-update staging distance
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
@@ -88,12 +97,18 @@ class Info:
|
||||
raise Error(f"bad info block: {raw.hex()}")
|
||||
if raw[2] != PROTOCOL_VERSION:
|
||||
raise Error(f"protocol version {raw[2]}, tool speaks {PROTOCOL_VERSION}")
|
||||
self.raw = bytes(raw)
|
||||
self.signature = raw[3:6]
|
||||
self.page = raw[6]
|
||||
self.base = raw[7] | (raw[8] << 8)
|
||||
self.eeprom_size = raw[9] | (raw[10] << 8)
|
||||
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):
|
||||
sig = " ".join(f"{b:02x}" for b in self.signature)
|
||||
@@ -107,7 +122,8 @@ class Info:
|
||||
|
||||
class Loader:
|
||||
"""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):
|
||||
self.port = port
|
||||
@@ -181,10 +197,23 @@ class Loader:
|
||||
def read_fuses(self):
|
||||
return self._command(b"F", 4, 2.0)
|
||||
|
||||
def run_application(self):
|
||||
self.port.write(b"G")
|
||||
def jump(self, word_address):
|
||||
"""'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()
|
||||
|
||||
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 ---
|
||||
|
||||
@@ -237,8 +266,7 @@ def rjmp_to(word_address, destination, flash_words):
|
||||
|
||||
def plan_flash(image, info):
|
||||
"""The pages to program, as {page_address: bytes}, already carrying the
|
||||
reset-vector surgery where the chip needs it. Page 0 must go last —
|
||||
callers get it separated."""
|
||||
reset-vector surgery where the chip needs it."""
|
||||
page = info.page
|
||||
limit = info.base - (2 if info.patch_vector else 0)
|
||||
if len(image) > limit:
|
||||
@@ -272,25 +300,254 @@ def plan_flash(image, info):
|
||||
return pages
|
||||
|
||||
|
||||
def covered(pages, skip_blank):
|
||||
"""Pages in programming order: ascending, page 0 last; optionally
|
||||
dropping all-0xff pages (sound only over erased flash) — never the
|
||||
load-bearing page 0."""
|
||||
rest = [a for a in sorted(pages) if a != 0]
|
||||
def covered(pages, info, skip_blank):
|
||||
"""Pages in programming order; optionally dropping all-0xff pages (sound
|
||||
only over erased flash) — never a load-bearing one.
|
||||
|
||||
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:
|
||||
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 ---
|
||||
|
||||
|
||||
def op_erase_flash(loader):
|
||||
"""0xff over the whole application area. Order does not matter here —
|
||||
every target byte is the same value — and a blank word 0 still falls
|
||||
through to the loader, so an interruption is harmless."""
|
||||
"""0xff over the whole application area. Descending on a patched-vector
|
||||
chip: page 0 — the patched reset vector — goes last, so an interrupted
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
def op_flash(loader, path, erase, verify):
|
||||
def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
|
||||
image = load_image(path)
|
||||
pages = plan_flash(image, loader.info)
|
||||
check_walk_region(pages, loader.info, fuse_bytes, force)
|
||||
if erase:
|
||||
op_erase_flash(loader)
|
||||
order = covered(pages, skip_blank=erase)
|
||||
order = covered(pages, loader.info, skip_blank=erase)
|
||||
for address in order:
|
||||
loader.write_page(address, pages[address])
|
||||
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}")
|
||||
|
||||
|
||||
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):
|
||||
low, lock, extended, high = loader.read_fuses()
|
||||
print(f"fuses: low {low:02x} high {high:02x} extended {extended:02x} lock {lock:02x}")
|
||||
return bytes((low, lock, extended, high))
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- cli ---
|
||||
@@ -389,6 +642,10 @@ def main():
|
||||
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("--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("--flash", metavar="FILE", help="program an application (bin or ihex)")
|
||||
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("--read-eeprom", metavar="FILE", help="dump the EEPROM")
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.timeout is not None and not 0 <= args.timeout <= 254:
|
||||
parser.error("--timeout must be 0..254 (255 is the erased cell)")
|
||||
if args.update_loader and (args.flash or args.erase_flash):
|
||||
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)
|
||||
try:
|
||||
@@ -411,10 +675,16 @@ def main():
|
||||
info = loader.connect(args.wait)
|
||||
if args.info:
|
||||
print(f"device: {info.describe()}")
|
||||
if args.fuses:
|
||||
op_fuses(loader)
|
||||
fuse_bytes = fuse_override
|
||||
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:
|
||||
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:
|
||||
op_erase_flash(loader)
|
||||
if args.read_flash:
|
||||
@@ -429,8 +699,6 @@ def main():
|
||||
op_read_eeprom(loader, args.read_eeprom)
|
||||
if args.verify_eeprom:
|
||||
op_verify_eeprom(loader, args.verify_eeprom)
|
||||
if args.timeout is not None:
|
||||
op_timeout(loader, args.timeout)
|
||||
if args.stay:
|
||||
print("loader stays in its session (reset to leave)")
|
||||
else:
|
||||
|
||||
53
test/check_pi.py
Normal file
53
test/check_pi.py
Normal 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()
|
||||
@@ -1,8 +1,14 @@
|
||||
// Test-fixture application for the pureboot protocol test: prints "APP" on
|
||||
// the chip's serial link (the same link the loader uses) and idles — the
|
||||
// proof that the loader's hand-over, and on the tinies the host's
|
||||
// reset-vector surgery, actually launched it. Linked normally (crt, vectors
|
||||
// at 0); on the tinies its reset vector is the rjmp the host re-homes.
|
||||
// Test-fixture application for the pureboot protocol tests: prints "APP" on
|
||||
// the chip's serial link (the same link the loader uses) — the proof that
|
||||
// the loader's hand-over, and on the tinies the host's reset-vector
|
||||
// surgery, actually launched it. Linked normally (crt, vectors at 0); on
|
||||
// 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>
|
||||
|
||||
using namespace avr::literals;
|
||||
@@ -27,6 +33,12 @@ struct link {
|
||||
{
|
||||
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>
|
||||
@@ -36,6 +48,11 @@ struct link<C, false> {
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
}
|
||||
[[noreturn]] static void idle()
|
||||
{
|
||||
while (true) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -46,6 +63,5 @@ int main()
|
||||
link<dev::clock>::tx('A');
|
||||
link<dev::clock>::tx('P');
|
||||
link<dev::clock>::tx('P');
|
||||
while (true) {
|
||||
}
|
||||
link<dev::clock>::idle();
|
||||
}
|
||||
|
||||
93
test/pbreloc.py
Normal file
93
test/pbreloc.py
Normal 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
61
test/pbsim.py
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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)
|
||||
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.
|
||||
|
||||
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
@@ -101,8 +101,8 @@ def main():
|
||||
try:
|
||||
# Session 1: knock from reset, identify, program everything, stay.
|
||||
out = run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
|
||||
"--eeprom", ee_path, "--timeout", "8", "--stay")
|
||||
for needed in ("device: signature", "fuses:", "verify:", "activation timeout: 8 s", "stays"):
|
||||
"--eeprom", ee_path, "--stay")
|
||||
for needed in ("device: signature", "fuses:", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
fail(f"session 1 output lacks {needed!r}")
|
||||
|
||||
@@ -116,8 +116,6 @@ def main():
|
||||
eeprom_back = open(read_eeprom, "rb").read()
|
||||
if eeprom_back[: len(ee_image)] != ee_image:
|
||||
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.
|
||||
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
|
||||
# runner resets them to address 0 like silicon) or BOOTRST (mega).
|
||||
# The loader must answer a fresh knock, and 'G' must land in the
|
||||
# application, which banners on the same link.
|
||||
# The loader must answer a fresh knock, and the 'J' hand-over must
|
||||
# land in the application, which banners on the same link.
|
||||
device.proc.send_signal(signal.SIGUSR1)
|
||||
port = pb.Port(device.pty, baud)
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(15)
|
||||
port.write(b"G")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("no ack for G")
|
||||
loader.run_application()
|
||||
banner = port.read_exact(3, 5.0)
|
||||
if banner != b"APP":
|
||||
fail(f"application banner was {banner!r}")
|
||||
@@ -168,7 +164,7 @@ def main():
|
||||
ee_true_path = dump + ".eeprom"
|
||||
if os.path.exists(ee_true_path):
|
||||
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")
|
||||
|
||||
print("pbtest: all scenarios pass")
|
||||
|
||||
211
test/pbupdate.py
Normal file
211
test/pbupdate.py
Normal 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()
|
||||
@@ -43,6 +43,43 @@ static const char *dump_path;
|
||||
static uint32_t reset_pc;
|
||||
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)
|
||||
{
|
||||
(void)sig;
|
||||
@@ -54,6 +91,7 @@ static void request_reset(int sig)
|
||||
typedef struct {
|
||||
avr_io_t io;
|
||||
uint8_t buffer[128];
|
||||
uint8_t used[128]; // a buffer word loads once until erased — like silicon
|
||||
unsigned page;
|
||||
} 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);
|
||||
if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0
|
||||
unsigned offset = z & (n->page - 1) & ~1u;
|
||||
n->buffer[offset] = mcu->data[0];
|
||||
n->buffer[offset + 1] = mcu->data[1];
|
||||
if (!n->used[offset]) { // first write wins until the buffer clears
|
||||
n->buffer[offset] = mcu->data[0];
|
||||
n->buffer[offset + 1] = mcu->data[1];
|
||||
n->used[offset] = 1;
|
||||
}
|
||||
} else if (command == 0x03) { // PGERS
|
||||
memset(mcu->flash + page_base, 0xff, n->page);
|
||||
} else if (command == 0x05) { // PGWRT: programming only clears bits
|
||||
for (unsigned i = 0; i < n->page; i++)
|
||||
mcu->flash[page_base + i] &= n->buffer[i];
|
||||
memset(n->buffer, 0xff, n->page);
|
||||
memset(n->used, 0, n->page);
|
||||
} else if (command == 0x11) { // CTPB
|
||||
memset(n->buffer, 0xff, n->page);
|
||||
memset(n->used, 0, n->page);
|
||||
}
|
||||
mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly
|
||||
return 0;
|
||||
@@ -162,8 +205,14 @@ static void rx_start_next(void)
|
||||
// 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,
|
||||
// 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)
|
||||
{
|
||||
avr_cycle_timer_cancel(avr, tx_sample, NULL);
|
||||
avr_cycle_timer_cancel(avr, rx_step, NULL);
|
||||
rx_head = rx_tail = 0;
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
@@ -215,8 +264,14 @@ static void finish(int sig)
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
if (argc != 8) {
|
||||
fprintf(stderr, "usage: %s <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>\n", argv[0]);
|
||||
if (argc < 8 || argc > 10) {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
|
||||
|
||||
elf_firmware_t fw = {0};
|
||||
if (elf_read_firmware(argv[1], &fw) != 0) {
|
||||
fprintf(stderr, "device: cannot read %s\n", argv[1]);
|
||||
return 1;
|
||||
if (argc > 9) {
|
||||
// Resume: the full flash image of an interrupted prior run.
|
||||
FILE *f = fopen(argv[9], "rb");
|
||||
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
|
||||
// tinies reset to word 0 like silicon — erased flash walks up into the
|
||||
// loader, and after the host's surgery the patched vector routes there.
|
||||
reset_pc = use_uart_pty ? base : 0;
|
||||
// The mega enters the loader in hardware (BOOTRST, not modeled — the
|
||||
// argument picks the modeled fuse's target); the tinies reset to word 0
|
||||
// like silicon — erased flash walks up into the loader, and after the
|
||||
// host's surgery the patched vector routes there.
|
||||
reset_pc = argc > 8 ? (uint32_t)strtoul(argv[8], NULL, 0) : (use_uart_pty ? base : 0);
|
||||
avr->pc = reset_pc;
|
||||
avr->codeend = avr->flashend;
|
||||
|
||||
@@ -258,6 +324,7 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
|
||||
if (use_uart_pty) {
|
||||
fix_mega_flash_erase();
|
||||
// POLL_SLEEP paces an idle-polling loader in host real time (a
|
||||
// no-hardware CPU-saving hack); clear it so cycles run free.
|
||||
uint32_t flags = 0;
|
||||
|
||||
157
test/test_planner.py
Normal file
157
test/test_planner.py
Normal 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()
|
||||
Reference in New Issue
Block a user