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>
195 lines
10 KiB
Markdown
195 lines
10 KiB
Markdown
# pureboot
|
||
|
||
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, **512 bytes on
|
||
each** — 488 B on the ATtiny13A, 502 B on the ATtiny85, 504 B on the
|
||
ATmega328P. The device speaks primitives; every composite — verify, 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
|
||
read/write paths take wire addresses, the write guard protects the 512-byte
|
||
slot the code is *running* in (from the runtime return address), the info
|
||
block is addressed from 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 — 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. On the tinies the
|
||
budget is 510, not 512: a slot's last word belongs to the host-managed
|
||
trampoline (below).
|
||
|
||
## Link
|
||
|
||
| Chip | Serial | Baud | Clock assumed |
|
||
|---|---|---|---|
|
||
| ATmega328P | USART0, RXD/TXD = PD0/PD1 | 115200 8N1 | 16 MHz crystal |
|
||
| ATtiny85 | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 8 MHz internal RC |
|
||
| ATtiny13A | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 9.6 MHz internal RC |
|
||
|
||
The tiny RX pin has its pull-up enabled; TX idles high. All multi-byte
|
||
quantities on the wire are little-endian.
|
||
|
||
## Activation
|
||
|
||
Reset enters the loader (BOOTRST on the mega, the patched reset vector on the
|
||
tinies) — except a watchdog reset, which hands straight to the application
|
||
(the application owns its watchdog; it must clear WDRF itself, which also
|
||
releases the WDRF-forced WDE).
|
||
|
||
The host then has one activation window per awaited byte to knock: `p` then
|
||
`b`. Each awaited byte gets a fresh window; any other byte is discarded and
|
||
awaited again (line noise cannot lock the loader, only delay it). A window
|
||
expiring with an idle line boots the application.
|
||
|
||
The window length is a compile-time constant — 8 s by default, another value
|
||
via the `PUREBOOT_TIMEOUT` CMake cache variable — so the whole EEPROM belongs
|
||
to the application; pureboot never uses it for its own state. Re-timing a
|
||
deployed loader is a self-update with a re-timed build (below).
|
||
|
||
## 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
|
||
write to finish and sends the prompt `+` (0x2b) — the prompt is therefore
|
||
also the completion ack of the previous command. A session is: await `+`,
|
||
send a command, read its reply, repeat.
|
||
|
||
| Cmd | Arguments | Reply |
|
||
|---|---|---|
|
||
| `b` | — | the 12-byte info block |
|
||
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) |
|
||
| `W` | addr16, then one page of data | — (completion = next prompt) |
|
||
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
|
||
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
|
||
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
|
||
| `J` | word address (16-bit) | `+`, then execution continues there |
|
||
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
|
||
|
||
`W` streams exactly one SPM page (size from the info block) into the buffer,
|
||
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
|
||
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
|
||
bits) only — fuse bytes are external-programming territory by hardware.
|
||
|
||
`J` is the one control-transfer primitive: the host uses it to run the
|
||
application (word 0 on the mega, the trampoline word on the tinies — both
|
||
known from the info block) and to move between loader copies during a
|
||
self-update. A jump to a loader slot's base re-enters that copy's own
|
||
startup; it must then be knocked afresh.
|
||
|
||
The info block (`b`):
|
||
|
||
| Offset | Content |
|
||
|---|---|
|
||
| 0–2 | `'P'`, `'B'`, protocol version (1) |
|
||
| 3–5 | device signature |
|
||
| 6 | SPM page size in bytes |
|
||
| 7–8 | loader base — application flash ends here |
|
||
| 9–10 | EEPROM size |
|
||
| 11 | bit 0 set: host must patch the reset vector (no hardware boot section) |
|
||
|
||
Composites are the host's job: verify = read back and compare, erase =
|
||
write `0xff` (per page for flash, per byte for EEPROM).
|
||
|
||
## Deployment
|
||
|
||
**ATmega328P**: program the loader at 0x7e00 with an external programmer.
|
||
Two fuse profiles, same binary:
|
||
|
||
| BOOTSZ | BOOTRST | Behavior |
|
||
|---|---|---|
|
||
| 256 words (512 B) | programmed | *Standalone*: reset always enters the loader; **self-update impossible** (the staging slot lies outside the boot section, where SPM is disabled). |
|
||
| 512 words (1 KB) | unprogrammed | *Self-update, app-first*: reset always boots the application, which owns all 31.5 KB and must offer its own jump to 0x7e00 to reach the loader (a virgin chip reaches it by reset across erased flash). Updates are power-fail-safe except mid-rewrite of the resident slot itself (no reset path leads to the staging copy then). |
|
||
| 512 words (1 KB) | programmed | *Self-update, loader-first*: reset lands at 0x7c00 — the staging slot, normally erased, so execution walks up into the loader; during an update it is the staging copy itself, so a mid-rewrite power loss recovers by reset. The loss windows move to the staging install/retire page writes instead (page-write scale). The host keeps `[0x7c00, 0x7e00)` clear of application data (`--force` overrides). |
|
||
|
||
Applications are flashed unmodified — word 0 stays the application's own
|
||
reset vector, and the hand-over jumps to 0.
|
||
|
||
**Tinies** (no boot section): program the loader at `flash − 512`; erased
|
||
flash below it walks up into the loader, so a virgin chip activates. When
|
||
flashing an application the host performs reset-vector surgery: word 0 is
|
||
rewritten to `rjmp` to the loader base, and the application's own entry is
|
||
re-encoded as a trampoline `rjmp` in the word just below the loader
|
||
(`base − 2`, where the hand-over jumps). Every other vector stays the
|
||
application's. The patched page 0 and the trampoline page are written
|
||
*first*, so from the first write on an interrupted flash still resets into
|
||
the loader; an erase runs top-down for the same reason.
|
||
|
||
## Updating the loader
|
||
|
||
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
|
||
with any pureboot build — a re-timed window, a newer protocol — using the
|
||
loader itself as its own staging loader:
|
||
|
||
1. The staging slot `[base−512, base)` is saved to a host-side state file
|
||
(on the 1 KB tiny13A that is the whole application, vectors included).
|
||
2. The resident installs the identical update image there. On the tinies the
|
||
host composes the slot's last word — the same address as the resident's
|
||
trampoline — as a jump to the resident base, so even an abandoned staging
|
||
copy times out into a loader, never into garbage.
|
||
3. `J` enters the staging copy, which rewrites the resident slot. On the
|
||
t85 the host first re-aims word 0 at the staging copy, so a power loss
|
||
mid-rewrite still resets into a loader; on the t13a the staging slot
|
||
carries the reset vector itself.
|
||
4. `J` enters the new resident, which restores the staging slot's saved
|
||
content (word 0 and the trampoline with it) and the state file is
|
||
discarded.
|
||
|
||
Every phase is idempotent and keyed off the actual flash state: re-running
|
||
the same command after any interruption resumes and completes. The state
|
||
file carries the only bytes not recoverable from the device; if it is lost
|
||
mid-update the update still completes, and the staging region is restored by
|
||
reflashing the application. The mega needs its fuses for the preflight
|
||
(BOOTSZ gate, profile notes) — read from the device, or supplied with
|
||
`--assume-fuses` where reading is impossible (simulators).
|
||
|
||
## Host tool
|
||
|
||
`pureboot.py` — Python 3, standard library only (termios drives any tty,
|
||
a USB adapter as well as a simavr pty):
|
||
|
||
pureboot.py --port /dev/ttyUSB0 --baud 57600 \
|
||
--info --fuses --flash app.hex
|
||
|
||
Operations run in a fixed order within one session: info, fuses, loader
|
||
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
|
||
application data into a mega's reset walk region).
|
||
|
||
## Tests
|
||
|
||
Per chip preset, `ctest` runs:
|
||
|
||
- `pureboot.size` — the 510-byte (tinies) / 512-byte (mega) budget;
|
||
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`
|
||
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;
|
||
- `pureboot.protocol` — end to end against a simavr device
|
||
(`test/pureboot_device.c` — the mega's USART as a pty; on the tinies a
|
||
cycle-timed GPIO⇄pty bridge for the software UART, plus the SPM/NVM module
|
||
simavr's tiny cores lack) driven by the real host tool through
|
||
knock-from-reset, program + verify of both memories, session reconnect, an
|
||
external reset through the patched vector, and the hand-over to a fixture
|
||
application whose banner proves the launch — cross-checked against the
|
||
simulator's ground-truth memory dumps and an independent decode of the
|
||
surgery's rjmp words;
|
||
- `pureboot.reloc` — the identical image installed one slot below the
|
||
resident serves the complete command set from there (the
|
||
position-independence acceptance test);
|
||
- `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
|
||
application intact throughout.
|