Files
bootloader/pureboot
BlackMark 3990f53180 pureboot: take the running slot from avr::startup::caller_page
The write guard's anchor was costing a materialised pointer and a byte swap to
use one byte of it. The libavr primitive answers it in a single load, which is
eight bytes off every build — and what lets the USART release fit the tightest
configuration in the space: the 1284 autobaud on USART-shared pins was 514 of
its 512 and is now 506, with the default pinning down from 510 to 502.

Verified on silicon: the guard still refuses an erase aimed at the slot it runs
from, and still permits one in the application region.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:47 +02:00
..

pureboot

A serial bootloader on libavr, pure by constraint: one C++ source, no inline assembly, no global register variables (attributes and compiler flags allowed), 512 bytes on every chip libavr targets — all 37. 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 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 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 come out byte-identical linked at a different base.

Chips

Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves per build — see Configuration. The autobaud column is the clock-free build, which is the largest the space produces and the tightest fit in the matrix; it carries the calibration machinery and no clock at all.

Chip Flash Loader at Link Stock Autobaud
ATtiny13, ATtiny13A † 1 KiB 0x0200 software 402 B 472 B
ATtiny25 † 2 KiB 0x0600 software 406 B 476 B
ATtiny45 † 4 KiB 0x0e00 software 410 B 480 B
ATtiny85 † 8 KiB 0x1e00 software 410 B 480 B
ATmega8, 8A 8 KiB 0x1e00 USART0 372 B 486 B
ATmega16, 16A 16 KiB 0x3e00 USART0 374 B 490 B
ATmega32, 32A 32 KiB 0x7e00 USART0 374 B 490 B
ATmega48, 48A, 48P, 48PA † 4 KiB 0x0e00 USART0 400 B 476 B
ATmega88, 88A, 88P, 88PA 8 KiB 0x1e00 USART0 410 B 486 B
ATmega168, 168A, 168P, 168PA 16 KiB 0x3e00 USART0 412 B 490 B
ATmega328, 328P 32 KiB 0x7e00 USART0 412 B 490 B
ATmega164A, 164P, 164PA 16 KiB 0x3e00 USART0 412 B 490 B
ATmega324A, 324P, 324PA 32 KiB 0x7e00 USART0 412 B 490 B
ATmega644, 644A, 644P, 644PA 64 KiB 0xfe00 USART0 406 B 484 B
ATmega1284, 1284P 128 KiB 0x1fe00 USART0 432 B 510 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 the 1284s' autobaud build, 510 of its 512 — they alone carry the far-flash machinery (ELPM reads, RAMPZ page commands) and autobaud alone carries the calibration loop. Everything else has 20 B of headroom or more. The flash bank riding in a transfer's selector byte keeps even those chips' addressing the same 16-bit form every other chip uses, which is why they are no longer the outlier they were.

The software UART enables the RX pull-up; TX idles high. All multi-byte wire quantities are little-endian.

Configuration

Every deployment axis is a build parameter of pureboot_add_loader() (in pureboot/CMakeLists.txt) — the one way a loader target is created, by this repo's 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|autobaud 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. Whatever is picked or overridden is re-checked in the compile: an infeasible combination, or a USART the chip does not have, fails with a named static assert.

SERIAL autobaud takes neither: the loader measures the host's bit timing at run time, so CLOCK and BAUD are not build parameters there and one binary per chip serves every clock and every rate. It is for the deployments whose clock is not known at build time and does not hold still — the internal RC oscillator, ±10 % from the factory and moving with supply and temperature — where a fixed-baud software build has to be rebuilt per clock and still drifts out of tolerance. The cost is that it is software-serial only (a hardware USART needs its divisor programmed) and that activation counts poll iterations rather than seconds, since there is no clock to convert them against (PUREBOOT_AUTOBAUD_POLLS, default 4,000,000).

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 — an ATmega328P on its shipped 1 MHz fuses with the software UART on hand-picked pins, say:

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 deployment runs the full protocol suite in CI (pureboot.custom).

Activation

Reset enters the loader (BOOTRST on the boot-sectioned megas, the patched reset vector elsewhere) — except a watchdog reset, which hands straight to the application with no activation window, since the application owns its watchdog. This is deliberate: it lets an application reboot itself instantly rather than sit through the window. The application must clear WDRF itself (libavr's watchdog::disable() does). Gotcha: WDRF is sticky (cleared only by software, not by a later reset), so an application that watchdog-resets and never clears it diverts every subsequent reset — external ones included — past the window too, and the loader becomes reachable only through an external programmer until the flag is cleared. A serial recovery path therefore assumes the application clears WDRF on its own reset path.

The host then knocks p then b, each awaited byte under a fresh activation window; any other byte is discarded and awaited again, so line noise can delay the loader but never lock it. A window expiring on an idle line boots the application.

An autobaud build opens differently, because it has to learn the rate before it can read a byte at all: the host sends the calibration byte 0xC0 — a start bit plus six zero data bits form one low pulse of seven bit-times — and the loader times that pulse into its bit period. A single p then activates; the pulse has already proven a host is present, which the two-byte knock exists to establish elsewhere. Both waits are bounded, so a stray low pulse with no host behind it costs one window and then boots the application rather than holding the loader.

The window is a compile-time constant (TIMEOUT, 8 s by default), so the whole EEPROM belongs to the application — pureboot keeps no state of its own. Re-timing a deployed loader is a self-update with a re-timed build. An autobaud build counts poll iterations instead (PUREBOOT_AUTOBAUD_POLLS), there being 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 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.

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: it takes a word address, because that is what the hardware's own jump takes. EEPROM and data-space addresses and all counts are bytes.

The loader trusts the host to keep addresses in range: it does not bound them against the chip. Gotcha: a write (or read) that runs past E2END wraps — EEAR is only as wide as the array, so an address past the end truncates onto 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 word address (16-bit) +, then execution continues there
other ignored; the loop re-prompts (send a junk byte, await +, to resync)

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.

Space
0 flash read-only here; it is written through W and the SPM space
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
4 SPM write-only: the byte goes to SPMCSR and fires the instruction at the address

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

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

The loader never clears the SPM buffer before a fill, so one W 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 self-programmed before entering. The next page write takes those stale words and clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on 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 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 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 — 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 the same facts from its own chip database at build time, so nothing is guessed, it is simply not sent twice.

An update image, though, is a bare 512-byte slot with no device to ask, and installing one built for another chip bricks the target. Every loader image therefore carries a six-byte stamp'P', 'B', the version, the three signature bytes — which the loader itself never reads and the host tool refuses to install a mismatch against.

Version

b'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 speaks (OLDEST_LOADER/NEWEST_LOADER in pureboot.py), and a version that changes the protocol becomes the new floor there. A loader newer than the tool is refused by name rather than decoded on the assumption that nothing moved.

Two generations exist. 1 through 4 speak one session — a 12-byte info block from b, and a command per memory (R/W flash, r/w EEPROM, F fuses). 5 replaced those with the single G/g pair over selector-named spaces above; the shipped tool speaks both, choosing on the version it reads, so a deployed pureboot 4 stays drivable and self-updatable to 5.

Collapsing four command bodies into one transfer loop is what paid for the version: the data space, the host-issued SPM operations and the fuses now share the loop, the cursor and the argument decode that R/r/w each carried a copy of. The loader shrank while gaining all three.

The tool carries its own version, free to drift; --version prints it and the window.

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.

Boot-sectioned megas: program the loader at flash 512 with an external programmer. Every such mega has a BOOTSZ step whose boot section is exactly the 512-byte slot — the second-smallest step on the 8 KiB and 16 KiB chips, the smallest on the 32 KiB ones — so the ATmega328P profiles below apply to every one of them with its own addresses; the per-chip BOOTSZ ladders live in the host tool (BOOT_FUSE).

The 644s and 1284s are the geometry's sweet spot: their smallest boot section (512 words = 1 KiB) is exactly two 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 one erased slot below the loader (0xfc00 / 0x1fc00) and walks up into it.

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 here — 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. Flashing an application then takes reset-vector surgery: word 0 becomes an 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 and an erase runs top-down, so from the first write on an interruption still resets into the loader.

A .bin programmed at address 0 by mistake is dead weight on a boot-sectioned mega (SPM only executes from the boot section — reflash the .hex), but runs on a patched-vector chip, and the ordinary --update-loader flow re-homes it into the top slot from there (pureboot.rehome).

Updating the loader

pureboot.py --update-loader new_pureboot.bin replaces the resident loader with any pureboot build — a re-timed window, a newer version — 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.

The preflight refuses an image built for another chip: the stamp every pureboot binary carries must resolve to the device's own geometry, and the error names both. Die revisions share their base signature and geometry, so their images are interchangeable — as the silicon is.

  1. The staging slot [base512, 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 update image there. On the patched-vector chips 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.
  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 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, and the state file is discarded.

Every phase is idempotent and keyed off the actual flash state, so re-running the same command after any interruption resumes and completes. The state file carries the only bytes not recoverable from the device; losing it mid-update still completes the update, and the staging region comes back by reflashing the application. A boot-sectioned mega needs its fuses for the preflight — 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 (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). --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.

--autobaud opens with the calibration pulse instead of the plain knock, for a loader built SERIAL autobaud; the rest of the session is identical, at whatever --baud the host chose.

--peek ADDR[:N] and --poke ADDR:HEX reach the data space (pureboot 5) — SRAM, and through the same address space the register file and every I/O register. Reading an I/O register can have side effects (reading UDR clears its flags), which is the caller's business to know.

Readouts come one fact per line: --info prints the device's version and signature and the geometry that follows from them, --fuses each fuse byte plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr when it is a tty. -v/--verbose adds the decisions as they happen: knock counts, the programming plan, update state handling and per-phase page counts.

Tests

tools/check.sh runs every chip's workflow (--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 (patched-vector) / 512-byte budget;
  • pureboot_*.size — the size matrix: the serial backends × the clock ladder (1/8/16 MHz; the t13s' own RC menu), the USART1 instance across that same ladder on the x4 chips, and pureboot_sw_wide, the slowest ladder rate at the fastest clock — where a software UART's per-bit spin outgrows its one-register delay loop and takes the 16-bit one. That is the largest image the configuration space produces, and a shape the ladder default (always the fastest rate a clock reaches) never picks. Pins are immediate operands and the timeout is a constant: neither is an axis;
  • pureboot_autobaud.size — the clock-free build, which has no clock or baud axis of its own: one binary per chip has to serve every point the matrix below sweeps;
  • pbm_*.size — with PUREBOOT_FULL_MATRIX=1, the exhaustive cross product replacing that compact matrix, on every chip: every plausible oscillator (the internal ones, the CKDIV8 floor, the plain and the UART crystals) × every rate reachable from it × every backend, unreachable combinations dropping out rather than aborting the configure. Thousands of points per chip, and cheap enough to run rather than reason about;
  • pureboot.pi — the position-independence lint: no absolute jmp/call, no flash-resident section but .text, and the image byte-identical when linked at a different base — which is position independence itself rather than a proxy for it;
  • pureboot.planner — the host tool's pure logic: programming orders and their recovery properties, the surgery, the staging composition, the boot-fuse decode, the update preflight over synthetic fuse bytes, and the repairing verify against a fake device;
  • 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, 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;
  • pureboot.reloc — the identical image one slot below the resident serves the complete command set from there;
  • pureboot.rehome (t85) — a loader programmed at address 0 or in the staging slot re-homes into the top slot through the ordinary update flow;
  • pureboot.custom (328P) — the configuration example's 1 MHz software-serial build driving the full protocol suite, proving the plumbing produces a working loader and not just one that fits;
  • pureboot.usart1 (644A) — the same 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.dirty (328P) — entering the loader from a running application over an SPM buffer it deliberately dirtied, the case the loader declines to guard: a bare verify must see the corruption and the repairing verify must fix it in one rewrite. Hardware forbids the state here, but simavr dispatches SPM from anywhere, which is what makes the path constructible;
  • pureboot.update — the full --update-loader flow, 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;
  • pureboot.autobaud (328P, 1284P) — the clock-free build over the GPIO⇄pty bridge: the calibration handshake, a flash + EEPROM + fuse round trip against the simulator's own memory, a data-space round trip, the hand-over — then the same binary again at double the clock, which is the property the backend exists for. A lone calibration pulse with no knock behind it must still let the application boot, so no wait in activation can be unbounded.

size, pi and planner are host logic and run anywhere; the simulator-driven targets need simavr and a pty, so they are POSIX-only.