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

@@ -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;
}