Files
bootloader/pureboot/README.md
BlackMark a977e507f3 pureboot: every deployment axis is a build parameter
Clock, baud, serial backend (hardware USART 0/1 or the software UART on
any pins) and the activation window all resolve through one CMake
function, pureboot_add_loader() in pureboot/CMakeLists.txt — the unit a
downstream project consumes. The default baud is the fastest standard
rate within 2.5 % (the same best-divisor search libavr's solver runs),
gated on software builds by the polled receiver's 100-cycles-a-bit
floor; every explicit pick is re-checked by the compile's static asserts.

The size matrix builds each axis that can move the image — backend x
clock ladder x USART instance, per chip — against the slot budget, and
two nondefault deployments run the whole protocol suite live: the 328P
on its shipped 1 MHz fuses over software serial on TX=PB1/RX=PB5
(pureboot.custom), and the 644A over USART1 (pureboot.usart1). The sim
runner takes -l to bridge any link, paces a fully quiet bridge toward
real time (a free-running 8 M-cycle window loses the reset-race knock),
and the fixture application speaks the deployment it is built for.

The loader itself shed bytes on the way: the return-address high byte
spelled through byteswap (the double swap folds to the one-byte pick),
the info-block address composed instead of bit_cast, and libavr's new
polled-UART helpers replacing the port's uart::detail reaches. Every
combination fits: 458-506 B across the megas' whole matrix, 470-484 B
on the tinies, 556-562 B in the 1284s' 1 KiB slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:42:57 +02:00

19 KiB
Raw Blame History

pureboot

A serial bootloader on 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).

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).

Configuration

Every deployment axis is a build parameter, resolved by the CMake function pureboot_add_loader() (in pureboot/CMakeLists.txt) — the one way a loader target is created, by this repo's own build and by a downstream project alike:

Argument Meaning Default
CLOCK <hz> the clock the board runs 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s
BAUD <bd> the wire rate the ladder below
SERIAL auto|hardware|software the link backend auto: the hardware USART where the chip has one
USART <n> the USART instance (x4 megas carry two) 0
RX <pin>, TX <pin> software-UART pins pb0, pb1
TIMEOUT <s> the activation window 8

The default baud is the fastest of 115200/57600/38400/19200/9600 the clock reaches within 2.5 % — the same U2X-included divisor search libavr's baud solver runs — and on a software build additionally within the polled receiver's 100-cycles-a-bit floor. 16 MHz lands 115200, 8 MHz 57600, 1 MHz 9600. Whatever is picked or overridden is re-checked in the compile: an infeasible clock/baud/backend combination, or a USART the chip does not have, fails with a named static assert.

A downstream project brings its usual libavr setup (the libavr target, the chip via the LIBAVR_MCU toolchain preset), consumes this directory, and states its deployment — for example an ATmega328P on its shipped 1 MHz fuses with the software UART on hand-picked pins:

FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main)
FetchContent_MakeAvailable(bootloader)
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)

pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)

The function emits the ELF plus myboot.hex (the programmer artifact) and myboot.bin (the self-update image), prints the size, and stamps the resolved deployment on the target as the PUREBOOT_HZ, PUREBOOT_BAUD and PUREBOOT_LINK properties — what a flashing script or test harness needs to speak to the build. This exact example deployment runs the full protocol suite in CI (pureboot.custom).

The stock builds assume the family's natural deployment; any axis moves per build (above).

Chip Serial Baud Clock assumed
every ATmega the hardware USART (USART0), RXD/TXD per pinout 115200 8N1 16 MHz crystal
ATtiny25/45/85 software UART, RX = PB0, TX = PB1 57600 8N1 8 MHz internal RC
ATtiny13/13A software UART, RX = PB0, TX = PB1 57600 8N1 9.6 MHz internal RC

The software-UART 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 boot-sectioned megas; the patched reset vector on the tinies and the boot-section-less m48s) — 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 pureboot_add_loader(... TIMEOUT <s>) (the stock target keeps the PUREBOOT_TIMEOUT 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 1284s — info-block flag bit 1) the R/W flash addresses are word addresses; everywhere else they are byte addresses (the 644s' 64 KiB is exactly the 16-bit byte space and stays byte-addressed). 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

The build leaves three artifacts per chip. The ELF is a container for the tests and objcopy — never flashed. The .hex is the programmer artifact: it carries its own addresses and lands the loader in its top slot, touching nothing else. The .bin is the self-update image — the slot's bare bytes with no addressing, which a programmer would put at address 0. On a boot-sectioned mega a copy at 0 is dead weight (SPM only executes from the boot section, so it cannot even heal itself — reflash the .hex); on the patched-vector chips it runs (the image is position-independent and reset enters word 0), reports its canonical geometry, and the ordinary --update-loader flow re-homes a build into the top slot from any position — the staging install and the word-0 redirect execute from copies outside page 0's slot, and a copy sitting in the staging slot itself is recognized as the installed staging copy and left in place (it streams the new resident like any staged copy, so an older build installs a newer one). pureboot.rehome is the acceptance test for both positions. Flashing the application afterwards overwrites the stale copy, vector surgery included.

Boot-sectioned megas: program the loader at flash slot with an external programmer. Every such mega has a BOOTSZ step whose boot section is exactly the loader slot — 512 B, the second-smallest step on the 8 KiB and 16 KiB chips (m8, m88, m16, m168, m164), the smallest on the 32 KiB ones (m32, m328, m324); on the 1284s that step is the smallest, 512 words, which is why their slot is 1 KiB — so the ATmega328P profiles below apply to every one of them with its own addresses and slot size; the per-chip BOOTSZ ladders live in the host tool (BOOT_FUSE). The 1284s' numbers: standalone = BOOTSZ 512 words (reset at the loader base 0x1fc00); self-update = 1024 words, covering both 1 KiB slots, the loader-first reset landing at 0x1f800 — the staging slot, walked across when erased.

The 644s are the geometry's sweet spot: their smallest boot section (512 words = 1 KiB) is exactly two 512-byte slots, so the resident and its staging slot both live inside the minimum section — self-update needs no fuse step up, and the standalone profile does not exist (reset lands at 0xfc00, one erased slot below the loader: the loader-first walk built in).

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.

Patched-vector chips — the tinies and the m48s (no boot section; the m48s' SPM runs from the entire flash, Atmel-8271 §26): 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. The m48s speak this profile over their hardware USART — no fuse preflight, BOOTRST does not exist there.

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:

The preflight refuses an image built for another chip: the info block embedded in every pureboot binary (signature, page size, loader base, EEPROM size, flags) must match the device's own, and the error names both. Die revisions share their base signature and geometry, so their images are interchangeable — as the silicon is. loader_image() also accepts a padded image (a raw .bin padded from 0, or a whole-flash read-back with the loader resident) and peels it to the slot content by the embedded base.

  1. The staging slot [baseslot, base) is saved to a host-side state file (on the 1 KB tiny13s that is the whole application, vectors included).
  2. The resident installs the identical update image there. On the patched-vector chips 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. A loader already sitting whole in the staging slot (its info block in place, the slot unchanged since the update began) is left as the staging copy instead — rewriting it would only meet its own running-slot guard.
  3. J enters the staging copy, which rewrites the resident slot. On the patched-vector chips whose staging slot sits away from page 0 the host first re-aims word 0 at the staging copy, so a power loss mid-rewrite still resets into a loader; on the tiny13s 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. A boot-sectioned 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); the patched-vector chips need none.

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).

Readouts come one fact per line: --info prints the decoded info block field by field, --fuses each fuse byte on its own line — plus, on a boot-sectioned mega, the decoded meaning (where the BOOTSZ section starts, what BOOTRST does to reset). Transfers that take wire time — programming, reading, erasing, verifying, the update phases — draw a transient progress bar on stderr when it is a tty; logs and pipes see only the summary lines. -v/--verbose adds the decisions as they happen: knock counts, the programming plan (vector-surgery targets, skipped blank pages), update state handling and per-phase page counts.

Tests

tools/check.sh runs every chip's workflow (tools/check.sh --full adds the reflect-mode builds of libavr's spot set; tools/make_presets.py regenerates the presets). Per chip preset, ctest runs:

  • pureboot.size — the 510-byte (tinies) / 512-byte (mega) budget;
  • pureboot_*.size — the size matrix: the serial backends × the clock ladder (1/8/16 MHz; the t13s' own RC menu), plus the USART1 build on the x4 chips — every configuration axis that could move the image, each variant against the same slot budget (pins are immediate operands and the timeout is a constant: size-neutral);
  • pureboot.custom (328P) — the configured-deployment acceptance test: the 1 MHz software-serial TX=PB1/RX=PB5 build from the configuration example drives the full protocol suite through the runner's GPIO bridge, fixture application included;
  • pureboot.usart1 (644A) — the same protocol suite over the second hardware USART: instance selection is compile-checked everywhere, but only a live session proves the loader polls the USART it claims;
  • 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 — a hardware USART as a pty, or a cycle-timed GPIO⇄pty bridge for a software-UART build, selected with -l to match the loader's link; 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 simulator-driven targets need simavr and a pty, so they are POSIX-only — on Windows the tool is exercised against real hardware.