pureboot: no SPM buffer discard, the host repairs instead

The temporary page buffer is write-once per word, so a page filled over
one an earlier writer left dirty programs the stale words. The same
datasheet clause carries the cure: the buffer auto-erases after a page
write (§26.2.1; §19.2 on the tinies), so the corruption clears itself by
happening, and rewriting the page programs correctly.

The loader therefore clears the buffer nowhere. The tinies' CTPB and the
m48s' RWWSRE discard are gone; the boot-sectioned megas keep only the
trailing RWWSRE they need anyway to re-enable the RWW section for
read-back, which discards the buffer as a side effect and keeps them off
the path entirely. 434 B on the tiny13s, 438-442 on the tiny25/45/85,
430 on the m48s; the megas are unchanged, the 1284s still 506.

The host takes over the guarantee: a flash page that reads back wrong is
rewritten up to RETRIES times before the run stops. Both read-back paths
repair — verify_pages for programming, and write_differing, which is the
loader-update path where a page left wrong is a half-written loader slot.
That one is not hypothetical: deleting the discard made attiny85
pureboot.rehome fail deterministically there, the only flow still
assuming the old contract.

Protocol-visible, so README's W command says it: one W may program the
wrong bytes after a refused page, or after an application that
self-programmed entered without a reset, and a host that programs without
reading back cannot trust it.

Tests: pureboot.dirty drives the case the loader declines to guard — the
fixture application dirties every buffer word and jumps in with no reset
(hardware forbids that on a boot-sectioned mega, but simavr dispatches SPM
from anywhere, which is what makes it constructible) — and asserts a bare
verify sees the corruption, the repairing verify fixes it in one rewrite,
and it stays fixed. pbreloc asserts the same shape after a refusal.
test_planner covers the bound against a fake device: one bad write
repaired in a single rewrite, a page that never comes good stopping after
exactly three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 16:29:27 +02:00
parent 52c3b6ca7f
commit 862bc14e1e
10 changed files with 335 additions and 93 deletions

View File

@@ -183,6 +183,22 @@ if(PROJECT_IS_TOP_LEVEL)
set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180 set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180
ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}") ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}")
# Entering the loader from a running application with no reset
# between, over a page buffer the application dirtied — the case the
# loader declines to guard and the host repairs. Hardware forbids the
# state here (SPM runs only from the boot section); simavr does not,
# which is what makes it constructible.
if(LIBAVR_MCU STREQUAL "atmega328p")
add_test(NAME pureboot.dirty
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbdirty.py
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud}
$<TARGET_FILE:pbapp>.bin
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbdirty-work)
set_tests_properties(pureboot.dirty PROPERTIES TIMEOUT 180)
endif()
# Re-homing: a loader mistakenly programmed at address 0 (a raw .bin # Re-homing: a loader mistakenly programmed at address 0 (a raw .bin
# handed to a programmer) or sitting in the staging slot must heal # handed to a programmer) or sitting in the staging slot must heal
# into the canonical slot through the ordinary --update-loader flow. # into the canonical slot through the ordinary --update-loader flow.

View File

@@ -294,6 +294,15 @@ function(pureboot_add_loader name)
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr) target_link_libraries(${name} PRIVATE libavr)
target_compile_definitions(${name} PRIVATE ${_defines}) target_compile_definitions(${name} PRIVATE ${_defines})
# Codegen shaping for the loader TU only, worth ~40 B on every chip and
# what carries the far-flash 1284 build under 512. At -Os GCC otherwise
# rewrites the byte-stream loops' counters into end-pointer forms that
# cost registers (-fno-ivopts, -fno-split-wide-types), leaves register
# pressure on the table with the default allocator
# (-fira-algorithm=priority), and spends bytes on rewrites a
# straight-line loader gains nothing from.
target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-expensive-optimizations -fno-split-wide-types)
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap}) -Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)

View File

@@ -2,19 +2,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 — all 37 — (attributes and compiler flags allowed), built for **every chip libavr
fitting each chip's smallest boot sector**: 512 bytes everywhere — 470 B on targets — all 37 — in 512 bytes each**: 434 B on the tiny13s, 438442 B on
the tiny13s, ~484 B on the tiny25/45/85, 458506 B across the megas and the tiny25/45/85, 412452 B across the megas and every configured variant of
every configured variant of them (the m8's software-serial build is the them, and 506 B on the ATmega1284/1284P, whose far-flash machinery (ELPM
fattest) — except the ATmega1284/1284P, whose smallest boot sector is 1 KiB reads, RAMPZ page commands, word-addressed wire) is the heaviest. The 1284s
and whose far-flash machinery (ELPM reads, RAMPZ page commands, are the tight case: the loop-placement attributes on the byte streamers
word-addressed wire) lands at 556562 B in a 1 KiB slot: the 512-byte (`pureboot.cpp`) and the codegen flags on the loader TU (`CMakeLists.txt`)
figure is a hardware boundary those chips simply do not have, and no are what carry that build under the line. Clock, baud, serial backend and
implementation of this feature set fits it there. Clock, baud, serial pins are per-build configuration (below); the size matrix in the test suite
backend and pins are per-build configuration (below); the size matrix in holds every combination inside its slot. The device speaks primitives; every
the test suite holds every combination inside its slot. The device speaks composite — verify, erase, reset-vector surgery, updating the loader itself —
primitives; every composite — verify, erase, reset-vector surgery, updating lives in the host tool (`pureboot.py`).
the loader itself — lives in the host tool (`pureboot.py`).
The 1284s still *deploy* in a 1 KiB slot, their smallest boot sector being
512 words; at 506 B the image would also fit the 644's
two-512-byte-slots-per-boot-sector geometry.
The image is **position-independent**: control flow is PC-relative, the The image is **position-independent**: control flow is PC-relative, the
read/write paths take wire addresses, the write guard protects the slot the read/write paths take wire addresses, the write guard protects the slot the
@@ -133,7 +136,20 @@ byte-addressed). EEPROM addresses are always bytes, counts always bytes.
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
512-byte slot the loader is *running* in are drained but never programmed — a 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 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 resident slot.
The loader never clears the SPM buffer before a fill, so **one `W` may
program the wrong bytes, and the host is what fixes it**. The buffer is
write-once per word until cleared, and two things leave words in it: a
refused page (drained, never programmed) and — where SPM runs from anywhere,
the tinies and the m48s — an application that self-programmed before
entering. The next `W` takes those stale words, and clears them: a page write
auto-erases the buffer (§26.2.1; §19.2 on the tinies), so repeating it
programs correctly. The host therefore verifies every page it writes and
rewrites what comes back wrong (three retries, then it stops); a host that
programs without reading back cannot trust the first `W` after either event.
`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 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. 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 Fuse *writing* does not exist: SPM reaches flash (and, on the mega, lock
@@ -283,8 +299,11 @@ update, flash (erase / program / read / verify), EEPROM (erase / program /
read / verify) — then the loader hands over to the application; `--stay` read / verify) — then the loader hands over to the application; `--stay`
keeps the session alive instead, and a later invocation reconnects into it keeps the session alive instead, and a later invocation reconnects into it
(the knock converges there too). `--flash` and `--eeprom` verify by (the knock converges there too). `--flash` and `--eeprom` verify by
read-back unless `--no-verify`; images are raw binary, or Intel HEX by read-back unless `--no-verify`, and a flash page that reads back wrong is
extension. `--force` overrides the refusable safety checks (today: flashing rewritten up to three times before the run stops — the loader leaves one
recoverable way for a page to land wrong (see `W` above), and rewriting is
what clears it. `--verify-flash` only reports. 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). application data into a mega's reset walk region).
Readouts come one fact per line: `--info` prints the decoded info block Readouts come one fact per line: `--info` prints the decoded info block
@@ -320,8 +339,10 @@ regenerates the presets). Per chip preset, `ctest` runs:
in the image, the info block within its first 256 bytes; in the image, the info block within its first 256 bytes;
- `pureboot.planner` — the host tool's pure logic: programming orders and - `pureboot.planner` — the host tool's pure logic: programming orders and
their recovery properties, the surgery, the staging composition, the their recovery properties, the surgery, the staging composition, the
boot-fuse decode, and the update preflight's error/warning matrix over boot-fuse decode, the update preflight's error/warning matrix over
synthetic fuse bytes; synthetic fuse bytes, and the repairing verify against a fake device — one
bad write repaired in a single rewrite, a page that never comes good
stopping after exactly three;
- `pureboot.protocol` — end to end against a simavr device - `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c` — a hardware USART as a pty, or a cycle-timed (`test/pureboot_device.c` — a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, selected with `-l` to match GPIO⇄pty bridge for a software-UART build, selected with `-l` to match
@@ -335,6 +356,14 @@ regenerates the presets). Per chip preset, `ctest` runs:
- `pureboot.reloc` — the identical image installed one slot below the - `pureboot.reloc` — the identical image installed one slot below the
resident serves the complete command set from there (the resident serves the complete command set from there (the
position-independence acceptance test); position-independence acceptance test);
- `pureboot.dirty` (328P) — entering the loader from a running application
with no reset between, over an SPM page buffer the fixture deliberately
dirtied: the case the loader declines to guard against. A bare verify must
see the corruption, the repairing verify must fix it in one rewrite, and a
plain verify afterwards must pass. On the boot-sectioned megas hardware
forbids the state outright (SPM runs only from the boot section, and reset
erases the buffer), but simavr dispatches SPM from anywhere — which is what
makes the path constructible at all;
- `pureboot.update` — the full `--update-loader` flow to a re-timed build, - `pureboot.update` — the full `--update-loader` flow to a re-timed build,
then every power-fail phase: the device is killed mid-write, restarted 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 from its flash dump, and a re-run must complete the update with the

View File

@@ -67,13 +67,11 @@ consteval std::int16_t wdrf_field()
// without a hardware boot section — the tinies and the m48s, whose SPM // without a hardware boot section — the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 §26). A boot section also means the CPU // runs from anywhere (Atmel-8271 §26). A boot section also means the CPU
// runs on while the RWW section programs; everywhere else it halts through // runs on while the RWW section programs; everywhere else it halts through
// the operation. The m48s still carry RWWSRE as their temporary-buffer // the operation.
// discard (§26.2), so the discard picks by that bit, not by the section.
constexpr std::uint16_t slot_bytes = spm::flash_bytes > 65536 ? 1024 : 512; constexpr std::uint16_t slot_bytes = spm::flash_bytes > 65536 ? 1024 : 512;
constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; constexpr std::uint32_t base = spm::flash_bytes - slot_bytes;
constexpr std::uint16_t page = spm::page_bytes; constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::curated::has_boot_section(); constexpr bool boot_section = avr::hw::curated::has_boot_section();
constexpr bool rww_discard = spm::detail::has_rww();
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so on the // Past 64 KiB a byte address no longer fits the wire's 16 bits, so on the
// large chips every flash address on the wire — and all slot arithmetic — // large chips every flash address on the wire — and all slot arithmetic —
@@ -94,11 +92,11 @@ constexpr std::uint16_t wire_page_mask = word_flash ? (page / 2 - 1) : (page - 1
#endif #endif
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; 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), word-aligned so its wire (word) // through flash_table (there is no crt to copy a .data image, and its storage
// address is exact on the large chips. The page byte is the wire count // carries the word alignment 'b' needs to halve the address on the large
// convention: 0 means 256. // chips). The page byte is the wire count convention: 0 means 256.
[[gnu::progmem]] alignas(2) inline constexpr std::array<std::uint8_t, 12> info_data = { inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{
'P', 'P',
'B', 'B',
1, // magic, protocol version 1, // magic, protocol version
@@ -113,7 +111,8 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
// bit 0: host must patch the reset vector (no hardware boot section); // bit 0: host must patch the reset vector (no hardware boot section);
// bit 1: flash wire addresses are word addresses // bit 1: flash wire addresses are word addresses
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)),
}; }>
info_data;
// The serial link. PUREBOOT_USART forces a hardware USART instance, // The serial link. PUREBOOT_USART forces a hardware USART instance,
// PUREBOOT_SOFT_SERIAL the polled software UART (no vector — the table // PUREBOOT_SOFT_SERIAL the polled software UART (no vector — the table
@@ -139,16 +138,6 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0'; constexpr char usart_digit = '0';
#endif #endif
// Whether the chip carries the selected USART: the suffixed instance name,
// or — for instance 0 — the classic megas' un-numbered block.
consteval bool usart_exists()
{
const char name[] = {'U', 'S', 'A', 'R', 'T', usart_digit};
if (avr::hw::db.has_instance(std::string_view{name, sizeof(name)}))
return true;
return usart_digit == '0' && avr::hw::db.has_instance("USART");
}
template <avr::hertz_t C> template <avr::hertz_t C>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>;
@@ -219,12 +208,13 @@ struct software_link {
}; };
#if defined(PUREBOOT_USART) #if defined(PUREBOOT_USART)
static_assert(usart_exists(), "PUREBOOT_USART selects a hardware USART this chip does not have"); static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
using link = hardware_link<dev::clock>; using link = hardware_link<dev::clock>;
#elif defined(PUREBOOT_SOFT_SERIAL) #elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock>; using link = software_link<dev::clock>;
#else #else
using link = std::conditional_t<usart_exists(), hardware_link<dev::clock>, software_link<dev::clock>>; using link =
std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock>, software_link<dev::clock>>;
#endif #endif
// The application's entry, an absolute address the linker pins (--defsym in // The application's entry, an absolute address the linker pins (--defsym in
@@ -274,36 +264,51 @@ std::uint8_t rx_deadline()
return link::rx(); return link::rx();
} }
std::uint16_t rx16() // Inlined into its call sites: reading two bytes across a call otherwise
// strands the first in a call-saved register the caller must push/pop; folded
// into the (noreturn) command loop that cost disappears.
[[gnu::always_inline]] inline std::uint16_t rx16()
{ {
std::uint16_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));
} }
// 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.
// send_flash stays out of line: its two callers ('b' and 'R') otherwise each //
// inline a private copy of the loop. On the large chips the address is a // Two functions, because they want opposite placement and placement is an
// word address and the read goes through ELPM (flash_load_far). // attribute: the byte-addressed loop is small enough to inline into both
[[gnu::noinline]] void send_flash(std::uint16_t address, std::uint8_t count) // callers, the word-addressed one stays out of line but flattened — a call to
// the transmit inside it would strand the 24-bit cursor in callee-saved
// registers. `word_flash` picks at the call site.
[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count)
{ {
if constexpr (word_flash) { do
// The 24-bit cursor as the machine holds it: the RAMPZ byte and a link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++)));
// 16-bit Z, carried explicitly (the reassembled 32-bit address while (--count);
// folds away inside the inlined far load). A single read never }
// crosses a 64 KiB boundary — the protocol forbids it and the host
// splits its chunks there — so RAMPZ holds for the whole run. // The 24-bit cursor as the machine holds it: the RAMPZ byte and a 16-bit Z,
std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15); // carried explicitly (the reassembled 32-bit address folds away inside the
std::uint16_t z = static_cast<std::uint16_t>(address << 1); // inlined far load).
do { [[maybe_unused, gnu::flatten, gnu::noinline]] void send_flash_far(std::uint16_t address, std::uint8_t count)
link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z)); {
if (++z == 0) std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15);
++rampz; // robustness for a host that reads across 64 KiB std::uint16_t z = static_cast<std::uint16_t>(address << 1);
} while (--count); do {
} else { link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z));
do // The protocol never reads across 64 KiB, but carrying the wrap is
link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++))); // smaller than the flat 32-bit cursor GCC builds without it.
while (--count); if (++z == 0)
} ++rampz;
} while (--count);
}
[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count)
{
if constexpr (word_flash)
send_flash_far(address, count);
else
send_flash_near(address, count);
} }
void send_eeprom(std::uint16_t address, std::uint8_t count) void send_eeprom(std::uint16_t address, std::uint8_t count)
@@ -331,19 +336,15 @@ void store_eeprom(std::uint16_t address, std::uint8_t count)
// itself. `slot_high` is the high byte of that running slot's base (run() // 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 // 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 // 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 // updates itself.
// immediately.
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high) void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
{ {
// A buffer word cannot be loaded twice without an erase (§26.2.1), so a // No discard before the fill: the buffer is write-once per word
// refused page's drained data must not linger for the next write: // (§26.2.1), so filling over one a refused page or an application left
// discard the buffer up front — CTPB on the tinies; on the megas // dirty programs stale words — but a page write auto-erases the buffer
// writing RWWSRE aborts a pending load (§26.2.2 — on the m48s that // (§26.2.1; §19.2 on the tinies), so that write clears the condition and
// flush is the bit's whole documented job). // the host's read-back rewrites the page.
if constexpr (rww_discard)
spm::rww_enable<off>();
else
spm::clear_buffer<off>();
// One induction either way. On the byte-addressed chips the wire address // One induction either way. On the byte-addressed chips the wire address
// itself walks the page (aligned, so the offset bits wrap to zero); on // itself walks the page (aligned, so the offset bits wrap to zero); on
// the word-addressed large chips the wire word address becomes a 32-bit // the word-addressed large chips the wire word address becomes a 32-bit
@@ -389,11 +390,14 @@ void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
if constexpr (boot_section) if constexpr (boot_section)
spm::wait(); spm::wait();
spm::write_page<off>(address); spm::write_page<off>(address);
if constexpr (boot_section) { if constexpr (boot_section)
spm::wait(); spm::wait();
spm::rww_enable<off>();
}
} }
// The megas program with their RWW section disabled; reads need it back
// on. The same store discards the buffer (§26.2.2), so they never meet
// the stale-word case above. boot_section implies an RWW section.
if constexpr (boot_section)
spm::rww_enable<off>();
} }
// The four fuse/lock bytes in the hardware's own Z order: low, lock, // The four fuse/lock bytes in the hardware's own Z order: low, lock,
@@ -449,7 +453,7 @@ void send_fuses()
// runtime address is the running slot's. Composed from the two // runtime address is the running slot's. Composed from the two
// bytes — the high half is runtime data, so no absolute address // bytes — the high half is runtime data, so no absolute address
// is ever materialized. // is ever materialized.
const auto link_low = reinterpret_cast<std::uint16_t>(info_data.data()); const auto link_low = reinterpret_cast<std::uint16_t>(info_data.storage.data());
const std::uint8_t low = const std::uint8_t low =
word_flash ? static_cast<std::uint8_t>(link_low >> 1) : static_cast<std::uint8_t>(link_low); word_flash ? static_cast<std::uint8_t>(link_low >> 1) : static_cast<std::uint8_t>(link_low);
send_flash(static_cast<std::uint16_t>(low | (slot_high << 8)), static_cast<std::uint8_t>(info_data.size())); send_flash(static_cast<std::uint16_t>(low | (slot_high << 8)), static_cast<std::uint8_t>(info_data.size()));

View File

@@ -37,6 +37,7 @@ else:
PROMPT = b"+" PROMPT = b"+"
PROTOCOL_VERSION = 1 PROTOCOL_VERSION = 1
SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
VERBOSE = False VERBOSE = False
@@ -767,9 +768,26 @@ def write_differing(loader, base, content, order=None, label=None):
bar.step() bar.step()
if label: if label:
verbose(f"{label}: {written} of {len(offsets)} pages differed") verbose(f"{label}: {written} of {len(offsets)} pages differed")
for at in range(0, len(content), 256): # Page-wise read-back with the same bounded repair as verify_pages: this
if loader.read_flash(base + at, min(256, len(content) - at)) != content[at : at + 256]: # is the loader-update path, where a page left wrong is a half-written
raise Error(f"verify failed at {base + at:#06x} after programming") # loader slot.
for retry in range(RETRIES + 1):
bad = [
offset
for offset in range(0, len(content), page)
if loader.read_flash(base + offset, len(content[offset : offset + page])) != content[offset : offset + page]
]
if not bad:
break
if retry == RETRIES:
raise Error(
f"verify failed at {base + bad[0]:#06x} after programming "
f"(still wrong after {RETRIES} retries)"
)
for offset in bad:
verbose(f"rewriting page {base + offset:#06x} (retry {retry + 1})")
loader.write_page(base + offset, content[offset : offset + page])
written += 1
return written return written
@@ -920,21 +938,37 @@ def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
bar.step() bar.step()
print(f"flash: {path}: {len(order)} pages") print(f"flash: {path}: {len(order)} pages")
if verify: if verify:
verify_pages(loader, pages) verify_pages(loader, pages, repair=True)
def verify_pages(loader, pages): def verify_pages(loader, pages, repair=False):
"""Read every page back and compare. With `repair`, a mismatched page is
rewritten and re-read, up to RETRIES times before it is raised: a page
filled over a dirty SPM buffer takes stale words, and the write that took
them cleared the buffer, so one rewrite settles it. Anything still wrong
after three is not that, and stops the run."""
repaired = 0
with Progress("verify", len(pages)) as bar: with Progress("verify", len(pages)) as bar:
for address in sorted(pages): for address in sorted(pages):
got = loader.read_flash(address, loader.info.page) for retry in range(RETRIES + 1):
if got != pages[address]: got = loader.read_flash(address, loader.info.page)
if got == pages[address]:
break
first = next(i for i in range(len(got)) if got[i] != pages[address][i]) first = next(i for i in range(len(got)) if got[i] != pages[address][i])
raise Error( detail = (
f"verify failed at {address + first:#06x}: " f"verify failed at {address + first:#06x}: "
f"wrote {pages[address][first]:02x}, read {got[first]:02x}" f"wrote {pages[address][first]:02x}, read {got[first]:02x}"
) )
if not repair:
raise Error(detail)
if retry == RETRIES:
raise Error(f"{detail} (still wrong after {RETRIES} retries)")
verbose(f"{detail} — rewriting page {address:#06x} (retry {retry + 1})")
loader.write_page(address, pages[address])
repaired += 1
bar.step() bar.step()
print(f"verify: {len(pages)} pages ok") note = f", {repaired} page rewrite(s)" if repaired else ""
print(f"verify: {len(pages)} pages ok{note}")
def op_verify_flash(loader, path): def op_verify_flash(loader, path):

View File

@@ -37,11 +37,12 @@ def main():
sys.exit(1) sys.exit(1)
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
info = [line for line in symbols.splitlines() if "info_data" in line] info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
if len(info) != 1: if len(info) != 1:
print(f"FAIL: expected one info-block storage symbol, found {len(info)}") print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
sys.exit(1) sys.exit(1)
offset = int(info[0].split()[0], 16) - text_start address = int(info[0].split()[0], 16)
offset = address - text_start
if not 0 <= offset < 256: if not 0 <= offset < 256:
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes") print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
sys.exit(1) sys.exit(1)

View File

@@ -69,9 +69,18 @@ struct link {
// 'L' hands back to the loader at the top slot — 512 bytes, or the // 'L' hands back to the loader at the top slot — 512 bytes, or the
// 1 KiB the >64 KiB chips use. // 1 KiB the >64 KiB chips use.
constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 512; constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 512;
for (;;) for (;;) {
if (tx_t::read_blocking() == 'L') auto command = tx_t::read_blocking();
if (command == 'L')
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))(); reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
// 'D' leaves every word of the SPM page buffer dirty, so that a
// following 'L' enters the loader with the buffer it never clears.
if (command == 'D') {
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2)
avr::spm::fill(at, 0xdead);
tx('D');
}
}
} }
}; };

90
test/pbdirty.py Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Dirty-page-buffer acceptance test: the loader carries no buffer discard,
so a page filled over words an earlier writer left behind programs those
instead. This asserts the whole contract — the corruption is real and a bare
verify sees it, the repairing verify fixes it in one rewrite (the write that
took the stale words auto-erased the buffer), and it stays fixed.
The state is reached the way the loader cannot prevent: an application
dirties the buffer and jumps in with no reset between. Real boot-sectioned
megas forbid that outright — SPM executes only from the boot section
(Atmel-8271 §26.2) — but simavr dispatches SPM from anywhere, which is what
makes the path constructible at all.
Usage: pbdirty.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <app_bin> <tool_py> <workdir>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
device_bin, elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir = sys.argv[1:]
page, baud = int(page), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "dump.bin")
# Reset boots the application on a BOOTRST-unprogrammed mega; its 'L' is
# the loader entry this test needs, reached without a reset.
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, reset_hex="0")
try:
port = pb.Port(device.pty, baud)
loader = pb.Loader(port)
loader.connect(25)
# Install the application and hand over to it.
pb.op_flash(loader, app_bin, erase=False, verify=True)
loader.run_application()
if port.read_exact(3, 5.0) != b"APP":
fail("the application did not start")
port.write(b"D")
if port.read_exact(1, 5.0) != b"D":
fail("the application did not acknowledge dirtying the page buffer")
port.write(b"L")
loader = pb.Loader(port)
loader.connect(25)
# Program by hand, so the corruption is observable before anything
# repairs it.
pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info)
for address in sorted(pages):
loader.write_page(address, pages[address])
try:
pb.verify_pages(loader, pages)
except pb.Error as error:
if "verify failed" not in str(error):
fail(f"the read-back failed, but not at verify: {error}")
else:
# Either the fixture no longer dirties the buffer, or the loader
# clears it again — in which case this test's premise is gone.
fail("programming over a dirty page buffer came back clean")
# What the programming path uses: one rewrite settles it, and it stays
# settled.
pb.verify_pages(loader, pages, repair=True)
pb.verify_pages(loader, pages)
# Ground truth beyond the loader's own read-back.
loader.run_application()
if port.read_exact(3, 5.0) != b"APP":
fail("the application did not start after the recovered write")
port.close()
finally:
device.stop()
print("pbdirty: a dirty page buffer is caught by verify and cleared by the retry")
if __name__ == "__main__":
main()

View File

@@ -66,8 +66,10 @@ def main():
if loader.read_eeprom(0, len(pattern)) != pattern: if loader.read_eeprom(0, len(pattern)) != pattern:
fail("EEPROM round-trip through the staged copy") fail("EEPROM round-trip through the staged copy")
# The guard, both ways: its own slot refused (drained, unchanged), # The guard, both ways: its own slot refused (drained, unchanged), the
# the resident slot writable. # resident slot writable. The refusal leaves its drained words in the
# SPM buffer, so the write that follows may take them — and clears
# them by writing, so the retry must not.
before = loader.read_flash(stage, page) before = loader.read_flash(stage, page)
loader.write_page(stage, bytes(page)) loader.write_page(stage, bytes(page))
if loader.read_flash(stage, page) != before: if loader.read_flash(stage, page) != before:
@@ -75,7 +77,9 @@ def main():
marker = bytes((i * 3) & 0xFF for i in range(page)) marker = bytes((i * 3) & 0xFF for i in range(page))
loader.write_page(base, marker) loader.write_page(base, marker)
if loader.read_flash(base, page) != marker: if loader.read_flash(base, page) != marker:
fail("the staged copy could not write the resident slot") loader.write_page(base, marker)
if loader.read_flash(base, page) != marker:
fail("the staged copy could not write the resident slot, even on retry")
# Restore the resident image through the staged copy, then 'J' back # Restore the resident image through the staged copy, then 'J' back
# into it and prove it lives. # into it and prove it lives.

View File

@@ -204,6 +204,52 @@ def main():
pb.check_walk_region({0x7800: bytes((0xFF,)) * 128}, mega, fuses(0xFA), False) pb.check_walk_region({0x7800: bytes((0xFF,)) * 128}, mega, fuses(0xFA), False)
pb.check_walk_region(deep, mega, None, False) # fuses unknown: no check pb.check_walk_region(deep, mega, None, False) # fuses unknown: no check
# The repairing verify: a mismatched page is rewritten rather than raised,
# bounded so a fault that is not self-clearing cannot spin.
class FakeLoader:
"""A device whose first `bad` writes of any page land wrong."""
def __init__(self, info, bad):
self.info = info
self.bad = bad
self.flash = {}
self.writes = 0
def write_page(self, address, data):
self.writes += 1
self.flash[address] = bytes(len(data)) if self.bad > 0 else bytes(data)
self.bad -= 1
def read_flash(self, address, count):
return self.flash.get(address, bytes(count))
want = {0: bytes((i * 5) & 0xFF for i in range(128))}
# One bad write, then good: repaired in place, and the caller never sees
# an error. The rewrite is counted, so a silent no-op cannot pass.
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=1)
device.write_page(0, want[0])
pb.verify_pages(device, want, repair=True)
if device.writes != 2:
fail(f"repairing verify made {device.writes} writes, expected 2")
# Without repair the same state raises, so the repair is what fixed it.
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=1)
device.write_page(0, want[0])
expect_error("verify without repair", lambda: pb.verify_pages(device, want), "verify failed")
# A page that never comes good stops after RETRIES rewrites, and says so.
device = FakeLoader(info_of(pb, 0x7E00, 128, False, 0x8000), bad=99)
device.write_page(0, want[0])
expect_error(
"unrepairable page",
lambda: pb.verify_pages(device, want, repair=True),
"verify failed",
f"after {pb.RETRIES} retries",
)
if device.writes != pb.RETRIES + 1:
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
print("test_planner: all planner and policy checks pass") print("test_planner: all planner and policy checks pass")