pureboot: one position-independent binary — its own staging loader

The image now runs from any 512-byte slot with every command intact:
control flow stays PC-relative, the write guard keys on the running slot
(the return-address anchor, computed once), the info block is addressed
from that same anchor as a byte pair (no absolute 16-bit address in the
image), and the application jump is an indirect call through a noipa-
laundered pointer to the absolute entry. 'J' — jump to a wire word
address, the one transfer primitive — replaces 'G': the host knows the
application entry from the info block, and moving between loader copies
needs arbitrary targets. The activation window is a compile-time 8 s
(PUREBOOT_TIMEOUT overrides), counted as a single calibrated poll loop.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:37:43 +02:00
parent 6594c1b044
commit f964b875b9
4 changed files with 602 additions and 177 deletions

View File

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

View File

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

View File

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

View File

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