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

@@ -2,19 +2,22 @@
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables
(attributes allowed), built for **every chip libavr targets — all 37 —
fitting each chip's smallest boot sector**: 512 bytes everywhere — 470 B on
the tiny13s, ~484 B on the tiny25/45/85, 458506 B across the megas and
every configured variant of them (the m8's software-serial build is the
fattest) — except the ATmega1284/1284P, whose smallest boot sector is 1 KiB
and whose far-flash machinery (ELPM reads, RAMPZ page commands,
word-addressed wire) lands at 556562 B in a 1 KiB slot: the 512-byte
figure is a hardware boundary those chips simply do not have, and no
implementation of this feature set fits it there. Clock, baud, serial
backend and pins are per-build configuration (below); the size matrix in
the test suite holds every combination inside its slot. The device speaks
primitives; every composite — verify, erase, reset-vector surgery, updating
the loader itself — lives in the host tool (`pureboot.py`).
(attributes and compiler flags allowed), built for **every chip libavr
targets — all 37 — in 512 bytes each**: 434 B on the tiny13s, 438442 B on
the tiny25/45/85, 412452 B across the megas and every configured variant of
them, and 506 B on the ATmega1284/1284P, whose far-flash machinery (ELPM
reads, RAMPZ page commands, word-addressed wire) is the heaviest. The 1284s
are the tight case: the loop-placement attributes on the byte streamers
(`pureboot.cpp`) and the codegen flags on the loader TU (`CMakeLists.txt`)
are what carry that build under the line. Clock, baud, serial backend and
pins are per-build configuration (below); the size matrix in the test suite
holds every combination inside its slot. The device speaks primitives; every
composite — verify, erase, reset-vector surgery, updating 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
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
512-byte slot the loader is *running* in are drained but never programmed — a
broken host cannot brick the running copy, and a staged copy may rewrite the
resident slot. `w` is host-paced: send the next byte only after the previous
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
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
@@ -283,8 +299,11 @@ update, flash (erase / program / read / verify), EEPROM (erase / program /
read / verify) — then the loader hands over to the application; `--stay`
keeps the session alive instead, and a later invocation reconnects into it
(the knock converges there too). `--flash` and `--eeprom` verify by
read-back unless `--no-verify`; images are raw binary, or Intel HEX by
extension. `--force` overrides the refusable safety checks (today: flashing
read-back unless `--no-verify`, and a flash page that reads back wrong is
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).
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;
- `pureboot.planner` — the host tool's pure logic: programming orders and
their recovery properties, the surgery, the staging composition, the
boot-fuse decode, and the update preflight's error/warning matrix over
synthetic fuse bytes;
boot-fuse decode, the update preflight's error/warning matrix over
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
(`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
@@ -335,6 +356,14 @@ regenerates the presets). Per chip preset, `ctest` runs:
- `pureboot.reloc` — the identical image installed one slot below the
resident serves the complete command set from there (the
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,
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