Files
bootloader/pureboot/README.md
BlackMark 65f8fdd537 pureboot: the 1284P rides a 1 KiB slot — its own boot-sector minimum
The far machinery (ELPM reads, RAMPZ page commands, wire-word math)
costs ~46 B over the m328P's 504, and the tsb-calibrated C++-to-asm
gap says no implementation of this feature set reaches 512 on this
chip — a boundary its hardware does not have anyway: the 1284P's
smallest boot sector is 1 KiB. The slot therefore becomes
per-geometry (512 B, or 1 KiB past 64 KiB), which the host derives
from the word-addressing flag; slot arithmetic unifies (the index is
the wire high byte with its low bit dropped in either unit), the
update preflight demands a two-slot boot section in the chip's own
terms, and pbapp's hand-back jumps to the real slot base. libavr's
far primitives split their RAMPZ/Z asm operands (a page never
crosses 64 KiB, so callers keep a byte and a 16-bit cursor — the
32-bit address folds away; flash_load_far's byte form becomes the
out-RAMPZ+elpm pair avr-libc's pgm_read_byte_far rebuilds per call),
and the host splits reads at 64 KiB boundaries. All ten chips pass
the full suite — the 1284P at 558 B including protocol, relocation,
and the power-fail self-update — with pureboot byte-identical across
generated and reflect modes everywhere, and the original three
chips' images unchanged to the byte (488/502/504).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:54:40 +02:00

227 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, **fitting each
chip's smallest boot sector**: 512 bytes everywhere — 488 B on the
ATtiny13A, 502 B on the ATtiny85, 466504 B across the megas — except the
ATmega1284P, whose smallest boot sector is 1 KiB and whose far-flash
machinery (ELPM reads, RAMPZ page commands, word-addressed wire) lands at
558 B in a 1 KiB slot: the 512-byte figure is a hardware boundary that chip
simply does not have, and no implementation of this feature set fits it
there. 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 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
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 slot is 512 bytes
(1 KiB on the word-addressed large chips, matching their boot-sector
minimum); 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 |
|---|---|---|---|
| every ATmega (8/8A, 16, 32/32A, 168A, 328P, 1284P) | the hardware USART (USART0), RXD/TXD per pinout | 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.
On chips whose flash exceeds 64 KiB (the 1284P — info-block flag bit 1) the
`R`/`W` flash addresses are **word** addresses; everywhere else they are byte
addresses. EEPROM addresses are always bytes, counts always bytes.
| 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 |
|---|---|
| 02 | `'P'`, `'B'`, protocol version (1) |
| 35 | device signature |
| 6 | SPM page size in bytes (0 means 256) |
| 78 | loader base — application flash ends here (a word address when bit 1 is set) |
| 910 | EEPROM size |
| 11 | bit 0: host must patch the reset vector (no hardware boot section); bit 1: flash wire addresses are word addresses |
Composites are the host's job: verify = read back and compare, erase =
write `0xff` (per page for flash, per byte for EEPROM).
## Deployment
**Megas**: program the loader at `flash 512` with an external programmer.
Every mega's smallest-but-one BOOTSZ puts the boot-section start exactly at
the loader base (512 B — the m8/16/168A reach it at their second-smallest
step, the m32/328P at their smallest), so the ATmega328P profiles below
apply to all of them with their own addresses; the per-chip BOOTSZ ladders
live in the host tool (`BOOT_FUSE`). The **ATmega1284P** is the exception:
its smallest boot section is 1 KB, so the standalone profile does not exist
— BOOTSZ = 512 words always, and with BOOTRST programmed reset lands at
0x1f800, one erased slot below the loader (the loader-first walk behavior
below, built in). Its staging slot sits inside that same 1 KB section, so
self-update needs no fuse change.
ATmega328P profiles (addresses for its 32 KiB):
| 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. The image is the loader's own 512
bytes as a raw binary, or the Intel HEX the build emits beside it, which
links the loader at its base inside an otherwise blank flash image:
1. The staging slot `[base512, 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. The port layer is the one
platform-specific part: termios drives any tty on POSIX (a USB adapter as
well as a simavr pty), the Win32 serial API through `ctypes` drives a COM
port on Windows (`--port COM6`; the `\\.\` form for two-digit ports is
supplied by the tool). Opening the port asserts DTR and RTS on both, so a
board that wires DTR to reset gets its reset pulse and opens the activation
window by itself.
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.
`size`, `pi`, and `planner` are host logic and run anywhere; the three
simulator-driven targets need simavr and a pty, so they are POSIX-only —
on Windows the tool is exercised against real hardware.