'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>
45 KiB
pureboot
A serial bootloader on libavr, pure by
constraint: one C++ source, no inline assembly, no global register variables
(attributes and compiler flags allowed), a 512-byte slot 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, 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 come out byte-identical linked at a different base.
Chips
The Stock column is 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
worst configuration the space produces for the chip: the clock-free build —
it alone carries the calibration machinery — with the OSCCAL trim baked
and, where the chip has a USART, the link deployed on that USART's own pins,
which the loader then has to release (Pin ownership). Folding the same
build onto a single pin (One-wire) measures identically on every chip, so
the column covers that twin too. On default pins without the trim the same
loaders run 4–10 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---|
| 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' 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 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 |
OSCCAL <byte> |
a measured oscillator trim, applied before anything runs | none — no value, no code |
HALF_DUPLEX |
one-wire: both directions on one line (One-wire below) | off |
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.
Putting a bit-banged link on a USART's own pins is a supported deployment, and
the usual one where a board's USB bridge is wired to RXD/TXD: the link's init
clears that USART's UCSRnB first, because while its TXEN is set the USART —
not the port register — owns the TX pin, and a loader entered from an
application that left it enabled would receive and obey while answering nothing
(§20.6.3). It costs one store — four bytes on the extended-I/O chips, two on
the classic megas — and only on those pins.
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). The wait spends nine cycles a
poll (measured, and held by the pureboot.window.autobaud gate), so the
default window is 36 M cycles: 4.5 s at 8 MHz, 3.75 s at 9.6 MHz, 36 s at
1 MHz.
Pick the rate by cycles a bit, and leave the oscillator room. What the calibration can measure is bounded by how many clock cycles one bit lasts, so a rate is only ever sensible relative to the clock. Two different floors matter:
| cycles a bit | |
|---|---|
| the logic's floor — exact clock, simulated | solid to ~36, fails outright by ~31 (pureboot.autobaud gates a point here) |
| a factory-trimmed internal RC, measured on an ATtiny13A | reliable at ~118; already locking 1 attempt in 5 by ~59 |
The gap is the oscillator's own jitter, and no exact-clock simulation shows it. So on an RC part, budget about 100 cycles a bit — the same order as the fixed-baud software receiver's floor — rather than the logic's ~36. Measured envelope on that ATtiny13A, with an application resident: 9.6 and 4.8 MHz reach 115200, 1.2 MHz reaches 9600, 600 kHz reaches 4800, 128 kHz reaches 2400.
One trap in testing this: on a patched-vector chip an erased application region walks straight back up into the loader, so every expired window opens another one and the host's retries eventually catch the pulse. That reads as far more reliable than the same part with an application resident, which gets one window per reset. Measure with an application in place.
One-wire
HALF_DUPLEX puts both directions on one line — the deployment for a board
with a single spare pin, or a native-UART bootloader's shared-line wiring.
Each backend has its shape:
- Software and autobaud links fold onto the RX pin (
RX == TXspells the same deployment directly). The pin idles as the receiver's pull-up input; each transmitted frame takes the pin's direction and hands it back with the stop bit's level already on the pull-up, so neither flip makes an edge. This costs nothing: the frame's direction wrap is exactly what the dropped second-pin init paid, and the tightest image in the space — the 1284s' autobaud +OSCCALon their USART's RXD — measures the same 502 bytes one-wire as two-wire. On a USART's own pin the release applies as ever, RXD included:RXENforces that pin's direction (§20.7.3), which a receive-only link could live with and a driven shared pin cannot. - The hardware USART (
SERIAL hardware/auto+HALF_DUPLEX) uses libavr's.half_duplexturn-around — exactly one direction enabled at a time, each written byte held to transmit-complete before the line can be released — and needs RXD and TXD tied together off-chip. It costs +42…50 B over the stock loader (m8 404, m328P 440, 1284P 460 — all far inside the slot); the activation window is unchanged, its poll merely runs through the release-line test (18 cycles a poll in bit-addressable I/O, 22 in extended — measured, and held per chip bypureboot.window.halfduplex).
Host wiring, for an FTDI-style adapter: adapter TX through ~1 kΩ to the
line, adapter RX and the MCU pin directly on it. The resistor lets the MCU
win the line while it answers; the price is that the adapter reads back every
byte it transmits. pureboot.py --one-wire consumes that echo byte for byte
— a missing echo is reported as the wiring fault it is, and a device reply
that lands between the echoes of the knock (a loader already in session
re-prompts mid-knock) is held for the reader. The knock is the protocol's
one blind multi-byte write, so on real wiring its second byte can be lost to
that collision outright; the tool's knock retries absorb it. Everything else
is ack-paced and cannot collide.
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. A submodule pins the loader version
(the tags name them; this repo pins its own libavr the same way), where
FetchContent tracks whatever main is:
# git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent
add_subdirectory(bootloader/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 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
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. 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.
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.
Every command but the identity has one header shape — six bytes, the last of them a seal over the other five:
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 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 |
| 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 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 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 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.
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 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 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).
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.
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.
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 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
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
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. 6 changes
nothing on the wire: it marks the builds that may carry a baked OSCCAL trim
(Configuration), so a tool driving an update knows such images exist. 7
moves J onto the unified decode — it gains the selector byte the table
shows, which older loaders do not read, so the tool sends each form to the
version that speaks it — and re-homes the autobaud unit into the GPIOR pair
on the chips that have one (Session: what must not be written), which is
where --info's measured clock now reads it on those parts. 8 changes
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. 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
version ever gained — and each tag carries the libavr/ submodule pinned to
the libavr that loader was built against, as the whole libavr era does commit
by commit. git checkout v3 && git submodule update --init libavr followed by
the usual preset build therefore reproduces the v3 loader exactly; the open
generation is main.
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).
Fixed-baud on an internal RC oscillator is a deployment risk the build
cannot see. The factory trim is ±10 % where an 8N1 frame survives about
±4: a part at the edge answers nothing at the built rate, and the symptom —
silence — reads as a wiring fault (a real ATtiny13A measured −5.5 %, outside
every standard rate at its own documented default). The autobaud build is
the deployment-proof backend: it has no rate to miss. Where fixed-baud on
RC is wanted anyway, measure first and bake the trim: an autobaud session's
--info prints the part's true clock from the loader's own measured bit
period, OSCCAL moves the oscillator about 1 % per step, and OSCCAL <byte>
builds the correction in — one build–measure iteration converges. A loader
already deployed and silent is diagnosed with --scan (Host tool).
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.
One thing the image cannot tell the host: which link it speaks. The update works by entering copies of the new image (steps 3 and 4 below), so a build made for another baud or another backend answers on that one and not on the session's — and 512 bytes of position-independent code carry no header to read it from. Where the new image's link differs, name it:
# a 57600 fixed-baud resident, replaced by an autobaud build
pureboot.py --port … --baud 57600 --update-loader ab.bin --staged-autobaud
# …or by a 38400 build of the same backend
pureboot.py --port … --baud 57600 --update-loader sw38400.bin --staged-baud 38400
The host retunes on the open port, so no DTR pulse resets the copy it is talking to. Omit them against a changed link and the update stops after installing the staging copy, saying so and naming this as the cause.
An OSCCAL-baked image is a link change in effect even at an unchanged rate
on paper: the staging copy shifts the physical clock the moment its run()
starts, and from then on speaks exactly what it was built for. Declare it
like any other link change — --staged-baud with the new build's rate.
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.
- The staging slot
[base−512, base)is saved to a host-side state file (on the 1 KB tiny13s that is the whole application, vectors included). - 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 in place would be a copy overwriting itself as it runs.
Jenters 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.Jenters 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 — with one
qualification, which is the link again: from step 2 on, the copy the re-run has
to reach is the new image, so a resumed run needs the same --staged-* as the
first one. On a patched-vector part step 3 also re-aims word 0 at the staging
copy, so after that point a reset reaches the new image's link and only that
one; a re-run on the resident's link finds nothing at all. 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 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.
--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. Its --info adds the measured clock —
the loader's bit-period unit, decoded and multiplied by the session rate —
which is the number an OSCCAL bake or a fixed-baud build for the part is
held against; --clock <hz> states the drift against a nominal.
--one-wire marks the link as a shared line (One-wire above): the tool
reads back and verifies its own echoed bytes, whatever the backend.
It combines with everything, --scan included — undiscarded echoes would
answer every rate a scan probes.
--scan is the diagnosis once a fixed-baud loader has gone silent: it walks
±10 % around --baud in 2 % steps, nearest first, one probe per activation
window — reset the target as each probe announces itself (a board with DTR
wired to reset is pulsed by the probe's own port-open). A loader
off-frequency answers at its oscillator's ratio, and the report gives the
found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it.
--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.
Reads are safe anywhere; two small regions cannot be written without ending the session, because they are what the loader is standing on:
- the top of SRAM, where its stack lives — a handful of bytes below RAMEND;
- on an autobaud build, the measured bit period: two bytes in
GPIOR2:GPIOR1 where the chip has the pair (data
0x32..0x33on the t25/45/85,0x4A..0x4Bfrom the x8 generation on — such a loader has no static RAM at all), and the two bytes at RAMSTART on the chips without one (the t13s and classic megas), where they are the whole of the loader's static RAM. Overwrite either home and the next reply is timed against garbage — the symptom is a mangled prompt byte rather than any error; the loader is fine, it simply is no longer speaking the agreed rate.
Both are self-inflicted rather than defects, and a reset clears them. Note also
that --poke can write OSCCAL, which does take effect — but a session can only
survive a step or two of it before the clock walks the link out of the rate
autobaud locked to, and OSCCAL reverts on reset regardless.
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
libavr rides as the libavr/ submodule (git submodule update --init libavr);
LIBAVR_ROOT (cache or environment) overrides it for tandem development
against a working tree. 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, andpureboot_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 an axis for one reason only, and it is enough: a bit-banged link on a USART's own pins has to release that USART, sopureboot_{sw,autobaud}_on_usart{0,1}build there too. The timeout is a constant and is no 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.pureboot*osccal*.sizeadd theOSCCALtrim on the stock shape and on the tightest image in the space (autobaud on a USART's own pins), holding both of the trim write's addressing encodings to the budget;pureboot_autobaud.unit— the measured bit period sits where--inforeads it (wire contract, not layout accident): in the GPIOR pair, with no RAM object at all, on the chips that have one; as the loader's only RAM object at exactly ram_start elsewhere;pbm_*.size— withPUREBOOT_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 absolutejmp/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.handshake— the host tool's activation must not hang on a target that never falls quiet: the drain after a prompt is bounded by the handshake deadline, and a well-behaved loader still connects;pureboot.updatelink— an update whose image changes the baud or the backend must follow the staging copy onto its link, since that copy is the new image; and where nothing was declared, the failure must name the link rather than report a bare activation timeout, because by then the staging slot is written and on a 1 KiB tiny that was the application;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.scan—--scan's walk and report logic: the probe order, the rate arithmetic, and the trim advice's direction. A pty carries bytes at any termios rate, so the rate physics itself belongs to the hardware harness, and what the wire would arbitrate is pinned as logic;presets.generated— CMakePresets.json matches its generator (tools/make_presets.py --check), so a hand edit or a generator change cannot drift the pair apart;pureboot.protocol— end to end against a simavr device (test/pureboot_device.cpp: 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.mute(328P) — a software link on USART0's own pins, entered from an application that handed over with that USART still enabled: the loader must still answer, which it does only because it releases it. The pin ownership is the runner's, not simavr's — simavr wires a USART through IRQs and never takes the pin from the port, so without that model the state under test could not arise at all;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-loaderflow, 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.osccal(328P, t85) — a loader built with theOSCCALaxis holds the trim register at the built byte from its first prompt, observed through the wire on one chip per addressing encoding (stsand low-I/Oout);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. The measured clock--infoprints is asserted against the simulator's exact clock, inside the unit encoding's own envelope, at both points. A lone calibration pulse with no knock behind it must still let the application boot, so no wait in activation can be unbounded.
size, unit, pi, planner, scan and handshake are host logic and run
anywhere; the simulator-driven targets need simavr and a pty, so they are
POSIX-only.
Hardware
The suite above proves the protocol on every chip; it cannot prove a board.
Two things live only on silicon: an RC oscillator that is not on its nominal, and
a reset edge that has to come from somewhere. tools/pbrig.py and
tools/pbhw.py cover that, and know nothing per-board — every deployment fact
is a flag or a PUREBOOT_* environment variable.
export PUREBOOT_PROGRAMMER=atmelice_isp PUREBOOT_PART=t13 PUREBOOT_PORT=COM6
tools/pbrig.py backup rig-backup/ # verified, before anything is written
tools/pbhw.py --autobaud --loader build/ab.bin --app build/pbapp.hex --marker APP
pbrig.py is the primitives — signature, reset, flash, fuses, backup,
rate — and the module pbhw.py builds on. Two rig facts are encoded in it
because neither is guessable: an ISP access is the reset edge (the part runs
the moment the programmer releases it, which is the only edge available when the
adapter's DTR is not wired to reset, so a session begins with an ISP touch and
knocks immediately after), and avrdude splits -U on colons, so a Windows
path's drive letter breaks the spec and every file is passed as a bare name with
avrdude run in its own directory.
pbrig.py rate is the one that turns "the loader is silent, so the wiring must
be wrong" into a number. Against a fixture built with PUREBOOT_HEARTBEAT — a
fixed cycles-per-bit transmitter — it sweeps the host rate, and the band where
the marker still decodes brackets the part's true bit rate; with the clock the
image was built for, that is the clock the part is really running at. No
instrument beyond the adapter already attached. An ATtiny13A measured this way
came out at 9.072 MHz against its 9.6 MHz nominal, −5.5 % — inside the
datasheet's ±10 % and outside what an 8N1 frame survives, which is the whole
case for the autobaud backend on such a part.
pbhw.py takes its bounds from the info block the loader reports, so one run
covers a 1 KiB tiny and a 128 KiB mega alike: identity, the EEPROM round trip
and erase, an application flashed and verified and then seen running, the
application region read and erased, the loader slot proven intact across that
erase by an independent ISP read, and an oversized image refused. It overwrites
the application flash and EEPROM, which is why backup comes first.