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:
@@ -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 4–10 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.
|
||||
|
||||
Reference in New Issue
Block a user