38 Commits

Author SHA1 Message Date
ab842c8d99 pureboot: a version number for the loader, not for the protocol
The info block's third byte was a protocol version that never moved in the
loader's lifetime. It is the pureboot version now, and this change is
version 2: the one number that says what a deployed loader is. Every loader
already in the field answers 1.

The protocol keeps no number of its own — a pureboot version implies it, and
the host tool is what holds that map. pureboot.py states the loader-version
window it speaks (OLDEST_LOADER/NEWEST_LOADER; a version that changes the
protocol becomes the new floor there), so a loader newer than the tool is
refused by name rather than decoded on the assumption nothing moved, while an
older one is read, identified and installed like any other. The tool carries
its own version, free to drift from the loader's: --version prints it and the
window, --info leads with the device's, --update-loader names the version it
installs.

Tests: the planner unit pins the window — every version in it decodes, one
above it is refused, an older loader's image is still found — and the live
suite pins the built loader against the tool beside it, so a bump that reaches
only one of them fails. The image is byte-identical to the previous build but
for that byte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:20:34 +02:00
a58b59c60f docs: correct the size figures for the configured variants
The headline numbers described the stock deployments but claimed "every
configured variant of them", which the matrix contradicts: choosing the
software UART where the chip has a USART costs 8-46 B, so the megas reach
460-462 rather than 452, and the 1284s' software-serial build is 546 B —
inside their 1 KiB boot sector, but not inside 512.

Also names the actual tightest chip. The 1284 looks like it at 506, but it
deploys in 1 KiB with 478 B spare; against its own budget the ATmega328P
has the least room, 50 B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:08:24 +02:00
ceacb61ba1 pureboot: no SPM buffer discard, the host repairs instead
The temporary page buffer is write-once per word, so a page filled over
one an earlier writer left dirty programs the stale words. The same
datasheet clause carries the cure: the buffer auto-erases after a page
write (§26.2.1; §19.2 on the tinies), so the corruption clears itself by
happening, and rewriting the page programs correctly.

The loader therefore clears the buffer nowhere. The tinies' CTPB and the
m48s' RWWSRE discard are gone; the boot-sectioned megas keep only the
trailing RWWSRE they need anyway to re-enable the RWW section for
read-back, which discards the buffer as a side effect and keeps them off
the path entirely. 434 B on the tiny13s, 438-442 on the tiny25/45/85,
430 on the m48s; the megas are unchanged, the 1284s still 506.

The host takes over the guarantee: a flash page that reads back wrong is
rewritten up to RETRIES times before the run stops. Both read-back paths
repair — verify_pages for programming, and write_differing, which is the
loader-update path where a page left wrong is a half-written loader slot.
That one is not hypothetical: deleting the discard made attiny85
pureboot.rehome fail deterministically there, the only flow still
assuming the old contract.

Protocol-visible, so README's W command says it: one W may program the
wrong bytes after a refused page, or after an application that
self-programmed entered without a reset, and a host that programs without
reading back cannot trust it.

Tests: pureboot.dirty drives the case the loader declines to guard — the
fixture application dirties every buffer word and jumps in with no reset
(hardware forbids that on a boot-sectioned mega, but simavr dispatches SPM
from anywhere, which is what makes it constructible) — and asserts a bare
verify sees the corruption, the repairing verify fixes it in one rewrite,
and it stays fixed. pbreloc asserts the same shape after a refusal.
test_planner covers the bound against a fake device: one bad write
repaired in a single rewrite, a page that never comes good stopping after
exactly three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:29:27 +02:00
dd7490a3df build: generated presets and a check entry point
tools/make_presets.py emits the uniform pipeline the hand-grown file had
drifted from — generated configure/build/test presets and workflows for
all 37 chips, reflect configure/build for libavr's 12-chip spot set —
and tools/check.sh runs every chip's workflow (--full adds the reflect
spot) as the port's gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:42:57 +02:00
bc24b63e65 host: one fact per line, progress bars, --verbose
--info prints the decoded info block field by field and --fuses each
byte on its own line plus the BOOTSZ/BOOTRST meaning on boot-sectioned
megas. Transfers that take wire time draw a transient progress bar on
stderr when it is a tty — logs, pipes and the tests see only the
summary lines. -v/--verbose narrates decisions: knock counts, the
programming plan, update state handling and per-phase page counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:42:57 +02:00
332b244ac3 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
61b687120c pureboot: a loader in the staging slot is the staging copy — leave it there
The update flow's first step wrote the staging content over whatever the
staging slot held; with a loader running right there (programmed by hand
onto erased flash), that write met the copy's own running-slot guard on
the composed through-word and the tool stopped at its verify — although
the copy is exactly an installed staging copy, able to stream the new
resident like any other. The install is now skipped when the slot holds a
complete loader: its info block where every image carries it, matching
the device's byte for byte, and the slot unchanged since the update began
(the state file's snapshot) — so a resumed half-written install still
differs from its snapshot and takes the install path, which completes it.
pbrehome gains the staging-slot position (an older build at stage
streaming a newer resident in); the README's wrong "cannot re-home from
the staging slot" claim is corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:55:15 +02:00
ef58d5d363 pureboot: artifact roles, wrong-chip refusal, and misplaced-loader re-homing
The README's deployment section now says what each build artifact is for:
the .hex is the programmer artifact (self-addressed into the top slot),
the .bin the self-update image — bare slot bytes a programmer would put
at address 0, where a boot-sectioned mega cannot even heal itself (SPM
only runs from the boot section) but a patched-vector chip runs the
position-independent copy and re-homes a build through the ordinary
--update-loader flow: the staging install and the word-0 redirect both
execute outside page 0's slot, so the running-slot guard never blocks it.
pbrehome.py is the acceptance test (misplaced at 0, guard intact,
re-home, app flash over the stale copy, banner); the staging slot is the
one position that cannot re-home itself, documented. The preflight's
wrong-chip refusal and loader_image's handling of padded images (peeled
to the slot content by the embedded base) are documented and the padded
case pinned in the planner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:18:10 +02:00
03bb1f56cf pureboot: every libavr chip — 37 loaders, the m48 class, the 644 geometry
The chip table becomes family blocks covering all 37 targets. The m48s
are a new deployment class: no boot section, so the tiny profile spoken
over the hardware USART — host-patched reset vector, trampoline
hand-over, a 510-byte budget (474 B built), no fuse preflight — while
their RWWSRE store stays the buffer discard (Atmel-8271 §26.2); the
device keys the patch flag and the CPU-halt waits on the curated
boot-section capability and the discard on the RWWSRE bit itself. The
644s' 64 KiB is exactly the 16-bit byte space: plain LPM, byte wire
addresses, 498 B in a 512-byte slot — and their 1 KiB minimum boot
section holds the resident and staging slots together, so self-update
needs no fuse step (the update test's slot pick now keys word-flash on
base >= 64 KiB; base + slot merely touching the boundary stays
byte-addressed). The 1284 joins the 1284P's word-addressed 1 KiB slot at
558 B. BOOT_FUSE gains every boot-sectioned family's ladder and fuse
byte; the planner exercises them all. The sim scaffolding keys
patch-vector-ness instead of the atmega name prefix, the fixture app
picks its clock by family (the tiny25/45/13 builds surfaced the 16 MHz
fallthrough as garbled banners), and the runner's wrapped flash ioctl
performs the m48 discard simavr's no-RWW cores turn into a stray buffer
fill. Sizes across the fleet: 466-504 B megas, 474 B m48s, 498 B 644s,
488-502 B tinies, 558 B 1284s — every chip passing
size/pi/planner/protocol/reloc/update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 18:53:04 +02:00
6a5e313530 pureboot: correct the 1284P deployment profiles in the README
The deployment section claimed the 1284P has no standalone profile, runs
BOOTSZ = 512 words always, and self-updates with no fuse change, reset
landing at 0x1f800 — internally contradictory (a 512-word section starts
at 0x1fc00, and the section holding both 1 KiB slots is 1024 words) and
contradicted by update_preflight, which refuses a self-update unless the
boot section covers two slots. The text described a 512-byte-slot
geometry this chip's loader cannot have. In truth the 328P profile table
maps onto the 1284P doubled: standalone = 512 words (the smallest
section is exactly the 1 KiB slot, reset at the loader base), self-update
= 1024 words with the loader-first reset walking the staging slot.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:54:40 +02:00
92503fbb2d pureboot: the classic megas and the word-addressed 1284P groundwork
Device: boot-section detection probes SPMCR beside SPMCSR, the link
picks any hardware USART through the instance-aware lookups (URSEL
chips included), WDRF reads MCUSR-or-MCUCSR, and the >64 KiB shape
lands — word-addressed wire flash (info flag bit 1, page byte 0 means
256, base as a word address), far reads through flash_load_far, a
single 32-bit byte-cursor page walk (the 256-byte page wraps its low
byte exactly), and slot arithmetic in words (the return address
already is one). Host: addresses stay bytes internally and scale at
the wire, the boot-fuse decode becomes a per-signature table (byte
index + BOOTSZ ladder — the m168A's lives in EXTENDED), and the
planner tests pin every chip's ladder plus the word-addressed info
decode. Tests: the device runner serves every mega over the USART pty,
pbapp banners over the right link, the update rehearsal synthesizes
its assumed fuses from the tool's own table, and the PI lint tracks
the renamed info symbol. All six classic-mega/168A targets pass the
full suite (size, PI, planner, protocol, reloc, self-update) at
466–504 B; the 1284P builds await a libavr far-path slimming to make
its 512.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 12:00:19 +02:00
8ba023ed6b pureboot: take the device signature from the chip database
libavr now exposes avr::hw::db.signature (compile-time, from the ATDF), so the
info block drops its per-chip hardcoded signature() for the db constant. The
loaders are byte-identical across modes with the correct signature, sizes
unchanged (488/502/504).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:38:20 +02:00
443fd39d31 build: emit a raw .bin beside the HEX for every loader image
The host tool takes either form — load_image() parses Intel HEX by extension
and treats anything else as raw bytes — but the build emitted only the HEX, so
the raw path had no artifact behind it. The reloc and update tests each shell
out to objcopy at runtime to produce one for themselves.

add_hex_output becomes add_image_outputs and emits both forms. The .bin is
byte-identical to the plain `objcopy -O binary` those tests generate (-R .eeprom
strips nothing the loaders carry), and decodes equal to the HEX payload — 504 B
at 0x7e00 either way for pureboot. Sizes come out at the flash sizes exactly
(504/510/836/526), so nothing stretches to the .data load address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:17:59 +02:00
08a0d77109 pureboot: take the emitted loader HEX for --update-loader
The build emits an Intel HEX beside every loader image, but --update-loader
could not consume one: load_image() anchors every image at address zero, and
a loader HEX links at its base, so it decoded to a 32760-byte blob carrying
504 bytes of loader at the end. staging_content() then refused it as "loader
image is 32760 B, the slot holds 512" - an error naming neither the cause nor
the raw .bin the tool wanted instead.

Drop the blank below the base in the update path. The base comes from the
image's own info block rather than the device's, so an image built for
another target survives the slice intact and the preflight still reports it
as another target rather than failing to find an info block at all.

Verified on an ATmega328P: the full self-update flow driven straight from
pureboot_timeout-5s.hex, resident slot byte-for-byte against the image
afterwards, application preserved; both refusal paths unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:50 +02:00
260ab2e4e3 pureboot: drive the serial port on Windows too
The host tool was standard-library-only but POSIX-only with it: termios and
select() bound the port layer, and importing termios failed outright on
Windows, so the module could not even load there.

Split Port into PosixPort (unchanged) and a WindowsPort over the Win32 serial
API through ctypes, picked by os.name; every call site keeps the Port name.
kernel32 only, so the standard-library constraint holds.

Windows has no select() for a COM handle, so the read deadlines move into the
driver as COMMTIMEOUTS, re-armed per read: read_available() ends on a gap
longer than a USB-serial latency timer coalesces (16 ms on FTDI parts),
read_exact() on the count or its deadline. Opening asserts DTR and RTS as a
POSIX open does, so a board wiring DTR to reset still pulses it. A failed
configuration closes the handle before raising - a COM handle is exclusive,
and the leak met the next open as "Access is denied". Win32 takes any integer
baud and a driver may accept one its hardware cannot produce (an FT232R
reports back a baud of 3 and keeps the old divisor), so obvious nonsense is
refused where termios' table would have.

Tested against an ATmega328P on COM6: info, fuses, both memories programmed
and verified, session reconnect, hand-over, the loader self-update, and the
write guard on its own slot. test_planner runs on Windows now as well.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:14 +02:00
e4aaf5bc62 build: emit an Intel-HEX beside every loader image
avrdude programs Intel-HEX, not ELF, and the build produced only ELFs — so
flashing a loader to a real chip meant running objcopy by hand. add_hex_output()
hangs a POST_BUILD objcopy on each loader image: the three tsb tiers through
add_tsb_variant, pureboot, and the re-timed pureboot9. .eeprom is dropped, being
its own avrdude update.

It uses the toolchain file's CMAKE_OBJCOPY rather than a hardcoded path, so
every chip preset emits hex, not just the mega. pbapp keeps its ELF alone: the
update test converts it to a raw binary itself, and it is not a flashing target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:57:26 +02:00
653602058a pureboot: PI lint, tightened gates, and the self-update test suite
check_pi.py asserts the two link-time facts position independence rests on
(no absolute jmp/call; the info block within the image's first 256 bytes);
the size gates drop to 510 on the tinies for the trampoline word.

New per-chip tests beside the reworked protocol test: the planner units
(programming orders and their recovery properties, the surgery, staging
composition, boot-fuse decode, and the update preflight's error/warning
matrix over synthetic fuse bytes), the relocated-copy sweep (the identical
image installed one slot lower serves the full command set — the PI
acceptance test, and the one that caught the temporary-buffer trap), and
the self-update end-to-end: --update-loader to a re-timed build
(pureboot9, byte-different by PUREBOOT_TIMEOUT alone), then every
power-fail phase killed mid-write, restarted from the runner's flash dump,
and completed by a re-run with the application intact throughout. The mega
rounds run the BOOTRST-unprogrammed profile: the fixture application's 'L'
jump is the application-owned loader entry that profile relies on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:57 +02:00
57d1c009cc pureboot: one position-independent binary — its own staging loader
The image now runs from any 512-byte slot with every command intact:
control flow stays PC-relative, the write guard keys on the running slot
(the return-address anchor, computed once), the info block is addressed
from that same anchor as a byte pair (no absolute 16-bit address in the
image), and the application jump is an indirect call through a noipa-
laundered pointer to the absolute entry. 'J' — jump to a wire word
address, the one transfer primitive — replaces 'G': the host knows the
application entry from the info block, and moving between loader copies
needs arbitrary targets. The activation window is a compile-time 8 s
(PUREBOOT_TIMEOUT overrides), counted as a single calibrated poll loop.

A refused page no longer poisons the write-once temporary buffer (a real
silicon trap: the next write would program the drained data): every page
write discards the buffer first — CTPB on the tinies, on the mega the same
RWWSRE store that re-enables RWW after programming. The tinies' post-op
busy-waits go with it: their CPU halts through page erase and write.

488 / 502 / 504 B on t13a / t85 / mega — under the tinies' 510-byte budget,
whose last slot word is the host-managed trampoline: the resident's holds
the application entry, a staging copy's the jump through which an abandoned
update still times out into a loader.

The host tool updates the loader with itself: --update-loader installs the
identical image one slot below the resident, jumps into it, lets it rewrite
the resident, and restores the staging region from a state file — each
phase idempotent off the flash state, resumable after any interruption
(t13a: the staging slot carries the reset vector, written last in and
first out; t85: word 0 redirected around the resident rewrite; mega:
fuse-matrix preflight with a hard BOOTSZ gate and --assume-fuses for
simulators). Application flashing recovers by reset from any interruption:
patched page 0 and trampoline first, erase descending, and a walk-region
refusal behind --force on BOOTRST-below-loader megas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:43 +02:00
06bb06d994 pureboot: harden the sim device runner
Cancel the GPIO bridge's cycle timers with the state they drive: avr_reset
drops the TX latch, whose falling edge starts a spurious decode before
bridge_reset runs, and the stale sampler then interleaves with the loader's
first real answer through the shared shift state — the first post-reset
replies came back corrupted and the knock retries burned the activation
window into the application.

Wrap the mega's registered flash ioctl to re-dispatch page erases with Z
masked to the page boundary: simavr's PGERS handler erases spm_pagesize
bytes from Z & ~1 (its PGWRT path masks correctly), wiping the neighbouring
page when Z sits past the page start, which hardware permits (§26.8.1).
Model the write-once temporary buffer in the tiny NVM module — silicon
refuses a second load per word until the buffer clears, and a last-write-
wins model masks real firmware bugs.

Optional arguments select the reset vector (the mega's fuse profiles) and a
raw flash image to resume from (power-fail tests re-enter a dumped state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:37:10 +02:00
9a9a7ef0e8 pureboot: review-pass fixes to the host tool and device runner
pureboot.py: reject an empty image file with a clear error instead of
an IndexError deep in the vector-surgery planner; tighten the erase
docstring (order is irrelevant there — every target byte is the same
value, unlike a real flash where page 0 must go last).

pureboot_device.c: the GPIO bridge's bit_cycles used plain truncating
division where the firmware computes its own bit period with
round-to-nearest (uart.hpp: (Clock.hz + Baud.bd/2)/Baud.bd) — one
cycle off per bit on both tinies, harmless in practice but needless
drift against a firmware built to a different constant. Matched
exactly. Also clear the queued-bytes/decode-in-progress bridge state
on the test-only reset signal, so a future reset-mid-transfer scenario
can't feed a freshly reset chip bytes queued for its previous life.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:19 +02:00
4acf358dda pureboot: gitignore python bytecode cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:43:56 +02:00
11c10f986d pureboot: stop tracking the python bytecode cache
A stray __pycache__/*.pyc from a local test run got swept into the
previous commit's git add. Untracked and gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:43:36 +02:00
f587f0a26e pureboot: host tool and end-to-end protocol tests, all three chips
pureboot.py (Python stdlib only): images as raw binary or Intel HEX,
flash and EEPROM programming with read-back verify, erase composites,
fuse and info readout, activation-timeout configuration, and the
tinies' reset-vector surgery — the trampoline word below the loader,
page 0 written last.

The test spawns a simavr device (pureboot_device.c) — the mega's USART
as a pty; on the tinies a cycle-timed GPIO<->pty bridge for the polled
software UART plus the NVM module simavr's tiny cores lack (their SPM
opcode ioctls into a void and silently does nothing) — and drives it
with the real tool: knock from reset (erased-flash walk on the tinies),
program and verify both memories, timeout write, session reconnect, an
external reset through the patched vector, hand-over, and the fixture
application's banner. Results are cross-checked against ground-truth
memory dumps and an independent decode of the surgery's rjmp words,
red-verified against a sabotaged encoder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:54:15 +02:00
27c768a102 pureboot: the device — one pure C++ source, 512 bytes, every chip
No inline assembly, no global register variables; libavr does the
datasheet work. The device speaks primitives — flash read/page-program,
EEPROM read/write, fuse read, info block, EEPROM-resident activation
timeout, hand-over — and verify, erase, reset-vector surgery, and
timeout configuration live in the host tool. 490 B on the ATtiny13A,
510 B on the ATtiny85, 484 B on the ATmega328P, each linked into the
top 512 bytes of flash; per-chip size tests gate all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:33:28 +02:00
d989b5b8cd tsb: third size pass — restructure to the oracle's shape
The second pass concluded the 168 B tricks->asm gap was per-call ABI
cost. Most of it was structure. Rebuilt around the oracle's own shape —
argless noinline primitives over a whole-loader call-saved register
protocol (g_addr in Y, count r16, window r7, direction latch r6), a
top-down erase_below whose loop tests against zero and hands callers
g_addr = 0 for free, bounded rx everywhere (a silent host unwinds to
the app from any state, as the oracle does), and a named tsb_app entry
that --pmem-wrap-around=32k relaxes to the wrapped rjmp:

  tsb_asm    510 B in the 512 B section (oracle: 500), C++ except rx
             and the page-store loop — the two routines whose remaining
             cost is the calling convention itself (~30 asm lines, was
             ~280)
  tsb_tricks 526 B, no assembly at all (was 666)
  tsb_pure   836 B, still one readable function per command (was 842)

Every g_* update placement works around a GCC 16.1 wrong-code bug
(stores into global register variables deleted when only callees read
them — repro and rules in libavr dev/lessons.md). Also fixes two
latent hardware bugs all earlier tiers carried, masked by simavr's
zeroed register file: the crt-less entries never established
__zero_reg__ = 0, and the direction latch was read before written —
power-on registers are undefined.

All tiers full oracle feature parity, protocol tests green in both
libavr modes, .text byte-identical across modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:00:27 +02:00
314e422b19 tsb: beat the first-pass size floors (tricks 666, pure 842)
tricks 778->666: always_inline every single-call handler into the
[[noreturn]] reset entry (which pays no prologue, so their push/pop of
call-saved registers vanishes), walk the page pointer in Y (adiw, base
recovered as g_addr-page) instead of recomputing Z=base+offset, bring
the UART up in the two registers that are not already at their reset
value, and seed the activation counter as __uint24.

pure 896->842: TU-local internal linkage (proper hygiene, and it lets
the compiler inline the one-call handlers), a byte-wide activation
count, __uint24 timeout. Still one readable function per command.

asm unchanged at 498: its C++-expressible parts are already C++; the
core stays asm (the 666 B all-tricks tier is 168 B over — per-call ABI
tax, not a feature). All three cross-mode byte-identical, protocol green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:15:49 +02:00
3e0717ef06 tsb: drive each tier to its size floor
asm 502->498 B (below the oracle's 500): the stack bring-up moves to plain C++,
and a register is reserved for the config-page high byte instead of reloading it
at each app-flash-boundary compare. tricks 808->778 B: shared erase/rww helpers
plus the libavr half-duplex W1C fix. pure 950->896 B and no SRAM: streams
rx->SPM/EEPROM instead of staging a 128 B page buffer. All three keep full oracle
feature parity and stay byte-identical across modes; protocol tests (round-trip +
password + emergency erase) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:47:38 +02:00
812be910c1 tsb: document the three tiers at full parity in the build file
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:50:18 +02:00
34529c8031 tsb: protocol test covers the password gate and emergency erase
Each scenario group now runs on its own freshly-reset device: the round-trip
on a blank config page, plus a password-config device that must be sent the
password after the knock to activate, and an emergency-erase device where a
0-byte + two confirms wipes flash, EEPROM and the config page (verified by
reading all three back as 0xff). All three tiers pass every group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:49:20 +02:00
06bd56af50 tsb: pure and tricks tiers reach full oracle feature parity
Both tiers gain the features the asm tier already carries — one-wire
half-duplex (via libavr's new .half_duplex), the config-page activation
timeout, and emergency erase (password \0 + double-confirm wipes flash,
EEPROM and the config page) — on top of the watchdog bail, password gate and
config/flash/EEPROM read-write they already had. pure stays idiomatic
(flash_table info block, one function per command) at 950 B; tricks keeps its
compiler trickery (call-saved global-register page walk, unified runtime-flag
paths pinned noinline/noclone, streaming stores, arithmetic command decode)
at 808 B. Both byte-identical across generated and reflect modes; the size
gradient across the three tiers is now 502 / 808 / 950 B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:47:21 +02:00
75bb84fa3e tsb: asm tier reaches full oracle feature parity at 502 B
Rewrite the inline-asm tier so it matches the hand-written fixed-baud oracle's
feature set inside the 512 B boot section: watchdog-reset bail, one-wire
half-duplex (RXEN/TXEN toggled per direction, TX turnaround guard),
config-page activation timeout, the password gate (wrong byte hangs draining
the UART), emergency erase (password \0 + double-confirm wipes flash, EEPROM
and the config page), and config/flash/EEPROM read-write. Every geometry,
baud and info-block constant comes from libavr consteval; only the dense
control flow is hand-written. 502 B, byte-identical across generated and
reflect modes.

Test harness: seed the config page from TSB_CONFIG so the password and
emergency-erase paths are exercisable, and clear simavr's AVR_UART_FLAG_POLL_
SLEEP — a host-CPU-saving usleep(1)-per-idle-poll hack that models no hardware
and paces a one-wire loader (which releases TX between bytes) in real time,
distorting protocol timing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:23:16 +02:00
778b8a0100 tsb: vendor the fixed-baud assembly oracle as the size/feature bar
The Seed Robotics native-UART fixed-baud TinySafeBoot (GPLv3), reference
only — not built. Assembles to 500 B with the full feature set, proving
≤512 B and full feature parity are simultaneously reachable. Also drops the
stale empty stk500v2/ leftover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:29:58 +02:00
48d11fe8e3 tsb: use the named register surface
Direct register access now reads through the named surface
(hw::mcusr::wdrf.test(), hw::ucsr0b::write(...)) instead of the string form,
matching how libavr itself is written. Zero-overhead: pure 740 B, tricks 658 B,
asm 508 B unchanged, all byte-identical across modes, protocol green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:55:01 +02:00
bb4784ae0c tsb: refactor the pure tier onto libavr sugar
The showcase tier now leans on the helpers it fed back instead of reaching under
them: the info block is an avr::flash_table (no raw [[gnu::progmem]]), a page is
filled with spm::fill(addr, span) (no hand-packed lo|hi<<8 loop), and the
WDT-reset bail reads field<"MCUSR","WDRF">::test() (no read() & {}(1).value).

Zero-overhead throughout: .text stays 740 B, byte-identical across generated and
reflect modes, protocol test green. The info block streams through the existing
address-based send_flash rather than a range-for over the flash_table — the
range-for is a distinct loop that cannot share the loader's one flash streamer,
so it would add 14 B for no functional gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:33:53 +02:00
d07586652d tsb: slim the port branch to the libavr reimplementation
main carried the whole pre-libavr tree beside the port: the other-bootloader
directories (blink, stk500v2), the Atmel Studio solution/project, and — dead in
the tsb dir itself — four submodule links to the superseded io/flash/uart/type
libraries the libavr sources never include. None are build inputs; CMake drives
the three variants through FetchContent. master keeps the full legacy tree
untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:12:56 +02:00
3a2f2a23c2 tsb: drop the local -O3 strip, now handled by the libavr toolchain
The -O3 leak is fixed upstream (cmake/release-os.cmake via CMAKE_PROJECT_INCLUDE),
so the port no longer needs its own string(REPLACE); a Release build is -Os
through the toolchain file. Verified: all three variants build at their sizes
(508/658/740) and pass the size + protocol ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 10:52:13 +02:00
4fa058653e tsb: reimplement TinySafeBoot on libavr in three size tiers
The native-UART fixed-baud TinySafeBoot protocol, ported onto libavr as a
crt-free boot-section loader, in three variants that trade clarity for size:

  tsb_pure   740 B  idiomatic C++: SRAM page buffer, separate flash/EEPROM
                    leaves, shared framing; the polled `unused` guard posture.
  tsb_tricks 658 B  unified runtime-flag paths (noinline/noclone), call-saved
                    global-register page walk — attributes only, no asm.
  tsb_asm    508 B  streaming store + hand-rolled UART/SPM/EEPROM/erase loops;
                    fits the 512 B boot section (BOOTSZ=11). Trims the optional
                    password gate and WDT-reset bail — unreachable in C++ with
                    both (hand-asm is ~15 % denser). Tiers 1-2 keep them and
                    live in the 1 KB section they fit.

All three are .text byte-identical across libavr's generated and reflect modes.
The CMake build strips the leaked -O3 (a Release build is silently -O3, not the
-Os this loader is measured against) and gates each variant's size against its
section. A simavr harness (test/device.c + test/tsbtest.py) drives the real wire
protocol over a pty and flashes the device; the size and protocol tests run in
ctest. Verified byte-for-byte against the reference tsbloader_adv (C#/mono):
activate, read info, flash write + verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 05:00:51 +02:00
19 changed files with 960 additions and 1647 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "libavr"]
path = libavr
url = ../libavr.git

View File

@@ -8,6 +8,9 @@ include(FetchContent)
if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT})
set(LIBAVR_ROOT $ENV{LIBAVR_ROOT})
endif()
if(NOT LIBAVR_ROOT)
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr)
endif()
if(LIBAVR_ROOT)
FetchContent_Declare(libavr SOURCE_DIR ${LIBAVR_ROOT})
else()
@@ -150,10 +153,7 @@ if(PROJECT_IS_TOP_LEVEL)
if(Python3_FOUND)
add_test(NAME pureboot.pi
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py
${CMAKE_OBJDUMP} ${CMAKE_OBJCOPY} ${CMAKE_CXX_COMPILER} ${LIBAVR_MCU}
$<TARGET_FILE:pureboot>
${CMAKE_BINARY_DIR}/CMakeFiles/pureboot.dir/pureboot/pureboot.cpp.obj
${PUREBOOT_BASE_HEX})
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${PUREBOOT_BASE_HEX})
add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
@@ -234,13 +234,12 @@ if(PROJECT_IS_TOP_LEVEL)
endif()
# The size matrix: every configuration axis that could move the image
# size — the serial backend (different code), the USART instance
# (different registers), the clock (different constants), and the baud
# through the shapes its bit timing takes — each combination must still
# fit the chip's slot budget. Pins are size-neutral (port and bit are
# immediate operands) and the timeout is a constant, so neither adds an
# axis. The stock build is one point of this matrix and already has its
# test.
# size — the serial backend (different code), the clock and its ladder
# baud (different constants and divisor shapes), the USART instance
# (different register class) — each combination must still fit the
# chip's slot budget. Pins are size-neutral (port and bit are immediate
# operands) and the timeout is a constant, so neither adds an axis. The
# stock build is one point of this matrix and already has its test.
function(pureboot_size_variant name)
pureboot_add_loader(${name} ${ARGN})
add_test(NAME ${name}.size
@@ -248,75 +247,14 @@ if(PROJECT_IS_TOP_LEVEL)
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
endfunction()
# The autobaud loader: one clock-agnostic image per chip, so it has no
# clock x baud axis of its own — the matrix below sweeps those for the
# fixed-baud builds, and this one binary has to serve all of them at run
# time. Size-tested against the same per-chip budget as every other variant.
pureboot_add_loader(pureboot_autobaud SERIAL autobaud)
add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
# One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. Unreachable rates drop
# out here rather than aborting the configure.
function(pureboot_matrix_point hz baud link)
if(link STREQUAL "software")
pureboot_baud_feasible(${hz} ${baud} 1 _ok)
set(_args SERIAL software)
else()
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
set(_args USART ${link})
endif()
if(_ok)
pureboot_size_variant(pbm_${hz}_${baud}_${link} CLOCK ${hz} BAUD ${baud} ${_args})
endif()
endfunction()
# Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and
# the crystal the stock build assumes (the tiny13's ladder is its own RC
# menu — it has no crystal option).
if(LIBAVR_MCU MATCHES "^attiny13")
set(_matrix_clocks 1200000 4800000 9600000)
set(_full_clocks 128000 600000 1200000 4800000 9600000)
else()
set(_matrix_clocks 1000000 8000000 16000000)
set(_full_clocks 128000 1000000 1843200 2000000 3686400 4000000 7372800 8000000
11059200 12000000 14745600 16000000 18432000 20000000)
endif()
# The exhaustive cross product: every clock a deployment plausibly runs
# — the internal oscillators, the shipped CKDIV8 floor, the plain
# crystals and the UART crystals — against every rate, against every
# backend. Beyond the ladder the list carries the slow rates a
# sub-megahertz oscillator is left with, which no ladder rate reaches
# (16000 Bd is the only rate the 128 kHz oscillator holds exactly); at
# the fast clocks those same rates also select the software UART's
# 16-bit _delay_loop_2 bit spin (two words more setup at each of its five
# sites), the largest image the space produces and a shape the ladder
# default — always the *fastest* rate a clock reaches — never picks.
#
# Every chip runs the full cross product: the size-bearing classes (flash
# addressing, hand-over shape, page size, USART inventory) are what make
# the image differ, and a chip outside them is expected to match its class
# — but "expected" is what a matrix is for, and the whole sweep is cheap
# enough to run rather than reason about. PUREBOOT_FULL_MATRIX is what
# selects it; the compact matrix below is the per-commit default.
get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
list(APPEND _full_bauds 16000 4800 2400 1200)
if(DEFINED ENV{PUREBOOT_FULL_MATRIX})
foreach(_matrix_hz IN LISTS _full_clocks)
foreach(_matrix_baud IN LISTS _full_bauds)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software)
if(PUREBOOT_HAS_USART)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0)
endif()
if(PUREBOOT_HAS_USART1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1)
endif()
endforeach()
endforeach()
else()
foreach(_matrix_hz IN LISTS _matrix_clocks)
math(EXPR _matrix_khz "${_matrix_hz} / 1000")
if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz)
@@ -325,13 +263,7 @@ if(PROJECT_IS_TOP_LEVEL)
if(PUREBOOT_HAS_USART AND NOT _matrix_hz EQUAL _pb_stock_hz)
pureboot_size_variant(pureboot_hw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL hardware)
endif()
if(PUREBOOT_HAS_USART1 AND NOT _matrix_hz EQUAL _pb_stock_hz)
pureboot_size_variant(pureboot_usart1_${_matrix_khz}k CLOCK ${_matrix_hz} USART 1)
endif()
endforeach()
list(GET _matrix_clocks -1 _matrix_top_hz)
pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software)
endif()
if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_usart1 USART 1)
endif()
@@ -386,28 +318,4 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbusart1-work usart1)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif()
# The autobaud variants driven end to end over the software-UART bridge (both
# under review — pureboot/autobaud.md): the host sends the 0xC0 calibration
# pulse, the loader times it, locks, and programs. Run on the near-flash 328P
# and the word-addressed 1284P — the two flash-addressing classes — and each
# at two clocks with the one binary, which is the clock-agnostic property
# autobaud exists for (test/pbautobaud.py). The fixture application banners
# over the same software link at the first clock's rate.
if(LIBAVR_MCU MATCHES "^atmega(328p|1284p)$" AND DEFINED PB_DEVICE)
add_executable(pbapp_autobaud test/pbapp.cpp)
target_link_libraries(pbapp_autobaud PRIVATE libavr)
target_compile_definitions(pbapp_autobaud PRIVATE PUREBOOT_CLOCK_HZ=1000000
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1)
add_custom_command(TARGET pbapp_autobaud POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:pbapp_autobaud> $<TARGET_FILE:pbapp_autobaud>.bin)
add_test(NAME pureboot.autobaud
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
${PB_DEVICE} $<TARGET_FILE:pureboot_autobaud> ${PUREBOOT_SIM_MCU}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $<TARGET_FILE:pbapp_autobaud>.bin
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
endif()
endif()

View File

@@ -6,7 +6,7 @@
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake",
"toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON",

1
libavr Submodule

Submodule libavr added at e81dad0131

View File

@@ -1,16 +1,27 @@
# pureboot as a consumable CMake unit: the per-chip geometry, the default baud
# ladder, and pureboot_add_loader() — the one way a loader target is created.
# A downstream project brings its usual libavr setup (the `libavr` target and
# pureboot as a consumable CMake unit: the per-chip geometry, the default
# baud ladder, and pureboot_add_loader() — the one way a loader target is
# created, both by this port's own build and by a downstream project. A
# downstream project brings its usual libavr setup (the `libavr` target and
# the LIBAVR_MCU toolchain preset), adds this directory, and states its
# deployment; every argument is optional (README.md):
# deployment:
#
# add_subdirectory(bootloader/pureboot)
# pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
#
# Every argument is optional — CLOCK defaults to the family assumption
# below, BAUD to the fastest standard rate the clock reaches within 2.5 %
# (the ladder), SERIAL to the chip's hardware USART where it has one
# (`hardware`/`software` force a backend, USART 1 picks the second
# instance), RX/TX to pb0/pb1 for the software UART, TIMEOUT to 8 s.
# Infeasible picks fail the build by name: libavr's baud-error and
# software-UART cycle-floor static asserts re-check whatever is passed.
# Per-family geometry, deployment defaults, and the linker wrap the PC modulo
# needs. The slot is 512 bytes on every chip. The USART flags mirror the
# hardware inventory the loader's own static asserts check — the plain 644 is
# the x4 family's one single-USART die (Atmel-2593).
# Per-family geometry: flash/page/EEPROM sizes and the linker wrap the PC
# modulo needs, the loader slot (each chip's smallest boot sector — 1 KiB on
# the word-addressed 1284s), and the deployment defaults (crystal assumption
# on the megas, calibrated RC on the tinies). The USART flags mirror the
# hardware inventory the loader's own static asserts check (the plain 644 is
# the x4 family's one single-USART die, Atmel-2593).
set(_pb_has_usart 1)
set(_pb_has_usart1 0)
if(LIBAVR_MCU MATCHES "^attiny13a?$")
@@ -80,9 +91,10 @@ elseif(LIBAVR_MCU MATCHES "^atmega324(a|p|pa)$")
set(_pb_eeprom 1024)
set(_pb_has_usart1 1)
elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
# 64 KiB is exactly the 16-bit byte space, so plain LPM still reaches
# everything and the wire stays byte-addressed. The plain 644 is the
# family's one single-USART die.
# 64 KiB is exactly the 16-bit byte space: plain LPM reaches everything,
# and the smallest boot section (1 KiB) holds the loader and its staging
# slot together (see README.md). The plain 644 is the family's one
# single-USART die.
set(_pb_flash 65536)
set(_pb_wrap -Wl,--pmem-wrap-around=64k)
set(_pb_page 256)
@@ -92,25 +104,33 @@ elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
set(_pb_has_usart1 1)
endif()
elseif(LIBAVR_MCU MATCHES "^atmega1284p?$")
# 128 KiB: wire addresses are words, reads go through ELPM, and the PC's
# modulo wrap exceeds what --pmem-wrap-around models.
# 128 KiB: wire flash addresses are word addresses, reads go through
# ELPM, and the PC's modulo wrap exceeds what --pmem-wrap-around models.
# The slot is 1 KiB — this chip's own smallest boot sector; the far
# machinery cannot fit 512 B (see README.md).
set(_pb_flash 131072)
set(_pb_wrap "")
set(_pb_page 256)
set(_pb_hz 16000000)
set(_pb_eeprom 4096)
set(_pb_slot 1024)
set(_pb_limit 1024)
set(_pb_has_usart1 1)
else()
message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}")
endif()
set(_pb_slot 512)
if(NOT DEFINED _pb_slot)
set(_pb_slot 512)
endif()
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
# Patched-vector chips hand over through the trampoline word below the slot,
# which is also the slot's own last word — their budget is slot 2.
if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
set(_pb_app 0)
if(NOT DEFINED _pb_limit)
set(_pb_limit ${_pb_slot})
endif()
else()
math(EXPR _pb_app "${_pb_base} - 2")
math(EXPR _pb_limit "${_pb_slot} - 2")
@@ -146,19 +166,17 @@ set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
# The rates a default may pick, fastest first.
set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
# Whether <baud> is reachable from <clock> within 2.5 %, by the same
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a build
# never trips the compile-time error it is checked against. A software build
# also needs the polled receiver's 100-cycles-a-bit floor: at low clocks the
# U2X divisor reaches rates the bit-banged sampler cannot.
function(pureboot_baud_feasible clock baud software outvar)
set(${outvar} 0 PARENT_SCOPE)
# The fastest standard rate the clock reaches within 2.5 % — the same
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a
# default never trips the compile-time error it is checked against. A
# software build additionally requires the polled receiver's 100-cycles-a-bit
# floor (its own static assert): at low clocks the U2X divisor still reaches
# rates the bit-banged sampler cannot, so the backend gates the ladder.
function(pureboot_default_baud clock software outvar)
foreach(baud 115200 57600 38400 19200 9600)
math(EXPR _cycles "${clock} / ${baud}")
if(software AND _cycles LESS 100)
return()
continue()
endif()
foreach(divisor 8 16)
math(EXPR _step "${divisor} * ${baud}")
@@ -173,40 +191,23 @@ function(pureboot_baud_feasible clock baud software outvar)
endif()
math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
if(_error_bp LESS_EQUAL 250)
set(${outvar} 1 PARENT_SCOPE)
return()
endif()
endforeach()
endfunction()
# The fastest ladder rate the clock reaches.
function(pureboot_default_baud clock software outvar)
get_property(_ladder GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
foreach(baud ${_ladder})
pureboot_baud_feasible(${clock} ${baud} ${software} _ok)
if(_ok)
set(${outvar} ${baud} PARENT_SCOPE)
return()
endif()
endforeach()
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 % "
"— pass BAUD <rate> to deploy a non-standard one")
endforeach()
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 %")
endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [SERIAL auto|hardware|software] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>])
#
# The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the
# target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
# usart0, usart1 or sw:<RX>,<TX>) — what a test harness speaks to it with.
#
# SERIAL autobaud measures the host's bit timing at run time, so the image
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
# and one binary per chip serves every F_CPU and every rate. The stamped
# PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
# not what it was built for.
# Creates the loader target plus its flashable images (<name>.hex for a
# programmer, <name>.bin for --update-loader) and stamps the resolved
# deployment on the target: the PUREBOOT_HZ, PUREBOOT_BAUD and PUREBOOT_LINK
# properties (the link as usart0/usart1/sw:<RX>,<TX> — what a test harness
# needs to speak to the build).
function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS)
@@ -228,8 +229,8 @@ function(pureboot_add_loader name)
if(NOT PB_SERIAL)
set(PB_SERIAL auto)
endif()
if(DEFINED PB_USART AND NOT PB_SERIAL MATCHES "^(auto|hardware)$")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL ${PB_SERIAL}")
if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software")
endif()
if(DEFINED PB_USART)
set(PB_SERIAL hardware)
@@ -258,7 +259,7 @@ function(pureboot_add_loader name)
set(PB_SERIAL software)
endif()
endif()
if(PB_SERIAL MATCHES "^(software|autobaud)$")
if(PB_SERIAL STREQUAL "software")
if(NOT PB_RX)
set(PB_RX pb0)
endif()
@@ -270,12 +271,9 @@ function(pureboot_add_loader name)
message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1")
endif()
endforeach()
if(PB_SERIAL STREQUAL "autobaud")
set(_serial_defines PUREBOOT_AUTOBAUD PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
else()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
endif()
# sw:<RX>,<TX> as port letter and bit, upcased.
# The link spec a test harness drives a GPIO bridge with: sw:<RX>,<TX>
# as the port letter and bit, the loader's own pin naming upcased.
string(SUBSTRING ${PB_RX} 1 2 _rx_pin)
string(SUBSTRING ${PB_TX} 1 2 _tx_pin)
string(TOUPPER "sw:${_rx_pin},${_tx_pin}" _link)
@@ -290,34 +288,27 @@ function(pureboot_add_loader name)
endif()
endif()
if(PB_SERIAL STREQUAL "autobaud")
# No clock and no baud reach the image; the window is a poll budget.
set(_defines ${_serial_defines})
else()
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines})
endif()
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr)
target_compile_definitions(${name} PRIVATE ${_defines})
# Codegen shaping for the loader TU only. At -Os GCC otherwise rewrites the
# byte-stream loops' counters into end-pointer forms that cost registers
# (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the
# table with the default allocator (-fira-algorithm=priority), and keeps
# expression temporaries in registers (-fno-tree-ter) — but every loop body
# here contains a call, so a register held across it costs more than the
# load-immediate it saves. The set is fitted to the loader's body and has to
# be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes
# now that it carries one.
# Codegen shaping for the loader TU only, worth ~40 B on every chip and
# what carries the far-flash 1284 build under 512. At -Os GCC otherwise
# rewrites the byte-stream loops' counters into end-pointer forms that
# cost registers (-fno-ivopts, -fno-split-wide-types), leaves register
# pressure on the table with the default allocator
# (-fira-algorithm=priority), and spends bytes on rewrites a
# straight-line loader gains nothing from.
target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
-fno-ivopts -fira-algorithm=priority -fno-expensive-optimizations -fno-split-wide-types)
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
# The ELF is a container, never flashed: .hex for a programmer, .bin (the
# slot's bare bytes) for --update-loader.
# The ELF is a container (symbols, section headers), never flashed; the
# flashable forms sit beside it: .hex for a programmer, .bin (the slot's
# bare bytes) for the host tool's raw path and --update-loader.
add_custom_command(TARGET ${name} POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.hex
@@ -326,4 +317,3 @@ function(pureboot_add_loader name)
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
PUREBOOT_LINK ${_link})
endfunction()

View File

@@ -2,72 +2,52 @@
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables
(attributes 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`).
(attributes and compiler flags allowed), built for **every chip libavr
targets — all 37 — in 512 bytes each**: 434 B on the tiny13s, 438442 B on
the tiny25/45/85, 412452 B across the megas, and 506 B on the
ATmega1284/1284P, whose far-flash machinery (ELPM reads, RAMPZ page commands,
word-addressed wire) is the heaviest. Those are the stock deployments;
choosing the software UART where the chip has a USART costs 846 B more (a
bit-bang against a peripheral), which every chip still absorbs inside its
slot — on the 1284s that means their 1 KiB boot sector, where the
software-serial image lands at 546 B. Bringing the 1284's default build
under 512 at all is what the loop-placement attributes on the byte streamers
(`pureboot.cpp`) and the codegen flags on the loader TU (`CMakeLists.txt`)
are for; measured against each chip's own budget the tightest is the
ATmega328P, 50 B spare. 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 1284s still *deploy* in a 1 KiB slot, their smallest boot sector being
512 words; at 506 B the image would also fit the 644's
two-512-byte-slots-per-boot-sector geometry.
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.
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 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:
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\|autobaud` | the link backend | `auto`: the hardware USART where the chip has one |
| `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 |
@@ -75,25 +55,15 @@ repo's build and by a downstream project alike:
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.
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.
`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:
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:
```cmake
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main)
@@ -104,187 +74,164 @@ 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`).
`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`).
## Link
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 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.
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 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.
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.
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.
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 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.
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.
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.
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` | — | 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) |
| `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) |
`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.
`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.
| 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 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 (drained, never programmed) and — where SPM runs from anywhere,
the tinies and the m48s — an application that self-programmed before
entering. The next `W` takes those stale words, and clears them: 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 host that
programs without reading back cannot trust the first `W` after either event.
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.
`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.
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.
`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 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.
The info block (`b`):
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.
| Offset | Content |
|---|---|
| 02 | `'P'`, `'B'`, pureboot version (2) |
| 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 |
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.
Composites are the host's job: verify = read back and compare, erase =
write `0xff` (per page for flash, per byte for EEPROM).
## 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.
The third byte of the info block 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 of its own, a
pureboot version implies its protocol, and the host tool is what holds that
map. It states the window of loader versions it speaks
(`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`); a version that changes
the protocol becomes the new floor there. So far none has: pureboot 1 and 2
speak the identical session, and 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.
The tool carries its own version, free to drift from the loader's:
`--version` prints both 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.
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 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`).
**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 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.
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):
@@ -294,160 +241,155 @@ ATmega328P profiles (addresses for its 32 KiB):
| 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
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. 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`).
**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 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.
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 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 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 `[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.
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, and the state file is discarded.
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, 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).
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.
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.
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`, and a flash page that reads back wrong is
rewritten up to three times before the run stops — the loader leaves one
recoverable way for a page to land wrong (see `W` above), and rewriting is
what clears it. `--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.
Readouts come one fact per line: `--info` prints the decoded info block
field by field, the loader's version first; `--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 (`--full` adds the reflect-mode
builds of libavr's spot set; `tools/make_presets.py` regenerates the presets).
Per chip preset, `ctest` runs:
`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 (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.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, the update preflight's error/warning matrix over
synthetic fuse bytes, and the repairing verify against a fake device — one
bad write repaired in a single rewrite, a page that never comes good
stopping after exactly three;
- `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.
(`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.dirty` (328P) — entering the loader from a running application
with no reset between, over an SPM page buffer the fixture deliberately
dirtied: the case the loader declines to guard against. A bare verify must
see the corruption, the repairing verify must fix it in one rewrite, and a
plain verify afterwards must pass. On the boot-sectioned megas hardware
forbids the state outright (SPM runs only from the boot section, and reset
erases the buffer), but simavr dispatches SPM from anywhere — which is what
makes the path constructible at all;
- `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.
`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.

View File

@@ -1,14 +1,28 @@
// pureboot — a serial bootloader on libavr: one C++ source, no inline
// assembly, no global register variables, 512 bytes on every chip libavr
// targets. The device speaks primitives; every composite (verify, erase,
// reset-vector surgery, self-update) lives in the host tool. Protocol,
// deployment and configuration: README.md next to this file.
// pureboot — a serial bootloader on libavr, pure by constraint: one C++
// source with no inline assembly and no global register variables, built for
// every chip libavr targets, 512 bytes on each. The device speaks primitives
// — read/program flash, read/write EEPROM, fuse bytes, an info block, a jump
// — and everything composite (verify, erase, reset-vector surgery, updating
// the loader itself) lives in the host tool. Protocol reference: README.md
// next to this file.
//
// The image is position-independent — PC-relative control flow, wire
// addresses in, the write guard and the info block both anchored on the
// runtime return address — so the identical binary runs from any slot. That
// is what makes a copy one slot below able to rewrite the resident one, and
// every change here has to keep it (test/check_pi.py).
// The image is position-independent: control flow is PC-relative, the write
// and read paths take wire addresses, the write guard refuses the 512-byte
// slot the code is *running* in (taken from the runtime return address), the
// info block is read relative to that same anchor, and the application jump
// is an indirect call to an absolute entry. The identical binary therefore
// runs from any 512-byte slot with every command intact: flashed one slot
// below the resident loader it becomes the staging loader that rewrites the
// resident — how pureboot updates itself, host-driven, with no other
// firmware involved.
//
// Entry: reset lands in avr::startup::entry below (BOOTRST on the
// boot-sectioned megas; the patched reset vector — or erased flash walking
// up into the loader — on the tinies and the boot-section-less m48s). A
// watchdog reset hands straight to the application. Otherwise the
// host has one activation window per awaited knock byte ("pb"); an idle line
// boots the application. A session then stays in the command loop until 'J'
// jumps away or the chip resets.
#include <libavr/libavr.hpp>
@@ -19,24 +33,24 @@ namespace ee = avr::eeprom;
namespace pureboot {
namespace {
// Purely polled: every interrupt guard folds to nothing.
// Purely polled interrupts stay off, every guard folds to nothing.
constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t ack = '+';
// Deployment parameters come from the build (pureboot_add_loader()). The
// signature is not one of them: the chip database is the only universal
// source — a tiny13A cannot read its own signature row from code. An autobaud
// build carries no clock and no baud at all; it measures both.
#if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD))
// Per-deployment personality, passed in by the build pureboot_add_loader()
// (the CMake function next to this file) resolves the defaults: the clock the
// board actually runs, the wire baud, the serial backend and its pins. The
// device signature needs no configuring — it comes from the chip database
// (avr::hw::db.signature), the only universal source, since the tiny13A
// cannot even read its signature row from code.
#if !defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)
#error \
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)"
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader() (README.md)"
#endif
#if !defined(PUREBOOT_AUTOBAUD)
using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
#endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
@@ -45,115 +59,79 @@ consteval std::int16_t wdrf_field()
return avr::hw::db.field_index(reg, "WDRF");
}
// The loader owns the top 512 bytes; a staging copy goes in the slot below.
// Chips without a hardware boot section — the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 §26) — keep the application's relocated
// reset vector in the word under the slot.
constexpr std::uint16_t slot_bytes = 512;
// Geometry: the resident loader owns the top slot of flash — 512 bytes,
// except on the >64 KiB chips whose own smallest boot sector is 1 KiB (the
// 1284s): there the slot is 1 KiB, matching the hardware boundary the
// 512-byte figure comes from everywhere else. The word below the slot is
// the trampoline (the application's relocated reset vector) on chips
// without a hardware boot section — the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 §26). A boot section also means the CPU
// runs on while the RWW section programs; everywhere else it halts through
// the operation.
constexpr std::uint16_t slot_bytes = spm::flash_bytes > 65536 ? 1024 : 512;
constexpr std::uint32_t base = spm::flash_bytes - slot_bytes;
constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::curated::has_boot_section();
// Past 64 KiB one bank of flash does not cover the chip, so a transfer's
// selector byte carries the bank and the wire address stays a byte address
// within it. 'J' is the exception: it is a word address everywhere, because
// that is what the hardware's own jump takes.
constexpr bool banked_flash = spm::flash_bytes > 65536;
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so on the
// large chips every flash address on the wire — and all slot arithmetic —
// is a word address instead ('J' always was one). A slot spans the same
// wire-high-byte pair in either unit (512 B = 2 x 256 bytes, 1 KiB =
// 2 x 256 words), so the slot index is the high byte with its low bit
// dropped everywhere.
constexpr bool word_flash = spm::flash_bytes > 65536;
constexpr std::uint16_t wire_base =
word_flash ? static_cast<std::uint16_t>(base / 2) : static_cast<std::uint16_t>(base);
constexpr std::uint16_t wire_page_mask = word_flash ? (page / 2 - 1) : (page - 1);
// A compile-time window, so the whole EEPROM belongs to the application;
// re-timing a deployed loader is a self-update with a re-timed build. An
// autobaud build has no clock to convert seconds against and counts polls.
// The activation window, in seconds, is a compile-time constant (the build
// may override it): the whole EEPROM belongs to the application, and
// re-timing the loader is a bootloader self-update with a re-timed binary.
#if !defined(PUREBOOT_TIMEOUT)
#define PUREBOOT_TIMEOUT 8
#endif
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
#if !defined(PUREBOOT_AUTOBAUD_POLLS)
#define PUREBOOT_AUTOBAUD_POLLS 4000000
#endif
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// The pureboot version: the loader's one identity number, carried in the info
// block so a host can tell a deployed loader apart from another. The wire
// protocol has no number of its own — a version implies its protocol, and the
// host tool is what holds that map (README.md).
constexpr std::uint8_t version = 2;
// The loader's one identity number. The protocol carries none of its own —
// a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 5;
// The image's identity stamp, for the host tool rather than for the wire: an
// update image is a bare 512-byte slot, and without this nothing in it says
// which chip it was built for. The tool refuses to install an image whose
// stamp does not match the device — flashing a foreign loader bricks the
// target, and the loader itself cannot check what has already replaced it.
//
// Never read from flash by the loader — 'b' answers out of this array, but at
// constant indices, so those fold to immediates and no runtime address of it
// is ever formed. `used` keeps the compiler from dropping the copy the host
// needs and `retain` keeps --gc-sections from collecting it.
// clang-format off
[[gnu::used, gnu::retain, gnu::section(".text.stamp")]]
inline constexpr std::uint8_t identity_stamp[]{
'P', 'B', // the magic the host scans an image for
version, // and from here on, exactly what 'b' answers
// The 12-byte info block the host reads with the 'b' command, flash-resident
// through flash_table (there is no crt to copy a .data image, and its storage
// carries the word alignment 'b' needs to halve the address on the large
// chips). The page byte is the wire count convention: 0 means 256.
inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{
'P',
'B',
version, // magic, then the loader's version
avr::hw::db.signature[0],
avr::hw::db.signature[1],
avr::hw::db.signature[2],
};
// clang-format on
// Where the identity proper starts: past the magic the host scans for.
constexpr std::uint8_t stamp_identity = 2;
static_cast<std::uint8_t>(page),
wire_base & 0xff,
wire_base >> 8, // app flash ends here; resident loader base (a word address on large chips)
avr::hw::db.mem.eeprom_size & 0xff,
avr::hw::db.mem.eeprom_size >> 8,
// bit 0: host must patch the reset vector (no hardware boot section);
// bit 1: flash wire addresses are word addresses
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)),
}>
info_data;
// The address spaces a transfer can name, in a selector byte's low nibble.
// Flash is 0 so it is the cheapest to select.
//
// spm_ops is the one that is not memory: a write there hands its byte to
// SPMCSR and fires the instruction at the transfer's address, which is how
// page erase, page write and RWW re-enable reach the wire without the loader
// carrying a command for each. The hardware's four-cycle store-to-SPM window
// is why this is one fused primitive and not a poke of SPMCSR — no host can
// hit that window across a serial link.
enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 };
// A selector's high nibble is the flash bank — the address bits above the
// 16-bit wire address, RAMPZ on the chips that have one. Keeping it here
// rather than widening the wire address is what lets one 16-bit cursor serve
// every space: a 24-bit cursor would pay its extra byte on EEPROM and data
// reads that can never need it.
[[gnu::always_inline]] inline std::uint8_t space_of(std::uint8_t selector)
{
return selector & 0x0f;
}
[[gnu::always_inline]] inline std::uint8_t bank_of(std::uint8_t selector)
{
return static_cast<std::uint8_t>(selector >> 4);
}
// The slot a flash address falls in, as one byte. A slot is half as many words
// as bytes, so the word address's high byte is exactly this index — which is
// what lets the write guard compare a single byte, and what the running copy's
// own return address yields for free.
constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes);
constexpr std::uint8_t bank_shift = 16 - slot_shift;
[[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
{
const auto within = static_cast<std::uint8_t>(at >> slot_shift);
if constexpr (banked_flash)
return static_cast<std::uint8_t>((bank << bank_shift) | within);
else
return within;
}
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL /
// PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The
// software receiver is the polled one: the vector table belongs to the
// application. Templates on the clock, so only the selected backend
// instantiates. pending() is the cheap line test the activation window polls;
// drain() holds until the last frame is off the wire, so a hand-over cannot
// let the target's re-init clip the ack.
// The serial link. PUREBOOT_USART forces a hardware USART instance,
// PUREBOOT_SOFT_SERIAL the polled software UART (no vector — the table
// belongs to the application) on PUREBOOT_RX/PUREBOOT_TX; with neither, the
// chip's first USART where it has one and the software UART elsewhere. Both
// are class templates on the clock so only the selected backend is ever
// instantiated. pending() is the cheap line test the activation window
// polls; rx() then picks the byte up; drain() holds until the last
// transmitted frame is fully on the wire (the jump hand-over must not let
// the target's re-init clip the ack).
#if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART)
#error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends"
#endif
#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART)
#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART"
#endif
#if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0
#endif
@@ -166,9 +144,9 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0';
#endif
template <avr::hertz_t C, avr::baud_t B>
template <avr::hertz_t C>
struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6).
@@ -200,10 +178,10 @@ struct hardware_link {
}
};
template <avr::hertz_t C, avr::baud_t B>
template <avr::hertz_t C>
struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, wire_baud>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, wire_baud>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6).
@@ -235,50 +213,22 @@ struct software_link {
}
};
// The clock-free link: the bit period is measured from the host's calibration
// pulse instead of derived from a clock, so one image serves every F_CPU and
// every rate. Activation differs in kind from the other two — there is no
// clock to time a window against — so this backend brings its own, below.
struct autobaud_link {
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX>;
static void init()
{
avr::init<uart>();
}
static std::uint8_t rx()
{
return uart::template read<off>();
}
static void tx(std::uint8_t byte)
{
uart::template write<off>(byte);
}
static void drain()
{
uart::drain();
}
};
#if defined(PUREBOOT_AUTOBAUD)
using link = autobaud_link;
#elif defined(PUREBOOT_USART)
#if defined(PUREBOOT_USART)
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
using link = hardware_link<dev::clock, wire_baud>;
using link = hardware_link<dev::clock>;
#elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock, wire_baud>;
using link = software_link<dev::clock>;
#else
using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
software_link<dev::clock, wire_baud>>;
using link =
std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock>, software_link<dev::clock>>;
#endif
// The application's entry, pinned by the linker (--defsym): word 0 on a
// boot-sectioned mega, the trampoline at base 2 elsewhere. Reaching it must
// not depend on where this copy runs, so the jump goes through a pointer, and
// [[gnu::noipa]] keeps the constant from folding back into a relative call.
// The application's entry, an absolute address the linker pins (--defsym in
// CMakeLists.txt): 0x0000 on the mega (word 0 stays the application's own
// vector — BOOTRST re-vectors a reset into the loader in hardware) and the
// trampoline word at base - 2 on the tinies. Reaching it must not depend on
// where this copy runs, so the jump goes through a pointer: [[gnu::noipa]]
// keeps the constant from folding back into a PC-relative call.
extern "C" [[noreturn]] void pureboot_app();
[[gnu::noipa, noreturn]] void jump(void (*target)())
@@ -292,29 +242,10 @@ extern "C" [[noreturn]] void pureboot_app();
jump(pureboot_app);
}
// Activation: a bounded wait for the host, then the knock. Both forms boot the
// application when the window closes on an idle line, and both bound *every*
// wait — a knock awaited without a deadline would let one stray edge hold an
// unattended device in the loader forever.
#if defined(PUREBOOT_AUTOBAUD)
// The window is a fixed poll budget: with no clock, whole seconds cannot be
// timed. A uint24_t holds it — a fourth byte would cost two words at every
// countdown step for range never used.
void await_host()
{
for (;;) {
if (!link::uart::calibrate(autobaud_budget))
run_app();
// The calibration pulse has already proven a host is there, so one
// byte activates. A knock that never arrives falls back to calibrate(),
// whose own budget then boots the application.
if (link::uart::template read<off>(autobaud_budget) == 'p')
return;
}
}
#else
// The window as one 32-bit countdown, divided by the backend's counted
// poll-loop cycles. Whole seconds is all it promises.
// One activation window is a single 32-bit poll countdown. The divisor is
// the backend's counted poll-loop cycles (its own comment reads them off the
// compiled loop); whole-second precision is all the window promises, so the
// nearest cycle count is plenty.
consteval std::uint32_t window_polls()
{
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles);
@@ -330,8 +261,8 @@ bool pending_before_deadline()
return false;
}
// A knock byte under the deadline: an idle window means no host, so the
// application runs.
// A knock byte under the activation deadline: an idle line means no host is
// there, and the application runs.
std::uint8_t rx_deadline()
{
if (!pending_before_deadline())
@@ -339,187 +270,226 @@ std::uint8_t rx_deadline()
return link::rx();
}
void await_host()
{
// 'p' then 'b', each under a fresh window; anything else is line noise.
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
}
}
#endif
// Inlined: read across a call, the first byte strands in a call-saved
// register the caller has to push and pop.
// Inlined into its call sites: reading two bytes across a call otherwise
// strands the first in a call-saved register the caller must push/pop; folded
// into the (noreturn) command loop that cost disappears.
[[gnu::always_inline]] inline std::uint16_t rx16()
{
std::uint16_t low = link::rx();
return static_cast<std::uint16_t>(low | (link::rx() << 8));
}
// The wire's byte pair as the word it is — AVR is little-endian too, so the
// cast is the identity a shift-and-or spelling makes the compiler rediscover.
// Callers read into named variables first: the wire order is a sequence of
// reads, not an argument order.
[[gnu::always_inline]] inline std::uint16_t word_of(std::array<std::uint8_t, 2> pair)
{
return std::bit_cast<std::uint16_t>(pair);
}
// Out of line: several sites send it, and a call is shorter than a
// load-immediate at each.
[[gnu::noinline]] void tx_ack()
{
link::tx(ack);
}
// A wire address and its selector's bank as the flash address they name.
[[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
{
if constexpr (banked_flash)
return (static_cast<spm::flash_address_t>(bank) << 16) | at;
else
return at;
}
// One byte out of any space. Every accessor shares the transfer's cursor, its
// loop and its call site, so a space costs only its own instruction rather
// than a body, a loop and a dispatch arm of its own.
[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank,
std::uint16_t at)
{
if (space == sp_eeprom)
return ee::read(at);
if (space == sp_data)
return *reinterpret_cast<volatile std::uint8_t *>(at);
if (space == sp_fuse)
return spm::read_fuse<off>(static_cast<spm::fuse>(at));
if constexpr (banked_flash)
return avr::flash_load_far<std::uint8_t>(flash_address(bank, at));
else
return avr::flash_load(reinterpret_cast<const std::uint8_t *>(at));
}
// One byte into a writable space. Flash is not one of them — it arrives a
// page at a time through 'W' and is committed through sp_spm — and the fuses
// are not writable at all: SPM reaches flash and boot lock bits only.
[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value,
std::uint8_t slot_high)
{
if (space == sp_data) {
*reinterpret_cast<volatile std::uint8_t *>(at) = value;
return;
}
if (space == sp_spm) {
// The running-slot write guard. An SPM command aimed at the slot this
// code executes from is dropped, so a broken host cannot brick the
// running loader — while a copy one slot lower may still rewrite the
// resident one, which is what a self-update is. Guarding the commit
// rather than the page fill covers erase and write both, and leaves a
// refused page's words in the buffer: harmless, since the next page
// write auto-erases it (§26.2.1).
if (slot_of(bank, at) != slot_high)
spm::command<off>(value, flash_address(bank, at));
// Only a boot-sectioned mega runs on while its RWW section programs;
// everywhere else the CPU halts through erase and write, so the wait
// is already over by the time it returns.
if constexpr (boot_section)
spm::wait();
return;
}
// Host-paced: the ack goes out once the write has begun, so the next byte
// arrives while it completes and nothing is missed without a buffer.
ee::write<off>(at, value);
}
// One page into the SPM buffer, and only that: the erase and the write that
// commit it are host-issued sp_spm stores, which reach the same fused
// store-and-SPM pair through the transfer path's own address and data.
// The streamers take the count in the wire's 8-bit form: 0 means 256.
//
// Nothing discards the buffer first: it is write-once per word (§26.2.1), so
// filling over a refused page or an application's leavings programs stale
// words — but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so
// that write clears the condition and the host's read-back rewrites the page.
void fill_page(std::uint8_t bank, std::uint16_t at)
// Two functions, because they want opposite placement and placement is an
// attribute: the byte-addressed loop is small enough to inline into both
// callers, the word-addressed one stays out of line but flattened — a call to
// the transmit inside it would strand the 24-bit cursor in callee-saved
// registers. `word_flash` picks at the call site.
[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count)
{
// The address names a page, so its in-page bits are dropped and the walk
// starts at the page base; the low byte of the cursor is the whole in-page
// offset, since a page is aligned and never crosses a bank.
std::uint16_t z = at & ~static_cast<std::uint16_t>(page - 1);
do
link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++)));
while (--count);
}
// The 24-bit cursor as the machine holds it: the RAMPZ byte and a 16-bit Z,
// carried explicitly (the reassembled 32-bit address folds away inside the
// inlined far load).
[[maybe_unused, gnu::flatten, gnu::noinline]] void send_flash_far(std::uint16_t address, std::uint8_t count)
{
std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15);
std::uint16_t z = static_cast<std::uint16_t>(address << 1);
do {
link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z));
// The protocol never reads across 64 KiB, but carrying the wrap is
// smaller than the flat 32-bit cursor GCC builds without it.
if (++z == 0)
++rampz;
} while (--count);
}
[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count)
{
if constexpr (word_flash)
send_flash_far(address, count);
else
send_flash_near(address, count);
}
void send_eeprom(std::uint16_t address, std::uint8_t count)
{
do
link::tx(ee::read(address++));
while (--count);
}
// EEPROM write, host-paced: each ack goes out once the byte's write has
// begun, so the next byte arrives while it completes and the following
// write's own ready-wait sees an idle line. Nothing is ever missed, on
// either serial backend, without a buffer.
void store_eeprom(std::uint16_t address, std::uint8_t count)
{
do {
ee::write<off>(address++, link::rx());
link::tx(ack);
} while (--count);
}
// One flash page: stream the bytes into the SPM buffer as little-endian
// words, then erase and program — except the 512-byte slot this code runs
// in, which is drained but never programmed, so a copy can never erase
// itself. `slot_high` is the high byte of that running slot's base (run()
// derives it); a broken host thus cannot brick the running loader, and a
// copy flashed one slot lower may rewrite the slot above it — how pureboot
// updates itself.
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
{
// No discard before the fill: the buffer is write-once per word
// (§26.2.1), so filling over one a refused page or an application left
// dirty programs stale words — but a page write auto-erases the buffer
// (§26.2.1; §19.2 on the tinies), so that write clears the condition and
// the host's read-back rewrites the page.
// One induction either way. On the byte-addressed chips the wire address
// itself walks the page (aligned, so the offset bits wrap to zero); on
// the word-addressed large chips the wire word address becomes a 32-bit
// byte cursor once, and their 256-byte page makes its low byte the whole
// in-page offset. The slot index is one high byte of the wire address —
// two values on byte-addressed chips (the & ~1), bits 16:9 re-packed on
// the large ones.
spm::flash_address_t address;
std::uint8_t page_high;
if constexpr (word_flash) {
// Pages are aligned, so one page never crosses a 64 KiB boundary:
// RAMPZ is a per-page constant and the fill cursor is a 16-bit Z
// whose low byte is the whole in-page offset (256-byte pages). The
// slot index is simply the wire word address's high byte.
const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15);
const std::uint16_t z0 = static_cast<std::uint16_t>(wire_address << 1);
std::uint16_t z = z0;
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(flash_address(bank, z), word_of({low, high}));
spm::fill<off>((static_cast<spm::flash_address_t>(rampz) << 16) | z,
static_cast<std::uint16_t>(low | (high << 8)));
z += 2;
} while (static_cast<std::uint8_t>(z) & (page - 1));
} while (static_cast<std::uint8_t>(z));
address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0;
page_high = static_cast<std::uint8_t>(wire_address >> 8) & 0xfe;
} else {
address = static_cast<spm::flash_address_t>(wire_address);
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(address, static_cast<std::uint16_t>(low | (high << 8)));
address += 2;
} while (static_cast<std::uint8_t>(address) & (page - 1));
address -= 2; // back inside the page — erase and write ignore the word bits
page_high = static_cast<std::uint8_t>(address >> 8) & 0xfe;
}
if (page_high != slot_high) {
// The tinies and the m48s halt the CPU through the erase and the
// write, so only the boot-sectioned megas — running on while their
// RWW section programs — wait.
spm::erase_page<off>(address);
if constexpr (boot_section)
spm::wait();
spm::write_page<off>(address);
if constexpr (boot_section)
spm::wait();
}
// The megas program with their RWW section disabled; reads need it back
// on. The same store discards the buffer (§26.2.2), so they never meet
// the stale-word case above. boot_section implies an RWW section.
if constexpr (boot_section)
spm::rww_enable<off>();
}
// The four fuse/lock bytes in the hardware's own Z order: low, lock,
// extended, high. Writing fuses is not a thing self-programming can do on
// AVR — SPM reaches flash (and boot lock bits) only.
void send_fuses()
{
std::uint8_t which = 0;
do
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
while (++which & 3);
}
[[noreturn]] void run()
{
// A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF — no activation window in its way.
// A watchdog reset belongs to the application (whose watchdog stays
// forced on until it clears WDRF) — no activation window in its way.
// The flag register is MCUSR, or the classic megas' MCUCSR.
if (avr::hw::field_impl<wdrf_field()>::test())
run_app();
link::init();
// The slot this copy runs in, which the write guard follows: the return
// address is a word address and a slot is half as many words as bytes, so
// its high byte is the slot index outright. No absolute address is ever
// formed, so the image stays position-independent.
const auto return_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
const auto slot_high = static_cast<std::uint8_t>(return_words >> 8);
// The high byte of the 512-byte-aligned base this copy runs at: the
// return address is a word address, whose high byte is the 256-word slot
// index — on byte-addressed chips doubled back into byte terms.
// program_flash refuses this one slot and the info block is addressed
// from it, so both follow wherever the code was flashed. The high byte is
// spelled as byteswap's low byte: the builtin's value is itself built by
// swapping the two stacked bytes, and the double swap folds to the single
// byte pick a hand assembler writes — `>> 8` leaves the swap materialized.
const std::uint16_t ra_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
const std::uint8_t ra_high = static_cast<std::uint8_t>(std::byteswap(ra_words));
const std::uint8_t slot_high = word_flash ? ra_high & 0xfe : static_cast<std::uint8_t>(ra_high << 1);
await_host();
// The knock: 'p' then 'b', each under a fresh window; any other byte is
// line noise and waits again. Falling out of a window runs the app.
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
}
for (;;) {
// No prompt while an EEPROM write runs: it blocks SPM and fuse reads
// (§26.2.1), and the prompt is the previous command's completion ack.
// No prompt while an EEPROM write runs: a pending write blocks SPM
// and fuse reads (§26.2.1), and the ack tells the host all is done.
ee::wait();
tx_ack();
link::tx(ack);
const std::uint8_t command = link::rx();
switch (command) {
case 'b': { // info block, read relative to the running slot
// The block sits in the image's first 256 bytes (the build lint
// asserts it), and slots are 512-aligned — so the low byte of its
// link address (in wire units: bytes, or words on the large
// chips) is its offset in any slot, and the high byte of its
// runtime address is the running slot's. Composed from the two
// bytes — the high half is runtime data, so no absolute address
// is ever materialized.
const auto link_low = reinterpret_cast<std::uint16_t>(info_data.storage.data());
const std::uint8_t low =
word_flash ? static_cast<std::uint8_t>(link_low >> 1) : static_cast<std::uint8_t>(link_low);
send_flash(static_cast<std::uint16_t>(low | (slot_high << 8)), static_cast<std::uint8_t>(info_data.size()));
break;
}
case 'J': { // jump to a wire word address: hand-over and staging transfer
auto target = reinterpret_cast<void (*)()>(rx16());
tx_ack();
link::tx(ack);
link::drain();
jump(target);
}
case 'b': // identity: the version, then the three signature bytes
// Straight out of the stamp, so the wire and the image can never
// disagree about what this loader is. The indices are constant and
// the array is constexpr, so these are immediates, not flash reads:
// nothing here needs the stamp's runtime address.
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
link::tx(identity_stamp[at]);
break;
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
case 'G': // read: sel8, addr16, n8 (0 = 256)
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
// One decode, one cursor and one loop for every space and both
// directions: a command per memory would carry a copy of all three
// each. 'W' joins the same decode rather than keeping an address
// form of its own, so flash addressing is uniform across every
// command that names it.
const std::uint8_t selector = link::rx();
const std::uint8_t space = space_of(selector);
const std::uint8_t bank = bank_of(selector);
std::uint16_t at = rx16();
if (command == 'W') {
fill_page(bank, at);
break;
}
case 'R': // read flash: addr16, n8 (0 = 256)
case 'r': // read EEPROM: addr16, n8
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
std::uint16_t address = rx16();
std::uint8_t count = link::rx();
do {
// Read and write are one letter apart in case, so the direction
// is a single bit and the loop picks it with a one-word skip.
if (command & 0x20) {
store(space, bank, at, link::rx(), slot_high);
tx_ack();
} else
link::tx(load(space, bank, at));
++at;
} while (--count);
if (command == 'R')
send_flash(address, count);
else if (command == 'r')
send_eeprom(address, count);
else
store_eeprom(address, count);
break;
}
case 'W': // program one flash page: addr16, page bytes
program_flash(rx16(), slot_high);
break;
case 'F': // fuse and lock bytes
send_fuses();
break;
default: // unknown bytes are ignored; the loop re-acks
break;
}

View File

@@ -1,13 +1,24 @@
#!/usr/bin/env python3
"""pureboot host tool — the smart half of the protocol (README.md).
"""pureboot host tool — the smart half of the pureboot protocol (README.md).
The device exposes primitives; everything composite is here: HEX/raw images,
programming with repairing read-back verification, the reset-vector surgery
the boot-section-less chips need, and the self-update that stages the loader
one slot lower and lets it rewrite the resident.
The device exposes primitives; this tool composes them: image loading (raw
binary or Intel HEX), flash programming with read-back verification, erase as
writing 0xff, EEPROM programming, fuse and info readout, the hand-over jump,
and — on chips without a hardware boot section — the reset-vector surgery
that re-homes the application's entry through the trampoline word below the
loader. Page 0 and the trampoline are written first, so every interruption
point of a flash leaves the chip reset-recoverable into the loader.
Standard library only. The port is termios on POSIX and the Win32 serial API
through ctypes on Windows, so any tty or COM port works.
It also updates the loader itself (--update-loader): pureboot's image is
position-independent, so the tool installs the identical binary one 512-byte
slot below the resident loader, jumps into that staging copy, lets it rewrite
the resident slot, and restores what the staging slot held — resumable at
every phase from the flash state plus a host-side state file carrying the
saved bytes.
Python standard library only; the serial port is driven with termios on POSIX
and the Win32 serial API (through ctypes) on Windows, so any tty or COM port
works — a USB adapter as well as a simavr pty.
"""
import argparse
@@ -24,79 +35,23 @@ else:
import termios
PROMPT = b"+"
VERSION = 4 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that
# map lives: every version so far speaks the same protocol, and one that
# changes it becomes the new floor here.
VERSION = 2 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks to. A pureboot version implies its wire
# protocol — the protocol carries no number of its own so knowing which
# versions speak what is the tool's job, and this window is where it says so:
# every pureboot so far speaks this protocol, and a version that changes it
# becomes the new floor here.
OLDEST_LOADER = 1
NEWEST_LOADER = 5
SLOT = 512 # the loader slot, on every chip
NEWEST_LOADER = 2
SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
# the spaces below. The loader carries one transfer loop instead of four bodies
# — which is what buys the data space and the host-issued SPM operations.
UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# A selector's high nibble is the flash bank — the address bits above the 16-bit
# wire address — so a transfer names a byte address within one 64 KiB bank and
# no command has to speak word addresses. No single transfer may cross a bank
# boundary; the host chunks to keep that true.
def selector(space, address):
return space | ((address >> 16) << 4)
# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its
# byte to SPMCSR and fires the instruction at the selected flash address. Every
# part pureboot targets agrees on these encodings.
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero
# data bits — one low pulse of seven bit-times, which the loader times into its
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
CALIBRATE = 0xC0
# pureboot 5 answers 'b' with its version and the chip signature; the host
# derives the rest of the geometry from the signature rather than reading a
# table off the device. flash, page, eeprom, patch-vector per distinct
# signature, over every chip pureboot targets (the loader computes the same
# from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon.
CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284
}
VERBOSE = False
def verbose(message):
"""Detail printed only under --verbose: decisions and derived facts, not
per-byte chatter — the progress bar carries the bulk transfers."""
if VERBOSE:
print(f" {message}")
@@ -106,9 +61,11 @@ class Error(Exception):
class Progress:
"""A transient bar on stderr, drawn only for a tty and erased when done —
logs and pipes see only the summary line each operation prints. No label
or a zero total disables it, so callers can pass one unconditionally."""
"""A transient in-place bar on stderr for the operations that take wire
time. Drawn only when stderr is a tty — logs, pipes and the test harness
see nothing — and erased once done; the summary line each operation
prints afterwards is the persistent record. A zero total (or no label)
disables it, so callers can pass one through unconditionally."""
def __init__(self, label, total, unit="pages"):
self.label, self.total, self.unit = label, total, unit
@@ -360,36 +317,6 @@ Port = WindowsPort if os.name == "nt" else PosixPort
class Info:
"""The 12-byte info block."""
@classmethod
def from_identity(cls, raw):
"""pureboot 5's reply: the version and the chip signature. The rest of
the geometry is looked up from the signature — the loader derived the
same facts from its chip database at build time, so nothing is guessed,
it is simply not sent. Reconstructs a block in the older layout, so
every derived attribute below is shared with the loaders that do send
one.
The base is where application flash ends, which is a property of the
chip and not of the copy answering: a loader staged one slot lower
reports the same geometry the resident one does, exactly as the loaders
that send a block do. Which slot a copy runs in matters only to its own
write guard, which is the loader's business."""
if len(raw) != 4:
raise Error(f"bad identity reply: {raw.hex()}")
version, signature = raw[0], tuple(raw[1:4])
geometry = CHIP_GEOMETRY.get(signature)
if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
flash, page, eeprom, patch = geometry
base = flash - SLOT
word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
raw12 = bytes((ord("P"), ord("B"), version, *signature, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, eeprom & 0xFF, eeprom >> 8, flags))
return cls(raw12)
def __init__(self, raw):
if len(raw) != 12 or raw[0:2] != b"PB":
raise Error(f"bad info block: {raw.hex()}")
@@ -403,19 +330,18 @@ class Info:
self.signature = raw[3:6]
self.page = raw[6] or 256 # the wire count convention: 0 means 256
self.patch_vector = bool(raw[11] & 1)
# Bit 1: the flash runs past what one 16-bit address covers. Through
# pureboot 4 that made flash addresses words on the wire; pureboot 5
# keeps them bytes and carries the bank in the selector instead. Every
# address in this tool stays a byte address either way and converts at
# the wire.
# Large chips speak word addresses for flash (bit 1); the host keeps
# every address in bytes and converts at the wire.
self.word_flash = bool(raw[11] & 2)
scale = 2 if self.word_flash else 1
self.base = (raw[7] | (raw[8] << 8)) * scale
self.eeprom_size = raw[9] | (raw[10] << 8)
self.flash_size = self.base + SLOT
self.stage = self.base - SLOT # where a staging copy of the loader goes
# The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware.
self.slot = 1024 if self.word_flash else SLOT
self.flash_size = self.base + self.slot
self.stage = self.base - self.slot # where a staging copy of the loader goes
# The hand-over target, as the word address 'J' takes: the trampoline
# below the loader (tinies), or word 0 (mega — the application's own
# reset vector; BOOTRST re-vectors a reset into the loader instead).
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
def describe(self):
@@ -428,7 +354,7 @@ class Info:
)
def lines(self):
"""One fact per line — what --info prints."""
"""The info block as one fact per line — what --info prints."""
if self.patch_vector:
hand_over = f"host-patched reset vector, trampoline at {self.base - 2:#06x}"
else:
@@ -437,9 +363,9 @@ class Info:
f"version pureboot {self.version}",
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
f"flash {self.flash_size} B, {self.page} B pages"
+ (", past one 16-bit bank" if self.word_flash else ""),
+ (", word-addressed wire" if self.word_flash else ""),
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
f"loader {self.base:#06x} ({SLOT} B slot)",
f"loader {self.base:#06x} ({self.slot} B slot)",
f"staging {self.stage:#06x}",
f"EEPROM {self.eeprom_size} B",
f"hand-over {hand_over}",
@@ -447,76 +373,36 @@ class Info:
class Loader:
"""A session. Between commands the loader has prompted and awaits a
command byte; every method restores that, except jump() — after which the
target must be knocked afresh."""
"""A pureboot session. Between commands the loader has prompted `+` and
awaits a command byte; every method restores that invariant — except
jump(), after which the target must be knocked afresh."""
def __init__(self, port):
self.port = port
self.info = None
# Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked.
self.autobaud = False
def _read_identity(self):
"""The 'b' reply, in either of the two layouts a loader may send.
pureboot 5 answers with its version and the signature; older loaders
answer with a 12-byte block. The version byte cannot be mistaken for
the older block's 'P', so four bytes are enough to tell them apart."""
head = self.port.read_exact(4, 2.0)
if head[0:2] == b"PB":
return Info(head + self.port.read_exact(8, 2.0))
return Info.from_identity(head)
def _handshake(self, wait, knock, what):
"""One activation, retried until the loader answers or the window
closes. The identity reply is what proves the loader is listening — a
prompt byte alone does not, since one left over from a previous session
can still be in the pipeline while the port opening resets the device
into a fresh window, where a command without its knock is discarded.
Each attempt is therefore the whole handshake. This also converges into
an already-live session: the knock bytes are ignored there and the
drain absorbs whatever they produced."""
def connect(self, wait):
"""Knock until the activation window answers, then read the info
block. Also converges when the loader already sits in its command
loop: the knock bytes are ignored-or-executed there, and the drain
absorbs whatever they produced."""
self.port.flush_input()
deadline = time.monotonic() + wait
knocks = 0
while True:
self.port.flush_input()
self.port.write(knock)
self.port.write(b"pb")
knocks += 1
if PROMPT in self.port.read_available(0.4):
break
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
while self.port.read_available(0.3):
pass
self.port.write(b"b")
try:
# A version the tool cannot speak is the loader's own
# answer, not a failed knock: Info reports it rather than
# sending the tool round the loop again.
self.info = self._read_identity()
except Error as failed:
if "pureboot" in str(failed):
raise
self.info = None
if self.info is not None:
self.info = Info(self.port.read_exact(12, 2.0))
self._expect_prompt()
verbose(f"loader answered {what} {knocks}; identity read")
verbose(f"loader answered knock {knocks}; info block read")
return self.info
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
def connect(self, wait):
"""Knock 'p' then 'b' and read the identity."""
return self._handshake(wait, b"pb", "knock")
def connect_autobaud(self, wait):
"""The autobaud handshake. In place of the p+b knock the host sends the
calibration pulse — one seven-bit-time low pulse at the host's chosen
baud, which the loader times into its per-bit unit — then a single 'p'
the loader decodes at the rate it just measured. A lost pulse, or a
knock landing while the loader is mid-frame, simply fails to answer and
leaves the measurement loop waiting for the next pulse, so the retry in
_handshake covers it."""
self.autobaud = True
return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration")
def _expect_prompt(self, timeout=2.0):
byte = self.port.read_exact(1, timeout)
@@ -540,58 +426,7 @@ class Loader:
count -= chunk
return data
@property
def unified(self):
"""pureboot 5 and later: one 'G'/'g' pair over selector-named spaces."""
return self.info is not None and self.info.version >= UNIFIED_LOADER
def _read_space(self, space, address, count):
"""A run out of any space, chunked to 256 bytes and to bank bounds."""
data = b""
while count:
chunk = min(count, 256, 0x10000 - (address & 0xFFFF))
head = bytes((ord("G"), selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, chunk & 0xFF))
data += self._command(head, chunk, 5.0)
address += chunk
count -= chunk
return data
def _write_space(self, space, address, data, progress=None):
"""A run into any space. Each byte is acked as its write begins — an
EEPROM cell and an SPM operation both need that pacing, and the ack is
what the loader sends in place of a completion status."""
offset = 0
while offset < len(data):
chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))]
head = bytes((ord("g"), selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, len(chunk) & 0xFF))
self.port.write(head)
for byte in chunk:
self.port.write(bytes((byte,)))
self._expect_prompt()
if progress:
progress.step()
self._expect_prompt() # the next command prompt
address += len(chunk)
offset += len(chunk)
def spm(self, operation, address):
"""One SPM operation at a flash address — the erase, write and RWW
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here."""
self._write_space(SP_SPM, address, bytes((operation,)))
def read_ram(self, address, count):
"""Data space: SRAM, and with it the register file and every I/O
register, which share the address space on AVR. New in pureboot 5."""
return self._read_space(SP_RAM, address, count)
def write_ram(self, address, data):
self._write_space(SP_RAM, address, data)
def read_flash(self, address, count):
if self.unified:
return self._read_space(SP_FLASH, address, count)
if not self.info.word_flash:
return self._stream_read("R", address, count)
# Word-addressed wire: widen to even bounds and never let one read
@@ -609,32 +444,15 @@ class Loader:
return data[address - start : address - start + count]
def read_eeprom(self, address, count):
if self.unified:
return self._read_space(SP_EEPROM, address, count)
return self._stream_read("r", address, count)
def write_page(self, address, data):
assert len(data) == self.info.page and address % self.info.page == 0
if self.unified:
# 'W' fills the page buffer and stops there; the erase and the write
# are host-issued SPM operations. Only a chip with a boot section
# has RWW to re-enable — on the others bit 4 of SPMCSR means
# something else entirely, so it must not be sent.
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
self._command(head + data, 0, 2.0)
self.spm(SPM_ERASE, address)
self.spm(SPM_WRITE, address)
if not self.info.patch_vector:
self.spm(SPM_RWWSRE, address)
return
wire = address // (2 if self.info.word_flash else 1)
head = bytes((ord("W"), wire & 0xFF, wire >> 8))
self._command(head + data, 0, 2.0)
def write_eeprom(self, address, data, progress=None):
if self.unified:
self._write_space(SP_EEPROM, address, data, progress)
return
offset = 0
while offset < len(data):
chunk = data[offset : offset + 256]
@@ -650,21 +468,21 @@ class Loader:
offset += len(chunk)
def read_fuses(self):
if self.unified:
return self._read_space(SP_FUSE, 0, 4)
return self._command(b"F", 4, 2.0)
def jump(self, word_address):
"""The device acks, then execution continues at the word address."""
"""'J': the device acks, then execution continues at the word
address — a loader slot's base (whose copy must then be knocked
afresh) or the application entry."""
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt()
def enter_copy(self, byte_address, wait):
"""Jump into the loader copy at `byte_address` and knock it — a slot
base is that copy's entry stub, so it can only land there."""
autobaud = self.autobaud
"""Jump into the loader copy at `byte_address` and knock it. Ending
up in the copy addressed is guaranteed by construction: a jump to a
slot base lands in that slot's entry stub."""
self.jump(byte_address // 2)
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
return self.connect(wait)
def run_application(self):
self.jump(self.info.app_entry_word)
@@ -758,14 +576,15 @@ def plan_flash(image, info):
def covered(pages, info, skip_blank):
"""Pages in programming order, optionally dropping all-0xff ones (sound
only over erased flash, and never a load-bearing page).
"""Pages in programming order; optionally dropping all-0xff pages (sound
only over erased flash) — never a load-bearing one.
A patched vector puts page 0 first and the trampoline page second, so from
the first write on a reset lands in the loader and its fall-through on the
application entry — every interruption point recoverable. A hardware boot
section re-vectors reset regardless; page 0 goes last there, which
maximizes what an interrupted image retains."""
With a patched vector (tinies), the patched page 0 goes first and the
trampoline page second: from the first write on, a reset lands in the
loader and the loader's own fall-through lands on the application entry,
so every interruption point of the flash is recoverable. With a hardware
boot section a reset re-vectors to the loader regardless; ascending
order, page 0 last, maximizes what an interrupted image retains."""
trampoline_page = info.base - info.page if info.patch_vector else None
first = [0, trampoline_page] if info.patch_vector else []
rest = [a for a in sorted(pages) if a not in first]
@@ -831,35 +650,24 @@ def mega_boot(info, fuse_bytes):
def image_info(image):
"""What a pureboot binary says about itself, or None.
An update image is a bare slot: nothing about it names the chip it was
built for, and installing a foreign one bricks the target — so every
loader carries a stamp for this. Through pureboot 4 the stamp is the
12-byte info block the device also serves; pureboot 5 serves its identity
from immediates and carries a 6-byte stamp (magic, version, signature)
that only this exists for, from which the geometry is looked up exactly as
it is for a live device.
Searched once per known version, so the magic stays three selective bytes
rather than two that code could carry by chance."""
"""The info block embedded in a pureboot binary, or None. Searched per
known loader version, so the magic stays three selective bytes rather than
two that code could carry by chance — and a binary this tool does not know
the version of reads as no block at all, which is what it is to the tool."""
for version in range(OLDEST_LOADER, NEWEST_LOADER + 1):
at = image.find(b"PB" + bytes((version,)))
if at < 0:
continue
if version >= UNIFIED_LOADER:
if at <= len(image) - 6:
return Info.from_identity(image[at + 2 : at + 6])
elif at <= len(image) - 12:
if 0 <= at <= len(image) - 12:
return Info(image[at : at + 12])
return None
def loader_image(path):
"""An update image as the slot's own content: a raw binary already is,
while a HEX carries the blank below the loader's base, which is peeled off
here. The base comes from the image's own block, not the device's, so a
foreign image survives intact for the preflight to reject by name."""
"""A loader update image, as the slot's own content. A raw binary is that
already; an Intel HEX links the loader at its base inside an otherwise
blank flash image, and load_image() anchors every image at zero, so the
blank below the base is dropped here. The base comes from the image's own
info block rather than the device's, so an image built for somewhere else
survives intact and the preflight can say so."""
image = load_image(path)
embedded = image_info(image)
if embedded and len(image) > embedded.base:
@@ -868,17 +676,18 @@ def loader_image(path):
def staging_content(image, info):
"""The staging slot's content: the image, padding, and — where the
hand-over jumps through the word below the resident — that word, which for
a staging copy is its own last one. Composed as an rjmp to the resident,
so an abandoned staging copy still falls through into a loader."""
budget = SLOT - 2 if info.patch_vector else SLOT
if len(image) > budget:
raise Error(f"loader image is {len(image)} B, the slot holds {budget}")
content = bytearray(image) + bytearray([0xFF] * (SLOT - len(image)))
"""The 512-byte staging-slot content: the image, padding, and — on
chips whose hand-over jumps through the word below the resident loader —
that word, which for a staging copy is the slot's own last word: an rjmp
to the resident base. The staging copy's fall-through and 'J'-free exit
both land in a loader instead of garbage."""
slot = info.slot
if len(image) > (slot - 2 if info.patch_vector else slot):
raise Error(f"loader image is {len(image)} B, the slot holds {slot - 2 if info.patch_vector else slot}")
content = bytearray(image) + bytearray([0xFF] * (slot - len(image)))
if info.patch_vector:
through = rjmp_to((info.base - 2) // 2, info.base // 2, info.flash_size // 2)
content[SLOT - 2], content[SLOT - 1] = through & 0xFF, through >> 8
content[slot - 2], content[slot - 1] = through & 0xFF, through >> 8
return bytes(content)
@@ -904,7 +713,7 @@ def update_preflight(image, info, fuse_bytes):
raise Error(
f"cannot self-update: the staging slot {info.stage:#06x} lies below the "
f"boot section ({bls_start:#06x}) where SPM is disabled "
f"— a boot section of at least two slots ({2 * SLOT} B, BOOTSZ) is "
f"— a boot section of at least two slots ({2 * info.slot} B, BOOTSZ) is "
f"required, and only an external programmer can change fuses"
)
if not bootrst:
@@ -946,7 +755,7 @@ class UpdateState:
self.data = {
"signature": info.signature.hex(),
"base": info.base,
"staging": loader.read_flash(info.stage, SLOT).hex(),
"staging": loader.read_flash(info.stage, info.slot).hex(),
"page0": loader.read_flash(0, info.page).hex() if info.patch_vector else "",
}
with open(self.path, "w") as f:
@@ -965,8 +774,9 @@ class UpdateState:
def write_differing(loader, base, content, order=None, label=None):
"""Program the pages of `content` at `base` that differ from flash, so a
resumed phase redoes only what an interruption left."""
"""Program the pages of `content` at `base` that differ from flash
idempotent, so a resumed phase redoes only what an interruption left.
A label puts the compare-and-program loop on the progress bar."""
page = loader.info.page
offsets = list(order) if order is not None else list(range(0, len(content), page))
written = 0
@@ -979,8 +789,9 @@ def write_differing(loader, base, content, order=None, label=None):
bar.step()
if label:
verbose(f"{label}: {written} of {len(offsets)} pages differed")
# The same bounded repair as verify_pages: here a page left wrong is a
# half-written loader slot.
# Page-wise read-back with the same bounded repair as verify_pages: this
# is the loader-update path, where a page left wrong is a half-written
# loader slot.
for retry in range(RETRIES + 1):
bad = [
offset
@@ -1002,8 +813,8 @@ def write_differing(loader, base, content, order=None, label=None):
def patch_word0(loader, page0, target_base):
"""Re-aim word 0 at `target_base` — the resume insurance around
rewriting a loader slot the reset path goes through."""
"""Rewrite page 0 with its word 0 re-aimed at `target_base` — the
resume insurance around rewriting a loader slot the reset path uses."""
info = loader.info
patched = bytearray(page0)
word = rjmp_to(0, target_base // 2, info.flash_size // 2)
@@ -1013,9 +824,10 @@ def patch_word0(loader, page0, target_base):
def op_update_loader(loader, wait, path, state_path, fuse_bytes):
"""Replace the resident loader with `path`, using the loader as its own
staging loader. Every phase is idempotent and keyed off the flash state,
so a re-run resumes; the state file carries what the staging slot held."""
"""Replace the resident loader with `path`, using the loader itself as
its own staging loader. Every phase is idempotent and keyed off the
actual flash state, so a re-run after any interruption resumes; the
state file carries the bytes the staging slot held."""
info = loader.info
image = loader_image(path)
for warning in update_preflight(image, info, fuse_bytes):
@@ -1023,7 +835,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
update = image_info(image) # the preflight proved it is there
verbose(f"installing pureboot {update.version} over pureboot {info.version}")
staged = staging_content(image, info)
resident = bytes(image) + bytes([0xFF] * (SLOT - len(image)))
resident = bytes(image) + bytes([0xFF] * (info.slot - len(image)))
page = info.page
state = UpdateState(state_path)
@@ -1033,32 +845,38 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
verbose(f"saving the staging slot to {state_path}")
state.load_or_save(loader)
# A loader already sitting whole in the staging slot IS the staging copy:
# rewriting it would only meet its own running-slot guard. Any pureboot
# with the device's info block serves, since a staged copy only streams
# pages. "Whole" needs both checks — the block where every image carries
# it and matching byte for byte, and the slot unchanged since this update
# began, so a half-written install takes the path below instead.
current = loader.read_flash(info.stage, SLOT)
# The whole slot is searched: a loader's stamp sits wherever its image put
# it, which is the end of the code on pureboot 5 and the front of it
# before that.
staged_loader = image_info(current)
# Install the staging copy — unless a loader already sits whole in the
# staging slot (a build programmed there by hand): that copy IS the
# installed staging copy, and rewriting it would only trip its own
# running-slot guard on the composed through-word. Any pureboot with
# the device's own info block serves — the staged copy just streams
# pages, so an older build installs a newer resident all the same. Two
# checks make "already a loader" mean a *complete* one: the block must
# sit where every image carries it (within the slot's first 256 bytes
# — the build's position lint), matching the device's block byte for
# byte, and the slot must be unchanged since this update began (the
# state file's snapshot) — a resumed, half-written install differs
# from its snapshot and takes the install path below, which completes
# it page by page.
current = loader.read_flash(info.stage, info.slot)
staged_loader = image_info(current[:268])
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
print("staging slot already holds a loader — left in place")
else:
# Where the staging slot starts at address 0 (the 1 KB tiny13s) its
# first page carries the reset vector, so it goes last: until then a
# reset still reaches the old resident.
order = list(range(0, SLOT, page))
# On a chip whose staging slot starts at address 0 (the 1 KB
# tiny13s), its first page carries the reset vector: written last,
# so any earlier interruption still resets into the old resident,
# and from then on resets enter the staging copy.
order = list(range(0, info.slot, page))
if info.stage == 0:
order = order[1:] + [0]
if write_differing(loader, info.stage, staged, order, label="staging copy"):
print(f"staging copy installed at {info.stage:#06x}")
# Enter it and let it rewrite the resident. Where a patched reset vector
# routes through the resident, word 0 is re-aimed at the staging copy for
# the rewrite, so a power loss mid-rewrite still resets into a loader.
# Enter it and let it rewrite the resident slot. Where a patched reset
# vector routes through the resident (a tiny with the staging slot away
# from page 0), word 0 is re-aimed at the staging copy around the
# rewrite, so a power failure mid-rewrite still resets into a loader.
verbose(f"entering the staging copy at {info.stage:#06x}")
loader.enter_copy(info.stage, wait)
redirect = info.patch_vector and info.stage != 0
@@ -1076,7 +894,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
if redirect:
verbose("word 0 restored")
write_differing(loader, 0, state.page0)
order = list(range(0, SLOT, page))
order = list(range(0, info.slot, page))
if info.stage == 0:
order = [0] + order[1:]
write_differing(loader, info.stage, state.staging, order, label="staging restore")
@@ -1086,9 +904,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
def check_walk_region(pages, info, fuse_bytes, force):
"""BOOTRST programmed below the loader means reset reaches it only by
walking across erased flash; application data in that span would divert
reset into itself. Needs the fuses (--fuses or --assume-fuses)."""
"""With BOOTRST programmed but targeting below the loader, reset reaches
the loader only by walking across erased flash from the boot-section
start; application data in that span would divert reset into itself.
Only checkable when the fuses are known (--fuses or --assume-fuses)."""
if info.patch_vector or fuse_bytes is None:
return
bootrst, bls_start = mega_boot(info, fuse_bytes)
@@ -1107,9 +926,10 @@ def check_walk_region(pages, info, fuse_bytes, force):
def op_erase_flash(loader):
"""0xff over the application area, descending where the reset vector is
patched: page 0 goes last, so an interrupted erase still resets into the
loader and once it is gone, the erased walk reaches it anyway."""
"""0xff over the whole application area. Descending on a patched-vector
chip: page 0 — the patched reset vector — goes last, so an interrupted
erase still resets into the loader, and once it is gone the whole area
is erased and the reset walk reaches the loader anyway."""
blank = bytes([0xFF] * loader.info.page)
addresses = range(0, loader.info.base, loader.info.page)
with Progress("erase", len(addresses)) as bar:
@@ -1145,10 +965,11 @@ def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
def verify_pages(loader, pages, repair=False):
"""Read every page back and compare. With `repair`, a mismatch is
rewritten and re-read up to RETRIES times first: a page filled over a
dirty SPM buffer takes stale words, and the write that took them cleared
the buffer, so one rewrite settles it. Anything still wrong is not that."""
"""Read every page back and compare. With `repair`, a mismatched page is
rewritten and re-read, up to RETRIES times before it is raised: a page
filled over a dirty SPM buffer takes stale words, and the write that took
them cleared the buffer, so one rewrite settles it. Anything still wrong
after three is not that, and stops the run."""
repaired = 0
with Progress("verify", len(pages)) as bar:
for address in sorted(pages):
@@ -1226,37 +1047,6 @@ def op_read_eeprom(loader, path):
print(f"read EEPROM: {len(data)} B -> {path}")
def _require_unified(loader, what):
if not loader.unified:
raise Error(f"{what} needs pureboot {UNIFIED_LOADER} or later; this loader is {loader.info.version}")
def _peek_spec(spec):
"""ADDR[:N] — addresses and counts in any Python integer base."""
address, _, count = spec.partition(":")
return int(address, 0), int(count, 0) if count else 1
def op_peek(loader, spec):
_require_unified(loader, "--peek")
address, count = _peek_spec(spec)
data = loader.read_ram(address, count)
for offset in range(0, len(data), 16):
row = data[offset : offset + 16]
text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
print(f"{address + offset:#06x} {row.hex(' '):<47} {text}")
def op_poke(loader, spec):
_require_unified(loader, "--poke")
address, _, payload = spec.partition(":")
if not payload:
raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef")
data = bytes.fromhex(payload.replace(" ", ""))
loader.write_ram(int(address, 0), data)
print(f"poke: {len(data)} B at {int(address, 0):#06x}")
def op_fuses(loader):
low, lock, extended, high = loader.read_fuses()
print("fuses:")
@@ -1290,9 +1080,6 @@ def main():
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1308,10 +1095,6 @@ def main():
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, "
"I/O) — pureboot 5 and later")
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space — "
"pureboot 5 and later")
parser.add_argument("--force", action="store_true", help="override refusable safety checks")
parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
parser.add_argument("-v", "--verbose", action="store_true",
@@ -1334,7 +1117,7 @@ def main():
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try:
loader = Loader(port)
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
info = loader.connect(args.wait)
if args.info:
print("device:")
for line in info.lines():
@@ -1363,10 +1146,6 @@ def main():
op_read_eeprom(loader, args.read_eeprom)
if args.verify_eeprom:
op_verify_eeprom(loader, args.verify_eeprom)
if args.poke:
op_poke(loader, args.poke)
if args.peek:
op_peek(loader, args.peek)
if args.stay:
print("loader stays in its session (reset to leave)")
else:

View File

@@ -1,75 +1,53 @@
#!/usr/bin/env python3
"""Position-independence lint: the property that lets the identical image run
from any slot, asserted from the built ELF and its object.
"""Position-independence lint for the pureboot image.
1. No absolute jmp/call — -mrelax normally guarantees it, but a branch that
grows out of relaxation range would break it silently.
2. Nothing flash-resident to address: the image is .text alone, so there is
no table whose runtime address has to be reconstructed.
3. The image is byte-identical when linked at a different base. This is
position independence itself rather than a proxy for it — an absolute
address anywhere in the image would move with the link and show up as a
differing byte.
The self-staging design lets the identical binary run from any 512-byte
slot, which holds only if nothing in the image addresses itself absolutely.
Two link-time facts guarantee it, both asserted here from the built ELF:
Usage: check_pi.py <objdump> <objcopy> <cxx> <mcu> <elf> <object> <text_start_hex>
1. No absolute jmp/call opcodes — all control flow is PC-relative
(rjmp/rcall/ijmp/icall). -mrelax normally guarantees this; a code
change that grows a branch out of relaxation range would break it
silently.
2. The info block sits within the image's first 256 bytes: the 'b'
command rebuilds its address as (running slot high byte : low byte of
the link address), which needs the offset to fit that low byte.
Usage: check_pi.py <objdump> <nm> <elf> <text_start_hex>
"""
import os
import re
import subprocess
import sys
import tempfile
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:]
objdump, nm, elf, text_start = sys.argv[1:]
text_start = int(text_start, 0)
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)]
absolute = [
line
for line in listing.splitlines()
if re.search(r"\t(jmp|call)\t", line)
]
if absolute:
fail("absolute control flow in the image:\n" + "\n".join(absolute))
print("FAIL: absolute control flow in the image:")
print("\n".join(absolute))
sys.exit(1)
# Allocated flash beyond .text would be data the running copy has to find.
# Only ALLOC sections reach the device at all; .comment and the debug
# sections ride along in the ELF container and are never flashed. objdump
# prints each section's flags on the line following its header.
headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines()
for index, line in enumerate(headers):
fields = line.split()
if len(fields) < 6 or not fields[0].isdigit():
continue
name, size = fields[1], int(fields[2], 16)
flags = headers[index + 1] if index + 1 < len(headers) else ""
if "ALLOC" not in flags or not size:
continue
if name not in (".text", ".noinit", ".bss"):
fail(f"flash-resident section {name} ({size} bytes): the image must be .text alone")
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
if len(info) != 1:
print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
sys.exit(1)
address = int(info[0].split()[0], 16)
offset = address - text_start
if not 0 <= offset < 256:
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
sys.exit(1)
# Relink at a different base and compare the bytes.
with tempfile.TemporaryDirectory() as work:
elsewhere = text_start - 0x200 if text_start >= 0x200 else text_start + 0x200
images = []
for base, tag in ((text_start, "here"), (elsewhere, "there")):
relinked = os.path.join(work, f"{tag}.elf")
binary = os.path.join(work, f"{tag}.bin")
subprocess.run(
[cxx, f"-mmcu={mcu}", "-nostartfiles", f"-Wl,--section-start=.text={base:#x}",
"-Wl,--defsym=pureboot_app=0", "-mrelax", obj, "-o", relinked],
check=True, capture_output=True)
subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True)
images.append(open(binary, "rb").read())
if images[0] != images[1]:
differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b]
fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: "
f"{len(differing)} byte(s) differ, first at offset {differing[0]:#x}")
print(f"PI lint: control flow PC-relative, .text only, identical linked at {text_start:#x} and {elsewhere:#x}")
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}")
if __name__ == "__main__":

View File

@@ -66,10 +66,9 @@ struct link {
}
[[noreturn]] static void idle()
{
// 'L' hands back to the loader in the top slot — 512 bytes on every
// chip. The jump takes a word address, which is what makes the
// >64 KiB chips' entry reachable through a 16-bit pointer at all.
constexpr std::uint32_t slot = 512;
// 'L' hands back to the loader at the top slot — 512 bytes, or the
// 1 KiB the >64 KiB chips use.
constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 512;
for (;;) {
auto command = tx_t::read_blocking();
if (command == 'L')

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""End-to-end autobaud test: drive an autobaud loader in simavr through the
calibration handshake and a flash + EEPROM + fuse round-trip, cross-checked
against the simulator's ground-truth memory — then repeat at a second F_CPU with
the *same* loader binary, which is the property autobaud exists for: one
clock-agnostic image that locks onto whatever rate the host sends.
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
<app_bin> <app_hz> <app_baud> <tool_py> <workdir>
The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's
default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). The
app fixture is built for (app_hz, app_baud); the hand-over is checked at that
point, and a second point at half the clock proves the lock is measured, not
baked in.
"""
import os
import sys
import time
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:]
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
ee_image = bytes(range(0xA0, 0xB0))
ee_path = os.path.join(workdir, "ee.bin")
open(ee_path, "wb").write(ee_image)
# The geometry the surgery planner needs, from the chip class the runner is
# told — the same derivation pbtest.py makes: the boot-sectioned megas need
# no vector surgery, the tinies and the boot-section-less m48s do, and the
# large chips speak word addresses.
mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48")
word_flash = base + pb.SLOT > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
ground_truth = pb.Info(bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, 0, 0, flags]))
def round_trip(hz, baud, label, hand_over):
"""One clock point: reset, calibrate + knock, program, verify against the
simulator's own flash, and (at the app's point) hand over to the fixture."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link="sw:B0,B1")
try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses",
"--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{out}")
# Read both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--verify-flash", app_bin,
"--verify-eeprom", ee_path, "--read-flash", read_flash,
"--read-eeprom", read_eeprom, "--stay")
if out.count("verify:") != 2:
fail(f"{label}: did not verify both memories\n{out}")
if open(read_eeprom, "rb").read()[: len(ee_image)] != ee_image:
fail(f"{label}: EEPROM read-back mismatch")
if hand_over:
# Regression: a calibration pulse with no knock behind it must
# not wedge the loader. The knock's edge wait used to be
# unbudgeted, so one stray low pulse — EMI, or a host that opens
# the port and never knocks — held the loader forever and the
# application never ran. The whole activation is bounded now, so
# the window closes and the app boots; the banner is the proof.
# (The pause lets the loader reach its measurement loop, so the
# pulse is genuinely seen and the test cannot pass vacuously.)
device.reset()
port = pb.Port(device.pty, baud)
try:
time.sleep(0.2)
port.write(bytes((pb.CALIBRATE,)))
# Accumulate rather than match exactly: the reset leaves the
# idle line a framing artefact ahead of the banner, which is
# noise here — the question is only whether the app ran.
seen = b""
deadline = time.monotonic() + 180.0
while b"APP" not in seen and time.monotonic() < deadline:
seen += port.read_available(1.0)
if b"APP" not in seen:
fail(f"{label}: lone calibration pulse wedged the loader — app never bannered, saw {seen!r}")
print(f" {label}: lone calibration pulse does not wedge the loader")
finally:
port.close()
device.reset()
port = pb.Port(device.pty, baud)
try:
loader = pb.Loader(port)
live = loader.connect_autobaud(15)
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
fail(f"{label}: loader reports pureboot {live.version}")
if loader.unified:
# pureboot 5's data space. 0x0200 is clear of the
# loader's own .noinit unit at the bottom of SRAM and of
# the stack at the top. Reading it back over the same
# locked link proves both directions of the new space.
probe = bytes(range(0x30, 0x40))
loader.write_ram(0x0200, probe)
if loader.read_ram(0x0200, len(probe)) != probe:
fail(f"{label}: RAM round-trip mismatch")
# The register file and the I/O space share the data
# address space on AVR, so the same command reaches a
# peripheral register. SPMCSR reads back as idle here.
verbose_ram = loader.read_ram(0x0200, 4)
print(f" {label}: RAM read/write ok ({verbose_ram.hex()})")
loader.run_application()
banner = port.read_exact(3, 5.0)
if banner != b"APP":
fail(f"{label}: application banner was {banner!r}")
finally:
port.close()
finally:
device.stop()
# Ground truth (read after the runner exits and writes its dump): what
# the tool programmed must be what the simulator actually holds.
pages = pb.plan_flash(open(app_bin, "rb").read(), ground_truth)
flash_true = open(dump, "rb").read()
for address, data in pages.items():
if flash_true[address : address + page] != data:
fail(f"{label}: simulator flash differs from the programmed image at {address:#06x}")
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
+ (", hand-over ok" if hand_over else ""))
# The app fixture is built for one clock; the hand-over banners there. A
# second point at double that clock, same loader binary, proves the lock is
# measured, not baked in — the whole point of autobaud. (Doubling keeps the
# bit period healthy; halving would drop it below the software UART's floor.)
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks")
if __name__ == "__main__":
main()

View File

@@ -1,13 +1,15 @@
#!/usr/bin/env python3
"""Dirty-page-buffer acceptance test: with no discard in the loader, a page
filled over words an earlier writer left takes those instead. The whole
contract is asserted — a bare verify sees the corruption, the repairing
verify fixes it in one rewrite, and it stays fixed.
"""Dirty-page-buffer acceptance test: the loader carries no buffer discard,
so a page filled over words an earlier writer left behind programs those
instead. This asserts the whole contract — the corruption is real and a bare
verify sees it, the repairing verify fixes it in one rewrite (the write that
took the stale words auto-erased the buffer), and it stays fixed.
The state is reached the one way the loader cannot prevent: an application
dirties the buffer and jumps in with no reset between. Boot-sectioned megas
forbid that outright (SPM runs only from the boot section, Atmel-8271 §26.2),
but simavr dispatches SPM from anywhere, which is what makes it constructible.
The state is reached the way the loader cannot prevent: an application
dirties the buffer and jumps in with no reset between. Real boot-sectioned
megas forbid that outright SPM executes only from the boot section
(Atmel-8271 §26.2) — but simavr dispatches SPM from anywhere, which is what
makes the path constructible at all.
Usage: pbdirty.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <app_bin> <tool_py> <workdir>

View File

@@ -1,13 +1,19 @@
#!/usr/bin/env python3
"""Re-homing acceptance test: an image programmed somewhere other than its
canonical slot must still be a working loader, and the ordinary
"""Re-homing acceptance test: a pureboot image programmed somewhere other
than its canonical top slot must still be a working loader
position-independent, guarding its accidental slot — and the ordinary
--update-loader flow must put a build into the top slot from there.
Two positions. Address 0, a raw .bin handed to a programmer: the staging
install and the word-0 redirect run from copies outside page 0's slot, so the
running-slot guard never blocks them. And the staging slot itself, where a
loader already sitting there IS the staging copy — recognized by its embedded
block and left in place, then streaming the new resident like any staged copy.
Two positions are exercised. Address 0 (a raw .bin handed to a programmer,
which defaults to offset 0): the staging install and the word-0 redirect
both run from copies whose slots are not page 0's, so the running-slot
guard never blocks the flow. The staging slot itself: a loader already
sitting there IS the installed staging copy — the tool recognizes it by
its embedded info block and leaves it in place instead of tripping the
copy's own guard on the composed through-word — and that (older) copy
streams the new resident like any staged copy. In both cases flashing an
application through the healed resident overwrites the stale copy, vector
surgery included, and the banner proves the launch.
Usage: pbrehome.py <device_bin> <pureboot_elf> <update_bin> <mcu> <hz>
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
@@ -84,7 +90,7 @@ def main():
# The staging slot: erased flash with the loader sitting exactly where
# a staging copy would — the tool must leave it in place and let it
# stream the (different) update build into the resident slot.
stage = base - pb.SLOT
stage = base - 512
rehome_from(pbsim, pb, device_bin, elf, hex(stage), hex(stage), update_bin, base, page, baud, app_bin, workdir,
mcu, hz)
print("re-home from the staging slot: converged")

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Position-independence acceptance test: the identical binary, flashed one
slot below the resident, must serve the complete command set from there. The
info block must come back byte-identical, the write guard must refuse the
staged copy's own slot and permit the resident's, and the staged copy must be
able to rewrite the resident verbatim.
"""Position-independence acceptance test: the identical pureboot binary,
flashed one slot below the resident loader, must serve the complete command
set from there. The resident installs it (through-word composed by the host
layer), 'J' transfers control, and every command is exercised against the
staged copy — the info block must come back byte-identical, the write guard
must protect the staged copy's own slot and permit the resident's, and the
staged copy must be able to rewrite the resident slot verbatim.
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
@@ -81,7 +83,7 @@ def main():
# Restore the resident image through the staged copy, then 'J' back
# into it and prove it lives.
resident = image + b"\xff" * (pb.SLOT - len(image))
resident = image + b"\xff" * (info.slot - len(image))
pb.write_differing(loader, base, resident)
back_info = loader.enter_copy(base, 25)
if back_info.raw != resident_info:

View File

@@ -1,13 +1,15 @@
#!/usr/bin/env python3
"""End-to-end protocol test: drive the simavr device with the real host tool
over its pty through flash, EEPROM, fuse and hand-over scenarios, and
cross-check the tool's view against the simulator's ground-truth dumps.
"""End-to-end pureboot protocol test: spawn the simavr device, then drive it
with the real host tool (pureboot.py, as a subprocess over the device's pty)
through flash + EEPROM + fuse + hand-over scenarios, and cross-check
the tool's view against the simulator's ground-truth memory dumps.
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link]
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...), for a
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...) for a
loader built off the chip's natural serial default.
Exits 0 if every scenario passes.
"""
import os
@@ -55,13 +57,7 @@ def main():
# the page byte is the wire's 0-means-256.
mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48")
# Where SRAM begins: the x8 and x4 megas push it past their extended I/O
# space, everything else starts right after the plain I/O registers. The
# loader keeps no statics and its stack sits at RAMEND, so the first SRAM
# byte is free for the data-space probe below.
classic = mcu in ("atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a")
ram_base = 0x0100 if mega and not classic else 0x0060
word_flash = base + pb.SLOT > 0x10000
word_flash = base + 512 > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
info = pb.Info(
@@ -79,20 +75,12 @@ def main():
if needed not in out:
fail(f"session 1 output lacks {needed!r}")
# Session 2: reconnect into the live session, verify, dump, exercise
# the data space; hand over is deferred — the pty must be reopened for
# the APP banner first.
probe = "c0ffee"
# Session 2: reconnect into the live session, verify, dump, hand over
# is deferred — the pty must be reopened for the APP banner first.
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay")
if out.count("verify:") != 2:
fail("session 2 did not verify both memories")
# What went into SRAM must come back out of it: the data space is one
# more selector on the same transfer as flash and EEPROM, so a wrong
# selector decode would show up here and nowhere else.
if probe not in out.replace(" ", ""):
fail(f"data-space round trip at {ram_base:#x} did not read back {probe}\n{out}")
eeprom_back = open(read_eeprom, "rb").read()
if eeprom_back[: len(ee_image)] != ee_image:
@@ -114,29 +102,11 @@ def main():
try:
loader = pb.Loader(port)
live = loader.connect(15)
# The loader built from this tree must report a version the tool
# beside it speaks — a bump the tool was never told about is a
# loader it would refuse to talk to. Not equality with the newest:
# the tool now spans two loader generations, the fixed-baud one
# here and the unified autobaud loader that follows it.
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool speaks "
f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}")
# A W addressed inside a page rather than at its base must still
# consume exactly one page and prompt. The loader's own slot is the
# target — the guard refuses to commit it — and the payload is
# erased-state bytes, so the probe can disturb neither the image nor
# the page buffer it leaves behind. Hand-built rather than through
# write_page(), which would follow the fill with its erase and
# write; the point here is that the fill alone consumes exactly one
# page whatever the address's low bits say.
wire = base + 1
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
+ b"\xff" * page)
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("unaligned W did not return to the prompt")
# The loader built from this tree and the tool beside it must
# agree on where the version numbering stands: a bump the tool
# was never told about is a loader it would refuse to speak to.
if live.version != pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}")
loader.run_application()
banner = port.read_exact(3, 5.0)
if banner != b"APP":
@@ -157,7 +127,7 @@ def main():
# loader, the trampoline on the application's own entry (patched-vector
# chips only — a boot-sectioned mega's word 0 stays the application's).
if patch:
flash_words = (base + pb.SLOT) // 2
flash_words = (base + 512) // 2
app = open(app_bin, "rb").read()
word0 = flash_true[0] | (flash_true[1] << 8)
if rjmp_decode(word0, 0, flash_words) != base // 2:

View File

@@ -1,12 +1,17 @@
#!/usr/bin/env python3
"""Self-update end-to-end: an application is flashed, the loader replaces
itself with a re-timed build, and every power-fail phase is rehearsed by
killing the device mid-write, restarting it from its flash dump, and letting
a re-run complete the update.
"""Self-update end-to-end: an application is flashed, then the loader
replaces itself with a re-timed build through the host tool's
--update-loader — and the power-fail phases of that update are rehearsed by
killing the simulated device mid-write, restarting it from its flash dump,
and letting a re-run complete the update.
The boot-sectioned megas run the BOOTRST-unprogrammed profile reset boots
the application, whose 'L' is the application-owned loader entry — with
--assume-fuses standing in for the fuse read simavr cannot model.
The boot-sectioned megas run the BOOTRST-unprogrammed profile (reset boots
the application; the fixture application's 'L' jump is the application-owned
loader entry), with --assume-fuses standing in for the fuse read simavr
cannot model. The patched-vector chips — the tinies and the m48s — reset
into a loader at every phase by construction: the t13a because its staging
slot carries the reset vector itself, the others through the word-0 redirect
the tool plants around the resident rewrite.
Usage: pbupdate.py <device_bin> <pureboot_elf> <update_elf> <mcu> <hz>
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
@@ -45,7 +50,7 @@ def assumed_fuses(pb, image):
image's embedded signature."""
info = pb.image_info(image)
which, ladder = pb.BOOT_FUSE[bytes(info.signature[1:3])]
bits = min((b for b in ladder if ladder[b] * 2 >= 2 * pb.SLOT), key=lambda b: ladder[b])
bits = min((b for b in ladder if ladder[b] * 2 >= 2 * info.slot), key=lambda b: ladder[b])
fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF))
fuses[which] = 0xF8 | (bits << 1) | 1
return bytes(fuses)
@@ -88,13 +93,16 @@ def main():
# The m48s are megas without a boot section: patched vector, no fuse
# preflight, and the same reset-to-0 the tinies get.
patch = not mega or mcu.startswith("atmega48")
# Word-addressed (>64 KiB) chips use the 1 KiB slot; their loader base
# itself sits beyond the 16-bit byte space — the 644's base + slot only
# touches the 64 KiB boundary and stays byte-addressed.
slot = 1024 if base >= 0x10000 and mega else 512
reset_hex = "0" if mega else None # the boot-sectioned mega runs BOOTRST-unprogrammed here
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
slot = pb.SLOT
os.makedirs(workdir, exist_ok=True)
objcopy = os.environ.get("PB_OBJCOPY", "avr-objcopy")
images = {}

View File

@@ -182,16 +182,9 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par
{
(void)mcu;
(void)param;
if (tx_bit < 8) {
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8)
return when + bit_cycles;
/* The byte is not delivered until its stop bit has passed. A real
* receiver cannot answer sooner, and a host that did would put its
* start bit on the wire while the device is still driving the stop
* bit — which the device, transmitting, is not watching for. */
return when + bit_cycles;
}
if (write(pty_master, &tx_shift, 1) != 1)
fprintf(stderr, "device: pty write lost a byte\n");
tx_active = 0;

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Host-tool unit tests — the planning and policy logic, no simulator:
programming orders and their recovery properties, the reset-vector surgery,
the staging composition, the boot-fuse decode, and the update preflight over
fuse combinations simavr cannot model.
"""Host-tool unit tests — the pure planning and policy logic, no simulator:
the flash-programming orders and their recovery properties, the reset-vector
surgery, the staging-slot composition, the mega boot-fuse decode, and the
update preflight's error/warning matrix (fuse combinations simavr cannot
model reach it here as synthetic bytes).
Usage: test_planner.py <tool_py>
"""
@@ -30,17 +31,10 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla
scale = 2 if word_flash else 1
wire_base = base // scale
flags = (1 if patch else 0) | (2 if word_flash else 0)
# The EEPROM size comes from the signature, as it must: pureboot 5 derives
# the whole geometry from the signature rather than sending it, so a
# synthetic block that disagreed with its own signature would describe a
# chip that cannot exist.
eeprom = pb.CHIP_GEOMETRY[signature][2]
raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version,
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
eeprom & 0xFF, eeprom >> 8, flags))
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags))
info = pb.Info(raw)
if info.flash_size != flash:
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
assert info.flash_size == flash
return info
@@ -100,7 +94,9 @@ def main():
((0x1E, 0x97, 0x05), 0x20000, 3, {0b11: 0x1FC00, 0b10: 0x1F800, 0b01: 0x1F000, 0b00: 0x1E000}), # 1284P
)
for signature, flash, which, ladder in cases:
chip = info_of(pb, flash - pb.SLOT, 128 if flash < 0x20000 else 0, False, flash,
# Word-addressed chips carry the 1 KiB slot (their smallest boot sector).
slot = 1024 if flash > 0x10000 else 512
chip = info_of(pb, flash - slot, 128 if flash < 0x20000 else 0, False, flash,
signature=signature, word_flash=flash > 0x10000)
for bits, start in ladder.items():
fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF))
@@ -113,12 +109,10 @@ def main():
if prog or at != start:
fail(f"mega_boot {signature[1]:02x}{signature[2]:02b} unprogrammed: {prog} {at:#07x}")
# Word-addressed info decode: the 1284P's base and page ride the wire
# scaled — a 17-bit base halved into the block's two bytes, a 256-byte page
# spelled 0 — and its slot is the same 512 bytes as everywhere else, so its
# staging slot lands inside the 1 KiB minimum boot section.
big = info_of(pb, 0x1FE00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True)
if big.page != 256 or big.base != 0x1FE00 or big.stage != 0x1FC00:
# Word-addressed info decode: the 1284P's base/page ride the wire scaled,
# and its slot is 1 KiB.
big = info_of(pb, 0x1FC00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True)
if big.page != 256 or big.base != 0x1FC00 or big.stage != 0x1F800 or big.slot != 1024:
fail(f"word-addressed info decode: page {big.page}, base {big.base:#x}, stage {big.stage:#x}")
# Surgery: word 0 lands on the loader, the trampoline on the original
@@ -168,16 +162,11 @@ def main():
fail("mega staging content should be the bare image")
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
# The image stamp: found in a synthetic binary, absent in noise. pureboot
# 5 stamps the magic, its version and the signature, and the geometry is
# looked up from there — so what comes back must equal what a live device
# of the same chip reports.
stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature)
binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10
# The embedded info block: found in a synthetic binary, absent in noise.
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10
found = pb.image_info(binary)
if found is None or found.raw != tiny.raw:
fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: "
f"{found.raw.hex() if found else None} != {tiny.raw.hex()}")
fail("image_info misses the embedded block")
if pb.image_info(bytes((0xAA,)) * 40) is not None:
fail("image_info invents a block")
# An older loader's image stays readable, so a deployed build can be
@@ -226,15 +215,6 @@ def main():
if pb.update_preflight(bytes((0xAA,)) * 8 + tiny.raw, tiny, None) != []:
fail("tiny preflight should pass without fuses")
# The 1284s' smallest boot section (512 words) is exactly the resident
# slot plus its staging slot, so self-update is possible at the minimum
# BOOTSZ — no fuse step up, the 644's geometry. That holds only while a
# slot is 512 B: at 1 KiB the staging slot would fall outside the section
# and the preflight would refuse.
notes = pb.update_preflight(bytes((0xAA,)) * 8 + big.raw, big, fuses(0xFE))
if not any("staging slot" in n for n in notes):
fail(f"1284 minimum-BOOTSZ notes: {notes}")
# The walk-region refusal: BOOTRST aimed below the loader plus app data
# in the walk span errors without --force; erased spans and unprogrammed
# BOOTRST pass.
@@ -291,68 +271,6 @@ def main():
if device.writes != pb.RETRIES + 1:
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
# The knock handshake against a device that is not listening yet — the
# state a port open leaves behind: it resets the chip into a fresh
# activation window while the previous session's prompt is still in
# flight, so the first knock is lost and a prompt arrives anyway.
class FakePort:
"""A loader in its activation window, plus `lost` leading writes the
reset swallows and one stale prompt still on the wire."""
def __init__(self, info_raw, lost=0, stale=b"", active=False):
self.info_raw = info_raw
self.lost = lost
self.inflight = bytearray(stale)
self.rx = bytearray()
self.active = active
self.last = None
def flush_input(self):
self.rx.clear()
def write(self, data):
if self.lost:
self.lost -= 1
return
for byte in bytes(data):
if not self.active:
self.active = self.last == ord("p") and byte == ord("b")
self.last = byte
if self.active:
self.rx += pb.PROMPT
elif byte == ord("b"):
self.rx += self.info_raw + pb.PROMPT
else:
self.rx += pb.PROMPT
def read_available(self, wait):
self.rx = self.inflight + self.rx # the stale prompt lands late
self.inflight.clear()
out, self.rx = bytes(self.rx), bytearray()
return out
def read_exact(self, count, timeout):
if len(self.rx) < count:
raise pb.Error(f"timeout: got {len(self.rx)} of {count} bytes")
out, self.rx = bytes(self.rx[:count]), self.rx[count:]
return out
raw = info_of(pb, 0x7E00, 128, False, 0x8000).raw
for what, port in (
("clean window", FakePort(raw)),
("stale prompt over a lost knock", FakePort(raw, lost=1, stale=pb.PROMPT)),
("live session", FakePort(raw, active=True)),
):
info = pb.Loader(port).connect(5)
if info.raw != raw:
fail(f"connect ({what}) returned {info.raw.hex()}")
# A device that never answers still says so, and a version the tool cannot
# speak is reported as such rather than retried into a timeout.
expect_error("dead device", lambda: pb.Loader(FakePort(raw, lost=99)).connect(0), "no answer")
old = bytes(raw[:2]) + bytes((pb.NEWEST_LOADER + 1,)) + bytes(raw[3:])
expect_error("unspeakable version", lambda: pb.Loader(FakePort(old)).connect(5), "needs a newer tool")
print("test_planner: all planner and policy checks pass")

View File

@@ -2,14 +2,12 @@
# The port's gate: every chip's generated workflow — build, size matrix, and
# the simulator-driven protocol suites. --full adds the reflect-spot builds
# (libavr's rule: reflect compiles are bounded to its spot set, never the
# full matrix) and swaps the compact size matrix for the exhaustive
# clock × baud × backend cross product. LIBAVR_ROOT must point at the libavr
# checkout.
# full matrix). LIBAVR_ROOT must point at the libavr checkout.
set -e
cd "$(dirname "$0")/.."
full=0
[[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
[[ "$1" == "--full" ]] && { full=1; shift; }
CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85
atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a