pureboot v9: seal every command, and stop guarding what the seal covers

'W' handed the loader a whole page with no ack inside it and sp_spm handed any
wire byte to SPMCSR, so a dropped byte re-aligned the stream and page data
arrived where commands belong. That is how a page-address byte became
BLBSET|SELFPRGEN on the tempmon board and programmed its lock bits.

The first answer was to refuse that one command. It was the wrong shape twice
over: it forbade a lock-bit write the owner may want, and it left every other
command decided by bytes nobody checked. v9 checks them instead. One header for
every command — opcode, selector, address, count, seal — folded and compared
before the command is decoded, and *answered* before any payload moves: '+'
accepts, 0xd4 (the ack inverted) refuses and nothing happened. An ack cannot do
this job; it reports a command that has already run.

It is smaller than v8 everywhere: 1284P 506→480, m8 498→480, 328P 484→468,
t13A 474→460. The seal costs 14 bytes; bit opcodes in place of the letters pay
for it twice over, since a letter costs a compare and a branch where a bit costs
a skip. Both guards go — the lock-bit refusal because the seal covers it, the
running-slot write guard because what it defended against was a wire fault
naming an address and a wire fault can no longer name one. That one is a real
trade: a host bug aimed at the running slot now lands. It buys a resident copy
that can write its own slot, which is the only self-update route on a chip whose
boot section *is* the slot.

Two things the tests caught, both introduced here. Removing the invalid-opcode
arm made every byte a command, so the knock stopped being harmless against a
loader already in session and ate the five bytes behind it — identify moves to
bit 5, which both 'p' and 'b' carry, so the knock is inert again and version
discovery still works before the version is known. And the SPM value rides the
count field because a data byte would arrive after the seal was checked.

pbselfwrite and pbglitch are the new gates, both red-green: the same erase of
the running page refused unsealed and performed sealed, and every header byte
damaged after sealing refused where the identical damage before sealing is
obeyed. Both judge by the simulator's flash, not the loader's opinion of it.
pbreloc and pbrehome lose their write-guard probes, which is what those two
gates replace. Defeating the seal in the loader turns seven tests red.

37 of 37 chips green with the exhaustive size matrix; README protocol section
and every size row rewritten. pbhw gains an adversarial --seal-rounds sweep for
the bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 18:38:41 +02:00
parent eb213e1025
commit 546b1589a3
10 changed files with 761 additions and 213 deletions

View File

@@ -8,10 +8,10 @@ erase, reset-vector surgery, updating the loader itself — lives in the host
tool (`pureboot.py`).
The image is **position-independent**: control flow is PC-relative, the
transfer paths take wire addresses, the write guard protects the slot the code
is *running* in (from the runtime return address), nothing else is
flash-resident to address at all, and the application jump is an indirect call
to an absolute entry. The identical binary therefore runs from any slot with
transfer paths take wire addresses, nothing is flash-resident to address at
all, and the application jump is an indirect call to an absolute entry. It does
not know which slot it occupies and does not need to. The identical binary
therefore runs from any 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. The lint holds it to that literally — the image must
@@ -33,26 +33,26 @@ loaders run 410 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 B |
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 372 B | 460 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 376 B | 452 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 376 B | 452 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 376 B | 452 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 350 B | 480 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 352 B | 484 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 352 B | 484 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 366 B | 454 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 376 B | 464 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 378 B | 468 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 378 B | 468 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 378 B | 468 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 378 B | 468 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 372 B | 462 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 390 B | 480 B |
† No hardware boot section: the host patches the reset vector, and the budget
is 510 bytes, since the slot's last word is the trampoline.
The tightest fit in the whole space is therefore the 1284s' 502 of their
The tightest fit in the whole space is therefore the 1284s' 480 of their
512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
commands) on top of everything the column already stacks. The flash bank
riding in a transfer's selector byte keeps even those chips' addressing the
@@ -223,15 +223,21 @@ no clock to turn into seconds.
## Session
After the knock the loader stays in its command loop until `J` jumps away or
the chip resets. Before reading each command it waits for any pending EEPROM
After the knock the loader stays in its command loop until a jump takes it away
or the chip resets. Before reading each command it waits for any pending EEPROM
write and sends the prompt `+` (0x2b), which is therefore also the previous
command's completion ack. A session is: await `+`, send a command, read its
reply, repeat.
verdict, then its reply, repeat.
There is no invalid opcode: every byte begins a command, and it is the seal
rather than a table of known letters that rejects noise. The knock is the one
thing that must survive being sent into a loader already in session, which is
why both its bytes — `p` and `b` — carry the identify bit: they answer the
identity and consume nothing else.
Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in
the command's selector byte, so no command has to speak word addresses. `J` is
the exception: its address is a word address, because that is what the
the command's selector byte, so no command has to speak word addresses. The jump
is the exception: its address is a word address, because that is what the
hardware's own jump takes — it still carries a selector byte (reserved,
ignored) so its decode is the same three reads as every other command's.
EEPROM and data-space addresses and all counts are bytes.
@@ -243,22 +249,38 @@ low EEPROM and the write silently overwrites it. Keeping transfers within the
real sizes is the host's job (the shipped tool does); the flash budget is
better spent on features than on re-checking a bound the host already holds.
| Cmd | Arguments | Reply |
|---|---|---|
| `b` | — | 4 bytes: the pureboot version, then the three signature bytes |
| `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) |
| `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `W` | sel8, addr16, then one page of data | — (completion = next prompt) |
| `J` | sel8 (reserved), word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
Every command but the identity has **one header shape** — six bytes, the last
of them a seal over the other five:
`G` and `g` are one letter in two cases, which is the whole command set for
every memory: the **selector** byte's low nibble names the space and its high
nibble carries the flash bank.
```text
op8 sel8 addr_lo8 addr_hi8 n8 seal8
seal = op ^ sel ^ addr_lo ^ addr_hi ^ n ^ 0x5A
```
The loader folds those fields and compares before it decodes what the command
is, then **answers the seal**: `+` accepts, `0xD4` refuses and nothing has
happened. The verdict comes before any payload, which is what keeps a refusal
local — a fill or a write burst sends its data only once the header is in, so
a rejected header never leaves the host pushing bytes into a loader that has
gone back to reading commands.
The opcode is bits, not a letter. A transfer is the absence of the other three,
and its direction is the low bit.
| Opcode | | Arguments after the header | Reply |
|---|---|---|---|
| 0x00 | read | none | verdict, then n bytes from the selected space (n = 0 means 256), then `+` |
| 0x01 | write | n data bytes | verdict, then `+` per byte once its write has begun, then `+` |
| 0x04 | fill | one page of data | verdict, then `+` when the page is in |
| 0x08 | jump | none | verdict, then execution continues at the word address |
| 0x20 | identify | *unsealed, one byte on its own* | 4 bytes: the version, then the three signature bytes, then `+` |
The **selector** byte's low nibble names the space and its high nibble carries
the flash bank.
| Space | | |
|---|---|---|
| 0 | flash | read-only here; it is written through `W` and the SPM space |
| 0 | flash | read-only here; it is written through the fill and the SPM command |
| 1 | EEPROM | |
| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR |
| 3 | fuse and lock | index 0..3 in the hardware's own Z order: low, lock, extended, high |
@@ -267,18 +289,26 @@ nibble carries the flash bank.
The data space is worth more than it looks. pureboot keeps **zero static RAM**
and pushes no register, so at loader entry an application's SRAM is still
whatever the application left there, bar the handful of bytes of return-address
stack — which makes `G` over space 2 a post-mortem of a running application,
stack — which makes a read over space 2 a post-mortem of a running application,
not just a poke hole. The same address space carries the register file and the
I/O registers, so peripheral state is readable too; reading some of those has
side effects (reading UDR clears its flags), which is the host's business to
know.
Programming a page is therefore `W` to fill the buffer, then a `g` to the SPM
space for the erase, another for the write, and on a boot-sectioned chip a
third to re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR
encodings every part pureboot targets shares. The loader carries no page-commit
logic of its own, and the same primitive reaches every other SPM operation,
lock bits included.
Programming a page is therefore a fill to load the buffer, then an SPM command
for the erase, another for the write, and on a boot-sectioned chip a third to
re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR encodings
every part pureboot targets shares. The loader carries no page-commit logic of
its own, and the same primitive reaches every other SPM operation, lock bits
included.
An SPM command has **no data phase**: its SPMCSR byte rides the header's count
field, where the seal covers it. That is the whole reason the field is
overloaded — a byte arriving behind the header would arrive after the seal had
been checked, and the one command that cannot be taken back is exactly the one
that must not be decided by an unchecked byte. Setting the lock bits is
therefore an ordinary sealed command (`0x09`) rather than something the loader
refuses: deliberate is expressible, accidental is not reachable.
The SPM store and the SPM instruction must issue within four cycles of each
other (§26.2), which no host can hit across a serial link — so this one
@@ -286,11 +316,21 @@ primitive is *fused* rather than being a poke of SPMCSR followed by a poke of
something else. That four-cycle window is the floor on how low-level a
bootloader's primitives can go; it is not a byte-count decision.
An SPM command aimed at the 512-byte slot the loader is **running in** is
dropped, so a broken host cannot brick the running copy, while a staged copy
one slot lower may rewrite the resident — which is what a self-update is.
Nothing refuses an address, the loader's own slot included. Through pureboot 8
a running-slot write was dropped; the seal replaced that guard, because what
the guard defended against was a wire fault naming an address, and a wire fault
can no longer name one. What it costs is that a host bug aimed at the running
slot now lands. What it buys is that a resident copy can write its own slot —
which is the only route a self-update has on a chip whose boot section *is* the
512-byte slot, where no staged copy can run SPM at all: the resident plants a
primitive in its own spare space and an application-side installer drives it.
A copy that erases the page it is executing from does not come back, so which
page matters; erasing any other page of its own slot it survives. That needs a
page the image does not reach into, which the stock builds have and the biggest
do not: a 378 B loader on a 128-byte-page mega leaves 384..511 entirely free,
while the 480 B autobaud build reaches into it and has none.
The loader never clears the SPM buffer before a fill, so **one `W` may program
The loader never clears the SPM buffer before a fill, so **one fill 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, and —
where SPM runs from anywhere, the tinies and the m48s — an application that
@@ -300,15 +340,15 @@ 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).
`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse
A write is host-paced: send the next byte only after the previous byte's `+`. Fuse
*writing* does not exist — SPM reaches flash and boot lock bits only.
`J` is the one control-transfer primitive: it runs the application (word 0 or
The jump is the one control-transfer primitive: it runs the application (word 0 or
the trampoline word, both derived from the chip) and moves between loader
copies during a self-update. A jump to a slot's base re-enters that copy's own
startup, which must then be knocked afresh.
`b` answers with the loader's identity — its version and the chip's signature —
Identify answers with the loader's identity — its version and the chip's signature —
and nothing else. Everything else the host needs (page size, loader base,
EEPROM size, whether the reset vector must be patched, how many flash banks)
follows from the signature, and the host holds that table; the loader derived
@@ -323,7 +363,7 @@ to install a mismatch against.
## Version
`b`'s first byte is the **pureboot version** — the loader's one identity
The identity's first byte is the **pureboot version** — the loader's one identity
number, and the only way to tell what a deployed loader is. Nothing else is
numbered: the wire protocol has no version, a pureboot version implies it, and
the host tool holds that map. The tool states the window of loader versions it
@@ -347,7 +387,14 @@ nothing on the wire either: it marks the builds whose deployment may be
one-wire (*One-wire* above) — the hardware USART's half-duplex turn-around,
or a software link folded onto a single pin. The host-side trace is
`--one-wire`, the echo discard a shared line requires of any tool driving
it.
it. **9** is the third wire change and the largest: bit opcodes in place of
the letters, one sealed header shape for every command, and a verdict on that
seal before the command runs (*Session* above). It also drops the
running-slot write guard, which the seal makes redundant and which was the
only thing standing between a resident copy and its own slot. Identify is
answered by both knock bytes so version discovery works before the version is
known, which is what keeps a deployed pureboot 8 drivable and self-updatable
to 9.
Every closed generation is tagged in this repo at its era's last commit — the
commit just before the next version bump, so a tag holds everything its
@@ -464,7 +511,7 @@ are interchangeable — as the silicon is.
the host composes the slot's last word as a jump to the resident base, so
even an abandoned staging copy times out into a loader. A loader already
sitting whole in the staging slot is left as the staging copy instead —
rewriting it would only meet its own running-slot guard.
rewriting it in place would be a copy overwriting itself as it runs.
3. `J` enters the staging copy, which rewrites the resident slot. Where a
patched reset vector routes through the resident, the host first re-aims
word 0 at the staging copy, so a power loss mid-rewrite still resets into a
@@ -502,7 +549,7 @@ update, flash (erase / program / read / verify), EEPROM (the same), then
`--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive
instead, and a later invocation reconnects into it. `--flash` and `--eeprom`
verify by read-back unless `--no-verify`, and a flash page that reads back
wrong is rewritten up to three times before the run stops (see `W` above).
wrong is rewritten up to three times before the run stops (see the fill above).
`--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.