36 Commits
v2 ... v6

Author SHA1 Message Date
a3ea099105 pureboot: the detail reaches become the library's own API
The three things this repo took from under libavr's counter are now over
it, and the local copies fold away. The USART release on a software
link's pins is the library's init contract (its guard here becomes a
deletion, byte-identical images held by the gate); the WDRF routing test
is power::peek_reset_cause().watchdog instead of a hand lookup of the
flag's register; the tsb tiers' baud arithmetic is the public solver.
libavr pin advances over those three additions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:12:35 +02:00
c535c4756c pureboot: the activation countdown in the narrowest type that holds it
The fixed-baud window counted down a uint32 where almost every window fits
24 bits; the countdown now takes avr::uint24_t when the poll budget allows
(the autobaud budget's own choice), uint32 past 16.7M polls — four bytes
off every fixed-baud image on every chip, the full suites green on the
changed window. The README size table is refreshed — its autobaud column
had also gone stale by the no-assembly pass's measurement-loop win, which
nothing gated: sizes.py check-readme now runs as the gate's final stage,
where every tree is freshly built and the table can actually be held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:47:29 +02:00
321ff8a4ee test: the device runners speak the C++ the rest of the repo does
pureboot_device and the tsb device, C until now, rewritten in C++23 with
every modeled behavior intact — the PGERS Z-mask and m48-discard ioctl
wraps, the GPIO bridge's timing and pacing, the tiny NVM's write-once
buffer, pin ownership, and the PB_PTY/TSB_PTY lines the harnesses parse.
The one linkage fact worth a comment: simavr's parts headers (uart_pty.h)
carry no C++ guards where its core headers do, so those includes sit in an
extern "C" block. Warning-clean at -Wall -Wextra on the build line; the
full protocol suites on all four sim-driven chips prove the conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:59:03 +02:00
531ae6c8dc build: the libavr pin advances over the audit rounds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:52:05 +02:00
b3f41caf6e audit: round three on the port — the hardware scan check fails soft
A rig hiccup mid-walk records the scan check as failed and lets the suite
continue, matching its siblings' envelope; a nonexistent --port path
reports as an error instead of a traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:31:32 +02:00
7716e1e291 audit: the port's pass — the scan that could not walk, and the drift a generator ends
--scan's walk was unwalkable on POSIX: probe rates have no termios
B-constant, so the first off-nominal probe raised out of the loop. The port
speaks termios2 BOTHER now (red-proven on a pty at 9984 Bd), the probe's
open lives inside the walk's error handling, an fd no longer leaks on an
unmakeable rate, and the swallowed unknown-signature reply is named at
timeout instead of reported as silence. CMakePresets.json's generator emits
the submodule toolchain path it had drifted from — a hand edit on a
generated file, exactly the class rule 10 exists for — and presets.generated
gates the pair from here on (the  marker CMake rejects at the
presets root stayed out; the check is the guard). The over-slot image guard
the tsb runner gained reaches the pureboot runner too; the GPIO bridge's
delivery comment states the hardware truth (RXC at the stop bit's sampling
point); the hardware suite gains the scan check — the one place the rate
physics is real; and the libavr pin advances over both audit rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:12:17 +02:00
1b18f10f4e docs: the OSCCAL axis, --scan, and the RC-oscillator deployment risk
Configuration gains the OSCCAL row; Deployment says what the build cannot
see (±10 % factory trim against a frame's ~±4 %, and silence that reads as
wiring); the update section names an OSCCAL bake as a link change in effect,
declared with --staged-baud; the host-tool section documents --scan and the
measured clock --info adds on an autobaud session; the version map gains 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:03:15 +02:00
52c4cdab32 pureboot.py: --scan walks a silent loader's rate; --info decodes the clock
The RC-oscillator answer's host half. --scan probes ±10 % around the built
rate in 2 % steps, nearest first, one activation window (one reset) per
probe: a fixed-baud loader whose oscillator drifted answers at the ratio,
and the report gives the session workaround (--baud), the offset, the
OSCCAL direction at ~1 %/step, and the autobaud way out. The walk and the
advice are logic-tested (test_scan.py, red-proven on the trim direction) —
a pty carries bytes at any rate, so the wire cannot arbitrate them.

On an autobaud session --info now reads the measured bit period from
ram_start — the geometry table gains that column — and undoes the unit's
encoding ((cycles − 8) / 4, floored: libavr's spin granule and per-bit
overhead), so the printed clock is the true one within a granule; --clock
turns it into a stated drift. The autobaud end-to-end asserts the figure
inside exactly that envelope at both clock points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:01:35 +02:00
69f089e53a pureboot 6: a build-time OSCCAL trim, applied ahead of every reset path
The RC-oscillator answer's device half (dev/tasks.md in libavr): OSCCAL joins
pureboot_add_loader() as one optional byte, written at the top of run() before
the WDRF bail so the watchdog hand-over inherits the corrected clock too.
Orthogonal to the backend — an autobaud build may carry it purely for the
application. No value, no code: the stock image differs from v5 in exactly
the version's two bytes (the stamp and the 'b' immediate).

Measured: +6 B where OSCCAL takes sts (328P, 404→410), +4 B in low I/O
(t85, 402→406); the tightest image in the space (1284 autobaud on USART
pins, 504) carries the sts form at 510 of 512. New gates: the OSCCAL size
points on every chip, the wire-observed trim byte on both addressing
classes (test/pbosccal.py, red-green), and the autobaud unit pinned to
ram_start (test/check_unit.cmake, red-green) — the address --info's
measured-clock read is about to rely on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:53:14 +02:00
fe0d9f8790 build: the submodule is how libavr arrives; the era already carries it
The pin the whole history now encodes becomes the primary route: the
submodule default replaces FetchContent and the unpinned forge fallback,
LIBAVR_ROOT stays as the tandem-development override, the presets already
take the toolchain file from the submodule, and the Studio projects anchor
their include path there — correct by construction. The version tags and
the one-command historical build are documented beside the version map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:28:49 +02:00
3ce817ea03 ide: the Atmel Studio solution master has, on the libavr port
master carries bootloader.atsln, so main does too. Two projects, because a
.cppproj is one binary at one flag set and this build has hundreds: the stock
328P pureboot loader (USART0 at 115200 on a 16 MHz crystal), and the tsb_asm
tier that occupies the same 512-byte section master's own tsb project targeted.
Both come out byte-identical to the Ninja build — 404 B and 510 B of .text —
in both configurations.

Debug keeps -Os and adds only -gdwarf-4. A loader's section is a correctness
bound, and -Og builds this source to 590 B: the link at 0x7e00 accepts that
without a diagnostic, 78 bytes past flash end, where rcall/rjmp wrap modulo
flash size and the image dies just after activation. Debug info costs no flash,
so the optimisation level stays where correctness needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:15:28 +02:00
af0dd15a77 tsb: the policy floor, measured and kept
A fourth tier answering one question: what does the full TinySafeBoot
feature set cost in C++ under pureboot's rules — no assembly, no
register variables, every pureboot lesson applied. 638 bytes, protocol
suite green: 198 below the idiomatic tier, 126 above the 512 B section,
and above the tiers that pay with the banned mechanisms (526 global
registers, 510 with two asm routines). The gap decomposes into the rent
policy-clean C++ pays for state held across calls — push/pop and
argument threading a global-register protocol avoids — and both
control-flow merges tried measured larger than the split cases they
replaced, while the data merge (one send loop over both memories) paid.
The tiers stay; this one keeps the floor an artifact instead of a claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:57:13 +02:00
a54075e526 test: the device runner refuses an image that runs past flash end
A boot-linked image larger than its slot cannot execute on hardware, and
the naive copy smashed the heap beyond avr->flash — after which the
simulation misbehaved in ways that pointed everywhere but at the size:
phantom byte losses on the UART, garbage in SPMCSR, all downstream of
the overrun. The size gate had said it plainly; now the runner does too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:57:13 +02:00
aec430c2e1 docs: name the two data-space regions a poke cannot survive
Writing 0x60-0x61 on an ATtiny13A reliably garbled the link, which looked like a
loader defect. It is the loader's own footing: .noinit lands at exactly 0x60,
size 2, and on an autobaud build that is unit_ — the measured bit period, and the
whole of its static RAM. Overwrite it and the next reply is timed against
garbage, so the symptom is a mangled prompt byte and no error, because nothing
went wrong except the rate both ends had agreed on.

Identical in kind to poking the stack at the top of SRAM, and cleared by a reset.
test/pbautobaud.py already steered its RAM round-trip clear of the bottom of SRAM
for this reason; only the README had not said it. Both regions are named there
now, beside the note that --poke does reach OSCCAL but that a session survives
only a step or two of moving the clock under itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:48:09 +02:00
9a8a5b0082 tools: measure the images, and hold the README to what is built
The size matrix proves every image fits its slot and says nothing about the
numbers the README prints. Those drift silently: caller_page() took eight bytes
off every build at once, so all fifteen rows went stale together and no test
noticed, because nothing was over budget. sizes.py check-readme compares the
table against the built images; sizes.py max reports the largest image per chip
and anything over its slot.

It is a check rather than a generator, so the table stays prose someone can
write. No chip geometry lives here either: the image/budget pairs come out of
each build's own CTestTestfile.cmake, which is what the gate checks, so a chip
added or a budget changed needs no edit. Only trees a preset still owns are
read — a stale directory answers with a size that was true once.

16243 images across 37 chips today, none over budget, tightest tsb_asm at 510
of 512.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:45:20 +02:00
7702c6b700 test: gate autobaud's logic floor, and say what an RC oscillator costs
The ATtiny13A run left autobaud's low-clock lock looking unreliable: 1/5 at
19200 on a 1.2 MHz RC part. The simulator does not reproduce it. At an exact
clock the calibration is solid down to ~36 cycles a bit and fails outright by
~31 — a sharp edge, not a fraying one — where the real part was already 1 in 5
by ~59. So the effect is the oscillator's own jitter and not backend logic, and
the two floors are different quantities about a factor of two apart.

Both are worth having. pureboot.autobaud gates a tight-bit point, since its two
existing clock points both sat near 100 cycles a bit and would not notice the
floor moving. The README carries the other half: both floors side by side, the
per-clock envelope measured on silicon, and the reason budgeting the logic's ~36
on an RC part is wrong.

It also carries the trap that produced the confusion. On a patched-vector chip an
erased application region walks back up into the loader, so every expired window
opens another and the host's retries eventually catch the pulse — 5/5 where the
same part with an application resident gives 1/5. Measure with an application in
place, or the fixture flatters the backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:44:21 +02:00
77dd45aeca pureboot.py: an update follows the staging copy onto its own link
--update-loader works by entering copies of the *new* image and letting them
rewrite the resident. Those copies speak the link they were built for, but the
host went on knocking with the session's baud and backend — the resident's. Where
the image changed either, the staging copy was installed and then never answered:
resident untouched, and on a 1 KiB tiny the staging slot is the whole application
region, so the application was already gone.

The wire cannot be probed for it. 512 bytes of position-independent code carry no
header saying what rate they were built for, so the operator declares it:
--staged-baud and --staged-autobaud, applied from the jump into the staging copy
onward. Retuning goes through the open port — SetCommState or tcsetattr on the
live handle, never a reopen — because a DTR pulse would reset the copy being
talked to. Undeclared against a changed link it still cannot work, but the error
now names that as the cause instead of reporting the bare activation timeout that
sent the operator looking at wiring.

The README's idempotence claim needed the same qualification: from step 2 a
re-run must reach the new image, and after step 3 word 0 points at the staging
copy, so on a patched-vector part the resident's link reaches nothing at all.

Found on an ATtiny13A, where two controls differing only in the activation window
updated cleanly and so isolated the link as the variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:31:27 +02:00
433bec3e58 tools: a hardware harness, so a board can be proven and not just a protocol
check.sh proves the protocol under simavr on every chip; it cannot prove a
board. Two things live only on silicon — an RC oscillator that is not on its
nominal, and a reset edge that has to come from somewhere — and until now the
scripts that reached them were per-session scratch on the machine holding the
programmer, which is where the ATtiny13A run's findings nearly stayed.

pbrig.py is the primitives, knowing nothing per-board: every deployment fact is
a flag or a PUREBOOT_* variable. Two rig facts are encoded in it because neither
is guessable and each cost a session to learn: an ISP access *is* the reset edge
where the adapter's DTR is unwired, so a session begins with an ISP touch and
knocks immediately after; and avrdude splits -U on colons, so a Windows drive
letter breaks the spec and every file goes as a bare name with avrdude run in
its own directory. Its `rate` subcommand is the one that turns "the loader is
silent, so the wiring must be wrong" into a number, by sweeping the host rate
against a fixed cycles-per-bit transmitter — PUREBOOT_HEARTBEAT makes the
existing fixture into one, software link only, since the hardware-link idle owes
the self-update tests its command loop.

pbhw.py takes every bound from the info block the loader reports, so one run
covers a 1 KiB tiny and a 128 KiB mega alike. Both are exercised on an ATtiny13A:
backup verified against a known-good capture, the clock measured at 9.048 MHz
against a 9.6 MHz nominal, and the suite 11/11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:06:15 +02:00
e89000f73e pureboot: the pin axis, and the mute it was hiding
Pins move the image for exactly one reason — a bit-banged link on a USART's own
pins has to release that USART — and the matrix said outright that they were no
axis, so the tightest configuration in the space was one nothing built. Not
subtly, either: the 1284's slot ends at flash end, so that build does not merely
exceed the size test's limit, it fails to link. pureboot_{sw,autobaud}_on_usart
{0,1} are gate points in both matrix modes now, and the exhaustive sweep carries
the pins across its whole cross product. The hand-measured table is the gate's
output: 506 B of 512 for the 1284 autobaud on USART0's pins, 504 on USART1's.

pureboot.mute drives the defect itself — an application hands over with USART0
still enabled and the loader on those pins must still answer. Reaching that
needed the runner to know an enabled USART owns its TxD, which simavr does not
model at all: it wires a USART through IRQs and never takes the pin from the
port. It also brings UCSRnB up with TXEN already set where silicon clears the
register, so the runner restores the reset value for the USART it models — the
mute must come from the application, not from power-on. The fixture stays
silent, since nothing is listening on the USART it brings up.

test_handshake.py, written where no gate could run it, is pureboot.handshake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 22:08:56 +02:00
b0737f7cc0 test: move the handshake regression in beside the rest
It was written next to the loader source; the harness lives at the repo root.
Not registered with ctest yet — it belongs beside pureboot.planner, which is
the other test of the host tool's pure logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:22:19 +02:00
07f93caba8 pureboot: take the running slot from avr::startup::caller_page
The write guard's anchor was costing a materialised pointer and a byte swap to
use one byte of it. The libavr primitive answers it in a single load, which is
eight bytes off every build — and what lets the USART release fit the tightest
configuration in the space: the 1284 autobaud on USART-shared pins was 514 of
its 512 and is now 506, with the default pinning down from 510 to 502.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:47 +02:00
45f10f843a pureboot: release a USART left enabled on the software link's pins
A software or autobaud link on a USART's own pins (PD0/PD1 on the mega328P, so
the Uno's USB bridge reaches it) was mute after an application handed over with
that USART still enabled: its TXEN keeps the USART owning the TX pin, so the
bit-banged transmitter cannot drive it — the loader locked and obeyed commands
but never answered. The link's init now clears the UCSRnB of the USART whose
TXD is its TX pin. Guarded with if constexpr on that pin match, so a link on
non-USART pins emits nothing: +4 bytes on a USART-pin build (494 of 512 for the
mega328P autobaud), zero on the default pb0/pb1 matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:22:48 +02:00
34b47048ca pureboot.py: bump the tool version to 5
The drain fix changes the tool's activation behaviour; mark it. The loader
version window is unchanged — the wire protocol did not move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:11:03 +02:00
392035923f pureboot.py: bound the activation drain against a flooding target
The post-prompt settle loop in _handshake had no deadline, so a target that
never falls quiet — a board stuck in a reset loop, whose UART-reset garbage
carries a stray prompt byte — spun the tool forever. Bound it by the handshake
deadline; a real loader still settles on its first quiet read. Regression:
test/test_handshake.py (flood terminates, valid loader still connects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:10:40 +02:00
f98ed406b8 pureboot 5: one command pair for every memory, and a clock-free backend
R/r/w/F collapse into G and g over a selector byte naming the space — flash,
EEPROM, data, fuse, SPM — with the flash bank in its high nibble. Four command
bodies, four transfer loops and four argument decodes become one of each, and
W joins the same decode instead of keeping an address form of its own. The
loader shrinks while gaining everything below: on the 1284P the stock build
goes 480 -> 432 B and the software one 496 -> 450.

What the freed space buys:

  - Data space. On AVR one pointer spans SRAM, the register file and the whole
    I/O space, so G over space 2 reads all three. pureboot keeps zero static
    RAM and pushes no register, so at loader entry an application's SRAM is
    still what the application left there — this is a post-mortem, not just a
    poke hole. As its own command it needed a dispatch arm and a loop; as one
    more space it is a single ld/st.
  - Host-issued SPM. W fills the page buffer and stops; erase, write and RWW
    re-enable are writes to space 4, which reach the same fused store-and-SPM
    pair through the transfer's own address and data. Any SPM operation, lock
    bits included, is now reachable and the loader carries no page-commit logic.
    The four-cycle SPMCSR-to-SPM window is why that primitive stays fused: no
    host can hit it across a serial link, and that — not the byte count — is
    the floor on how low-level a bootloader's primitives can go.
  - Byte addresses everywhere. The bank in the selector retires the
    word-addressed wire the >64 KiB parts needed, so the 1284s stop being the
    outlier.

SERIAL autobaud is a third backend on the same loader, over libavr's
software_autobaud: no clock, no baud, one binary per chip for every F_CPU and
every rate. Activation counts poll iterations rather than seconds and bounds
every wait, so a stray pulse cannot hold an unattended device.

b answers with the version and signature only; the host derives geometry from
the signature, which is what an autobaud build requires anyway. An update image
is a bare slot with no device to ask, so every image carries a six-byte stamp —
the same bytes b answers with, and the source of both — that the loader never
reads from flash and the host refuses to install a mismatch against. The
running-slot write guard moved onto the SPM commit, which covers erase and
write both where guarding W covered neither directly.

The position-independence lint now proves the property instead of a proxy for
it: the image must come out byte-identical linked at a different base.
-fno-move-loop-invariants left the tuned flag set — it was fitted to a command
loop carrying four transfer bodies and costs bytes now that it carries one.

Verified: the exhaustive matrix on all 37 chips (every clock x every baud x
every backend, non-standard rates included, plus the autobaud build) —
8174 size checks, no failures, tightest fit the 1284s' autobaud at 510 of 512.
Behavioral suites green on every chip class: t13a 10/10, t85 11/11, m8 13/13,
m16a 13/13, m48pa 13/13, 328P 23/23, 644A 17/17, 1284P 17/17. Data-space
round trip through --peek/--poke and the autobaud handshake are both red-green
proven.

The two prototype sources and their findings file go; the README carries the
protocol and dev/done.md in libavr carries the reasoning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:50:48 +02:00
a4da885e36 pureboot: the unified autobaud loader, and the hang that settled the decision
Hardware testing found that a lone calibration pulse wedged the autobaud loader:
run() budgeted only the start-edge wait in measure(), and the rx() that read the
knock behind it was unbudgeted, so one stray low pulse held an unattended device
in the loader and the application never ran. Bound the whole activation — an
expired knock budget returns a byte that cannot be the knock, so control falls
back into the budgeted measure() and an idle line boots the app there.

That fix costs ~22 B, which neither version under review could absorb: the pure
one goes 508 -> 530 on the 1284P and the register one 512 -> 534, both over a
512 B slot. Their margin was never spare capacity, it was the space the missing
fix should have occupied. So the choice between them is moot; both are kept for
the record and no longer built.

pureboot_autobaud_uni.cpp replaces them at 464 B. It is pureboot 5: one read
command and one write command over named spaces (G/g, sel8, addr16, n8) instead
of four per-memory bodies, which collapses four transfer loops into one. The
selector's high nibble carries flash's bank, so the shared cursor stays 16 bits
and no command speaks word addresses. Three things fall out of the freed space:
RAM read/write — the missing feature, and with it arbitrary I/O access, since
AVR maps peripherals into the data space; host-issued SPM, so W's hardcoded
erase/write/RWW tail becomes three writes to a space and any SPM operation is
reachable; and W on the same selector-and-address decode as everything else.

Strictly pure throughout: no inline asm, no global register variable, and no
GPIOR either — the unit lives in a .noinit static, so the loader claims no chip
resource and the chips without GPIOR stop being a special case.

pureboot.py speaks both generations, keyed on the version, so the fixed-baud
path is untouched; --peek/--poke reach the new data space. pbautobaud.py adds a
RAM round-trip and a regression for the hang: a lone pulse must still let the
app boot. All 37 chips plus the 12-preset reflect spot set build and size-test
green, 444-466 B, worst case 46 B under budget. Sim suites 100%: 1284P 17/17,
328P 23/23. Only real-hardware acceptance remains (pureboot/autobaud.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 00:48:54 +02:00
335e494a31 pureboot: autobaud host support and simavr end-to-end for both variants
pureboot.py --autobaud sends the 0xC0 calibration pulse and a single knock at
the host's chosen baud, reads the slimmed info block, and derives the full
geometry from the signature (AUTOBAUD_GEOMETRY, a table over every pureboot
chip). Everything downstream — flash, EEPROM, fuses, hand-over, verify — is the
fixed-baud path unchanged; the dropped write guard is host-transparent.

test/pbautobaud.py drives each variant over the GPIO⇄pty software-UART bridge
through the calibration handshake and a flash + EEPROM + fuse round-trip
cross-checked against the simulator's ground-truth memory, then repeats at
double the F_CPU with the same binary — the clock-agnostic property autobaud
exists for. Wired as pureboot.autobaud_pure/reg on the near-flash 328P and the
word-addressed 1284P. A wrong measured unit fails the flash/verify, so the test
also pins the codegen-coupled calibration constant against a toolchain bump.

Both variants green in sim on both chips at two clocks each; the fixed-baud
suite is unaffected. Only real-hardware acceptance on an RC part remains
(pureboot/autobaud.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:45:22 +02:00
799709efcf pureboot: autobaud variant, two versions for review
Measure the host's bit timing at runtime from a 0xC0 calibration pulse, so one
clock-agnostic image per chip runs at any F_CPU — the RC-oscillator deployments
no longer need a per-clock build.

Two source files, differing only in the write-guard/purity tradeoff:
pureboot_autobaud_pure.cpp (the measured unit in the GPIOR I/O scratch
registers, running-slot write guard dropped, 508 B on the 1284) stays strictly
pure; pureboot_autobaud_reg.cpp (unit in one global register variable, guard
kept, 512 B) keeps every feature at the cost of that single GRV. Both fit
512/510 on all 37 chips and share two licensed simplifications: a slimmed info
block (version + signature; the host derives geometry from the chip database)
and a single-byte activation knock.

pureboot/autobaud.md records the decision, the hand-assembly floor (506 B) that
set the target, and the compiler-knob path to it. Size-tested on every chip via
pureboot_add_autobaud(); the fixed-baud loader is untouched. Sim validation, the
host calibration handshake, and real-hardware acceptance remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:15:38 +02:00
7ae80087b3 docs: the watchdog-lockout and EEPROM-wrap gotchas
A sticky WDRF diverts every reset past the activation window (deliberate, so
an app can reboot instantly, at the cost of a possible lockout); an EEPROM
address past E2END wraps onto low EEPROM (the host bounds it, not the loader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:01:33 +02:00
b477f53ca5 pureboot: the info block reads as a table, one wire byte per line
clang-format bin-packs braced lists to the column limit, collapsing the
'b' reply's byte layout into dense rows. A minimal clang-format-off span
keeps each wire byte on its own line, where the layout is legible against
the protocol. Whitespace only; image byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:19:09 +02:00
84d3f679c2 style: clang-format the W-fix line
Layout only, byte-identical output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:13:51 +02:00
b5020a1e20 pureboot 4: the loader carries its fixes' identity
The unaligned-W and U2X-hand-over fixes change the loader's observable
on-wire behavior, and the --stay reconnect fix changes the host tool, so
both move: loader version 3 -> 4, tool VERSION 2 -> 3. The protocol and info
block are unchanged, so OLDEST_LOADER stays 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:09:38 +02:00
1dbf0089d6 pureboot: the info block is what proves a knock landed
A prompt byte alone does not: 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 the bare command that follows is discarded. Each
attempt is now the whole handshake, retried until the block comes back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:49:37 +02:00
39dfe40dbf pureboot: W addresses a page, not a word in it
The in-page bits of a W address are dropped so the fill always walks from
the page base; the wire contract is one page of data for any address
inside it, on both the byte- and the word-addressed path. +2 B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:43:41 +02:00
8470ce3da0 test: the exhaustive clock x baud x backend size matrix
Every plausible oscillator against every rate it reaches against every
backend, on one chip per size-bearing class, under --full only. The baud
ladder becomes a reachability predicate the enumeration filters on, so an
unreachable point drops out instead of aborting the configure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:32:28 +02:00
d634741015 pureboot 3: a 512-byte slot on every chip, the 1284s included
The word-addressed 1284s were the one family deploying in a 1 KiB slot,
because the far-flash machinery (ELPM reads, RAMPZ page commands, a
word-addressed wire) did not fit 512 B. It does now: 478 B stock, 494 B in
the heaviest configuration the build can produce. They take the 644s'
geometry, where the smallest boot section holds the resident slot and its
staging slot together. The loader's version goes to 3; the host tool did
not change, so its own version stays 2 and only the window it speaks
widens.

Most of the saving is one restructure. The info block and a flash read are
the same act, so giving all four streamed commands one address-and-count
path leaves exactly one call site for the flash streamer: it inlines into
the never-returning command loop and its 24-bit cursor stops being saved
and restored around every transmit. Around it, the ack byte moved out of
line, the wire's byte pair is bit_cast into the word it already is, the
fuse loop ends on its count, the info block's in-slot offset is taken as
the one-byte relocation it is, and -fno-expensive-optimizations gives way
to -fno-move-loop-invariants -fno-tree-ter. Every chip shrank 14-18 B.

The size matrix grew the axes it was missing: the USART1 instance across
the whole clock ladder, and the shape a slow baud gives a software UART —
past 255 delay iterations libavr takes the 16-bit delay loop, which the
ladder default never selects and which was 4 B over the 1284's slot the
first time it was built.

The protocol fixture stopped deriving the loader entry from the flash
size; on the 1284s it had been jumping a slot low and reaching the loader
only because erased flash walked it up.

Docs and comments were consolidated across the port in the same pass: the
README carries a per-chip size table instead of prose, and prose that
restated the code is gone — 190 lines, no behaviour with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:49:23 +02:00
35 changed files with 4626 additions and 1186 deletions

View File

@@ -2,21 +2,19 @@ cmake_minimum_required(VERSION 3.28)
project(tsb_libavr LANGUAGES CXX) project(tsb_libavr LANGUAGES CXX)
# libavr from a local checkout (LIBAVR_ROOT) or the forge; the toolchain file # libavr rides as the pinned submodule; LIBAVR_ROOT (cache or environment)
# comes from the same checkout via CMakePresets.json. # overrides it for tandem development against a working tree. The toolchain
include(FetchContent) # file comes from the submodule via CMakePresets.json either way.
if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT}) if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT})
set(LIBAVR_ROOT $ENV{LIBAVR_ROOT}) set(LIBAVR_ROOT $ENV{LIBAVR_ROOT})
endif() endif()
if(NOT LIBAVR_ROOT) if(NOT LIBAVR_ROOT)
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr) set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr)
endif() endif()
if(LIBAVR_ROOT) if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt)
FetchContent_Declare(libavr SOURCE_DIR ${LIBAVR_ROOT}) message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} — run: git submodule update --init libavr")
else()
FetchContent_Declare(libavr GIT_REPOSITORY git@git.blackmark.me:avr/libavr.git GIT_TAG main)
endif() endif()
FetchContent_MakeAvailable(libavr) add_subdirectory(${LIBAVR_ROOT} libavr-build)
if(PROJECT_IS_TOP_LEVEL) if(PROJECT_IS_TOP_LEVEL)
add_compile_options(-Werror) # warnings are errors for the port's own code add_compile_options(-Werror) # warnings are errors for the port's own code
@@ -24,15 +22,17 @@ if(PROJECT_IS_TOP_LEVEL)
# The behavioral tests drive the real wire protocols over a simavr pty # The behavioral tests drive the real wire protocols over a simavr pty
# (as the host tools do) and actually flash the device. The runners are # (as the host tools do) and actually flash the device. The runners are
# host programs built at configure time against libsimavr; if they or # host programs built at configure time against libsimavr (C++23 — what
# Python are missing, only the size tests run. # the distribution's compiler speaks in full); if they or Python are
find_program(_host_cc NAMES cc gcc) # missing, only the size tests run.
find_program(_host_cxx NAMES c++ g++)
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(_host_cc AND Python3_FOUND) if(_host_cxx AND Python3_FOUND)
set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device) set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device)
execute_process( execute_process(
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c -I/usr/include/simavr -I/usr/include/simavr/parts
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.cpp
-lsimavr -lsimavrparts -lelf -lutil -lsimavr -lsimavrparts -lelf -lutil
RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err) RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err)
if(NOT _pbdev_res EQUAL 0) if(NOT _pbdev_res EQUAL 0)
@@ -42,8 +42,9 @@ if(PROJECT_IS_TOP_LEVEL)
if(LIBAVR_MCU STREQUAL "atmega328p") if(LIBAVR_MCU STREQUAL "atmega328p")
set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device)
execute_process( execute_process(
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c -I/usr/include/simavr -I/usr/include/simavr/parts
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.cpp
-lsimavr -lsimavrparts -lelf -lsimavr -lsimavrparts -lelf
RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err) RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err)
if(NOT _dev_res EQUAL 0) if(NOT _dev_res EQUAL 0)
@@ -68,16 +69,18 @@ function(add_image_outputs name)
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin) $<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin)
endfunction() endfunction()
# The TinySafeBoot protocol reimplemented on libavr in three variants that trade # The TinySafeBoot protocol reimplemented on libavr in variants that trade
# clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects # clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects
# its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled # its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled
# loader has no use for the crt or the vector table. The naked entry sits in # loader has no use for the crt or the vector table. The entry sits in
# .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section # .vectors, laid first, and runs — avr::startup::entry on the policy tier,
# size; the linker section-start and the source's boot_bytes agree. tsb_app is # the experiment tiers' own naked stubs elsewhere, each documented in its
# source. The boot base is FLASHEND+1 minus the section size; the linker
# section-start and the source's boot_bytes agree. tsb_app is
# the application's reset vector, pinned to 0 here so the loaders jump to a # the application's reset vector, pinned to 0 here so the loaders jump to a
# named function; --pmem-wrap-around lets relaxation turn that absolute jump # named function; --pmem-wrap-around lets relaxation turn that absolute jump
# into the wrapped rjmp AVR's modulo-flash PC actually executes. # into the wrapped rjmp AVR's modulo-flash PC actually executes.
# All three implement the full oracle feature set (see oracle/README.md): # All four implement the full oracle feature set (see oracle/README.md):
# watchdog bail, one-wire half-duplex, config-page activation timeout, password # watchdog bail, one-wire half-duplex, config-page activation timeout, password
# gate, emergency erase, config/flash/EEPROM read-write. They differ only in how, # gate, emergency erase, config/flash/EEPROM read-write. They differ only in how,
# and the size gradient is the cost of that "how" — see dev/lessons.md. # and the size gradient is the cost of that "how" — see dev/lessons.md.
@@ -97,6 +100,10 @@ endfunction()
# tsb_pure — pure idiomatic libavr, one function per command, TU-local # tsb_pure — pure idiomatic libavr, one function per command, TU-local
# (internal linkage), streaming (no SRAM page buffer): 836 B in # (internal linkage), streaming (no SRAM page buffer): 836 B in
# the 1 KB section. # the 1 KB section.
# tsb_policy — the policy floor: pureboot's rules (no asm, no register
# variables) with every pureboot lesson applied. 638 B in the
# 1 KB section — the measured evidence that the 512 B fit is a
# property of the mechanisms philosophy #5 bans.
# #
# add_tsb_variant(<name> <boot-section-bytes>) # add_tsb_variant(<name> <boot-section-bytes>)
function(add_tsb_variant name bytes) function(add_tsb_variant name bytes)
@@ -124,8 +131,13 @@ endfunction()
# chips build pureboot alone. # chips build pureboot alone.
if(LIBAVR_MCU STREQUAL "atmega328p") if(LIBAVR_MCU STREQUAL "atmega328p")
add_tsb_variant(tsb_asm 512) add_tsb_variant(tsb_asm 512)
add_tsb_variant(tsb_policy 1024)
add_tsb_variant(tsb_pure 1024) add_tsb_variant(tsb_pure 1024)
add_tsb_variant(tsb_tricks 1024) add_tsb_variant(tsb_tricks 1024)
# The policy tier's floor is measured with the loop flags pureboot's size
# work found (a loader's loop bodies all contain calls); the other tiers
# keep the flag set their recorded floors were measured with — none.
target_compile_options(tsb_policy PRIVATE -fno-move-loop-invariants -fno-tree-ter)
endif() endif()
# pureboot — the pure-constraint port (see pureboot/README.md): one source, # pureboot — the pure-constraint port (see pureboot/README.md): one source,
@@ -153,10 +165,25 @@ if(PROJECT_IS_TOP_LEVEL)
if(Python3_FOUND) if(Python3_FOUND)
add_test(NAME pureboot.pi add_test(NAME pureboot.pi
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${PUREBOOT_BASE_HEX}) ${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})
add_test(NAME pureboot.planner add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
add_test(NAME pureboot.scan
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
# CMakePresets.json is generated; hand edits drift the moment the
# generator reruns, so the gate holds the pair together.
add_test(NAME presets.generated
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/make_presets.py
--check)
add_test(NAME pureboot.handshake
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
add_test(NAME pureboot.updatelink
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_update_link.py)
endif() endif()
# The protocol test flashes this fixture through the loader with the real # The protocol test flashes this fixture through the loader with the real
@@ -234,12 +261,13 @@ if(PROJECT_IS_TOP_LEVEL)
endif() endif()
# The size matrix: every configuration axis that could move the image # The size matrix: every configuration axis that could move the image
# size — the serial backend (different code), the clock and its ladder # size — the serial backend (different code), the USART instance
# baud (different constants and divisor shapes), the USART instance # (different registers), the clock (different constants), the baud
# (different register class) — each combination must still fit the # through the shapes its bit timing takes, and the pins through the one
# chip's slot budget. Pins are size-neutral (port and bit are immediate # thing they decide (whether a bit-banged link has to release the USART
# operands) and the timeout is a constant, so neither adds an axis. The # that owns them) — each combination must still fit the chip's slot
# stock build is one point of this matrix and already has its test. # budget. The timeout is a constant and adds no axis. The stock build is
# one point of this matrix and already has its test.
function(pureboot_size_variant name) function(pureboot_size_variant name)
pureboot_add_loader(${name} ${ARGN}) pureboot_add_loader(${name} ${ARGN})
add_test(NAME ${name}.size add_test(NAME ${name}.size
@@ -247,27 +275,172 @@ if(PROJECT_IS_TOP_LEVEL)
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
endfunction() 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)
# The measured unit is the loader's only RAM object and sits at the very
# start of SRAM — where the host reads the bit period from (--info's
# measured clock), so the address is wire contract, not layout accident.
add_test(NAME pureboot_autobaud.unit
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
# One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. `pins` is empty for the
# default pair, or the index of the USART whose own pins a bit-banged
# link sits on. Unreachable rates drop out here rather than aborting the
# configure.
function(pureboot_matrix_point hz baud link pins)
set(_name pbm_${hz}_${baud}_${link})
if(link STREQUAL "software")
pureboot_baud_feasible(${hz} ${baud} 1 _ok)
set(_args SERIAL software)
if(NOT pins STREQUAL "")
list(APPEND _args RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_TX})
set(_name ${_name}_on${pins})
endif()
else()
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
set(_args USART ${link})
endif()
if(_ok)
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args})
endif()
endfunction()
# Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and # 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 # the crystal the stock build assumes (the tiny13's ladder is its own RC
# menu — it has no crystal option). # menu — it has no crystal option).
if(LIBAVR_MCU MATCHES "^attiny13") if(LIBAVR_MCU MATCHES "^attiny13")
set(_matrix_clocks 1200000 4800000 9600000) set(_matrix_clocks 1200000 4800000 9600000)
set(_full_clocks 128000 600000 1200000 4800000 9600000)
else() else()
set(_matrix_clocks 1000000 8000000 16000000) set(_matrix_clocks 1000000 8000000 16000000)
set(_full_clocks 128000 1000000 1843200 2000000 3686400 4000000 7372800 8000000
11059200 12000000 14745600 16000000 18432000 20000000)
endif() endif()
foreach(_matrix_hz IN LISTS _matrix_clocks)
math(EXPR _matrix_khz "${_matrix_hz} / 1000") # The exhaustive cross product: every clock a deployment plausibly runs
if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz) # — the internal oscillators, the shipped CKDIV8 floor, the plain
pureboot_size_variant(pureboot_sw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL software) # 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} software 0)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
endif()
if(PUREBOOT_HAS_USART1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1)
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)
pureboot_size_variant(pureboot_sw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL software)
endif()
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)
# The pin axis at the widest software image — the slowest ladder rate
# against the fastest clock, whose bit spin needs the 16-bit delay
# loop — with the USART release on top of it. The exhaustive sweep
# above carries the same axis across its whole cross product.
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_sw_wide_on_usart0 CLOCK ${_matrix_top_hz} BAUD 9600
SERIAL software RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
endif() endif()
if(PUREBOOT_HAS_USART AND NOT _matrix_hz EQUAL _pb_stock_hz) if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_hw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL hardware) pureboot_size_variant(pureboot_sw_wide_on_usart1 CLOCK ${_matrix_top_hz} BAUD 9600
SERIAL software RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
endif() endif()
endforeach() endif()
if(PUREBOOT_HAS_USART1) if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_usart1 USART 1) pureboot_size_variant(pureboot_usart1 USART 1)
endif() endif()
# The pin axis at its fixed points, in both matrix modes. The autobaud
# loader carries no clock and no baud, so the sweep has nothing to vary
# for it — yet it is the tightest image in the space, and on a USART's
# own pins it pays the release too: that combination is the one that
# overflowed the 1284's slot. The software build on those pins is the
# same deployment the mute test drives.
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_sw_on_usart0 SERIAL software
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
pureboot_size_variant(pureboot_autobaud_on_usart0 SERIAL autobaud
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
endif()
if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_sw_on_usart1 SERIAL software
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
pureboot_size_variant(pureboot_autobaud_on_usart1 SERIAL autobaud
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
endif()
# The OSCCAL axis at its fixed points: the stock shape, and the tightest
# image in the space with the trim on top — the axis adds one register
# write, and these points hold both of its addressing encodings to every
# chip's budget.
pureboot_size_variant(pureboot_osccal OSCCAL 0x9c)
pureboot_size_variant(pureboot_autobaud_osccal SERIAL autobaud OSCCAL 0x9c)
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
endif()
# The trim byte, observed through the wire from the first prompt — one
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
# an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out —
# Atmel-2586 §21).
if(LIBAVR_MCU MATCHES "^(atmega328p|attiny85)$" AND DEFINED PB_DEVICE)
if(LIBAVR_MCU STREQUAL "atmega328p")
set(_osccal_addr 0x66)
else()
set(_osccal_addr 0x51)
endif()
get_target_property(_osccal_hz pureboot_osccal PUREBOOT_HZ)
get_target_property(_osccal_baud pureboot_osccal PUREBOOT_BAUD)
add_test(NAME pureboot.osccal
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbosccal.py
${PB_DEVICE} $<TARGET_FILE:pureboot_osccal> ${PUREBOOT_SIM_MCU}
${_osccal_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_osccal_baud}
${_osccal_addr} 0x9c ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbosccal-work)
set_tests_properties(pureboot.osccal PROPERTIES TIMEOUT 120)
endif()
# One configured deployment end to end — a real board's shape rather # One configured deployment end to end — a real board's shape rather
# than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses,
# the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder
@@ -296,6 +469,33 @@ if(PROJECT_IS_TOP_LEVEL)
set_tests_properties(pureboot.custom PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.custom PROPERTIES TIMEOUT 180)
endif() endif()
# Hand-over with the USART that owns the loader's pins left enabled — the
# state an application reaches by jumping in without a reset, and the one
# that made a bit-banged loader on PD0/PD1 (where the Uno's USB bridge
# lands) receive and obey while answering nothing. Run where it was found
# on silicon; the runner supplies the pin ownership simavr has no model
# for, which is what lets this fail when the release is gone.
if(LIBAVR_MCU STREQUAL "atmega328p" AND DEFINED PB_DEVICE)
get_target_property(_mute_hz pureboot_sw_on_usart0 PUREBOOT_HZ)
get_target_property(_mute_baud pureboot_sw_on_usart0 PUREBOOT_BAUD)
get_target_property(_mute_link pureboot_sw_on_usart0 PUREBOOT_LINK)
add_executable(pbapp_handover test/pbapp.cpp)
target_link_libraries(pbapp_handover PRIVATE libavr)
target_compile_definitions(pbapp_handover PRIVATE PUREBOOT_CLOCK_HZ=${_mute_hz}
PUREBOOT_BAUD=${_mute_baud} PUREBOOT_HANDOVER)
add_custom_command(TARGET pbapp_handover POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:pbapp_handover> $<TARGET_FILE:pbapp_handover>.bin)
add_test(NAME pureboot.mute
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbmute.py
${PB_DEVICE} $<TARGET_FILE:pureboot_sw_on_usart0> ${PUREBOOT_SIM_MCU} ${_mute_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_mute_baud}
$<TARGET_FILE:pbapp_handover>.bin
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbmute-work ${_mute_link})
set_tests_properties(pureboot.mute PROPERTIES TIMEOUT 180)
endif()
# The second USART, driven for real on one chip: instance selection is # The second USART, driven for real on one chip: instance selection is
# compile-checked everywhere, but only a live session proves the loader # compile-checked everywhere, but only a live session proves the loader
# initialized and polls the USART it claims to. The fixture application # initialized and polls the USART it claims to. The fixture application
@@ -318,4 +518,28 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbusart1-work usart1) ${CMAKE_BINARY_DIR}/pbusart1-work usart1)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif() endif()
# The autobaud loader driven end to end over the software-UART bridge:
# 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() endif()

77
ide/README.md Normal file
View File

@@ -0,0 +1,77 @@
# Atmel Studio
`master` carries `bootloader.atsln`, so this branch does too: `ide/bootloader.atsln`
builds the loaders from the same sources Ninja does, to a **byte-identical
`.text`** — 400 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
its 512-byte section. CMake remains the build system; the solution is here so the
port opens in Studio as its predecessor did.
## The two projects, and why two
pureboot is a chip × backend × clock × baud matrix — `pureboot_add_loader()`
resolves a deployment into compile definitions — and a `.cppproj` is one binary
at one set of flags, so a project can only ever be one point of it. `pureboot`
is that point: the stock 328P deployment, USART0 at 115200 on a 16 MHz crystal,
an 8-second activation window. `tsb_asm` is the TinySafeBoot tier that occupies
the same 512-byte section `master`'s `tsb` project targeted.
The other three tsb tiers (`tsb_pure`, `tsb_tricks`, `tsb_policy`) are not here.
They differ from `tsb_asm` in their source file, their section size, and — for
`tsb_policy` — two loop flags; nothing about that is a Studio concern, and what
they exist to demonstrate is a size gradient only the CMake size tests measure.
Adding one is a copy of `tsb_asm/tsb_asm.cppproj` in its own directory, with its
name, its GUID, its source path and its `--section-start` changed (`0x7c00` for
the 1 KiB tiers), plus four lines in the solution.
`avrdevice` is a project property, so each project gets its own directory:
Studio builds into `<project dir>/<Configuration>` whatever `OutputDirectory`
says, and two projects sharing a directory would share one object file.
## Debug keeps `-Os`
Both configurations compile at `-Os`; Debug adds only `-gdwarf-4`. The `.text`
is therefore identical in both, which is the point — a loader's section is a
**correctness** bound and not a budget. `-Og` builds this same source to 590 B,
and linking it at `--section-start=.text=0x7e00` on a 32 KiB part puts 78 bytes
past flash end **without a diagnostic**: `rcall`/`rjmp` targets there wrap
modulo flash size, so the image dies right after activation. A debug
configuration that silently produces that is worse than none, and DWARF costs no
flash, so the optimisation level stays where correctness needs it.
## What Studio needs from the machine
libavr from the **submodule**, found at
`$(MSBuildProjectDirectory)\..\..\libavr\include` — correct by construction, and
anchored to the project because a plain relative path resolves against the
generated makefile's directory (the configuration's output directory), not the
project's. There is no `LIBAVR_ROOT` escape hatch: a variable exported in a
shell is invisible to Studio launched from the Start menu, and the failure reads
as a missing `libavr/libavr.hpp` — which is what the submodule answers.
A GCC 16.1 toolchain registered as flavour `avr-g++-16.1.0`, nothing older
reaching `-std=c++26`.
## Generating and gating
One generated file is required before a project will load at all, and one command
per project checks the flags have not drifted (both from libavr's
`tools/atmelstudio/`):
```sh
for name in pureboot tsb_asm; do
python libavr/tools/atmelstudio/componentinfo.py \
"ide/$name/$name.componentinfo.xml" --device ATmega328P
python libavr/tools/atmelstudio/check-flags.py \
--solution ide/bootloader.atsln --project "$name" --target "$name" \
--compile-commands build/atmega328p-generated/compile_commands.json \
--log "build/as-$name.log"
done
```
`--project` because one reference describes one binary; `--target` because
`pureboot.cpp` is compiled by every point of the size matrix and the flags
differ per point, so the basename alone does not name a reference. Release is
what the gate compares — the presets define no debug build, and Debug differs
from Release only in `-gdwarf-4`.
Legacy (the yazoalfa-era submodules) stays on `master`.

28
ide/bootloader.atsln Normal file
View File

@@ -0,0 +1,28 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Atmel Studio Solution File, Format Version 11.00
VisualStudioVersion = 14.0.23107.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "pureboot", "pureboot\pureboot.cppproj", "{99067222-32D5-49E3-B4F8-5ABA0F7722B7}"
EndProject
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "tsb_asm", "tsb_asm\tsb_asm.cppproj", "{6618D3BE-7EB3-49A2-9113-F128E396FF06}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|AVR = Debug|AVR
Release|AVR = Release|AVR
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Debug|AVR.ActiveCfg = Debug|AVR
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Debug|AVR.Build.0 = Debug|AVR
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Release|AVR.ActiveCfg = Release|AVR
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Release|AVR.Build.0 = Release|AVR
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Debug|AVR.ActiveCfg = Debug|AVR
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Debug|AVR.Build.0 = Debug|AVR
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Release|AVR.ActiveCfg = Release|AVR
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Release|AVR.Build.0 = Release|AVR
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
<PropertyGroup>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>7.0</ProjectVersion>
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
<ProjectGuid>99067222-32d5-49e3-b4f8-5aba0f7722b7</ProjectGuid>
<avrdevice>ATmega328P</avrdevice>
<avrdeviceseries>none</avrdeviceseries>
<OutputType>Executable</OutputType>
<Language>CPP</Language>
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
<OutputFileExtension>.elf</OutputFileExtension>
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
<AssemblyName>pureboot</AssemblyName>
<Name>pureboot</Name>
<RootNamespace>pureboot</RootNamespace>
<ToolchainFlavour>avr-g++-16.1.0</ToolchainFlavour>
<KeepTimersRunning>true</KeepTimersRunning>
<OverrideVtor>false</OverrideVtor>
<CacheFlash>true</CacheFlash>
<ProgFlashFromRam>true</ProgFlashFromRam>
<RamSnippetAddress>0x20000000</RamSnippetAddress>
<UncachedRange />
<preserveEEPROM>true</preserveEEPROM>
<OverrideVtorValue>exception_table</OverrideVtorValue>
<BootSegment>2</BootSegment>
<ResetRule>0</ResetRule>
<eraseonlaunchrule>0</eraseonlaunchrule>
<EraseKey />
<AsfFrameworkConfig>
<framework-data xmlns="">
<options />
<configurations />
<files />
<documentation help="" />
<offline-documentation help="" />
<dependencies>
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.52.0" />
</dependencies>
</framework-data>
</AsfFrameworkConfig>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>NDEBUG</Value>
<Value>PUREBOOT_CLOCK_HZ=16000000</Value>
<Value>PUREBOOT_BAUD=115200</Value>
<Value>PUREBOOT_TIMEOUT=8</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=pureboot_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>DEBUG</Value>
<Value>PUREBOOT_CLOCK_HZ=16000000</Value>
<Value>PUREBOOT_BAUD=115200</Value>
<Value>PUREBOOT_TIMEOUT=8</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types -gdwarf-4</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=pureboot_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\pureboot\pureboot.cpp">
<SubType>compile</SubType>
<Link>pureboot\pureboot.cpp</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="pureboot" />
</ItemGroup>
<Import Project="$(AVRSTUDIO_EXE_PATH)\Vs\Compiler.targets" />
</Project>

112
ide/tsb_asm/tsb_asm.cppproj Normal file
View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
<PropertyGroup>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>7.0</ProjectVersion>
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
<ProjectGuid>6618d3be-7eb3-49a2-9113-f128e396ff06</ProjectGuid>
<avrdevice>ATmega328P</avrdevice>
<avrdeviceseries>none</avrdeviceseries>
<OutputType>Executable</OutputType>
<Language>CPP</Language>
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
<OutputFileExtension>.elf</OutputFileExtension>
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
<AssemblyName>tsb_asm</AssemblyName>
<Name>tsb_asm</Name>
<RootNamespace>tsb_asm</RootNamespace>
<ToolchainFlavour>avr-g++-16.1.0</ToolchainFlavour>
<KeepTimersRunning>true</KeepTimersRunning>
<OverrideVtor>false</OverrideVtor>
<CacheFlash>true</CacheFlash>
<ProgFlashFromRam>true</ProgFlashFromRam>
<RamSnippetAddress>0x20000000</RamSnippetAddress>
<UncachedRange />
<preserveEEPROM>true</preserveEEPROM>
<OverrideVtorValue>exception_table</OverrideVtorValue>
<BootSegment>2</BootSegment>
<ResetRule>0</ResetRule>
<eraseonlaunchrule>0</eraseonlaunchrule>
<EraseKey />
<AsfFrameworkConfig>
<framework-data xmlns="">
<options />
<configurations />
<files />
<documentation help="" />
<offline-documentation help="" />
<dependencies>
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.52.0" />
</dependencies>
</framework-data>
</AsfFrameworkConfig>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>NDEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>DEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -gdwarf-4</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\tsb\tsb_asm.cpp">
<SubType>compile</SubType>
<Link>tsb\tsb_asm.cpp</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="tsb" />
</ItemGroup>
<Import Project="$(AVRSTUDIO_EXE_PATH)\Vs\Compiler.targets" />
</Project>

2
libavr

Submodule libavr updated: e81dad0131...911a87538f

View File

@@ -1,27 +1,16 @@
# pureboot as a consumable CMake unit: the per-chip geometry, the default # pureboot as a consumable CMake unit: the per-chip geometry, the default baud
# baud ladder, and pureboot_add_loader() — the one way a loader target is # ladder, and pureboot_add_loader() — the one way a loader target is created.
# created, both by this port's own build and by a downstream project. A # A downstream project brings its usual libavr setup (the `libavr` target and
# downstream project brings its usual libavr setup (the `libavr` target and
# the LIBAVR_MCU toolchain preset), adds this directory, and states its # the LIBAVR_MCU toolchain preset), adds this directory, and states its
# deployment: # deployment; every argument is optional (README.md):
# #
# add_subdirectory(bootloader/pureboot) # add_subdirectory(bootloader/pureboot)
# pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5) # 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: flash/page/EEPROM sizes and the linker wrap the PC # Per-family geometry, deployment defaults, and the linker wrap the PC modulo
# modulo needs, the loader slot (each chip's smallest boot sector — 1 KiB on # needs. The slot is 512 bytes on every chip. The USART flags mirror the
# the word-addressed 1284s), and the deployment defaults (crystal assumption # hardware inventory the loader's own static asserts check — the plain 644 is
# on the megas, calibrated RC on the tinies). The USART flags mirror the # the x4 family's one single-USART die (Atmel-2593).
# 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_usart 1)
set(_pb_has_usart1 0) set(_pb_has_usart1 0)
if(LIBAVR_MCU MATCHES "^attiny13a?$") if(LIBAVR_MCU MATCHES "^attiny13a?$")
@@ -91,10 +80,9 @@ elseif(LIBAVR_MCU MATCHES "^atmega324(a|p|pa)$")
set(_pb_eeprom 1024) set(_pb_eeprom 1024)
set(_pb_has_usart1 1) set(_pb_has_usart1 1)
elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$") elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
# 64 KiB is exactly the 16-bit byte space: plain LPM reaches everything, # 64 KiB is exactly the 16-bit byte space, so plain LPM still reaches
# and the smallest boot section (1 KiB) holds the loader and its staging # everything and the wire stays byte-addressed. The plain 644 is the
# slot together (see README.md). The plain 644 is the family's one # family's one single-USART die.
# single-USART die.
set(_pb_flash 65536) set(_pb_flash 65536)
set(_pb_wrap -Wl,--pmem-wrap-around=64k) set(_pb_wrap -Wl,--pmem-wrap-around=64k)
set(_pb_page 256) set(_pb_page 256)
@@ -104,38 +92,41 @@ elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
set(_pb_has_usart1 1) set(_pb_has_usart1 1)
endif() endif()
elseif(LIBAVR_MCU MATCHES "^atmega1284p?$") elseif(LIBAVR_MCU MATCHES "^atmega1284p?$")
# 128 KiB: wire flash addresses are word addresses, reads go through # 128 KiB: wire addresses are words, reads go through ELPM, and the PC's
# ELPM, and the PC's modulo wrap exceeds what --pmem-wrap-around models. # 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_flash 131072)
set(_pb_wrap "") set(_pb_wrap "")
set(_pb_page 256) set(_pb_page 256)
set(_pb_hz 16000000) set(_pb_hz 16000000)
set(_pb_eeprom 4096) set(_pb_eeprom 4096)
set(_pb_slot 1024)
set(_pb_limit 1024)
set(_pb_has_usart1 1) set(_pb_has_usart1 1)
else() else()
message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}") message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}")
endif() endif()
if(NOT DEFINED _pb_slot) set(_pb_slot 512)
set(_pb_slot 512)
endif()
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}") math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL) math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
# Patched-vector chips hand over through the trampoline word below the slot, # 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. # 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") if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
set(_pb_app 0) set(_pb_app 0)
if(NOT DEFINED _pb_limit) set(_pb_limit ${_pb_slot})
set(_pb_limit ${_pb_slot})
endif()
else() else()
math(EXPR _pb_app "${_pb_base} - 2") math(EXPR _pb_app "${_pb_base} - 2")
math(EXPR _pb_limit "${_pb_slot} - 2") math(EXPR _pb_limit "${_pb_slot} - 2")
endif() endif()
# The pins each USART owns. A bit-banged link deployed on them has to release
# that USART before it can drive the line, and those instructions are the one
# way the choice of pins moves the image — so a size matrix needs them as an
# axis even though pins are otherwise immediate operands. Uniform across every
# mega libavr covers: USART0 (the classics' un-numbered USART included) on
# PD0/PD1, USART1 on PD2/PD3.
set(_pb_usart0_rx pd0)
set(_pb_usart0_tx pd1)
set(_pb_usart1_rx pd2)
set(_pb_usart1_tx pd3)
# simavr names its cores after the base dies; the A revisions run on them # simavr names its cores after the base dies; the A revisions run on them
# (the 644PA on the 644P core). # (the 644PA on the 644P core).
set(_pb_sim_mcu ${LIBAVR_MCU}) set(_pb_sim_mcu ${LIBAVR_MCU})
@@ -145,6 +136,19 @@ elseif(LIBAVR_MCU STREQUAL "atmega644pa")
set(_pb_sim_mcu atmega644p) set(_pb_sim_mcu atmega644p)
endif() endif()
# Where SRAM begins: the classic megas keep it right after the plain I/O
# registers, the x8/x4 generations push it past their extended I/O file, and
# the tinies match the classics. An autobaud loader's measured unit lives at
# exactly this address (the host reads it there — pureboot.py), and the
# unit-position test holds the layout to it.
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
set(_pb_ram 0x60)
elseif(LIBAVR_MCU MATCHES "^atmega")
set(_pb_ram 0x100)
else()
set(_pb_ram 0x60)
endif()
# The function runs in its caller's scope, so everything it needs crosses # The function runs in its caller's scope, so everything it needs crosses
# scopes as global properties. # scopes as global properties.
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex}) set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
@@ -153,6 +157,8 @@ set_property(GLOBAL PROPERTY PUREBOOT_WRAP "${_pb_wrap}")
set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz}) set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart}) set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1}) set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1})
set_property(GLOBAL PROPERTY PUREBOOT_USART0_TX ${_pb_usart0_tx})
set_property(GLOBAL PROPERTY PUREBOOT_USART1_TX ${_pb_usart1_tx})
# The port's own build (tests, the size matrix) reads the geometry from the # The port's own build (tests, the size matrix) reads the geometry from the
# parent scope; a downstream consumer gets the same variables for free. # parent scope; a downstream consumer gets the same variables for free.
@@ -162,54 +168,86 @@ set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE)
set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE) set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE) set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE) set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE)
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE) set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE) set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE) set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
set(PUREBOOT_USART0_RX ${_pb_usart0_rx} PARENT_SCOPE)
set(PUREBOOT_USART0_TX ${_pb_usart0_tx} PARENT_SCOPE)
set(PUREBOOT_USART1_RX ${_pb_usart1_rx} PARENT_SCOPE)
set(PUREBOOT_USART1_TX ${_pb_usart1_tx} PARENT_SCOPE)
# The fastest standard rate the clock reaches within 2.5 % — the same # The rates a default may pick, fastest first.
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
# default never trips the compile-time error it is checked against. A
# software build additionally requires the polled receiver's 100-cycles-a-bit # Whether <baud> is reachable from <clock> within 2.5 %, by the same
# floor (its own static assert): at low clocks the U2X divisor still reaches # best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a build
# rates the bit-banged sampler cannot, so the backend gates the ladder. # never trips the compile-time error it is checked against. A software build
function(pureboot_default_baud clock software outvar) # also needs the polled receiver's 100-cycles-a-bit floor: at low clocks the
foreach(baud 115200 57600 38400 19200 9600) # U2X divisor reaches rates the bit-banged sampler cannot.
math(EXPR _cycles "${clock} / ${baud}") function(pureboot_baud_feasible clock baud software outvar)
if(software AND _cycles LESS 100) set(${outvar} 0 PARENT_SCOPE)
math(EXPR _cycles "${clock} / ${baud}")
if(software AND _cycles LESS 100)
return()
endif()
foreach(divisor 8 16)
math(EXPR _step "${divisor} * ${baud}")
math(EXPR _n "(${clock} + ${_step} / 2) / ${_step}")
if(_n LESS 1 OR _n GREATER 4096)
continue() continue()
endif() endif()
foreach(divisor 8 16) math(EXPR _actual "${clock} / (${divisor} * ${_n})")
math(EXPR _step "${divisor} * ${baud}") math(EXPR _delta "${_actual} - ${baud}")
math(EXPR _n "(${clock} + ${_step} / 2) / ${_step}") if(_delta LESS 0)
if(_n LESS 1 OR _n GREATER 4096) math(EXPR _delta "-(${_delta})")
continue() endif()
endif() math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
math(EXPR _actual "${clock} / (${divisor} * ${_n})") if(_error_bp LESS_EQUAL 250)
math(EXPR _delta "${_actual} - ${baud}") set(${outvar} 1 PARENT_SCOPE)
if(_delta LESS 0) return()
math(EXPR _delta "-(${_delta})") endif()
endif()
math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
if(_error_bp LESS_EQUAL 250)
set(${outvar} ${baud} PARENT_SCOPE)
return()
endif()
endforeach()
endforeach() endforeach()
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 %") 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")
endfunction() endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>] # pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software] [USART <n>] # [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>]) # [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
# #
# Creates the loader target plus its flashable images (<name>.hex for a # The loader target plus its flashable images (<name>.hex for a programmer,
# programmer, <name>.bin for --update-loader) and stamps the resolved # <name>.bin for --update-loader). The resolved deployment is stamped on the
# deployment on the target: the PUREBOOT_HZ, PUREBOOT_BAUD and PUREBOOT_LINK # target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
# properties (the link as usart0/usart1/sw:<RX>,<TX> — what a test harness # usart0, usart1, or sw:<RX>,<TX> with a trailing @<n> where those pins are a
# needs to speak to the build). # USART's own) — 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.
#
# OSCCAL bakes a measured oscillator trim into the loader (README.md: the
# RC-oscillator deployment answer): the byte is written at the top of run(),
# so every reset path — the watchdog hand-over included — runs on the
# corrected clock. Orthogonal to the backend: an autobaud build may carry it
# purely for the application's benefit, its own link being clock-free. No
# value, no code.
function(pureboot_add_loader name) function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN}) cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS) if(PB_UNPARSED_ARGUMENTS)
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}") message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
endif() endif()
@@ -229,8 +267,8 @@ function(pureboot_add_loader name)
if(NOT PB_SERIAL) if(NOT PB_SERIAL)
set(PB_SERIAL auto) set(PB_SERIAL auto)
endif() endif()
if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software") if(DEFINED PB_USART AND NOT PB_SERIAL MATCHES "^(auto|hardware)$")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software") message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL ${PB_SERIAL}")
endif() endif()
if(DEFINED PB_USART) if(DEFINED PB_USART)
set(PB_SERIAL hardware) set(PB_SERIAL hardware)
@@ -259,7 +297,7 @@ function(pureboot_add_loader name)
set(PB_SERIAL software) set(PB_SERIAL software)
endif() endif()
endif() endif()
if(PB_SERIAL STREQUAL "software") if(PB_SERIAL MATCHES "^(software|autobaud)$")
if(NOT PB_RX) if(NOT PB_RX)
set(PB_RX pb0) set(PB_RX pb0)
endif() endif()
@@ -271,13 +309,26 @@ function(pureboot_add_loader name)
message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1") message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1")
endif() endif()
endforeach() endforeach()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) if(PB_SERIAL STREQUAL "autobaud")
# The link spec a test harness drives a GPIO bridge with: sw:<RX>,<TX> set(_serial_defines PUREBOOT_AUTOBAUD PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
# as the port letter and bit, the loader's own pin naming upcased. 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 — with @<n> where
# the TX pin is a USART's own TXD, since a harness driving that
# link has to know the USART owns the pin until the loader
# releases it.
string(SUBSTRING ${PB_RX} 1 2 _rx_pin) string(SUBSTRING ${PB_RX} 1 2 _rx_pin)
string(SUBSTRING ${PB_TX} 1 2 _tx_pin) string(SUBSTRING ${PB_TX} 1 2 _tx_pin)
string(TOUPPER "sw:${_rx_pin},${_tx_pin}" _link) string(TOUPPER "sw:${_rx_pin},${_tx_pin}" _link)
string(REPLACE "SW" "sw" _link ${_link}) string(REPLACE "SW" "sw" _link ${_link})
get_property(_tx0 GLOBAL PROPERTY PUREBOOT_USART0_TX)
get_property(_tx1 GLOBAL PROPERTY PUREBOOT_USART1_TX)
if(_usart AND PB_TX STREQUAL _tx0)
set(_link "${_link}@0")
elseif(_usart1 AND PB_TX STREQUAL _tx1)
set(_link "${_link}@1")
endif()
endif() endif()
endif() endif()
if(NOT PB_BAUD) if(NOT PB_BAUD)
@@ -288,27 +339,41 @@ function(pureboot_add_loader name)
endif() endif()
endif() endif()
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} if(PB_SERIAL STREQUAL "autobaud")
${_serial_defines}) # 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()
if(DEFINED PB_OSCCAL)
math(EXPR _osccal "${PB_OSCCAL}" OUTPUT_FORMAT DECIMAL)
if(_osccal LESS 0 OR _osccal GREATER 255)
message(FATAL_ERROR "pureboot_add_loader(${name}): OSCCAL ${PB_OSCCAL} is not one byte")
endif()
list(APPEND _defines PUREBOOT_OSCCAL=${_osccal})
endif()
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr) target_link_libraries(${name} PRIVATE libavr)
target_compile_definitions(${name} PRIVATE ${_defines}) target_compile_definitions(${name} PRIVATE ${_defines})
# Codegen shaping for the loader TU only, worth ~40 B on every chip and # Codegen shaping for the loader TU only. At -Os GCC otherwise rewrites the
# what carries the far-flash 1284 build under 512. At -Os GCC otherwise # byte-stream loops' counters into end-pointer forms that cost registers
# rewrites the byte-stream loops' counters into end-pointer forms that # (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the
# cost registers (-fno-ivopts, -fno-split-wide-types), leaves register # table with the default allocator (-fira-algorithm=priority), and keeps
# pressure on the table with the default allocator # expression temporaries in registers (-fno-tree-ter) — but every loop body
# (-fira-algorithm=priority), and spends bytes on rewrites a # here contains a call, so a register held across it costs more than the
# straight-line loader gains nothing from. # 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.
target_compile_options(${name} PRIVATE target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-expensive-optimizations -fno-split-wide-types) -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap}) -Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
# The ELF is a container (symbols, section headers), never flashed; the # The ELF is a container, never flashed: .hex for a programmer, .bin (the
# flashable forms sit beside it: .hex for a programmer, .bin (the slot's # slot's bare bytes) for --update-loader.
# bare bytes) for the host tool's raw path and --update-loader.
add_custom_command(TARGET ${name} POST_BUILD add_custom_command(TARGET ${name} POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.hex $<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.hex
@@ -317,3 +382,4 @@ function(pureboot_add_loader name)
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD} set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
PUREBOOT_LINK ${_link}) PUREBOOT_LINK ${_link})
endfunction() endfunction()

View File

@@ -2,236 +2,332 @@
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables constraint: one C++ source, no inline assembly, no global register variables
(attributes and compiler flags allowed), built for **every chip libavr (attributes and compiler flags allowed), **512 bytes on every chip libavr
targets — all 37 — in 512 bytes each**: 434 B on the tiny13s, 438442 B on targets — all 37**. The device speaks primitives; every composite — verify,
the tiny25/45/85, 412452 B across the megas, and 506 B on the erase, reset-vector surgery, updating the loader itself — lives in the host
ATmega1284/1284P, whose far-flash machinery (ELPM reads, RAMPZ page commands, tool (`pureboot.py`).
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 The image is **position-independent**: control flow is PC-relative, the
read/write paths take wire addresses, the write guard protects the slot the transfer paths take wire addresses, the write guard protects the slot the code
code is *running* in (from the runtime return address), the info block is is *running* in (from the runtime return address), nothing else is
addressed from that same anchor, and the application jump is an indirect flash-resident to address at all, and the application jump is an indirect call
call to an absolute entry. The identical binary therefore runs from any to an absolute entry. The identical binary therefore runs from any slot with
slot with every command intact which makes pureboot **its own staging every command intact, which makes pureboot **its own staging loader**: the host
loader**: the host installs the same binary one slot below the resident, installs the same binary one slot below the resident, jumps into it, and lets
jumps into it, and lets it rewrite the resident. The slot is 512 bytes it rewrite the resident. The lint holds it to that literally — the image must
(1 KiB on the word-addressed large chips, matching their boot-sector come out byte-identical linked at a different base.
minimum); on the tinies the budget is 510, not 512: a slot's last word
belongs to the host-managed trampoline (below). ## 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 | 390 B | 460 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 394 B | 464 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 398 B | 468 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 398 B | 468 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 360 B | 474 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 362 B | 480 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 362 B | 480 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 388 B | 464 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 398 B | 474 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 394 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 420 B | 500 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 deployed on a
USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud
alone carries the calibration loop, a bit-banged link on a USART's pins alone
has to release it (below), and the trim adds its one register write. Without
the trim that build is 504; on the default pins, 500. The flash bank riding
in a transfer's selector byte keeps even those chips' addressing the same
16-bit form every other chip uses, which is why they are no longer the
outlier they were.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire
quantities are little-endian.
## Configuration ## Configuration
Every deployment axis is a build parameter, resolved by the CMake function Every deployment axis is a build parameter of `pureboot_add_loader()` (in
`pureboot_add_loader()` (in `pureboot/CMakeLists.txt`) — the one way a `pureboot/CMakeLists.txt`) — the one way a loader target is created, by this
loader target is created, by this repo's own build and by a downstream repo's build and by a downstream project alike:
project alike:
| Argument | Meaning | Default | | Argument | Meaning | Default |
|---|---|---| |---|---|---|
| `CLOCK <hz>` | the clock the board runs | 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s | | `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 | | `BAUD <bd>` | the wire rate | the ladder below |
| `SERIAL auto\|hardware\|software` | the link backend | `auto`: the hardware USART where the chip has one | | `SERIAL auto\|hardware\|software\|autobaud` | the link backend | `auto`: the hardware USART where the chip has one |
| `USART <n>` | the USART instance (x4 megas carry two) | 0 | | `USART <n>` | the USART instance (x4 megas carry two) | 0 |
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` | | `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 | | `TIMEOUT <s>` | the activation window | 8 |
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock 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 reaches within 2.5 % — the same U2X-included divisor search libavr's baud
solver runs — and on a software build additionally within the polled solver runs — and on a software build additionally within the polled
receiver's 100-cycles-a-bit floor. 16 MHz lands 115200, 8 MHz 57600, receiver's 100-cycles-a-bit floor. Whatever is picked or overridden is
1 MHz 9600. Whatever is picked or overridden is re-checked in the compile: re-checked in the compile: an infeasible combination, or a USART the chip does
an infeasible clock/baud/backend combination, or a USART the chip does not not have, fails with a named static assert.
have, fails with a named static assert.
A downstream project brings its usual libavr setup (the `libavr` target, Putting a bit-banged link on a USART's own pins is a supported deployment, and
the chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, the usual one where a board's USB bridge is wired to RXD/TXD: the link's `init`
and states its deployment — for example an ATmega328P on its shipped clears that USART's `UCSRnB` first, because while its `TXEN` is set the USART —
1 MHz fuses with the software UART on hand-picked pins: not the port register — owns the TX pin, and a loader entered from an
application that left it enabled would receive and obey while answering nothing
(§20.2). It costs four bytes, and only on those pins.
`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing
at run time, so `CLOCK` and `BAUD` are not build parameters there and one
binary per chip serves every clock and every rate. It is for the deployments
whose clock is not known at build time and does not hold still — the internal
RC oscillator, ±10 % from the factory and moving with supply and temperature —
where a fixed-baud software build has to be rebuilt per clock and still drifts
out of tolerance. The cost is that it is software-serial only (a hardware USART
needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000).
**Pick the rate by cycles a bit, and leave the oscillator room.** What the
calibration can measure is bounded by how many clock cycles one bit lasts, so a
rate is only ever sensible relative to the clock. Two different floors matter:
| | cycles a bit |
|---|---|
| the logic's floor — exact clock, simulated | solid to ~36, fails outright by ~31 (`pureboot.autobaud` gates a point here) |
| a factory-trimmed internal RC, measured on an ATtiny13A | reliable at ~118; already locking 1 attempt in 5 by ~59 |
The gap is the oscillator's own jitter, and no exact-clock simulation shows it.
So on an RC part, **budget about 100 cycles a bit** — the same order as the
fixed-baud software receiver's floor — rather than the logic's ~36. Measured
envelope on that ATtiny13A, with an application resident: 9.6 and 4.8 MHz reach
115200, 1.2 MHz reaches 9600, 600 kHz reaches 4800, 128 kHz reaches 2400.
One trap in testing this: on a patched-vector chip an **erased** application
region walks straight back up into the loader, so every expired window opens
another one and the host's retries eventually catch the pulse. That reads as far
more reliable than the same part with an application resident, which gets one
window per reset. Measure with an application in place.
A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
software UART on hand-picked pins, say. A submodule pins the loader version
(the tags name them; this repo pins its own libavr the same way), where
FetchContent tracks whatever `main` is:
```cmake ```cmake
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main) # git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent
FetchContent_MakeAvailable(bootloader) add_subdirectory(bootloader/pureboot pureboot)
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)
pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5) pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
``` ```
The function emits the ELF plus `myboot.hex` (the programmer artifact) and The function emits the ELF plus `myboot.hex` (the programmer artifact) and
`myboot.bin` (the self-update image), prints the size, and stamps the `myboot.bin` (the self-update image), prints the size, and stamps the resolved
resolved deployment on the target as the `PUREBOOT_HZ`, `PUREBOOT_BAUD` deployment on the target as the `PUREBOOT_HZ`, `PUREBOOT_BAUD` and
and `PUREBOOT_LINK` properties — what a flashing script or test harness `PUREBOOT_LINK` properties — what a flashing script or test harness needs to
needs to speak to the build. This exact example deployment runs the full speak to the build. This exact deployment runs the full protocol suite in CI
protocol suite in CI (`pureboot.custom`). (`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 ## Activation
Reset enters the loader (BOOTRST on the boot-sectioned megas; the patched 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 reset vector elsewhere) — except a watchdog reset, which hands straight to the
watchdog reset, which hands straight to the application (the application application with no activation window, since the application owns its watchdog.
owns its watchdog; it must clear WDRF itself, which also releases the This is deliberate: it lets an application reboot itself instantly rather than
WDRF-forced WDE). sit through the window. The application must clear WDRF itself (libavr's
`watchdog::disable()` does). **Gotcha:** WDRF is sticky (cleared only by
software, not by a later reset), so an application that watchdog-resets and
never clears it diverts *every* subsequent reset — external ones included —
past the window too, and the loader becomes reachable only through an external
programmer until the flag is cleared. A serial recovery path therefore assumes
the application clears WDRF on its own reset path.
The host then has one activation window per awaited byte to knock: `p` then The host then knocks `p` then `b`, each awaited byte under a fresh activation
`b`. Each awaited byte gets a fresh window; any other byte is discarded and window; any other byte is discarded and awaited again, so line noise can delay
awaited again (line noise cannot lock the loader, only delay it). A window the loader but never lock it. A window expiring on an idle line boots the
expiring with an idle line boots the application. application.
The window length is a compile-time constant — 8 s by default, another An autobaud build opens differently, because it has to learn the rate before it
value via `pureboot_add_loader(... TIMEOUT <s>)` (the stock target keeps can read a byte at all: the host sends the **calibration byte 0xC0** — a start
the `PUREBOOT_TIMEOUT` cache variable) — so the whole EEPROM belongs to bit plus six zero data bits form one low pulse of seven bit-times — and the
the application; pureboot never uses it for its own state. Re-timing a loader times that pulse into its bit period. A single `p` then activates; the
deployed loader is a self-update with a re-timed build (below). pulse has already proven a host is present, which the two-byte knock exists to
establish elsewhere. Both waits are bounded, so a stray low pulse with no host
behind it costs one window and then boots the application rather than holding
the loader.
The window is a compile-time constant (`TIMEOUT`, 8 s by default), so the whole
EEPROM belongs to the application — pureboot keeps no state of its own.
Re-timing a deployed loader is a self-update with a re-timed build. An autobaud
build counts poll iterations instead (`PUREBOOT_AUTOBAUD_POLLS`), there being
no clock to turn into seconds.
## Session ## Session
After the knock the loader stays in its command loop until `J` jumps away or 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 the chip resets. Before reading each command it waits for any pending EEPROM
write to finish and sends the prompt `+` (0x2b) — the prompt is therefore write and sends the prompt `+` (0x2b), which is therefore also the previous
also the completion ack of the previous command. A session is: await `+`, command's completion ack. A session is: await `+`, send a command, read its
send a command, read its reply, repeat. reply, repeat.
On chips whose flash exceeds 64 KiB (the 1284s — info-block flag bit 1) the Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in
`R`/`W` flash addresses are **word** addresses; everywhere else they are byte the command's selector byte, so no command has to speak word addresses. `J` is
addresses (the 644s' 64 KiB is exactly the 16-bit byte space and stays the exception: it takes a word address, because that is what the hardware's own
byte-addressed). EEPROM addresses are always bytes, counts always bytes. jump takes. EEPROM and data-space addresses and all counts are bytes.
The loader trusts the host to keep addresses in range: it does not bound them
against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps —
EEAR is only as wide as the array, so an address past the end truncates onto
low EEPROM and the write silently overwrites it. Keeping transfers within the
real sizes is the host's job (the shipped tool does); the flash budget is
better spent on features than on re-checking a bound the host already holds.
| Cmd | Arguments | Reply | | Cmd | Arguments | Reply |
|---|---|---| |---|---|---|
| `b` | — | the 12-byte info block | | `b` | — | 4 bytes: the pureboot version, then the three signature bytes |
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) | | `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) |
| `W` | addr16, then one page of data | — (completion = next prompt) | | `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) | | `W` | sel8, addr16, then one page of data | — (completion = next prompt) |
| `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 | | `J` | word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) | | other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`W` streams exactly one SPM page (size from the info block) into the buffer, `G` and `g` are one letter in two cases, which is the whole command set for
then erases and programs; the address must be page-aligned. Pages inside the every memory: the **selector** byte's low nibble names the space and its high
512-byte slot the loader is *running* in are drained but never programmed — a nibble carries the flash bank.
broken host cannot brick the running copy, and a staged copy may rewrite the
resident slot.
The loader never clears the SPM buffer before a fill, so **one `W` may | Space | | |
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 | 0 | flash | read-only here; it is written through `W` and the SPM space |
refused page (drained, never programmed) and — where SPM runs from anywhere, | 1 | EEPROM | |
the tinies and the m48s — an application that self-programmed before | 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR |
entering. The next `W` takes those stale words, and clears them: a page write | 3 | fuse and lock | index 0..3 in the hardware's own Z order: low, lock, extended, high |
auto-erases the buffer (§26.2.1; §19.2 on the tinies), so repeating it | 4 | SPM | write-only: the byte goes to SPMCSR and fires the instruction at the address |
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.
`w` is host-paced: send the next byte only after the previous The data space is worth more than it looks. pureboot keeps **zero static RAM**
byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip and pushes no register, so at loader entry an application's SRAM is still
without an extended fuse byte (the ATtiny13A) that slot carries no meaning. whatever the application left there, bar the handful of bytes of return-address
Fuse *writing* does not exist: SPM reaches flash (and, on the mega, lock stack — which makes `G` over space 2 a post-mortem of a running application,
bits) only — fuse bytes are external-programming territory by hardware. 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.
`J` is the one control-transfer primitive: the host uses it to run the Programming a page is therefore `W` to fill the buffer, then a `g` to the SPM
application (word 0 on the mega, the trampoline word on the tinies — both space for the erase, another for the write, and on a boot-sectioned chip a
known from the info block) and to move between loader copies during a third to re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR
self-update. A jump to a loader slot's base re-enters that copy's own encodings every part pureboot targets shares. The loader carries no page-commit
startup; it must then be knocked afresh. logic of its own, and the same primitive reaches every other SPM operation,
lock bits included.
The info block (`b`): 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.
| Offset | Content | 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
| 02 | `'P'`, `'B'`, pureboot version (2) | one slot lower may rewrite the resident — which is what a self-update is.
| 35 | device signature |
| 6 | SPM page size in bytes (0 means 256) |
| 78 | loader base — application flash ends here (a word address when bit 1 is set) |
| 910 | EEPROM size |
| 11 | bit 0: host must patch the reset vector (no hardware boot section); bit 1: flash wire addresses are word addresses |
Composites are the host's job: verify = read back and compare, erase = The loader never clears the SPM buffer before a fill, so **one `W` may program
write `0xff` (per page for flash, per byte for EEPROM). the wrong bytes, and the host is what fixes it**. The buffer is write-once per
word until cleared, and two things leave words in it: a refused page, and —
where SPM runs from anywhere, the tinies and the m48s — an application that
self-programmed before entering. The next page write takes those stale words
and clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on
the tinies), so repeating it programs correctly. The host therefore verifies
every page it writes and rewrites what comes back wrong (three retries, then it
stops).
`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse
*writing* does not exist — SPM reaches flash and boot lock bits only.
`J` is the one control-transfer primitive: it runs the application (word 0 or
the trampoline word, both derived from the chip) and moves between loader
copies during a self-update. A jump to a slot's base re-enters that copy's own
startup, which must then be knocked afresh.
`b` answers with the loader's identity — its version and the chip's signature —
and nothing else. Everything else the host needs (page size, loader base,
EEPROM size, whether the reset vector must be patched, how many flash banks)
follows from the signature, and the host holds that table; the loader derived
the same facts from its own chip database at build time, so nothing is guessed,
it is simply not sent twice.
An update image, though, is a bare 512-byte slot with no device to ask, and
installing one built for another chip bricks the target. Every loader image
therefore carries a six-byte **stamp**`'P'`, `'B'`, the version, the three
signature bytes — which the loader itself never reads and the host tool refuses
to install a mismatch against.
## Version ## Version
The third byte of the info block is the **pureboot version** — the loader's `b`'s first byte is the **pureboot version** — the loader's one identity
one identity number, and the only way to tell what a deployed loader is. number, and the only way to tell what a deployed loader is. Nothing else is
Nothing else is numbered: the wire protocol has no version of its own, a numbered: the wire protocol has no version, a pureboot version implies it, and
pureboot version implies its protocol, and the host tool is what holds that the host tool holds that map. The tool states the window of loader versions it
map. It states the window of loader versions it speaks speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a version that
(`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`); a version that changes changes the protocol becomes the new floor there. A loader newer than the tool
the protocol becomes the new floor there. So far none has: pureboot 1 and 2 is refused by name rather than decoded on the assumption that nothing moved.
speak the identical session, and a loader newer than the tool is refused by
name rather than decoded on the assumption that nothing moved.
The tool carries its own version, free to drift from the loader's: Two generations exist. **1 through 4** speak one session — a 12-byte info block
`--version` prints both it and the window. from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses).
**5** replaced those with the single `G`/`g` pair over selector-named spaces
above; the shipped tool speaks both, choosing on the version it reads, so a
deployed pureboot 4 stays drivable and self-updatable to 5. **6** changes
nothing on the wire: it marks the builds that may carry a baked `OSCCAL` trim
(Configuration), so a tool driving an update knows such images exist.
Every closed generation is tagged in this repo at its era's last commit — the
commit just before the next version bump, so a tag holds everything its
version ever gained — and each tag carries the `libavr/` submodule pinned to
the libavr that loader was built against, as the whole libavr era does commit
by commit. `git checkout v3 && git submodule update --init libavr` followed by
the usual preset build therefore reproduces the v3 loader exactly; the open
generation is `main`.
Collapsing four command bodies into one transfer loop is what paid for the
version: the data space, the host-issued SPM operations and the fuses now share
the loop, the cursor and the argument decode that `R`/`r`/`w` each carried a
copy of. The loader shrank while gaining all three.
The tool carries its own version, free to drift; `--version` prints it and the
window.
## Deployment ## Deployment
The build leaves three artifacts per chip. The ELF is a container for the 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**: tests and objcopy, never flashed. The **.hex is the programmer artifact**: it
it carries its own addresses and lands the loader in its top slot, carries its own addresses and lands the loader in its top slot, touching
touching nothing else. The **.bin is the self-update image** — the slot's nothing else. The **.bin is the self-update image** — the slot's bare bytes.
bare bytes with no addressing, which a programmer would put at address 0.
On a boot-sectioned mega a copy at 0 is dead weight (SPM only executes
from the boot section, so it cannot even heal itself — reflash the .hex);
on the patched-vector chips it *runs* (the image is position-independent
and reset enters word 0), reports its canonical geometry, and the ordinary
`--update-loader` flow re-homes a build into the top slot from any
position — the staging install and the word-0 redirect execute from
copies outside page 0's slot, and a copy sitting in the staging slot
itself is recognized as the installed staging copy and left in place (it
streams the new resident like any staged copy, so an older build installs
a newer one). `pureboot.rehome` is the acceptance test for both
positions. Flashing the application afterwards overwrites the stale copy,
vector surgery included.
**Boot-sectioned megas**: program the loader at `flash slot` with an **Boot-sectioned megas**: program the loader at `flash 512` with an external
external programmer. Every such mega has a BOOTSZ step whose boot section programmer. Every such mega has a BOOTSZ step whose boot section is exactly
is exactly the loader slot — 512 B, the second-smallest step on the 8 KiB the 512-byte slot — the second-smallest step on the 8 KiB and 16 KiB chips,
and 16 KiB chips (m8, m88, m16, m168, m164), the smallest on the 32 KiB the smallest on the 32 KiB ones — so the ATmega328P profiles below apply to
ones (m32, m328, m324); on the 1284s that step is the smallest, 512 words, every one of them with its own addresses; the per-chip BOOTSZ ladders live in
which is why their slot is 1 KiB — so the ATmega328P profiles below apply the host tool (`BOOT_FUSE`).
to every one of them with its own addresses and slot size; the per-chip
BOOTSZ ladders live in the host tool (`BOOT_FUSE`). The 1284s' numbers:
standalone = BOOTSZ 512 words (reset at the loader base 0x1fc00);
self-update = 1024 words, covering both 1 KiB slots, the loader-first
reset landing at 0x1f800 — the staging slot, walked across when erased.
The **644s** are the geometry's sweet spot: their smallest boot section The **644s and 1284s** are the geometry's sweet spot: their smallest boot
(512 words = 1 KiB) is exactly *two* 512-byte slots, so the resident and section (512 words = 1 KiB) is exactly *two* slots, so the resident and its
its staging slot both live inside the minimum section — self-update needs staging slot both live inside the minimum section. Self-update needs no fuse
no fuse step up, and the standalone profile does not exist (reset lands at step up, and the standalone profile does not exist reset lands one erased
0xfc00, one erased slot below the loader: the loader-first walk built in). slot below the loader (0xfc00 / 0x1fc00) and walks up into it.
ATmega328P profiles (addresses for its 32 KiB): ATmega328P profiles (addresses for its 32 KiB):
@@ -241,155 +337,303 @@ 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) | 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). | | 512 words (1 KB) | programmed | *Self-update, loader-first*: reset lands at 0x7c00 — the staging slot, normally erased, so execution walks up into the loader; during an update it is the staging copy itself, so a mid-rewrite power loss recovers by reset. The loss windows move to the staging install/retire page writes instead (page-write scale). The host keeps `[0x7c00, 0x7e00)` clear of application data (`--force` overrides). |
Applications are flashed unmodified — word 0 stays the application's own Applications are flashed unmodified here — word 0 stays the application's own
reset vector, and the hand-over jumps to 0. reset vector, and the hand-over jumps to 0.
**Patched-vector chips — the tinies and the m48s** (no boot section; the **Patched-vector chips — the tinies and the m48s** (no boot section; the m48s'
m48s' SPM runs from the entire flash, Atmel-8271 §26): program the loader SPM runs from the entire flash, Atmel-8271 §26): program the loader at
at `flash 512`; erased flash below it walks up into the loader, so a `flash 512`; erased flash below it walks up into the loader, so a virgin
virgin chip activates. When flashing an application the host performs chip activates. Flashing an application then takes reset-vector surgery: word
reset-vector surgery: word 0 is rewritten to `rjmp` to the loader base, and 0 becomes an `rjmp` to the loader base, and the application's own entry is
the application's own entry is re-encoded as a trampoline `rjmp` in the re-encoded as a trampoline `rjmp` in the word just below the loader
word just below the loader (`base 2`, where the hand-over jumps). Every (`base 2`, where the hand-over jumps). Every other vector stays the
other vector stays the application's. The patched page 0 and the trampoline application's. The patched page 0 and the trampoline page are written *first*
page are written *first*, so from the first write on an interrupted flash and an erase runs top-down, so from the first write on an interruption still
still resets into the loader; an erase runs top-down for the same reason. resets into the loader.
The m48s speak this profile over their hardware USART — no fuse preflight,
BOOTRST does not exist there. A .bin programmed at address 0 by mistake is dead weight on a boot-sectioned
mega (SPM only executes from the boot section — reflash the .hex), but *runs*
on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it
into the top slot from there (`pureboot.rehome`).
**Fixed-baud on an internal RC oscillator is a deployment risk the build
cannot see.** The factory trim is ±10 % where an 8N1 frame survives about
±4: a part at the edge answers nothing at the built rate, and the symptom —
silence — reads as a wiring fault (a real ATtiny13A measured 5.5 %, outside
every standard rate at its own documented default). The **autobaud build is
the deployment-proof backend**: it has no rate to miss. Where fixed-baud on
RC is wanted anyway, measure first and bake the trim: an autobaud session's
`--info` prints the part's true clock from the loader's own measured bit
period, OSCCAL moves the oscillator about 1 % per step, and `OSCCAL <byte>`
builds the correction in — one buildmeasure iteration converges. A loader
already deployed and silent is diagnosed with `--scan` (Host tool).
## Updating the loader ## Updating the loader
`pureboot.py --update-loader new_pureboot.bin` replaces the resident 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 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 loader itself as its own staging loader. The image is the loader's own 512
bytes as a raw binary, or the Intel HEX the build emits beside it, which bytes as a raw binary, or the Intel HEX the build emits beside it.
links the loader at its base inside an otherwise blank flash image:
The preflight refuses an image built for another chip: the info block One thing the image cannot tell the host: **which link it speaks.** The update
embedded in every pureboot binary (signature, page size, loader base, works by entering copies of the *new* image (steps 3 and 4 below), so a build
EEPROM size, flags) must match the device's own, and the error names both. made for another baud or another backend answers on that one and not on the
Die revisions share their base signature and geometry, so their images are session's — and 512 bytes of position-independent code carry no header to read
interchangeable — as the silicon is. `loader_image()` also accepts a it from. Where the new image's link differs, name it:
padded image (a raw .bin padded from 0, or a whole-flash read-back with
the loader resident) and peels it to the slot content by the embedded base.
1. The staging slot `[baseslot, base)` is saved to a host-side state file ```sh
(on the 1 KB tiny13s that is the whole application, vectors included). # a 57600 fixed-baud resident, replaced by an autobaud build
2. The resident installs the identical update image there. On the pureboot.py --port … --baud 57600 --update-loader ab.bin --staged-autobaud
patched-vector chips the host composes the slot's last word — the same # …or by a 38400 build of the same backend
address as the resident's trampoline — as a jump to the resident base, pureboot.py --port … --baud 57600 --update-loader sw38400.bin --staged-baud 38400
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 host retunes on the open port, so no DTR pulse resets the copy it is talking
the staging copy instead — rewriting it would only meet its own to. Omit them against a changed link and the update stops after installing the
running-slot guard. staging copy, saying so and naming this as the cause.
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 An `OSCCAL`-baked image is a link change in effect even at an unchanged rate
first re-aims word 0 at the staging copy, so a power loss mid-rewrite on paper: the staging copy shifts the physical clock the moment its `run()`
still resets into a loader; on the tiny13s the staging slot carries the starts, and from then on speaks exactly what it was built for. Declare it
reset vector itself. like any other link change — `--staged-baud` with the new build's rate.
The preflight refuses an image built for another chip: the stamp every pureboot
binary carries must resolve to the device's own geometry, and the error names
both. Die revisions share their base signature and geometry, so their images
are interchangeable — as the silicon is.
1. The staging slot `[base512, base)` is saved to a host-side state file (on
the 1 KB tiny13s that is the whole application, vectors included).
2. The resident installs the update image there. On the patched-vector chips
the host composes the slot's last word as a jump to the resident base, so
even an abandoned staging copy times out into a loader. A loader already
sitting whole in the staging slot is left as the staging copy instead —
rewriting it would only meet its own running-slot guard.
3. `J` enters the staging copy, which rewrites the resident slot. Where a
patched reset vector routes through the resident, the host first re-aims
word 0 at the staging copy, so a power loss mid-rewrite still resets into a
loader; on the tiny13s the staging slot carries the reset vector itself.
4. `J` enters the new resident, which restores the staging slot's saved 4. `J` enters the new resident, which restores the staging slot's saved
content (word 0 and the trampoline with it) and the state file is content, and the state file is discarded.
discarded.
Every phase is idempotent and keyed off the actual flash state: re-running 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 the same command after any interruption resumes and completes — with one
file carries the only bytes not recoverable from the device; if it is lost qualification, which is the link again: from step 2 on, the copy the re-run has
mid-update the update still completes, and the staging region is restored by to reach is the *new* image, so a resumed run needs the same `--staged-*` as the
reflashing the application. A boot-sectioned mega needs its fuses for the first one. On a patched-vector part step 3 also re-aims word 0 at the staging
preflight (BOOTSZ gate, profile notes) — read from the device, or supplied copy, so after that point a reset reaches the new image's link and **only** that
with `--assume-fuses` where reading is impossible (simulators); the one; a re-run on the resident's link finds nothing at all. The state file carries
patched-vector chips need none. the only bytes not recoverable from the device; losing it mid-update still
completes the update, and the staging region comes back by reflashing the
application. A boot-sectioned mega needs its fuses for the preflight — read
from the device, or supplied with `--assume-fuses` where reading is impossible
(simulators).
## Host tool ## Host tool
`pureboot.py` — Python 3, standard library only. The port layer is the one `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 platform-specific part: termios drives any tty on POSIX (a USB adapter as well
well as a simavr pty), the Win32 serial API through `ctypes` drives a COM as a simavr pty), the Win32 serial API through `ctypes` drives a COM port on
port on Windows (`--port COM6`; the `\\.\` form for two-digit ports is Windows (`--port COM6`; the `\\.\` form for two-digit ports is supplied by the
supplied by the tool). Opening the port asserts DTR and RTS on both, so a tool). Opening the port asserts DTR and RTS on both, so a board that wires DTR
board that wires DTR to reset gets its reset pulse and opens the activation to reset gets its reset pulse and opens the activation window by itself.
window by itself.
pureboot.py --port /dev/ttyUSB0 --baud 57600 \ pureboot.py --port /dev/ttyUSB0 --baud 57600 \
--info --fuses --flash app.hex --info --fuses --flash app.hex
Operations run in a fixed order within one session: info, fuses, loader Operations run in a fixed order within one session: info, fuses, loader
update, flash (erase / program / read / verify), EEPROM (erase / program / update, flash (erase / program / read / verify), EEPROM (the same), then
read / verify) — then the loader hands over to the application; `--stay` `--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive
keeps the session alive instead, and a later invocation reconnects into it instead, and a later invocation reconnects into it. `--flash` and `--eeprom`
(the knock converges there too). `--flash` and `--eeprom` verify by verify by read-back unless `--no-verify`, and a flash page that reads back
read-back unless `--no-verify`, and a flash page that reads back wrong is wrong is rewritten up to three times before the run stops (see `W` above).
rewritten up to three times before the run stops — the loader leaves one `--verify-flash` only reports. Images are raw binary, or Intel HEX by
recoverable way for a page to land wrong (see `W` above), and rewriting is extension. `--force` overrides the refusable safety checks — today, flashing
what clears it. `--verify-flash` only reports. Images are raw binary, or application data into a mega's reset walk region.
Intel HEX by extension. `--force` overrides the refusable safety checks (today: flashing
application data into a mega's reset walk region).
Readouts come one fact per line: `--info` prints the decoded info block `--autobaud` opens with the calibration pulse instead of the plain knock, for a
field by field, the loader's version first; `--fuses` each fuse byte on its loader built `SERIAL autobaud`; the rest of the session is identical, at
own line — plus, on a boot-sectioned mega, the decoded meaning (where the whatever `--baud` the host chose. Its `--info` adds the **measured clock**
BOOTSZ section starts, what BOOTRST does to reset). Transfers that take the loader's bit-period unit, decoded and multiplied by the session rate —
wire time — programming, reading, erasing, verifying, the update phases — which is the number an `OSCCAL` bake or a fixed-baud build for the part is
draw a transient progress bar on stderr when it is a tty; logs and pipes held against; `--clock <hz>` states the drift against a nominal.
see only the summary lines.
`-v`/`--verbose` adds the decisions as they happen: knock counts, the `--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
programming plan (vector-surgery targets, skipped blank pages), update ±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
state handling and per-phase page counts. window — reset the target as each probe announces itself (a board with DTR
wired to reset is pulsed by the probe's own port-open). A loader
off-frequency answers at its oscillator's ratio, and the report gives the
found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it.
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) —
SRAM, and through the same address space the register file and every I/O
register. Reading an I/O register can have side effects (reading UDR clears its
flags), which is the caller's business to know.
Reads are safe anywhere; **two small regions cannot be written without ending the
session,** because they are what the loader is standing on:
- the **top of SRAM**, where its stack lives — a handful of bytes below RAMEND;
- on an **autobaud** build, the **two bytes at RAMSTART**: the measured bit
period, in `.noinit`, which is the whole of that loader's static RAM. Overwrite
it and its next reply is timed against garbage. On an ATtiny13A that is
`0x60..0x61`, and the symptom is a mangled prompt byte rather than any error —
the loader is fine, it simply is no longer speaking the agreed rate.
Both are self-inflicted rather than defects, and a reset clears them. Note also
that `--poke` can write OSCCAL, which does take effect — but a session can only
survive a step or two of it before the clock walks the link out of the rate
autobaud locked to, and OSCCAL reverts on reset regardless.
Readouts come one fact per line: `--info` prints the device's version and
signature and the geometry that follows from them, `--fuses` each fuse byte
plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr
when it is a tty. `-v`/`--verbose` adds the decisions as they happen: knock
counts, the programming plan, update state handling and per-phase page counts.
## Tests ## Tests
`tools/check.sh` runs every chip's workflow (`tools/check.sh --full` adds libavr rides as the `libavr/` submodule (`git submodule update --init libavr`);
the reflect-mode builds of libavr's spot set; `tools/make_presets.py` `LIBAVR_ROOT` (cache or environment) overrides it for tandem development
regenerates the presets). Per chip preset, `ctest` runs: against a working tree. `tools/check.sh` runs every chip's workflow (`--full`
adds the reflect-mode builds of libavr's spot set; `tools/make_presets.py`
regenerates the presets).
Per chip preset, `ctest` runs:
- `pureboot.size` — the 510-byte (tinies) / 512-byte (mega) budget; - `pureboot.size` — the 510-byte (patched-vector) / 512-byte budget;
- `pureboot_*.size` — the size matrix: the serial backends × the clock - `pureboot_*.size` — the size matrix: the serial backends × the clock ladder
ladder (1/8/16 MHz; the t13s' own RC menu), plus the USART1 build on the (1/8/16 MHz; the t13s' own RC menu), the USART1 instance across that same
x4 chips — every configuration axis that could move the image, each ladder on the x4 chips, and `pureboot_sw_wide`, the slowest ladder rate at
variant against the same slot budget (pins are immediate operands and the the fastest clock — where a software UART's per-bit spin outgrows its
timeout is a constant: size-neutral); one-register delay loop and takes the 16-bit one. That is the largest image
- `pureboot.custom` (328P) — the configured-deployment acceptance test: the the configuration space produces, and a shape the ladder default (always the
1 MHz software-serial TX=PB1/RX=PB5 build from the configuration example *fastest* rate a clock reaches) never picks. Pins are an axis for one reason
drives the full protocol suite through the runner's GPIO bridge, fixture only, and it is enough: a bit-banged link on a USART's own pins has to
application included; release that USART, so `pureboot_{sw,autobaud}_on_usart{0,1}` build there
- `pureboot.usart1` (644A) — the same protocol suite over the second too. The timeout is a constant and is no axis;
hardware USART: instance selection is compile-checked everywhere, but - `pureboot_autobaud.size` — the clock-free build, which has no clock or baud
only a live session proves the loader polls the USART it claims; axis of its own: one binary per chip has to serve every point the matrix
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call` below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock
in the image, the info block within its first 256 bytes; shape and on the tightest image in the space (autobaud on a USART's own
- `pureboot.planner` — the host tool's pure logic: programming orders and pins), holding both of the trim write's addressing encodings to the budget;
their recovery properties, the surgery, the staging composition, the - `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
boot-fuse decode, the update preflight's error/warning matrix over object and sits exactly at ram_start, where `--info` reads it: wire
synthetic fuse bytes, and the repairing verify against a fake device — one contract, not layout accident;
bad write repaired in a single rewrite, a page that never comes good - `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
stopping after exactly three; 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.handshake` — the host tool's activation must not hang on a target
that never falls quiet: the drain after a prompt is bounded by the handshake
deadline, and a well-behaved loader still connects;
- `pureboot.updatelink` — an update whose image changes the baud or the backend
must follow the staging copy onto *its* link, since that copy is the new image;
and where nothing was declared, the failure must name the link rather than
report a bare activation timeout, because by then the staging slot is written
and on a 1 KiB tiny that was the application;
- `pureboot.planner` — the host tool's pure logic: programming orders and their
recovery properties, the surgery, the staging composition, the boot-fuse
decode, the update preflight over synthetic fuse bytes, and the repairing
verify against a fake device;
- `pureboot.scan``--scan`'s walk and report logic: the probe order, the
rate arithmetic, and the trim advice's direction. A pty carries bytes at
any termios rate, so the rate physics itself belongs to the hardware
harness, and what the wire would arbitrate is pinned as logic;
- `presets.generated` — CMakePresets.json matches its generator
(`tools/make_presets.py --check`), so a hand edit or a generator change
cannot drift the pair apart;
- `pureboot.protocol` — end to end against a simavr device - `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c` a hardware USART as a pty, or a cycle-timed (`test/pureboot_device.cpp`: a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, selected with `-l` to match GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's
the loader's link; plus the SPM/NVM module simavr's tiny cores lack) tiny cores lack) driven by the real host tool through knock-from-reset,
driven by the real host tool through program + verify of both memories, session reconnect, an external reset
knock-from-reset, program + verify of both memories, session reconnect, an through the patched vector, and the hand-over to a fixture application whose
external reset through the patched vector, and the hand-over to a fixture banner proves the launch — cross-checked against the simulator's
application whose banner proves the launch — cross-checked against the ground-truth memory dumps and an independent decode of the surgery;
simulator's ground-truth memory dumps and an independent decode of the - `pureboot.reloc` — the identical image one slot below the resident serves the
surgery's rjmp words; complete command set from there;
- `pureboot.reloc` — the identical image installed one slot below the - `pureboot.rehome` (t85) — a loader programmed at address 0 or in the staging
resident serves the complete command set from there (the slot re-homes into the top slot through the ordinary update flow;
position-independence acceptance test); - `pureboot.custom` (328P) — the configuration example's 1 MHz software-serial
- `pureboot.dirty` (328P) — entering the loader from a running application build driving the full protocol suite, proving the plumbing produces a
with no reset between, over an SPM page buffer the fixture deliberately working loader and not just one that fits;
dirtied: the case the loader declines to guard against. A bare verify must - `pureboot.usart1` (644A) — the same suite over the second hardware USART:
see the corruption, the repairing verify must fix it in one rewrite, and a instance selection is compile-checked everywhere, but only a live session
plain verify afterwards must pass. On the boot-sectioned megas hardware proves the loader polls the USART it claims;
forbids the state outright (SPM runs only from the boot section, and reset - `pureboot.mute` (328P) — a software link on USART0's own pins, entered from an
erases the buffer), but simavr dispatches SPM from anywhere — which is what application that handed over with that USART still enabled: the loader must
makes the path constructible at all; still answer, which it does only because it releases it. The pin ownership is
- `pureboot.update` — the full `--update-loader` flow to a re-timed build, the runner's, not simavr's — simavr wires a USART through IRQs and never takes
then every power-fail phase: the device is killed mid-write, restarted the pin from the port, so without that model the state under test could not
from its flash dump, and a re-run must complete the update with the arise at all;
application intact throughout. - `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.osccal` (328P, t85) — a loader built with the `OSCCAL` axis holds
the trim register at the built byte from its first prompt, observed through
the wire on one chip per addressing encoding (`sts` and low-I/O `out`);
- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
bridge: the calibration handshake, a flash + EEPROM + fuse round trip against
the simulator's own memory, a data-space round trip, the hand-over — then the
same binary again at double the clock, which is the property the backend
exists for. The measured clock `--info` prints is asserted against the
simulator's exact clock, inside the unit encoding's own envelope, at both
points. A lone calibration pulse with no knock behind it must still let
the application boot, so no wait in activation can be unbounded.
`size`, `pi`, and `planner` are host logic and run anywhere; the `size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run
simulator-driven targets need simavr and a pty, so they are POSIX-only — anywhere; the simulator-driven targets need simavr and a pty, so they are
on Windows the tool is exercised against real hardware. POSIX-only.
## Hardware
The suite above proves the protocol on every chip; it cannot prove a *board*.
Two things live only on silicon: an RC oscillator that is not on its nominal, and
a reset edge that has to come from somewhere. `tools/pbrig.py` and
`tools/pbhw.py` cover that, and know nothing per-board — every deployment fact
is a flag or a `PUREBOOT_*` environment variable.
```sh
export PUREBOOT_PROGRAMMER=atmelice_isp PUREBOOT_PART=t13 PUREBOOT_PORT=COM6
tools/pbrig.py backup rig-backup/ # verified, before anything is written
tools/pbhw.py --autobaud --loader build/ab.bin --app build/pbapp.hex --marker APP
```
`pbrig.py` is the primitives — `signature`, `reset`, `flash`, `fuses`, `backup`,
`rate` — and the module `pbhw.py` builds on. Two rig facts are encoded in it
because neither is guessable: an **ISP access is the reset edge** (the part runs
the moment the programmer releases it, which is the only edge available when the
adapter's DTR is not wired to reset, so a session begins with an ISP touch and
knocks immediately after), and **avrdude splits `-U` on colons**, so a Windows
path's drive letter breaks the spec and every file is passed as a bare name with
avrdude run in its own directory.
`pbrig.py rate` is the one that turns "the loader is silent, so the wiring must
be wrong" into a number. Against a fixture built with `PUREBOOT_HEARTBEAT` — a
*fixed* cycles-per-bit transmitter — it sweeps the host rate, and the band where
the marker still decodes brackets the part's true bit rate; with the clock the
image was built for, that is the clock the part is really running at. No
instrument beyond the adapter already attached. An ATtiny13A measured this way
came out at 9.072 MHz against its 9.6 MHz nominal, 5.5 % — inside the
datasheet's ±10 % and outside what an 8N1 frame survives, which is the whole
case for the autobaud backend on such a part.
`pbhw.py` takes its bounds from the info block the loader reports, so one run
covers a 1 KiB tiny and a 128 KiB mega alike: identity, the EEPROM round trip
and erase, an application flashed and verified and then *seen running*, the
application region read and erased, the loader slot proven intact across that
erase by an independent ISP read, and an oversized image refused. It overwrites
the application flash and EEPROM, which is why `backup` comes first.

View File

@@ -1,28 +1,14 @@
// pureboot — a serial bootloader on libavr, pure by constraint: one C++ // pureboot — a serial bootloader on libavr: one C++ source, no inline
// source with no inline assembly and no global register variables, built for // assembly, no global register variables, 512 bytes on every chip libavr
// every chip libavr targets, 512 bytes on each. The device speaks primitives // targets. The device speaks primitives; every composite (verify, erase,
// — read/program flash, read/write EEPROM, fuse bytes, an info block, a jump // reset-vector surgery, self-update) lives in the host tool. Protocol,
// — and everything composite (verify, erase, reset-vector surgery, updating // deployment and configuration: README.md next to this file.
// the loader itself) lives in the host tool. Protocol reference: README.md
// next to this file.
// //
// The image is position-independent: control flow is PC-relative, the write // The image is position-independent — PC-relative control flow, wire
// and read paths take wire addresses, the write guard refuses the 512-byte // addresses in, the write guard and the info block both anchored on the
// slot the code is *running* in (taken from the runtime return address), the // runtime return address — so the identical binary runs from any slot. That
// info block is read relative to that same anchor, and the application jump // is what makes a copy one slot below able to rewrite the resident one, and
// is an indirect call to an absolute entry. The identical binary therefore // every change here has to keep it (test/check_pi.py).
// 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> #include <libavr/libavr.hpp>
@@ -33,105 +19,142 @@ namespace ee = avr::eeprom;
namespace pureboot { namespace pureboot {
namespace { namespace {
// Purely polled interrupts stay off, every guard folds to nothing. // Purely polled: every interrupt guard folds to nothing.
constexpr auto off = avr::irq::guard_policy::unused; constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t ack = '+'; constexpr std::uint8_t ack = '+';
// Per-deployment personality, passed in by the build pureboot_add_loader() // Deployment parameters come from the build (pureboot_add_loader()). The
// (the CMake function next to this file) resolves the defaults: the clock the // signature is not one of them: the chip database is the only universal
// board actually runs, the wire baud, the serial backend and its pins. The // source — a tiny13A cannot read its own signature row from code. An autobaud
// device signature needs no configuring — it comes from the chip database // build carries no clock and no baud at all; it measures both.
// (avr::hw::db.signature), the only universal source, since the tiny13A #if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD))
// cannot even read its signature row from code.
#if !defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)
#error \ #error \
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader() (README.md)" "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)"
#endif #endif
#if !defined(PUREBOOT_AUTOBAUD)
using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>; using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD}; constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
#endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. // The loader owns the top 512 bytes; a staging copy goes in the slot below.
consteval std::int16_t wdrf_field() // Chips without a hardware boot section — the tinies and the m48s, whose SPM
{ // runs from anywhere (Atmel-8271 §26) — keep the application's relocated
auto reg = std::string_view{avr::hw::db.regs[static_cast<std::size_t>(avr::power::detail::reset_reg())].name}; // reset vector in the word under the slot.
return avr::hw::db.field_index(reg, "WDRF"); 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 std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::curated::has_boot_section(); constexpr bool boot_section = avr::hw::curated::has_boot_section();
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so on the // Past 64 KiB one bank of flash does not cover the chip, so a transfer's
// large chips every flash address on the wire — and all slot arithmetic — // selector byte carries the bank and the wire address stays a byte address
// is a word address instead ('J' always was one). A slot spans the same // within it. 'J' is the exception: it is a word address everywhere, because
// wire-high-byte pair in either unit (512 B = 2 x 256 bytes, 1 KiB = // that is what the hardware's own jump takes.
// 2 x 256 words), so the slot index is the high byte with its low bit constexpr bool banked_flash = spm::flash_bytes > 65536;
// 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);
// The activation window, in seconds, is a compile-time constant (the build // A compile-time window, so the whole EEPROM belongs to the application;
// may override it): the whole EEPROM belongs to the application, and // re-timing a deployed loader is a self-update with a re-timed build. An
// re-timing the loader is a bootloader self-update with a re-timed binary. // autobaud build has no clock to convert seconds against and counts polls.
#if !defined(PUREBOOT_TIMEOUT) #if !defined(PUREBOOT_TIMEOUT)
#define PUREBOOT_TIMEOUT 8 #define PUREBOOT_TIMEOUT 8
#endif #endif
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
// The pureboot version: the loader's one identity number, carried in the info #if !defined(PUREBOOT_AUTOBAUD_POLLS)
// block so a host can tell a deployed loader apart from another. The wire #define PUREBOOT_AUTOBAUD_POLLS 4000000
// protocol has no number of its own — a version implies its protocol, and the #endif
// host tool is what holds that map (README.md). constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
constexpr std::uint8_t version = 2;
// The 12-byte info block the host reads with the 'b' command, flash-resident // A build may bake a measured oscillator trim (README.md: the RC-oscillator
// through flash_table (there is no crt to copy a .data image, and its storage // deployment answer); the byte is applied at the top of run(). Orthogonal to
// carries the word alignment 'b' needs to halve the address on the large // the serial backend — an autobaud build may carry it for the application's
// chips). The page byte is the wire count convention: 0 means 256. // benefit alone.
inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{ #if defined(PUREBOOT_OSCCAL)
'P', static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
'B', #endif
version, // magic, then the loader's version
// 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 = 6;
// 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
avr::hw::db.signature[0], avr::hw::db.signature[0],
avr::hw::db.signature[1], avr::hw::db.signature[1],
avr::hw::db.signature[2], avr::hw::db.signature[2],
static_cast<std::uint8_t>(page), };
wire_base & 0xff, // clang-format on
wire_base >> 8, // app flash ends here; resident loader base (a word address on large chips) // Where the identity proper starts: past the magic the host scans for.
avr::hw::db.mem.eeprom_size & 0xff, constexpr std::uint8_t stamp_identity = 2;
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 serial link. PUREBOOT_USART forces a hardware USART instance, // The address spaces a transfer can name, in a selector byte's low nibble.
// PUREBOOT_SOFT_SERIAL the polled software UART (no vector — the table // Flash is 0 so it is the cheapest to select.
// 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 // spm_ops is the one that is not memory: a write there hands its byte to
// are class templates on the clock so only the selected backend is ever // SPMCSR and fires the instruction at the transfer's address, which is how
// instantiated. pending() is the cheap line test the activation window // page erase, page write and RWW re-enable reach the wire without the loader
// polls; rx() then picks the byte up; drain() holds until the last // carrying a command for each. The hardware's four-cycle store-to-SPM window
// transmitted frame is fully on the wire (the jump hand-over must not let // is why this is one fused primitive and not a poke of SPMCSR — no host can
// the target's re-init clip the ack). // 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.
#if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART) #if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART)
#error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends" #error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends"
#endif #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) #if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0 #define PUREBOOT_RX pb0
#endif #endif
@@ -144,9 +167,9 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0'; constexpr char usart_digit = '0';
#endif #endif
template <avr::hertz_t C> template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6). // sbiw + sbci + sbci + brne (6).
@@ -178,10 +201,10 @@ struct hardware_link {
} }
}; };
template <avr::hertz_t C> template <avr::hertz_t C, avr::baud_t B>
struct software_link { struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, wire_baud>; using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, wire_baud>; using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6). // sbci + brne (6).
@@ -213,22 +236,50 @@ struct software_link {
} }
}; };
#if defined(PUREBOOT_USART) // 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)
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have"); 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>; using link = hardware_link<dev::clock, wire_baud>;
#elif defined(PUREBOOT_SOFT_SERIAL) #elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock>; using link = software_link<dev::clock, wire_baud>;
#else #else
using link = using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock>, software_link<dev::clock>>; software_link<dev::clock, wire_baud>>;
#endif #endif
// The application's entry, an absolute address the linker pins (--defsym in // The application's entry, pinned by the linker (--defsym): word 0 on a
// CMakeLists.txt): 0x0000 on the mega (word 0 stays the application's own // boot-sectioned mega, the trampoline at base 2 elsewhere. Reaching it must
// vector — BOOTRST re-vectors a reset into the loader in hardware) and the // not depend on where this copy runs, so the jump goes through a pointer, and
// trampoline word at base - 2 on the tinies. Reaching it must not depend on // [[gnu::noipa]] keeps the constant from folding back into a relative call.
// 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(); extern "C" [[noreturn]] void pureboot_app();
[[gnu::noipa, noreturn]] void jump(void (*target)()) [[gnu::noipa, noreturn]] void jump(void (*target)())
@@ -242,18 +293,42 @@ extern "C" [[noreturn]] void pureboot_app();
jump(pureboot_app); jump(pureboot_app);
} }
// One activation window is a single 32-bit poll countdown. The divisor is // Activation: a bounded wait for the host, then the knock. Both forms boot the
// the backend's counted poll-loop cycles (its own comment reads them off the // application when the window closes on an idle line, and both bound *every*
// compiled loop); whole-second precision is all the window promises, so the // wait — a knock awaited without a deadline would let one stray edge hold an
// nearest cycle count is plenty. // 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.
consteval std::uint32_t window_polls() consteval std::uint32_t window_polls()
{ {
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles); return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles);
} }
// The countdown in the narrowest type that holds it: a fourth byte would
// cost a wider decrement chain at every poll for range most windows never
// use (the autobaud budget makes the same choice).
using window_t = std::conditional_t<window_polls() <= 0xffffff, avr::uint24_t, std::uint32_t>;
bool pending_before_deadline() bool pending_before_deadline()
{ {
std::uint32_t polls = window_polls(); window_t polls = window_polls();
do { do {
if (link::pending()) if (link::pending())
return true; return true;
@@ -261,8 +336,8 @@ bool pending_before_deadline()
return false; return false;
} }
// A knock byte under the activation deadline: an idle line means no host is // A knock byte under the deadline: an idle window means no host, so the
// there, and the application runs. // application runs.
std::uint8_t rx_deadline() std::uint8_t rx_deadline()
{ {
if (!pending_before_deadline()) if (!pending_before_deadline())
@@ -270,226 +345,192 @@ std::uint8_t rx_deadline()
return link::rx(); return link::rx();
} }
// Inlined into its call sites: reading two bytes across a call otherwise void await_host()
// strands the first in a call-saved register the caller must push/pop; folded {
// into the (noreturn) command loop that cost disappears. // '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.
[[gnu::always_inline]] inline std::uint16_t rx16() [[gnu::always_inline]] inline std::uint16_t rx16()
{ {
std::uint16_t low = link::rx(); std::uint16_t low = link::rx();
return static_cast<std::uint16_t>(low | (link::rx() << 8)); return static_cast<std::uint16_t>(low | (link::rx() << 8));
} }
// The streamers take the count in the wire's 8-bit form: 0 means 256. // 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.
// Two functions, because they want opposite placement and placement is an // Callers read into named variables first: the wire order is a sequence of
// attribute: the byte-addressed loop is small enough to inline into both // reads, not an argument order.
// callers, the word-addressed one stays out of line but flattened — a call to [[gnu::always_inline]] inline std::uint16_t word_of(std::array<std::uint8_t, 2> pair)
// 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)
{ {
do return std::bit_cast<std::uint16_t>(pair);
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, // Out of line: several sites send it, and a call is shorter than a
// carried explicitly (the reassembled 32-bit address folds away inside the // load-immediate at each.
// inlined far load). [[gnu::noinline]] void tx_ack()
[[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); link::tx(ack);
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) // 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 (word_flash) if constexpr (banked_flash)
send_flash_far(address, count); return (static_cast<spm::flash_address_t>(bank) << 16) | at;
else else
send_flash_near(address, count); return at;
} }
void send_eeprom(std::uint16_t address, std::uint8_t count) // 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)
{ {
do if (space == sp_eeprom)
link::tx(ee::read(address++)); return ee::read(at);
while (--count); 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));
} }
// EEPROM write, host-paced: each ack goes out once the byte's write has // One byte into a writable space. Flash is not one of them — it arrives a
// begun, so the next byte arrives while it completes and the following // page at a time through 'W' and is committed through sp_spm — and the fuses
// write's own ready-wait sees an idle line. Nothing is ever missed, on // are not writable at all: SPM reaches flash and boot lock bits only.
// either serial backend, without a buffer. [[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value,
void store_eeprom(std::uint16_t address, std::uint8_t count) 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.
//
// 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)
{
// 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 { do {
ee::write<off>(address++, link::rx()); std::uint8_t low = link::rx();
link::tx(ack); std::uint8_t high = link::rx();
} while (--count); spm::fill<off>(flash_address(bank, z), word_of({low, high}));
} z += 2;
} while (static_cast<std::uint8_t>(z) & (page - 1));
// 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>((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));
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() [[noreturn]] void run()
{ {
// A watchdog reset belongs to the application (whose watchdog stays #if defined(PUREBOOT_OSCCAL)
// forced on until it clears WDRF) — no activation window in its way. // The build's oscillator trim, ahead of everything — the WDRF bail
// The flag register is MCUSR, or the classic megas' MCUCSR. // included — so every path out of reset, the watchdog hand-over to the
if (avr::hw::field_impl<wdrf_field()>::test()) // application first among them, runs on the corrected clock.
avr::clock::calibrate(PUREBOOT_OSCCAL);
#endif
// A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF — no activation window in its way.
if (avr::power::peek_reset_cause().watchdog)
run_app(); run_app();
link::init(); link::init();
// The high byte of the 512-byte-aligned base this copy runs at: the // The slot this copy runs in, which the write guard follows: the return
// return address is a word address, whose high byte is the 256-word slot // address is a word address and a slot is half as many words as bytes, so
// index — on byte-addressed chips doubled back into byte terms. // its high byte is the slot index outright. No absolute address is ever
// program_flash refuses this one slot and the info block is addressed // formed, so the image stays position-independent.
// from it, so both follow wherever the code was flashed. The high byte is const auto slot_high = avr::startup::caller_page();
// 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);
// The knock: 'p' then 'b', each under a fresh window; any other byte is await_host();
// line noise and waits again. Falling out of a window runs the app.
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
}
for (;;) { for (;;) {
// No prompt while an EEPROM write runs: a pending write blocks SPM // No prompt while an EEPROM write runs: it blocks SPM and fuse reads
// and fuse reads (§26.2.1), and the ack tells the host all is done. // (§26.2.1), and the prompt is the previous command's completion ack.
ee::wait(); ee::wait();
link::tx(ack); tx_ack();
const std::uint8_t command = link::rx(); const std::uint8_t command = link::rx();
switch (command) { 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 case 'J': { // jump to a wire word address: hand-over and staging transfer
auto target = reinterpret_cast<void (*)()>(rx16()); auto target = reinterpret_cast<void (*)()>(rx16());
link::tx(ack); tx_ack();
link::drain(); link::drain();
jump(target); jump(target);
} }
case 'R': // read flash: addr16, n8 (0 = 256) case 'b': // identity: the version, then the three signature bytes
case 'r': // read EEPROM: addr16, n8 // Straight out of the stamp, so the wire and the image can never
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked // disagree about what this loader is. The indices are constant and
std::uint16_t address = rx16(); // 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;
}
std::uint8_t count = link::rx(); std::uint8_t count = link::rx();
if (command == 'R') do {
send_flash(address, count); // Read and write are one letter apart in case, so the direction
else if (command == 'r') // is a single bit and the loop picks it with a one-word skip.
send_eeprom(address, count); if (command & 0x20) {
else store(space, bank, at, link::rx(), slot_high);
store_eeprom(address, count); tx_ack();
} else
link::tx(load(space, bank, at));
++at;
} while (--count);
break; 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 default: // unknown bytes are ignored; the loop re-acks
break; break;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,75 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Position-independence lint for the pureboot image. """Position-independence lint: the property that lets the identical image run
from any slot, asserted from the built ELF and its object.
The self-staging design lets the identical binary run from any 512-byte 1. No absolute jmp/call — -mrelax normally guarantees it, but a branch that
slot, which holds only if nothing in the image addresses itself absolutely. grows out of relaxation range would break it silently.
Two link-time facts guarantee it, both asserted here from the built ELF: 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.
1. No absolute jmp/call opcodes — all control flow is PC-relative Usage: check_pi.py <objdump> <objcopy> <cxx> <mcu> <elf> <object> <text_start_hex>
(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 re
import subprocess import subprocess
import sys import sys
import tempfile
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main(): def main():
objdump, nm, elf, text_start = sys.argv[1:] objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:]
text_start = int(text_start, 0) text_start = int(text_start, 0)
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
absolute = [ absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)]
line
for line in listing.splitlines()
if re.search(r"\t(jmp|call)\t", line)
]
if absolute: if absolute:
print("FAIL: absolute control flow in the image:") fail("absolute control flow in the image:\n" + "\n".join(absolute))
print("\n".join(absolute))
sys.exit(1)
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout # Allocated flash beyond .text would be data the running copy has to find.
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line] # Only ALLOC sections reach the device at all; .comment and the debug
if len(info) != 1: # sections ride along in the ELF container and are never flashed. objdump
print(f"FAIL: expected one info-block storage symbol, found {len(info)}") # prints each section's flags on the line following its header.
sys.exit(1) headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines()
address = int(info[0].split()[0], 16) for index, line in enumerate(headers):
offset = address - text_start fields = line.split()
if not 0 <= offset < 256: if len(fields) < 6 or not fields[0].isdigit():
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes") continue
sys.exit(1) 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")
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}") # 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}")
if __name__ == "__main__": if __name__ == "__main__":

23
test/check_unit.cmake Normal file
View File

@@ -0,0 +1,23 @@
# Asserts the autobaud loader's measured unit is the first RAM object: the
# host tool reads the bit period from ram_start (--info's measured clock), so
# the unit's address is wire contract. Run as
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> -P check_unit.cmake
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
if(NOT _res EQUAL 0)
message(FATAL_ERROR "${OBJDUMP} -t ${ELF} failed")
endif()
# The symbol line: "00800100 l O .noinit 00000002 <mangled>unit_E".
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_[^ \t\n]*\n" _line "${_syms}")
if(NOT _line)
message(FATAL_ERROR "no unit_ symbol in ${ELF} — is this the autobaud loader?")
endif()
# AVR data-space symbols carry the 0x800000 VMA offset.
math(EXPR _want "0x800000 + ${RAM_START}" OUTPUT_FORMAT HEXADECIMAL)
math(EXPR _have "0x${CMAKE_MATCH_1}" OUTPUT_FORMAT HEXADECIMAL)
if(NOT _have STREQUAL _want)
message(FATAL_ERROR "unit_ sits at ${_have}, ram_start is ${_want} — the host peeks ram_start")
endif()
message(STATUS "unit_ at ${_have} == ram_start")

View File

@@ -7,77 +7,95 @@
// SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM) // SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM)
// we dump the flash image to a file for a ground-truth cross-check against // we dump the flash image to a file for a ground-truth cross-check against
// what the client read back through the bootloader. // what the client read back through the bootloader.
#include <signal.h> #include <csignal>
#include <stdint.h> #include <cstdint>
#include <stdio.h> #include <cstdio>
#include <stdlib.h> #include <cstdlib>
#include <string.h> #include <cstring>
#include <print>
#include <unistd.h> #include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers — the block covers both harmlessly.
extern "C" {
#include "avr_uart.h" #include "avr_uart.h"
#include "sim_avr.h" #include "sim_avr.h"
#include "sim_elf.h" #include "sim_elf.h"
#include "uart_pty.h" #include "uart_pty.h"
}
static avr_t *avr; namespace {
static uart_pty_t uart_pty;
static const char *dump_path;
static void finish(int sig) avr_t *avr;
uart_pty_t uart_pty;
const char *dump_path;
[[noreturn]] void finish(int)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
FILE *f = fopen(dump_path, "wb"); std::FILE *f = std::fopen(dump_path, "wb");
if (f) { if (f) {
fwrite(avr->flash, 1, avr->flashend + 1, f); std::fwrite(avr->flash, 1, avr->flashend + 1, f);
fclose(f); std::fclose(f);
} }
} }
uart_pty_stop(&uart_pty); uart_pty_stop(&uart_pty);
_exit(0); _exit(0);
} }
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
if (argc < 3) { if (argc < 3) {
fprintf(stderr, "usage: %s <tsb.elf> <boot_base_hex> [flash_dump.bin]\n", argv[0]); std::println(stderr, "usage: {} <tsb.elf> <boot_base_hex> [flash_dump.bin]", argv[0]);
return 2; return 2;
} }
uint32_t boot_base = (uint32_t)strtoul(argv[2], NULL, 0); auto boot_base = static_cast<std::uint32_t>(std::strtoul(argv[2], nullptr, 0));
dump_path = argc >= 4 ? argv[3] : NULL; dump_path = argc >= 4 ? argv[3] : nullptr;
avr = avr_make_mcu_by_name("atmega328p"); avr = avr_make_mcu_by_name("atmega328p");
if (!avr) { if (!avr) {
fprintf(stderr, "device: no ATmega328P core\n"); std::println(stderr, "device: no ATmega328P core");
return 1; return 1;
} }
avr_init(avr); avr_init(avr);
avr->frequency = 16000000; avr->frequency = 16000000;
// Real flash powers up erased (0xff); the app region must look erased // Real flash powers up erased (0xff); the app region must look erased
// before the bootloader programs it. // before the bootloader programs it.
memset(avr->flash, 0xff, avr->flashend + 1); std::memset(avr->flash, 0xff, avr->flashend + 1);
// simavr's ELF loader flattens the flash base to 0 (it expects an app at // simavr's ELF loader flattens the flash base to 0 (it expects an app at
// 0x0), but it hands back the boot code in fw.flash; place it at the boot // 0x0), but it hands back the boot code in fw.flash; place it at the boot
// section base ourselves and enter there (BOOTRST is not modelled). // section base ourselves and enter there (BOOTRST is not modelled).
elf_firmware_t fw = {0}; elf_firmware_t fw{};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
fprintf(stderr, "device: cannot read %s\n", argv[1]); std::println(stderr, "device: cannot read {}", argv[1]);
return 1; return 1;
} }
memcpy(avr->flash + boot_base, fw.flash, fw.flashsize); // An image that runs past flash end cannot execute on hardware, and a
// naive copy of it would smash the heap beyond avr->flash — after which
// the simulation misbehaves in ways that point everywhere but here.
// Refuse it loudly instead.
if (boot_base + fw.flashsize > avr->flashend + 1) {
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
fw.flashsize, boot_base, avr->flashend);
return 1;
}
std::memcpy(avr->flash + boot_base, fw.flash, fw.flashsize);
avr->pc = boot_base; avr->pc = boot_base;
avr->codeend = avr->flashend; avr->codeend = avr->flashend;
// Optional: seed the config page (one page below the boot section) with a // Optional: seed the config page (one page below the boot section) with a
// hex byte string, so the password gate and emergency erase can be tested. // hex byte string, so the password gate and emergency erase can be tested.
// Layout: [appjump lo][appjump hi][timeout][password...][0xff]. // Layout: [appjump lo][appjump hi][timeout][password...][0xff].
const char *cfg = getenv("TSB_CONFIG"); const char *cfg = std::getenv("TSB_CONFIG");
if (cfg) { if (cfg) {
uint32_t app_end = boot_base - 128; // config page sits directly below the boot code std::uint32_t app_end = boot_base - 128; // config page sits directly below the boot code
for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) { for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) {
char b[3] = {cfg[i], cfg[i + 1], 0}; char b[3] = {cfg[i], cfg[i + 1], 0};
avr->flash[app_end + i / 2] = (uint8_t)strtoul(b, NULL, 16); avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(b, nullptr, 16));
} }
} }
@@ -86,18 +104,18 @@ int main(int argc, char *argv[])
// tight-polling loader (one that releases TX between bytes, as one-wire does) // tight-polling loader (one that releases TX between bytes, as one-wire does)
// in real time, distorting protocol timing. Clear it so the loader runs at // in real time, distorting protocol timing. Clear it so the loader runs at
// true cycle speed. // true cycle speed.
uint32_t uflags = 0; std::uint32_t uflags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &uflags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &uflags);
uflags &= ~AVR_UART_FLAG_POLL_SLEEP; uflags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &uflags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &uflags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, '0'); uart_pty_connect(&uart_pty, '0');
printf("TSB_PTY %s\n", uart_pty.pty.slavename); std::println("TSB_PTY {}", uart_pty.pty.slavename);
fflush(stdout); std::fflush(stdout);
signal(SIGTERM, finish); std::signal(SIGTERM, finish);
signal(SIGINT, finish); std::signal(SIGINT, finish);
for (;;) { for (;;) {
int state = avr_run(avr); int state = avr_run(avr);
@@ -105,5 +123,4 @@ int main(int argc, char *argv[])
break; break;
} }
finish(0); finish(0);
return 0;
} }

View File

@@ -11,6 +11,10 @@
// reset reaches those loaders through the patched vector (or the runner // reset reaches those loaders through the patched vector (or the runner
// models BOOTRST), so the application owes them nothing. // models BOOTRST), so the application owes them nothing.
// //
// PUREBOOT_HANDOVER drops the listening and jumps straight in, leaving the
// USART enabled behind it — the hand-over state a loader bit-banging on that
// USART's own pins has to survive.
//
// The fixture speaks the deployment its loader was built for: the same // The fixture speaks the deployment its loader was built for: the same
// PUREBOOT_* defines configure it, and without them it assumes the stock // PUREBOOT_* defines configure it, and without them it assumes the stock
// deployment (the crystal/RC clock table below, the chip's natural link). // deployment (the crystal/RC clock table below, the chip's natural link).
@@ -64,15 +68,29 @@ struct link {
{ {
tx_t::write(static_cast<std::uint8_t>(c)); tx_t::write(static_cast<std::uint8_t>(c));
} }
// The loader sits 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.
static void enter_loader()
{
constexpr std::uint32_t slot = 512;
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
}
[[noreturn]] static void idle() [[noreturn]] static void idle()
{ {
// 'L' hands back to the loader at the top slot — 512 bytes, or the #if defined(PUREBOOT_HANDOVER)
// 1 KiB the >64 KiB chips use. // Hand back at once, with this USART still enabled — the state that
constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 512; // leaves a bit-banged loader on its pins mute unless the loader
// releases it. Unconditional because there is no command wire to
// wait on: that loader's link is the pins, not this peripheral.
enter_loader();
__builtin_unreachable();
#else
for (;;) { for (;;) {
auto command = tx_t::read_blocking(); auto command = tx_t::read_blocking();
if (command == 'L') if (command == 'L')
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))(); enter_loader();
// 'D' leaves every word of the SPM page buffer dirty, so that a // 'D' leaves every word of the SPM page buffer dirty, so that a
// following 'L' enters the loader with the buffer it never clears. // following 'L' enters the loader with the buffer it never clears.
if (command == 'D') { if (command == 'D') {
@@ -81,6 +99,7 @@ struct link {
tx('D'); tx('D');
} }
} }
#endif
} }
}; };
@@ -98,8 +117,27 @@ struct link<C, false> {
} }
[[noreturn]] static void idle() [[noreturn]] static void idle()
{ {
#if defined(PUREBOOT_HEARTBEAT)
// Repeat the banner forever, which turns the fixture into a fixed
// cycles-per-bit transmitter: `tools/pbrig.py rate` sweeps the host rate
// against it to find the part's true bit rate, and from that the clock
// its RC oscillator is really running at. Only the *bit* timing carries
// the measurement — the delay merely spaces the lines out, so its own
// error does not matter. Software link only: the hardware-link idle owes
// the self-update tests a command loop, and a crystal deployment has
// nothing to measure.
while (true) {
tx('A');
tx('P');
tx('P');
tx('\r');
tx('\n');
dev::delay<50_ms>();
}
#else
while (true) { while (true) {
} }
#endif
} }
}; };
@@ -108,8 +146,13 @@ struct link<C, false> {
int main() int main()
{ {
avr::init<typename link<dev::clock>::tx_t>(); avr::init<typename link<dev::clock>::tx_t>();
#if !defined(PUREBOOT_HANDOVER)
link<dev::clock>::tx('A'); link<dev::clock>::tx('A');
link<dev::clock>::tx('P'); link<dev::clock>::tx('P');
link<dev::clock>::tx('P'); link<dev::clock>::tx('P');
#endif
// The hand-over fixture stays silent: nothing is listening on the USART it
// brings up — the loader it hands to speaks those pins directly — so its
// banner would be a write into a peer that does not exist.
link<dev::clock>::idle(); link<dev::clock>::idle();
} }

199
test/pbautobaud.py Normal file
View File

@@ -0,0 +1,199 @@
#!/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 re
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", "--clock", str(hz),
"--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}")
# The measured clock, decoded from the unit at ram_start. The
# runner's clock is exact, so the figure must land inside the
# encoding's own envelope: the loader floors the bit period to
# 4-cycle spin granules after an 8-cycle discount, and the edge
# poll can shave a few cycles more — one granule of slack below
# the true clock, none above (in cycles per bit, times the rate).
measured = re.search(r"measured\s+(\d+) Hz", out)
if not measured:
fail(f"{label}: --info lacks the measured clock\n{out}")
measured = int(measured.group(1))
if not hz - 19 * baud <= measured <= hz + 4 * baud:
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
# 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 ""))
def must_lock(hz, baud, label):
"""The calibration alone, at a tight bit period. Nothing is programmed —
the question is only whether the loader can still measure the pulse."""
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:
port = pb.Port(device.pty, baud)
try:
live = pb.Loader(port).connect_autobaud(15)
if live.version != pb.NEWEST_LOADER:
fail(f"{label}: loader reports pureboot {live.version}")
finally:
port.close()
finally:
device.stop()
print(f" {label}: locked at {hz} Hz / {baud} Bd ({hz / baud:.0f} cycles a bit)")
# 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)
# Both points above sit near 100 cycles a bit, which is comfortable. The
# calibration's real floor is far tighter, and it is worth a gate: measured
# here, the lock is solid down to ~36 cycles a bit and fails outright by ~31
# — a sharp edge, not a fraying one. This pins the tightest standard rate the
# fixture's clock reaches, so a change that raises the floor is caught.
#
# It does *not* bound what a real deployment can use. On silicon the
# oscillator's own jitter costs roughly a factor of two: an ATtiny13A on its
# factory RC trim was reliable at ~118 cycles a bit and already locking only
# 1 attempt in 5 by ~59, which no exact-clock simulation can show. The
# deployable envelope is a README matter; this is the logic's floor.
must_lock(app_hz, app_baud * 2, "tight-bit")
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks, "
"and the tight bit period still locks")
if __name__ == "__main__":
main()

View File

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

73
test/pbmute.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Hand-over with a USART left enabled on the loader's own pins.
A software or autobaud link deployed on a USART's TxD is mute if an
application hands over with that USART still enabled: TXEN keeps the USART
owning the pin, so the bit-banged transmitter's port writes go nowhere and the
loader receives and obeys while answering nothing. The link's init releases it.
The state is reached the way silicon reaches it — an application that sets up
its USART and jumps in with no reset between, so nothing clears UCSRnB for it.
The pin ownership itself is modelled by the device runner: simavr wires a
USART through IRQs alone and never takes the pin from the port, so without
that the mute could not happen here at all (test/pureboot_device.cpp).
Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <app_bin> <tool_py> <workdir> <link>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
device_bin, elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir, link = sys.argv[1:]
page, baud = int(page), int(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
if "@" not in link:
fail(f"the link {link} names no owning USART — nothing would be under test")
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
port = pb.Port(device.pty, baud)
loader = pb.Loader(port)
loader.connect(25)
resident = loader.info.version
# Install the fixture and let it take over. It brings up the USART
# that owns these pins and jumps straight back in.
pb.op_flash(loader, app_bin, erase=False, verify=True)
loader.run_application()
# The loader is running again with that USART enabled behind it. Only
# the release makes it audible; without it the connect times out.
loader = pb.Loader(port)
try:
loader.connect(25)
except pb.Error as error:
fail(f"the loader never answered after the hand-over — the USART still owns its TX pin ({error})")
if loader.info.version != resident:
fail(f"identity changed across the hand-over: {resident} then {loader.info.version}")
# Answering is not enough: it has to still be a working loader.
pb.verify_pages(loader, pb.plan_flash(open(app_bin, "rb").read(), loader.info))
port.close()
finally:
device.stop()
print("pbmute: a loader on a USART's own pins answers after a hand-over that left it enabled")
if __name__ == "__main__":
main()

46
test/pbosccal.py Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""The build-time OSCCAL trim, observed through the wire: a loader built with
the OSCCAL axis holds the trim register at the built byte from its first
prompt on — the write sits at the top of run(), ahead of the WDRF bail, so
every path out of reset runs on the corrected clock. simavr's clock does not
follow OSCCAL, which is what makes the value assertable at all: the register
is plain state there, and the peek must return exactly what the build
declared rather than whatever the oscillator needed.
Usage: pbosccal.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <osccal_addr> <osccal_value> <tool_py> <workdir>
[link]
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
args = sys.argv[1:]
link = args.pop() if len(args) == 12 else None
(device_bin, elf, mcu, hz, base_hex, page, baud, addr, value, tool, workdir) = args
addr, value, baud = int(addr, 0), int(value, 0), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "flash_dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
out = pbsim.run_tool(tool, device.pty, baud, "--peek", f"{addr:#x}:1")
want = f"{addr:#06x} {value:02x}"
if want not in out:
fail(f"OSCCAL at {addr:#x} did not read back {value:#04x}:\n{out}")
finally:
device.stop()
print("OK")
if __name__ == "__main__":
main()

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""End-to-end pureboot protocol test: spawn the simavr device, then drive it """End-to-end protocol test: drive the simavr device with the real host tool
with the real host tool (pureboot.py, as a subprocess over the device's pty) over its pty through flash, EEPROM, fuse and hand-over scenarios, and
through flash + EEPROM + fuse + hand-over scenarios, and cross-check cross-check the tool's view against the simulator's ground-truth dumps.
the tool's view against the simulator's ground-truth memory dumps.
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page> Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link] <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. loader built off the chip's natural serial default.
Exits 0 if every scenario passes.
""" """
import os import os
@@ -57,7 +55,13 @@ def main():
# the page byte is the wire's 0-means-256. # the page byte is the wire's 0-means-256.
mega = mcu.startswith("atmega") mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48") patch = not mega or mcu.startswith("atmega48")
word_flash = base + 512 > 0x10000 # 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
wire_base = base // 2 if word_flash else base wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0) flags = (1 if patch else 0) | (2 if word_flash else 0)
info = pb.Info( info = pb.Info(
@@ -75,12 +79,20 @@ def main():
if needed not in out: if needed not in out:
fail(f"session 1 output lacks {needed!r}") fail(f"session 1 output lacks {needed!r}")
# Session 2: reconnect into the live session, verify, dump, hand over # Session 2: reconnect into the live session, verify, dump, exercise
# is deferred — the pty must be reopened for the APP banner first. # the data space; hand over is deferred — the pty must be reopened for
# the APP banner first.
probe = "c0ffee"
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path, out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay") "--read-flash", read_flash, "--read-eeprom", read_eeprom,
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
if out.count("verify:") != 2: if out.count("verify:") != 2:
fail("session 2 did not verify both memories") 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() eeprom_back = open(read_eeprom, "rb").read()
if eeprom_back[: len(ee_image)] != ee_image: if eeprom_back[: len(ee_image)] != ee_image:
@@ -102,11 +114,29 @@ def main():
try: try:
loader = pb.Loader(port) loader = pb.Loader(port)
live = loader.connect(15) live = loader.connect(15)
# The loader built from this tree and the tool beside it must # The loader built from this tree must report a version the tool
# agree on where the version numbering stands: a bump the tool # beside it speaks — a bump the tool was never told about is a
# was never told about is a loader it would refuse to speak to. # loader it would refuse to talk to. Not equality with the newest:
if live.version != pb.NEWEST_LOADER: # the tool now spans two loader generations, the fixed-baud one
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}") # 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")
loader.run_application() loader.run_application()
banner = port.read_exact(3, 5.0) banner = port.read_exact(3, 5.0)
if banner != b"APP": if banner != b"APP":
@@ -127,7 +157,7 @@ def main():
# loader, the trampoline on the application's own entry (patched-vector # loader, the trampoline on the application's own entry (patched-vector
# chips only — a boot-sectioned mega's word 0 stays the application's). # chips only — a boot-sectioned mega's word 0 stays the application's).
if patch: if patch:
flash_words = (base + 512) // 2 flash_words = (base + pb.SLOT) // 2
app = open(app_bin, "rb").read() app = open(app_bin, "rb").read()
word0 = flash_true[0] | (flash_true[1] << 8) word0 = flash_true[0] | (flash_true[1] << 8)
if rjmp_decode(word0, 0, flash_words) != base // 2: if rjmp_decode(word0, 0, flash_words) != base // 2:

View File

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

View File

@@ -10,7 +10,9 @@
// //
// The link follows the chip's natural default (USART0 on the megas, the // The link follows the chip's natural default (USART0 on the megas, the
// software UART on PB0/PB1 elsewhere) unless -l overrides it: `-l usart1` // software UART on PB0/PB1 elsewhere) unless -l overrides it: `-l usart1`
// for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins. // for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins,
// and `-l sw:D0,D1@0` where those pins are a USART's own — see the pin
// ownership the bridge models below.
// //
// simavr's tiny cores decode the SPM opcode but attach no NVM module — SPM // simavr's tiny cores decode the SPM opcode but attach no NVM module — SPM
// is a silent no-op (the mega's boot section has one, avr_flash). The // is a silent no-op (the mega's boot section has one, avr_flash). The
@@ -21,16 +23,22 @@
// //
// On exit (or SIGTERM) the flash and EEPROM are dumped to files for a // On exit (or SIGTERM) the flash and EEPROM are dumped to files for a
// ground-truth cross-check against what the host read back. // ground-truth cross-check against what the host read back.
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <print>
#include <string_view>
#include <fcntl.h> #include <fcntl.h>
#include <pty.h> #include <pty.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers — the block covers both harmlessly.
extern "C" {
#include "avr_eeprom.h" #include "avr_eeprom.h"
#include "avr_flash.h" #include "avr_flash.h"
#include "avr_ioport.h" #include "avr_ioport.h"
@@ -39,30 +47,39 @@
#include "sim_elf.h" #include "sim_elf.h"
#include "sim_io.h" #include "sim_io.h"
#include "uart_pty.h" #include "uart_pty.h"
}
static avr_t *avr; namespace {
static uart_pty_t uart_pty;
static int link_software;
static char uart_digit = '0';
static char sw_rx_port = 'B', sw_tx_port = 'B';
static int sw_rx_bit = 0, sw_tx_bit = 1;
static const char *dump_path;
static uint32_t reset_pc;
static volatile sig_atomic_t reset_requested;
static int parse_link(const char *spec) avr_t *avr;
uart_pty_t uart_pty;
bool link_software;
char uart_digit = '0';
char sw_rx_port = 'B', sw_tx_port = 'B';
int sw_rx_bit = 0, sw_tx_bit = 1;
char sw_tx_owner = 0; // the USART whose TXD the software link sits on
const char *dump_path;
std::uint32_t reset_pc;
volatile std::sig_atomic_t reset_requested;
int parse_link(std::string_view spec)
{ {
if (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) { if (spec == "usart0" || spec == "usart1") {
link_software = 0; link_software = false;
uart_digit = spec[5]; uart_digit = spec[5];
return 0; return 0;
} }
if (strncmp(spec, "sw", 2) == 0) { if (spec.starts_with("sw")) {
link_software = 1; link_software = true;
if (spec[2] == '\0') if (spec.size() == 2)
return 0; return 0;
if (sscanf(spec + 2, ":%c%d,%c%d", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit) == 4) char owner = 0;
int fields =
std::sscanf(spec.data() + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner);
if (fields == 4 || fields == 5) {
sw_tx_owner = owner;
return 0; return 0;
}
} }
return -1; return -1;
} }
@@ -80,19 +97,19 @@ static int parse_link(const char *spec)
// core — so the discard store falls through into the buffer-fill branch and // core — so the discard store falls through into the buffer-fill branch and
// plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard // plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard
// here instead. // here instead.
static avr_flash_t *mega_flash; avr_flash_t *mega_flash;
static int (*mega_flash_ioctl)(avr_io_t *io, uint32_t ctl, void *param); int (*mega_flash_ioctl)(avr_io_t *io, std::uint32_t ctl, void *param);
static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param) int fixed_flash_ioctl(avr_io_t *io, std::uint32_t ctl, void *param)
{ {
if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) { if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) {
uint16_t z = (uint16_t)(io->avr->data[30] | (io->avr->data[31] << 8)); auto z = static_cast<std::uint16_t>(io->avr->data[30] | (io->avr->data[31] << 8));
uint16_t masked = (uint16_t)(z & ~(mega_flash->spm_pagesize - 1)); auto masked = static_cast<std::uint16_t>(z & ~(mega_flash->spm_pagesize - 1));
io->avr->data[30] = (uint8_t)masked; io->avr->data[30] = static_cast<std::uint8_t>(masked);
io->avr->data[31] = (uint8_t)(masked >> 8); io->avr->data[31] = static_cast<std::uint8_t>(masked >> 8);
int result = mega_flash_ioctl(io, ctl, param); int result = mega_flash_ioctl(io, ctl, param);
io->avr->data[30] = (uint8_t)z; io->avr->data[30] = static_cast<std::uint8_t>(z);
io->avr->data[31] = (uint8_t)(z >> 8); io->avr->data[31] = static_cast<std::uint8_t>(z >> 8);
return result; return result;
} }
if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) && if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) &&
@@ -107,46 +124,44 @@ static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param)
return mega_flash_ioctl(io, ctl, param); return mega_flash_ioctl(io, ctl, param);
} }
static void fix_mega_flash_erase(void) void fix_mega_flash_erase()
{ {
for (avr_io_t *io = avr->io_port; io; io = io->next) { for (avr_io_t *io = avr->io_port; io; io = io->next) {
if (io->kind && strcmp(io->kind, "flash") == 0) { if (io->kind && std::string_view{io->kind} == "flash") {
mega_flash = (avr_flash_t *)io; mega_flash = reinterpret_cast<avr_flash_t *>(io);
mega_flash_ioctl = io->ioctl; mega_flash_ioctl = io->ioctl;
io->ioctl = fixed_flash_ioctl; io->ioctl = fixed_flash_ioctl;
return; return;
} }
} }
fprintf(stderr, "device: no flash module to fix — SPM page erases may misalign\n"); std::println(stderr, "device: no flash module to fix — SPM page erases may misalign");
} }
static void request_reset(int sig) void request_reset(int)
{ {
(void)sig;
reset_requested = 1; reset_requested = 1;
} }
// ------------------------------------------------------------- tiny NVM --- // ------------------------------------------------------------- tiny NVM ---
typedef struct { struct tiny_nvm_t {
avr_io_t io; avr_io_t io;
uint8_t buffer[128]; std::uint8_t buffer[128];
uint8_t used[128]; // a buffer word loads once until erased — like silicon std::uint8_t used[128]; // a buffer word loads once until erased — like silicon
unsigned page; unsigned page;
} tiny_nvm_t; };
static tiny_nvm_t nvm; tiny_nvm_t nvm;
static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param) int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
{ {
(void)param;
if (ctl != AVR_IOCTL_FLASH_SPM) if (ctl != AVR_IOCTL_FLASH_SPM)
return -1; return -1;
tiny_nvm_t *n = (tiny_nvm_t *)io; auto *n = reinterpret_cast<tiny_nvm_t *>(io);
avr_t *mcu = io->avr; avr_t *mcu = io->avr;
uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
uint16_t z = (uint16_t)(mcu->data[30] | (mcu->data[31] << 8)); auto z = static_cast<std::uint16_t>(mcu->data[30] | (mcu->data[31] << 8));
uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1); std::uint32_t page_base = static_cast<std::uint32_t>(z & ~(n->page - 1)) % (mcu->flashend + 1);
if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0 if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0
unsigned offset = z & (n->page - 1) & ~1u; unsigned offset = z & (n->page - 1) & ~1u;
if (!n->used[offset]) { // first write wins until the buffer clears if (!n->used[offset]) { // first write wins until the buffer clears
@@ -155,66 +170,109 @@ static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
n->used[offset] = 1; n->used[offset] = 1;
} }
} else if (command == 0x03) { // PGERS } else if (command == 0x03) { // PGERS
memset(mcu->flash + page_base, 0xff, n->page); std::memset(mcu->flash + page_base, 0xff, n->page);
} else if (command == 0x05) { // PGWRT: programming only clears bits } else if (command == 0x05) { // PGWRT: programming only clears bits
for (unsigned i = 0; i < n->page; i++) for (unsigned i = 0; i < n->page; i++)
mcu->flash[page_base + i] &= n->buffer[i]; mcu->flash[page_base + i] &= n->buffer[i];
memset(n->buffer, 0xff, n->page); std::memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page); std::memset(n->used, 0, n->page);
} else if (command == 0x11) { // CTPB } else if (command == 0x11) { // CTPB
memset(n->buffer, 0xff, n->page); std::memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page); std::memset(n->used, 0, n->page);
} }
mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly
return 0; return 0;
} }
// ----------------------------------------------------------- GPIO bridge --- // ----------------------------------------------------------- GPIO bridge ---
static int pty_master = -1; int pty_master = -1;
static avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
static avr_cycle_count_t bit_cycles; avr_cycle_count_t bit_cycles;
static int tx_level = 1, tx_active, tx_bit; int tx_level = 1, tx_active, tx_bit;
static uint8_t tx_shift; std::uint8_t tx_shift;
static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param) avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
{ {
(void)mcu; if (tx_bit < 8) {
(void)param; tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); if (++tx_bit < 8)
if (++tx_bit < 8) return when + bit_cycles;
// The byte is delivered at the stop bit's sampling point (9.5 bit
// times), where a hardware receiver raises its RXC — not sooner: a
// host answering before the stop bit would put its start bit on the
// wire while the device is still driving, which the device,
// transmitting, is not watching for.
return when + bit_cycles; return when + bit_cycles;
}
if (write(pty_master, &tx_shift, 1) != 1) if (write(pty_master, &tx_shift, 1) != 1)
fprintf(stderr, "device: pty write lost a byte\n"); std::println(stderr, "device: pty write lost a byte");
tx_active = 0; tx_active = 0;
return 0; return 0;
} }
static void tx_hook(avr_irq_t *irq, uint32_t value, void *param) // A USART owns its TxD pin whenever its transmitter is enabled, and the port
// register cannot drive it (§20.2 / Atmel-8271 §19.2) — which is why a
// bit-banged link deployed on those pins is mute until it clears UCSRnB.
// simavr wires a USART entirely through IRQs and never touches the port pin
// model, so the ownership does not exist there and the mute cannot happen:
// supply it, or the very state this models is untestable. The link spec's
// trailing @n names the USART; without one the pins are nobody's.
avr_uart_t *tx_owner;
bool tx_pin_taken()
{ {
(void)irq; return tx_owner && avr_regbit_get(avr, tx_owner->txen);
(void)param; }
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
// whole register (§20.11.3) — which would hand the pin to a USART no code has
// enabled, making a freshly reset chip mute for reasons hardware does not
// have. Reset it the way the datasheet does, so the ownership starts from
// nobody's and only an application that really enables the USART takes it.
void reset_tx_owner()
{
if (tx_owner)
avr_regbit_clear(avr, tx_owner->txen);
}
void find_tx_owner()
{
for (avr_io_t *io = avr->io_port; io; io = io->next)
if (io->kind && std::string_view{io->kind} == "uart" &&
reinterpret_cast<avr_uart_t *>(io)->name == sw_tx_owner) {
tx_owner = reinterpret_cast<avr_uart_t *>(io);
reset_tx_owner();
return;
}
std::println(stderr, "device: no USART{} to own the software link's TX pin", sw_tx_owner);
}
void tx_hook(avr_irq_t *, std::uint32_t value, void *)
{
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
tx_level = 1;
return;
}
int level = value & 1; int level = value & 1;
if (!tx_active && tx_level == 1 && level == 0) { // start edge if (!tx_active && tx_level == 1 && level == 0) { // start edge
tx_active = 1; tx_active = 1;
tx_bit = 0; tx_bit = 0;
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, NULL); avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr);
} }
tx_level = level; tx_level = level;
} }
static uint8_t rx_queue[8192]; std::uint8_t rx_queue[8192];
static unsigned rx_head, rx_tail; // ring: head = next to send unsigned rx_head, rx_tail; // ring: head = next to send
static int rx_active, rx_bit; int rx_active, rx_bit;
static uint8_t rx_byte; std::uint8_t rx_byte;
static void rx_start_next(void); void rx_start_next();
static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param) avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
{ {
(void)mcu;
(void)param;
if (rx_bit < 8) { if (rx_bit < 8) {
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1); avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
rx_bit++; rx_bit++;
@@ -230,7 +288,7 @@ static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param
return 0; return 0;
} }
static void rx_start_next(void) void rx_start_next()
{ {
if (rx_active || rx_head == rx_tail) if (rx_active || rx_head == rx_tail)
return; return;
@@ -239,7 +297,7 @@ static void rx_start_next(void)
rx_active = 1; rx_active = 1;
rx_bit = 0; rx_bit = 0;
avr_raise_irq(rx_pin, 0); // start bit avr_raise_irq(rx_pin, 0); // start bit
avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL); avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
} }
// A reset abandons whatever the bridge was mid-transfer: bytes still queued // A reset abandons whatever the bridge was mid-transfer: bytes still queued
@@ -249,10 +307,10 @@ static void rx_start_next(void)
// output latch, whose falling edge starts a spurious decode before this // output latch, whose falling edge starts a spurious decode before this
// runs, and a stale tx_sample would then interleave with the loader's first // runs, and a stale tx_sample would then interleave with the loader's first
// real answer through the shared shift state, corrupting it. // real answer through the shared shift state, corrupting it.
static void bridge_reset(void) void bridge_reset()
{ {
avr_cycle_timer_cancel(avr, tx_sample, NULL); avr_cycle_timer_cancel(avr, tx_sample, nullptr);
avr_cycle_timer_cancel(avr, rx_step, NULL); avr_cycle_timer_cancel(avr, rx_step, nullptr);
rx_head = rx_tail = 0; rx_head = rx_tail = 0;
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
@@ -260,9 +318,9 @@ static void bridge_reset(void)
avr_raise_irq(rx_pin, 1); // idle line avr_raise_irq(rx_pin, 1); // idle line
} }
static void poll_pty(void) void poll_pty()
{ {
uint8_t chunk[256]; std::uint8_t chunk[256];
ssize_t got = read(pty_master, chunk, sizeof(chunk)); ssize_t got = read(pty_master, chunk, sizeof(chunk));
for (ssize_t i = 0; i < got; i++) { for (ssize_t i = 0; i < got; i++) {
unsigned next = (rx_tail + 1) % sizeof(rx_queue); unsigned next = (rx_tail + 1) % sizeof(rx_queue);
@@ -277,23 +335,22 @@ static void poll_pty(void)
// ------------------------------------------------------------------ main --- // ------------------------------------------------------------------ main ---
static void finish(int sig) [[noreturn]] void finish(int)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
FILE *f = fopen(dump_path, "wb"); std::FILE *f = std::fopen(dump_path, "wb");
if (f) { if (f) {
fwrite(avr->flash, 1, avr->flashend + 1, f); std::fwrite(avr->flash, 1, avr->flashend + 1, f);
fclose(f); std::fclose(f);
} }
avr_eeprom_desc_t ee = {.ee = NULL, .offset = 0, .size = 0}; avr_eeprom_desc_t ee = {.ee = nullptr, .offset = 0, .size = 0};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) { if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) {
char path[512]; char path[512];
snprintf(path, sizeof(path), "%s.eeprom", dump_path); std::snprintf(path, sizeof(path), "%s.eeprom", dump_path);
f = fopen(path, "wb"); f = std::fopen(path, "wb");
if (f) { if (f) {
fwrite(ee.ee, 1, ee.size, f); std::fwrite(ee.ee, 1, ee.size, f);
fclose(f); std::fclose(f);
} }
} }
} }
@@ -302,76 +359,87 @@ static void finish(int sig)
_exit(0); _exit(0);
} }
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
int link_given = 0; bool link_given = false;
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) { for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) {
if (opt != 'l' || parse_link(optarg) != 0) { if (opt != 'l' || parse_link(optarg) != 0) {
fprintf(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)\n"); std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
return 2; return 2;
} }
link_given = 1; link_given = true;
} }
int args = argc - optind; int args = argc - optind;
if (args < 7 || args > 9) { if (args < 7 || args > 9) {
fprintf(stderr, std::print(stderr,
"usage: %s [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>" "usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
" [reset_hex] [resume_flash]\n" " [reset_hex] [resume_flash]\n"
" -l link: usart0 | usart1 | sw[:B0,B1] (RX,TX); default: the chip's own\n" " -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
" reset_hex: reset vector (default: base with a boot section, else 0)\n" " them); default: the chip's own\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n" " reset_hex: reset vector (default: base with a boot section, else 0)\n"
" run's dump, for power-fail resume tests\n", " resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
argv[0]); " run's dump, for power-fail resume tests\n",
argv[0]);
return 2; return 2;
} }
argv += optind - 1; // argv[1] is the ELF again, whatever was parsed argv += optind - 1; // argv[1] is the ELF again, whatever was parsed
const char *mcu_name = argv[2]; const std::string_view mcu_name = argv[2];
uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0); auto base = static_cast<std::uint32_t>(std::strtoul(argv[4], nullptr, 0));
unsigned page = (unsigned)atoi(argv[5]); auto page = static_cast<unsigned>(std::atoi(argv[5]));
unsigned baud = (unsigned)atoi(argv[6]); auto baud = static_cast<unsigned>(std::atoi(argv[6]));
dump_path = argv[7]; dump_path = argv[7];
int is_mega = strncmp(mcu_name, "atmega", 6) == 0; const bool is_mega = mcu_name.starts_with("atmega");
if (!link_given) if (!link_given)
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1 link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
avr = avr_make_mcu_by_name(mcu_name); avr = avr_make_mcu_by_name(mcu_name.data());
if (!avr) { if (!avr) {
fprintf(stderr, "device: no %s core\n", mcu_name); std::println(stderr, "device: no {} core", mcu_name);
return 1; return 1;
} }
avr_init(avr); avr_init(avr);
avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0); avr->frequency = static_cast<std::uint32_t>(std::strtoul(argv[3], nullptr, 0));
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased std::memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
if (args > 8) { if (args > 8) {
// Resume: the full flash image of an interrupted prior run. // Resume: the full flash image of an interrupted prior run.
FILE *f = fopen(argv[9], "rb"); std::FILE *f = std::fopen(argv[9], "rb");
if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) { if (!f || std::fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
fprintf(stderr, "device: cannot read %s\n", argv[9]); std::println(stderr, "device: cannot read {}", argv[9]);
return 1; return 1;
} }
fclose(f); std::fclose(f);
} else { } else {
elf_firmware_t fw = {0}; elf_firmware_t fw{};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
fprintf(stderr, "device: cannot read %s\n", argv[1]); std::println(stderr, "device: cannot read {}", argv[1]);
return 1; return 1;
} }
memcpy(avr->flash + base, fw.flash, fw.flashsize); // An image past flash end would smash the simulator's heap and turn
// into phantom peripheral behavior (lessons: believe the size gate
// first) — refuse it loudly instead.
if (base + fw.flashsize > avr->flashend + 1) {
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
fw.flashsize, base, avr->flashend);
return 1;
}
std::memcpy(avr->flash + base, fw.flash, fw.flashsize);
} }
// The boot-sectioned megas enter the loader in hardware (BOOTRST, not // The boot-sectioned megas enter the loader in hardware (BOOTRST, not
// modeled — the argument picks the modeled fuse's target); the tinies // modeled — the argument picks the modeled fuse's target); the tinies
// and the boot-section-less m48s reset to word 0 like silicon — erased // and the boot-section-less m48s reset to word 0 like silicon — erased
// flash walks up into the loader, and after the host's surgery the // flash walks up into the loader, and after the host's surgery the
// patched vector routes there. // patched vector routes there.
int boot_section = is_mega && strncmp(mcu_name, "atmega48", 8) != 0; const bool boot_section = is_mega && !mcu_name.starts_with("atmega48");
reset_pc = args > 7 ? (uint32_t)strtoul(argv[8], NULL, 0) : (boot_section ? base : 0); reset_pc = args > 7 ? static_cast<std::uint32_t>(std::strtoul(argv[8], nullptr, 0)) : (boot_section ? base : 0);
avr->pc = reset_pc; avr->pc = reset_pc;
avr->codeend = avr->flashend; avr->codeend = avr->flashend;
// Erased EEPROM, as hardware powers up (simavr zeroes it). // Erased EEPROM, as hardware powers up (simavr zeroes it).
uint8_t blank[1024]; std::uint8_t blank[1024];
memset(blank, 0xff, sizeof(blank)); std::memset(blank, 0xff, sizeof(blank));
avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0}; avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) { if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) {
seed.ee = blank; seed.ee = blank;
@@ -385,7 +453,7 @@ int main(int argc, char *argv[])
fix_mega_flash_erase(); fix_mega_flash_erase();
} else { } else {
nvm.page = page; nvm.page = page;
memset(nvm.buffer, 0xff, sizeof(nvm.buffer)); std::memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
nvm.io.kind = "tiny_nvm"; nvm.io.kind = "tiny_nvm";
nvm.io.ioctl = nvm_ioctl; nvm.io.ioctl = nvm_ioctl;
avr_register_io(avr, &nvm.io); avr_register_io(avr, &nvm.io);
@@ -394,35 +462,38 @@ int main(int argc, char *argv[])
if (!link_software) { if (!link_software) {
// POLL_SLEEP paces an idle-polling loader in host real time (a // POLL_SLEEP paces an idle-polling loader in host real time (a
// no-hardware CPU-saving hack); clear it so cycles run free. // no-hardware CPU-saving hack); clear it so cycles run free.
uint32_t flags = 0; std::uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP; flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit); uart_pty_connect(&uart_pty, uart_digit);
printf("PB_PTY %s\n", uart_pty.pty.slavename); std::println("PB_PTY {}", uart_pty.pty.slavename);
} else { } else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), (unsigned)sw_rx_bit); if (sw_tx_owner)
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), (unsigned)sw_tx_bit), tx_hook, find_tx_owner();
NULL); rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), static_cast<unsigned>(sw_rx_bit));
avr_irq_register_notify(
avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), static_cast<unsigned>(sw_tx_bit)), tx_hook,
nullptr);
avr_raise_irq(rx_pin, 1); // idle line avr_raise_irq(rx_pin, 1); // idle line
int slave; int slave;
struct termios raw; struct termios raw;
cfmakeraw(&raw); cfmakeraw(&raw);
if (openpty(&pty_master, &slave, NULL, &raw, NULL) != 0) { if (openpty(&pty_master, &slave, nullptr, &raw, nullptr) != 0) {
fprintf(stderr, "device: openpty failed\n"); std::println(stderr, "device: openpty failed");
return 1; return 1;
} }
fcntl(pty_master, F_SETFL, O_NONBLOCK); fcntl(pty_master, F_SETFL, O_NONBLOCK);
printf("PB_PTY %s\n", ttyname(slave)); std::println("PB_PTY {}", ttyname(slave));
} }
fflush(stdout); std::fflush(stdout);
signal(SIGTERM, finish); std::signal(SIGTERM, finish);
signal(SIGINT, finish); std::signal(SIGINT, finish);
signal(SIGUSR1, request_reset); // an external reset line, for the tests std::signal(SIGUSR1, request_reset); // an external reset line, for the tests
long since_poll = 0; long since_poll = 0;
for (;;) { for (;;) {
@@ -434,12 +505,13 @@ int main(int argc, char *argv[])
avr_reset(avr); avr_reset(avr);
avr->pc = reset_pc; avr->pc = reset_pc;
if (!link_software) { // reset restores the pacing hack; re-clear it if (!link_software) { // reset restores the pacing hack; re-clear it
uint32_t flags = 0; std::uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP; flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
} else { } else {
bridge_reset(); bridge_reset();
reset_tx_owner();
} }
} }
if (link_software && ++since_poll >= 2000) { if (link_software && ++since_poll >= 2000) {
@@ -457,5 +529,4 @@ int main(int argc, char *argv[])
} }
} }
finish(0); finish(0);
return 0;
} }

105
test/test_handshake.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Host-tool activation handshake: it must not hang on a flooding target.
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
trailing bytes before it asks for the identity. That drain must be bounded: a
target that never falls quiet — a board stuck in a reset loop presents exactly
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
beside pureboot.planner.
"""
import importlib.util
import pathlib
import threading
import time
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
_spec = importlib.util.spec_from_file_location("pureboot", PB)
pb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(pb)
P = F = 0
def check(name, ok):
global P, F
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
class FloodPort:
"""A line that never falls quiet: read_available always returns bytes, and
they contain a prompt. No identity ever completes."""
def flush_input(self):
pass
def write(self, data):
pass
def read_available(self, wait):
time.sleep(0.01) # a real read waits; keep the busy loop off a core
return b"+\x00\xff"
def read_exact(self, count, timeout):
raise pb.Error("no identity")
class LoaderPort:
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the
slim identity (version 5 + m328p signature) and a closing prompt."""
def __init__(self):
self.reads = self.exacts = 0
def flush_input(self):
pass
def write(self, data):
pass
def read_available(self, wait):
self.reads += 1
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet
def read_exact(self, count, timeout):
self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging."""
done = threading.Event()
def run():
try:
pb.Loader(port).connect_autobaud(wait)
except Exception:
pass
finally:
done.set()
threading.Thread(target=run, daemon=True).start()
return done.wait(budget)
def main():
# the hang: a flooding target must not spin the drain forever. With wait=0.5
# the whole handshake has to give up well inside a few seconds.
check("flooding target: handshake terminates, drain is bounded",
terminates(FloodPort(), wait=0.5, budget=4.0))
# the control: a real loader still connects and reads identity.
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5)
print(f"\n {P} passed, {F} failed")
return 1 if F else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,9 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host-tool unit tests — the pure planning and policy logic, no simulator: """Host-tool unit tests — the planning and policy logic, no simulator:
the flash-programming orders and their recovery properties, the reset-vector programming orders and their recovery properties, the reset-vector surgery,
surgery, the staging-slot composition, the mega boot-fuse decode, and the the staging composition, the boot-fuse decode, and the update preflight over
update preflight's error/warning matrix (fuse combinations simavr cannot fuse combinations simavr cannot model.
model reach it here as synthetic bytes).
Usage: test_planner.py <tool_py> Usage: test_planner.py <tool_py>
""" """
@@ -31,10 +30,17 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla
scale = 2 if word_flash else 1 scale = 2 if word_flash else 1
wire_base = base // scale wire_base = base // scale
flags = (1 if patch else 0) | (2 if word_flash else 0) 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, raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version,
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags)) *signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
eeprom & 0xFF, eeprom >> 8, flags))
info = pb.Info(raw) info = pb.Info(raw)
assert info.flash_size == flash if info.flash_size != flash:
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
return info return info
@@ -94,9 +100,7 @@ def main():
((0x1E, 0x97, 0x05), 0x20000, 3, {0b11: 0x1FC00, 0b10: 0x1F800, 0b01: 0x1F000, 0b00: 0x1E000}), # 1284P ((0x1E, 0x97, 0x05), 0x20000, 3, {0b11: 0x1FC00, 0b10: 0x1F800, 0b01: 0x1F000, 0b00: 0x1E000}), # 1284P
) )
for signature, flash, which, ladder in cases: for signature, flash, which, ladder in cases:
# Word-addressed chips carry the 1 KiB slot (their smallest boot sector). chip = info_of(pb, flash - pb.SLOT, 128 if flash < 0x20000 else 0, False, flash,
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) signature=signature, word_flash=flash > 0x10000)
for bits, start in ladder.items(): for bits, start in ladder.items():
fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF)) fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF))
@@ -109,10 +113,12 @@ def main():
if prog or at != start: if prog or at != start:
fail(f"mega_boot {signature[1]:02x}{signature[2]:02b} unprogrammed: {prog} {at:#07x}") fail(f"mega_boot {signature[1]:02x}{signature[2]:02b} unprogrammed: {prog} {at:#07x}")
# Word-addressed info decode: the 1284P's base/page ride the wire scaled, # Word-addressed info decode: the 1284P's base and page ride the wire
# and its slot is 1 KiB. # scaled — a 17-bit base halved into the block's two bytes, a 256-byte page
big = info_of(pb, 0x1FC00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True) # spelled 0 — and its slot is the same 512 bytes as everywhere else, so its
if big.page != 256 or big.base != 0x1FC00 or big.stage != 0x1F800 or big.slot != 1024: # 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:
fail(f"word-addressed info decode: page {big.page}, base {big.base:#x}, stage {big.stage:#x}") 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 # Surgery: word 0 lands on the loader, the trampoline on the original
@@ -162,11 +168,16 @@ def main():
fail("mega staging content should be the bare image") fail("mega staging content should be the bare image")
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512") expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
# The embedded info block: found in a synthetic binary, absent in noise. # The image stamp: found in a synthetic binary, absent in noise. pureboot
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10 # 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
found = pb.image_info(binary) found = pb.image_info(binary)
if found is None or found.raw != tiny.raw: if found is None or found.raw != tiny.raw:
fail("image_info misses the embedded block") fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: "
f"{found.raw.hex() if found else None} != {tiny.raw.hex()}")
if pb.image_info(bytes((0xAA,)) * 40) is not None: if pb.image_info(bytes((0xAA,)) * 40) is not None:
fail("image_info invents a block") fail("image_info invents a block")
# An older loader's image stays readable, so a deployed build can be # An older loader's image stays readable, so a deployed build can be
@@ -215,6 +226,15 @@ def main():
if pb.update_preflight(bytes((0xAA,)) * 8 + tiny.raw, tiny, None) != []: if pb.update_preflight(bytes((0xAA,)) * 8 + tiny.raw, tiny, None) != []:
fail("tiny preflight should pass without fuses") 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 # The walk-region refusal: BOOTRST aimed below the loader plus app data
# in the walk span errors without --force; erased spans and unprogrammed # in the walk span errors without --force; erased spans and unprogrammed
# BOOTRST pass. # BOOTRST pass.
@@ -271,6 +291,68 @@ def main():
if device.writes != pb.RETRIES + 1: if device.writes != pb.RETRIES + 1:
fail(f"unrepairable page took {device.writes} writes, expected {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") print("test_planner: all planner and policy checks pass")

71
test/test_scan.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""--scan's walk and report logic, no simulator: the probe order, the rate
arithmetic, and the advice's direction. The rate physics itself is not
sim-testable — a pty carries bytes at any termios rate — so what the wire
would arbitrate is pinned here as logic instead.
Usage: test_scan.py <tool_py>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
import pureboot as pb
walk = pb.scan_ratios()
if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]:
fail(f"probe walk is not built-rate-first, nearest-out: {walk}")
if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216:
fail("probe rate arithmetic")
if pb.scan_rate(115200, 0) != 115200:
fail("the built rate must probe unchanged")
# A loader answering fast means a fast oscillator: the trim goes down.
report = "\n".join(pb.scan_report(9600, 4, 6))
for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"):
if needle not in report:
fail(f"+4 % report lacks {needle!r}:\n{report}")
report = "\n".join(pb.scan_report(9600, -6, 6))
if "6 steps higher" not in report:
fail(f"-6 % report advises the wrong direction:\n{report}")
report = "\n".join(pb.scan_report(9600, 0, 6))
if "none" not in report or "steps" in report:
fail(f"an on-rate answer must advise no trim:\n{report}")
report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000))
if "9984000" not in report:
fail(f"the absolute clock must scale with the found ratio:\n{report}")
# The walk's rates mostly have no termios B-constant, so the POSIX port
# must set them through termios2 — probed on a pty, which accepts the
# ioctl without caring about the speed. Without this every off-nominal
# probe would abort the walk on the platform --scan matters most on.
if os.name == "posix":
import pty
master, slave = pty.openpty()
try:
port = pb.Port(os.ttyname(slave), pb.scan_rate(9600, 4))
port.set_baud(pb.scan_rate(9600, -4))
port.close()
except pb.Error as error:
fail(f"PosixPort refused an off-nominal probe rate: {error}")
finally:
os.close(master)
os.close(slave)
print("OK")
if __name__ == "__main__":
main()

167
test/test_update_link.py Executable file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Self-update across a link change: the host must follow the staging copy.
`--update-loader` installs the new image in the staging slot and then *enters
it* to have it rewrite the resident. That copy is the new image, so it speaks the
new image's baud and backend — but the host was talking to the *resident*. Where
the two differ, the host kept knocking at the old rate in the old mode, the
staging copy never answered, and the update stranded: staging installed, resident
untouched, and on a 1 KiB tiny the application region (which *is* the staging
slot there) already gone.
The wire cannot be probed for this — 512 bytes of position-independent code carry
no header saying what rate they were built for — so the operator declares it, and
a mismatch with nothing declared has to say so instead of reporting a bare
timeout.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset beside
pureboot.planner.
"""
import importlib.util
import pathlib
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
_spec = importlib.util.spec_from_file_location("pureboot", PB)
pb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(pb)
IDENTITY = b"\x05\x1e\x95\x0f" # pureboot 5 + m328p signature
P = F = 0
def check(name, ok, detail=""):
global P, F
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f"{detail}" if detail else ""))
class TwoLinkPort:
"""A board whose resident and staging copy answer on different links.
Only the rate currently set decides who can be heard, which is the physical
truth: a loader's bit timing is a cycle count, so a copy built for another
rate is unreadable until the host retunes. The knock bytes carry the mode, so
a backend mismatch is caught the same way.
"""
def __init__(self, resident=(57600, False), staged=(38400, False)):
self.resident, self.staged = resident, staged
self.baud = resident[0]
self.entered = False # a 'J' has handed control to the staging copy
self.switches = [] # every retune the host asked for
self.pending = bytearray() # what the device has queued to send
# --- the part under test needs this to exist at all
def set_baud(self, baud):
self.baud = baud
self.switches.append(baud)
def flush_input(self):
self.pending.clear()
def _audible(self, knock=None):
baud, autobaud = self.staged if self.entered else self.resident
if self.baud != baud:
return False
if knock is None:
return True
return knock == (bytes((pb.CALIBRATE, ord("p"))) if autobaud else b"pb")
def write(self, data):
data = bytes(data)
if data[:1] == b"J" and len(data) == 3:
# The resident acks the jump, then control moves to the copy.
if self._audible():
self.pending += pb.PROMPT
self.entered = True
elif data in (b"pb", bytes((pb.CALIBRATE, ord("p")))):
if self._audible(data):
self.pending += pb.PROMPT
elif data == b"b":
if self._audible():
self.pending += IDENTITY + pb.PROMPT
def read_available(self, wait):
out, self.pending = bytes(self.pending), bytearray()
return out
def read_exact(self, count, timeout):
if len(self.pending) < count:
raise pb.Error(f"timeout: got {len(self.pending)} of {count} bytes")
out, self.pending = bytes(self.pending[:count]), self.pending[count:]
return out
def connected(port):
"""A Loader already in session with the resident."""
loader = pb.Loader(port)
loader.connect(2.0)
return loader
def main():
# The control first: where the staged image keeps the resident's link, the
# flow works and needs no retune. This is the case that always passed, and
# it is what made the bug look like "self-update is broken" rather than
# "self-update cannot change the link".
port = TwoLinkPort(resident=(57600, False), staged=(57600, False))
loader = connected(port)
try:
loader.enter_copy(0x7C00, 2.0)
check("same link: staging copy entered", True)
except pb.Error as error:
check("same link: staging copy entered", False, str(error))
# A baud change, declared. The host must retune before knocking.
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
loader = connected(port)
try:
loader.enter_copy(0x7C00, 2.0, link=(38400, False))
check("baud change declared: entered after retuning", 38400 in port.switches,
f"switches={port.switches}")
except (pb.Error, TypeError) as error:
check("baud change declared: entered after retuning", False, repr(error))
# A backend change, declared: the knock itself has to become the calibration
# pulse, or an autobaud staging copy never hears a thing.
port = TwoLinkPort(resident=(57600, False), staged=(57600, True))
loader = connected(port)
try:
loader.enter_copy(0x7C00, 2.0, link=(57600, True))
check("backend change declared: entered as autobaud", True)
except (pb.Error, TypeError) as error:
check("backend change declared: entered as autobaud", False, repr(error))
# Nothing declared against a changed link: it still cannot work, but the
# error has to name the cause. A bare "no answer" sent the operator looking
# at the wiring while the application region sat erased.
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
loader = connected(port)
try:
loader.enter_copy(0x7C00, 0.3)
check("undeclared mismatch: reported", False, "unexpectedly succeeded")
except pb.Error as error:
text = str(error).lower()
check("undeclared mismatch: error names the link, not just a timeout",
"link" in text or "baud" in text or "backend" in text, str(error))
except TypeError as error:
check("undeclared mismatch: error names the link, not just a timeout",
False, repr(error))
# The resident's own link must be restored for the caller: a declared
# staging link is for the copy, and the tool talks to the new resident after.
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
loader = connected(port)
try:
loader.enter_copy(0x7C00, 2.0, link=(38400, False))
check("session records the link it is now speaking", loader.baud == 38400,
f"loader.baud={getattr(loader, 'baud', None)}")
except (pb.Error, TypeError, AttributeError) as error:
check("session records the link it is now speaking", False, repr(error))
print(f"\n {P} passed, {F} failed")
return 1 if F else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -2,12 +2,14 @@
# The port's gate: every chip's generated workflow — build, size matrix, and # The port's gate: every chip's generated workflow — build, size matrix, and
# the simulator-driven protocol suites. --full adds the reflect-spot builds # the simulator-driven protocol suites. --full adds the reflect-spot builds
# (libavr's rule: reflect compiles are bounded to its spot set, never the # (libavr's rule: reflect compiles are bounded to its spot set, never the
# full matrix). LIBAVR_ROOT must point at the libavr checkout. # full matrix) and swaps the compact size matrix for the exhaustive
# clock × baud × backend cross product. libavr resolves from the `libavr/`
# submodule; LIBAVR_ROOT overrides it for a working tree.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
full=0 full=0
[[ "$1" == "--full" ]] && { full=1; shift; } [[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85 CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85
atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a
@@ -34,4 +36,10 @@ if ((full)); then
done done
fi fi
# Every tree is freshly built now — the one moment the README's size table
# can be held to what the images measure (a per-preset ctest sees only its
# own chip; the table needs all of them, and ungated it drifts: a
# common-code shave moves every row at once with nothing over budget).
python3 tools/sizes.py check-readme
echo "check: every chip green" echo "check: every chip green"

View File

@@ -7,11 +7,14 @@ port's TUs compile identically; the sims prove nothing new there) exist for
libavr's reflect spot set only, mirroring its rule: the full reflect matrix libavr's reflect spot set only, mirroring its rule: the full reflect matrix
is never built, one chip per hardware class and pack vintage is. is never built, one chip per hardware class and pack vintage is.
Run from the repo root: tools/make_presets.py Run from the repo root: tools/make_presets.py — or with --check, which
verifies the committed file matches this generator and edits nothing (the
ctest entry `presets.generated` runs that, so drift reds the gate).
""" """
import json import json
import os import os
import sys
CHIPS = [ CHIPS = [
"attiny13", "attiny13a", "attiny25", "attiny45", "attiny85", "attiny13", "attiny13a", "attiny25", "attiny45", "attiny85",
@@ -41,7 +44,7 @@ def main():
"hidden": True, "hidden": True,
"generator": "Ninja", "generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}", "binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake", "toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
@@ -72,6 +75,9 @@ def main():
for chip in REFLECT_SPOT: for chip in REFLECT_SPOT:
add(chip, "reflect") add(chip, "reflect")
# CMake rejects unknown fields in the presets root, $comment included, so
# the file cannot carry a generated-file marker; the --check ctest is the
# whole of rule 10's guard here.
presets = { presets = {
"version": 8, "version": 8,
"configurePresets": configure, "configurePresets": configure,
@@ -79,12 +85,19 @@ def main():
"testPresets": test, "testPresets": test,
"workflowPresets": workflows, "workflowPresets": workflows,
} }
rendered = json.dumps(presets, indent=1) + "\n"
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json") path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json")
if "--check" in sys.argv[1:]:
current = open(path).read() if os.path.exists(path) else ""
if current != rendered:
print("CMakePresets.json does not match its generator — run tools/make_presets.py")
return 1
return 0
with open(path, "w") as f: with open(path, "w") as f:
json.dump(presets, f, indent=1) f.write(rendered)
f.write("\n")
print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}") print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}")
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())

247
tools/pbhw.py Executable file
View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Hardware acceptance suite for a pureboot deployment.
`tools/check.sh` proves the protocol under simavr on every chip. This proves one
*board*: that the loader actually installed on it answers, that the memories
round-trip over the real link, that the application it flashes runs afterwards,
and that the refusals which keep a 512-byte slot alive still fire. Run it once
when a board is brought up, and again whenever the deployment moves — a new
clock, a new backend, new pins.
Every check derives its bounds from the info block the loader itself reports, so
nothing here is per-chip: the same run covers a 1 KiB tiny whose application
region is 510 usable bytes and a 128 KiB mega whose flash needs a bank in the
selector.
**This overwrites the board's application flash and EEPROM.** Capture them first
with `pbrig.py backup`, which verifies what it captured.
tools/pbhw.py --programmer atmelice_isp --part t13 --port COM6 \
--autobaud --loader build/ab.bin --app build/pbapp.hex \
--marker APP
"""
from __future__ import annotations
import argparse
import pathlib
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import pbrig # noqa: E402
class Suite:
def __init__(self, rig: pbrig.Rig, work: pathlib.Path):
self.rig = rig
self.work = work
self.results: list[tuple[str, bool, str]] = []
def check(self, name: str, ok: bool, detail: str = "") -> bool:
self.results.append((name, ok, detail))
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" {detail}" if detail else ""))
return ok
@staticmethod
def _brief(text: str, limit: int = 78) -> str:
return " | ".join(l.strip() for l in text.splitlines() if l.strip())[:limit]
# ----------------------------------------------------------------- checks
def identity(self) -> object | None:
"""The info block, which every later check takes its bounds from."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
self.rig.reset()
port = module.Port(self.rig.d.port, self.rig.d.baud)
try:
loader = module.Loader(port)
if self.rig.d.autobaud:
loader.connect_autobaud(self.rig.d.wait)
else:
loader.connect(self.rig.d.wait)
info = loader.info
self.check("identity read", True, info.describe())
return info
except Exception as error: # noqa: BLE001 — a dead link is a result
self.check("identity read", False, str(error)[:70])
return None
finally:
try:
port.close()
except Exception:
pass
def scan(self) -> None:
"""The --scan walk against real termios and a real oscillator: every
probe rate must open a port (the off-nominal rates exist only through
termios2), and one probe must answer — the nominal on a healthy board,
a neighbor on a drifted one. The rig injects the one reset per probe
the operator supplies in the field; this is the rate physics the
simulator cannot arbitrate (a pty carries bytes at any rate), pinned
on silicon."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
found = None
try:
for pct in module.scan_ratios():
rate = module.scan_rate(self.rig.d.baud, pct)
self.rig.reset()
try:
port = module.Port(self.rig.d.port, rate)
except module.Error as error:
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
return
try:
module.Loader(port).connect(min(self.rig.d.wait, 6.0))
found = pct
break
except module.Error:
continue
finally:
port.close()
except Exception as error: # noqa: BLE001 — a rig hiccup is a result
self.check("scan walks the probe ladder", False, str(error)[:70])
return
self.check("scan finds the board's rate", found is not None,
"no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd")
def eeprom(self, info) -> None:
size = info.eeprom_size
if not size:
print(" skip EEPROM (this part has none)")
return
# A pattern no erase or partial write could produce by accident.
pattern = bytes((i * 7 + 3) & 0xFF for i in range(size))
image = self.work / "ee.bin"
image.write_bytes(pattern)
rc, out = self.rig.pureboot("--eeprom", str(image), "--verify-eeprom", str(image))
self.check(f"EEPROM write + verify ({size} B)", rc == 0, self._brief(out))
back = self.work / "ee-back.bin"
rc, out = self.rig.pureboot("--read-eeprom", str(back))
got = back.read_bytes() if back.exists() else b""
self.check("EEPROM reads back what was written", got == pattern, f"{len(got)} B")
self.rig.pureboot("--erase-eeprom")
erased = self.work / "ee-erased.bin"
self.rig.pureboot("--read-eeprom", str(erased))
got = erased.read_bytes() if erased.exists() else b""
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
def application(self, info, app: pathlib.Path, marker: str) -> None:
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
if marker:
# The tool hands over as it ends its session, so the application is
# already running; opening the port does not reset a board whose DTR
# is unwired, so this simply listens.
data = self.rig.capture(seconds=2.5)
seen = marker.encode() in data
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
self.check(f"application runs (emits {marker!r})", seen, f"|{sample}|")
back = self.work / "app-back.bin"
rc, out = self.rig.pureboot("--read-flash", str(back))
got = back.read_bytes() if back.exists() else b""
self.check("application flash reads back", rc == 0 and len(got) == info.base,
f"{len(got)} B of {info.base}")
def erase_and_guard(self, info, loader_image: pathlib.Path | None) -> None:
rc, out = self.rig.pureboot("--erase-flash")
self.check("application region erases", rc == 0, self._brief(out))
# The slot must be untouched by an application erase, which only an
# independent read can show — so this one goes over ISP, not the link.
whole = self.work / "whole.bin"
if not self.rig.read_memory("flash", whole, "r"):
self.check("loader slot survives the erase", False, "ISP read failed")
return
image = whole.read_bytes()
image += b"\xff" * (info.flash_size - len(image))
# Erased application flash, up to the trampoline word the host composes
# on a patched-vector part.
limit = info.base - 2 if info.patch_vector else info.base
self.check("erased application region is 0xff",
set(image[0:limit]) <= {0xFF}, f"0x0000..{limit:#06x}")
if loader_image and loader_image.exists():
want = loader_image.read_bytes()
got = image[info.base:info.base + len(want)]
self.check("loader slot survives the erase", got == want,
f"{len(want)} B at {info.base:#06x}")
else:
print(" skip loader slot comparison (pass --loader <image.bin>)")
def refusals(self, info) -> None:
# One word too many: a patched-vector part spends the slot's last word
# on the trampoline, so its application stops two bytes short.
limit = info.base - 2 if info.patch_vector else info.base
oversized = self.work / "oversized.bin"
oversized.write_bytes(bytes(limit + 2))
rc, out = self.rig.pureboot("--flash", str(oversized))
self.check(f"image over {limit} B refused", rc != 0, self._brief(out))
# ------------------------------------------------------------------- run
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
marker: str) -> int:
print("identity")
info = self.identity()
if info is None:
print("\nthe loader never answered; nothing below can be trusted")
return 1
if not self.rig.d.autobaud:
print("\nscan")
self.scan()
print("\nEEPROM")
self.eeprom(info)
if app:
print("\napplication")
self.application(info, app, marker)
else:
print("\nskip application checks (pass --app <image.hex>)")
print("\nerase and the write guard")
self.erase_and_guard(info, loader_image)
print("\nrefusals")
self.refusals(info)
passed = sum(1 for _, ok, _ in self.results if ok)
print(f"\n{passed}/{len(self.results)} passed")
return 0 if passed == len(self.results) else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="hardware acceptance suite for one pureboot deployment",
epilog="overwrites the board's application flash and EEPROM — back them up first")
pbrig.Deployment.add_arguments(parser)
parser.add_argument("--app", type=pathlib.Path,
help="application image to flash (test/pbapp.cpp built for this deployment)")
parser.add_argument("--loader", type=pathlib.Path,
help="the resident loader's .bin, to prove the slot survives an erase")
parser.add_argument("--marker", default="",
help="text the application emits when it runs, e.g. APP")
args = parser.parse_args(argv)
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
print(f"rig: {args.part} on {args.programmer}, link {args.port} at {args.baud} Bd"
f"{' (autobaud)' if args.autobaud else ''}")
print("this overwrites the application flash and EEPROM\n")
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker)
if __name__ == "__main__":
try:
sys.exit(main())
except pbrig.Error as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(2)

427
tools/pbrig.py Executable file
View File

@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Hardware rig driver for pureboot: an ISP programmer beside a serial link.
The simulated suites (`test/pb*.py`) prove the protocol; this drives the same
loader on real silicon, where the things a cycle-exact simulator cannot model
live — an RC oscillator off its nominal, a reset edge that has to come from
somewhere, a serial bridge with its own idea of what a baud is.
Nothing here knows a port name, a part or a programmer. Every deployment fact
arrives from the command line or the environment, so the same script serves any
board: see `Deployment`. As a module it is the reset/flash/talk primitives that
`pbhw.py` builds its acceptance suite from; as a command it is the handful of
one-shot operations worth having on a rig — most importantly `backup`, which is
the only thing standing between a fuse experiment and an unrecoverable part.
Two rig facts are encoded here because they are not guessable and cost a
session each to learn:
* **An ISP access resets the part**, and it runs again the moment the programmer
releases it. That is the only reset edge available when the serial adapter's
DTR is not wired to reset — so a loader session begins with an ISP touch and
knocks immediately after, which is what `Rig.pureboot()` does.
* **avrdude splits `-U memory:op:file:format` on colons**, so a Windows path's
drive letter breaks the spec. Every file argument is therefore passed as a
bare filename with avrdude run in that file's own directory.
"""
from __future__ import annotations
import argparse
import dataclasses
import importlib.util
import os
import pathlib
import subprocess
import sys
import time
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_PUREBOOT = HERE.parent / "pureboot" / "pureboot.py"
# Memories worth capturing before an experiment, and the format each is read in.
# Fuses and lock are per-part: a part without an extended fuse simply fails that
# one read, which `backup` reports and steps over rather than aborting on.
BACKUP_MEMORIES = (
("flash", "i", "hex"),
("flash", "r", "bin"),
("eeprom", "i", "hex"),
("eeprom", "r", "bin"),
("lfuse", "h", "hex"),
("hfuse", "h", "hex"),
("efuse", "h", "hex"),
("lock", "h", "hex"),
("calibration", "h", "hex"),
)
class Error(Exception):
pass
def bitclock_for(hz: int) -> str:
"""A safe ISP bitclock for a part *currently running* at `hz`.
SCK must stay under a quarter of the target clock, so the bitclock follows
the clock in force — not the one about to be fused in. Halving that ceiling
again costs nothing on a link that moves a few hundred bytes and buys margin
against an oscillator that is already known to be off its nominal.
"""
ceiling = hz // 8
for candidate in (1000, 4000, 8000, 32000, 125000, 400000):
if candidate <= ceiling:
best = candidate
else:
break
else:
best = 400000
if ceiling < 1000:
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
return f"{best // 1000}kHz"
@dataclasses.dataclass
class Deployment:
"""Everything about one board. No default names a real device."""
port: str = "" # serial device the loader speaks on
baud: int = 57600 # host rate; for autobaud, the rate to drive
autobaud: bool = False # send the calibration pulse instead of p+b
programmer: str = "" # avrdude -c
part: str = "" # avrdude -p
avrdude: str = "avrdude"
bitclock: str = "125kHz" # see bitclock_for()
pureboot: pathlib.Path = DEFAULT_PUREBOOT
wait: int = 12 # seconds the host keeps knocking
@classmethod
def from_env(cls) -> "Deployment":
"""Environment defaults, so a rig's facts live in one place per machine."""
return cls(
port=os.environ.get("PUREBOOT_PORT", ""),
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
part=os.environ.get("PUREBOOT_PART", ""),
avrdude=os.environ.get("AVRDUDE", "avrdude"),
bitclock=os.environ.get("PUREBOOT_BITCLOCK", "125kHz"),
pureboot=pathlib.Path(os.environ.get("PUREBOOT_TOOL", str(DEFAULT_PUREBOOT))),
)
@staticmethod
def add_arguments(parser: argparse.ArgumentParser) -> None:
"""Deployment flags, shared by this tool and pbhw.py."""
env = Deployment.from_env()
parser.add_argument("--port", default=env.port, help="serial device the loader speaks on")
parser.add_argument("--baud", type=int, default=env.baud,
help="host rate (for autobaud, the rate to drive)")
parser.add_argument("--autobaud", action="store_true", default=env.autobaud,
help="send the calibration pulse instead of the p+b knock")
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
parser.add_argument("--bitclock", default=env.bitclock, help="ISP bitclock, e.g. 125kHz or 8kHz")
parser.add_argument("--pureboot", type=pathlib.Path, default=env.pureboot,
help="path to pureboot.py")
parser.add_argument("--wait", type=int, default=env.wait, help="seconds to keep knocking")
@classmethod
def from_args(cls, args: argparse.Namespace) -> "Deployment":
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
def load_pureboot(path: pathlib.Path = DEFAULT_PUREBOOT):
"""The host tool as a module — its Port and Loader, not a subprocess.
Used where a subprocess cannot express what is needed: a poke followed by a
peek in the *same* session, or a raw read at an arbitrary baud.
"""
spec = importlib.util.spec_from_file_location("pureboot", path)
if spec is None or spec.loader is None:
raise Error(f"cannot load the host tool from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Rig:
"""One board: its programmer on one side, its serial link on the other."""
def __init__(self, deployment: Deployment):
self.d = deployment
if not deployment.programmer or not deployment.part:
raise Error("a rig needs --programmer and --part")
# ------------------------------------------------------------- programmer
def avrdude(self, *args: str, cwd: pathlib.Path | None = None,
bitclock: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
command = [self.d.avrdude, "-c", self.d.programmer, "-p", self.d.part,
"-B", bitclock or self.d.bitclock, *args]
return subprocess.run(command, capture_output=True, text=True,
cwd=None if cwd is None else str(cwd), timeout=timeout)
@staticmethod
def _ok(result: subprocess.CompletedProcess) -> bool:
return result.returncode == 0
def reset(self, bitclock: str | None = None) -> None:
"""An ISP access, which resets the part; it runs when avrdude exits."""
self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
def signature(self, bitclock: str | None = None) -> str:
result = self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
for line in reversed(result.stdout.splitlines()):
if line.strip().startswith("0x"):
return line.strip()
raise Error(f"no signature read: {(result.stderr or result.stdout).strip()[:200]}")
def read_memory(self, memory: str, destination: pathlib.Path, fmt: str = "r",
bitclock: str | None = None) -> bool:
"""Read `memory` into `destination`, whose directory avrdude runs in."""
destination = pathlib.Path(destination).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
result = self.avrdude("-U", f"{memory}:r:{destination.name}:{fmt}",
cwd=destination.parent, bitclock=bitclock)
# A memory the part does not have (a tiny's extended fuse) leaves avrdude
# happy and the file empty. An empty capture is a miss, not a backup.
return self._ok(result) and destination.exists() and destination.stat().st_size > 0
def write_memory(self, memory: str, source: pathlib.Path, fmt: str = "i",
erase: bool = False, bitclock: str | None = None) -> bool:
source = pathlib.Path(source).resolve()
args = ["-U", f"{memory}:w:{source.name}:{fmt}"]
if erase:
args.insert(0, "-e")
result = self.avrdude(*args, cwd=source.parent, bitclock=bitclock)
return "verified" in (result.stdout + result.stderr)
def flash_hex(self, image: pathlib.Path, erase: bool = True,
bitclock: str | None = None) -> bool:
return self.write_memory("flash", image, "i", erase=erase, bitclock=bitclock)
def read_fuses(self, bitclock: str | None = None) -> dict[str, str]:
out: dict[str, str] = {}
for fuse in ("lfuse", "hfuse", "efuse", "lock"):
result = self.avrdude("-U", f"{fuse}:r:-:h", bitclock=bitclock)
values = [l.strip() for l in result.stdout.splitlines() if l.strip().startswith("0x")]
if values:
out[fuse] = values[-1]
return out
def write_fuses(self, bitclock: str | None = None, **fuses: str) -> bool:
"""Write named fuses. A fuse change moves the clock the *next* access is
timed against, so pass a bitclock safe for both sides of the change."""
args: list[str] = []
for name, value in fuses.items():
args += ["-U", f"{name}:w:{value}:m"]
if not args:
return True
result = self.avrdude(*args, bitclock=bitclock)
text = result.stdout + result.stderr
return "verified" in text or "written" in text
# ------------------------------------------------------------ backup
def backup(self, directory: pathlib.Path, prefix: str = "") -> dict[str, bool]:
"""Capture every memory worth keeping, then prove it by a second read.
A backup nobody verified is a guess. Each memory is read twice and the
two reads compared; a mismatch is reported rather than quietly stored.
"""
directory = pathlib.Path(directory).resolve()
directory.mkdir(parents=True, exist_ok=True)
stem = prefix or self.d.part
status: dict[str, bool] = {}
for memory, fmt, extension in BACKUP_MEMORIES:
name = f"{stem}-{memory}.{extension}"
if not self.read_memory(memory, directory / name, fmt):
status[f"{memory}.{extension}"] = False
continue
if extension == "bin": # only the raw form is worth comparing byte-wise
again = directory / f".{name}.again"
self.read_memory(memory, again, fmt)
same = again.exists() and again.read_bytes() == (directory / name).read_bytes()
again.unlink(missing_ok=True)
status[f"{memory}.{extension}"] = same
else:
status[f"{memory}.{extension}"] = True
return status
# ------------------------------------------------------------ serial link
def pureboot(self, *args: str, reset_first: bool = True, baud: int | None = None,
autobaud: bool | None = None, timeout: int = 300,
bitclock: str | None = None) -> tuple[int, str]:
"""Reset, then knock immediately — see the module docstring.
Returns the host tool's exit status and its combined output, so a caller
can assert on what it printed as well as on whether it succeeded.
"""
if reset_first:
self.reset(bitclock=bitclock)
command = [sys.executable, str(self.d.pureboot), "--port", self.d.port,
"--baud", str(self.d.baud if baud is None else baud),
"--wait", str(self.d.wait)]
if self.d.autobaud if autobaud is None else autobaud:
command.append("--autobaud")
command += [str(a) for a in args]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as expired:
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
return result.returncode, (result.stdout or "") + (result.stderr or "")
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
"""Listen to whatever the board is saying, at an arbitrary rate.
Opening the port does not reset a board whose DTR is unwired, so this can
sample a running application repeatedly without disturbing it — which is
what makes the rate sweep below possible.
"""
module = load_pureboot(self.d.pureboot)
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
try:
data = b""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
chunk = port.read_available(0.2)
if chunk:
data += chunk
return data
finally:
try:
port.close()
except Exception:
pass
def measure_rate(rig: Rig, marker: bytes, built_baud: int, nominal_hz: int | None = None,
span_percent: float = 12.0, step_percent: float = 0.5,
seconds: float = 0.75) -> dict:
"""Find a transmitting board's true bit rate, using only the serial port.
The board must be emitting something recognisable at a *fixed* cycles-per-bit
— `test/pbapp.cpp` built with PUREBOOT_HEARTBEAT does. Since its bit timing is
a cycle count, its wire rate scales with its actual clock, so the host rates
at which `marker` still decodes bracket that rate; the centre of the band is
the answer, and with the clock the image was built for it gives the real one.
This is the measurement that turns "the loader is silent, so the wiring must
be wrong" into a number, and it needs no instrument beyond the adapter
already attached.
"""
steps = int(span_percent / step_percent)
clean: list[int] = []
samples: list[tuple[int, int, bool]] = []
for index in range(-steps, steps + 1):
baud = int(round(built_baud * (1 + index * step_percent / 100.0)))
if baud <= 0:
continue
data = rig.capture(seconds=seconds, baud=baud)
hit = marker in data
samples.append((baud, len(data), hit))
if hit:
clean.append(baud)
result: dict = {"samples": samples, "clean": clean, "built_baud": built_baud}
if clean:
low, high = min(clean), max(clean)
centre = (low + high) / 2.0
result |= {"low": low, "high": high, "centre": centre,
"half_width_percent": (high - low) / 2.0 / centre * 100.0,
"error_percent": (centre / built_baud - 1.0) * 100.0}
if nominal_hz:
result["measured_hz"] = nominal_hz * centre / built_baud
return result
# ------------------------------------------------------------------- command
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="pureboot hardware rig: ISP reset/flash beside the serial link")
Deployment.add_arguments(parser)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("signature", help="read the part signature over ISP")
sub.add_parser("reset", help="reset the part (an ISP access) and let it run")
sub.add_parser("fuses", help="read the fuse and lock bytes")
p = sub.add_parser("flash", help="program a hex image over ISP")
p.add_argument("image", type=pathlib.Path)
p.add_argument("--no-erase", action="store_true", help="do not chip-erase first")
p = sub.add_parser("backup", help="capture and verify every memory")
p.add_argument("directory", type=pathlib.Path)
p.add_argument("--prefix", default="", help="filename stem (default: the part name)")
p = sub.add_parser("rate", help="measure the board's true bit rate and clock")
p.add_argument("--marker", default="APP", help="text the board emits (default: APP)")
p.add_argument("--built-baud", type=int, required=True,
help="the baud the running image was built for")
p.add_argument("--nominal-hz", type=int, default=0,
help="the clock the image was built for, to report the real one")
p.add_argument("--span", type=float, default=12.0, help="sweep +-this many percent")
p.add_argument("--step", type=float, default=0.5, help="sweep step in percent")
p.add_argument("--verbose", action="store_true", help="print every step")
p = sub.add_parser("bitclock", help="a safe ISP bitclock for a clock in force")
p.add_argument("hz", type=int)
args = parser.parse_args(argv)
if args.command == "bitclock":
print(bitclock_for(args.hz))
return 0
rig = Rig(Deployment.from_args(args))
if args.command == "signature":
print(rig.signature())
elif args.command == "reset":
rig.reset()
print("reset")
elif args.command == "fuses":
for name, value in rig.read_fuses().items():
print(f"{name:<6} {value}")
elif args.command == "flash":
ok = rig.flash_hex(args.image, erase=not args.no_erase)
print(f"{args.image.name}: {'verified' if ok else 'FAILED'}")
return 0 if ok else 1
elif args.command == "backup":
status = rig.backup(args.directory, args.prefix)
for name, ok in status.items():
print(f" {'ok ' if ok else 'FAIL'} {name}")
missing = [n for n, ok in status.items() if not ok]
# Fuses a part does not have are expected misses, not failures.
fatal = [n for n in missing if not n.startswith(("efuse", "calibration"))]
print(f"\n{len(status) - len(missing)}/{len(status)} captured into {args.directory}")
return 1 if fatal else 0
elif args.command == "rate":
result = measure_rate(rig, args.marker.encode(), args.built_baud,
args.nominal_hz or None, args.span, args.step)
if args.verbose:
for baud, size, hit in result["samples"]:
print(f" {baud:7d} Bd {size:5d} B {'MARKER' if hit else ''}")
if not result["clean"]:
print(f"no capture contained {args.marker!r} at any rate — is the board "
f"transmitting, and on the pin this port is wired to?")
return 1
print(f"clean band {result['low']}..{result['high']} Bd")
print(f"centre {result['centre']:.0f} Bd "
f"(+-{result['half_width_percent']:.1f} %)")
print(f"vs built {result['built_baud']} Bd ({result['error_percent']:+.1f} %)")
if "measured_hz" in result:
print(f"true clock {result['measured_hz'] / 1e6:.3f} MHz")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Error as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(2)

160
tools/sizes.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""What the loader images actually measure, and whether the README still agrees.
The size matrix asserts every image fits its slot; it says nothing about the
numbers the README prints, and those drift. Every row of that table was eight
bytes stale once `startup::caller_page()` landed — common code, so every build
moved at once and no test noticed, because none of them was over budget.
Two questions, both answered from built trees:
sizes.py max the largest image per chip, and anything over budget
sizes.py check-readme the README's per-chip table against what is built
Nothing here knows a chip's geometry. The (image, budget) pairs come from each
build's own `CTestTestfile.cmake` — the same values the gate checks — so the
slot rules stay where they belong, in `pureboot/CMakeLists.txt`, and a chip
added or a budget changed needs no edit here. Only trees a configure preset
still owns are read: a stale directory keeps its last build, and a loader built
before a slot changed will happily report a size that was true once
(`tools/prune-build-trees.sh` in libavr removes them).
Sizes come from `avr-size`, and a target is only as current as its last build —
run the gate first if you want the table checked against today's source.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import shutil
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
# add_test(<name>.size ... -DELF=<path> ... -DLIMIT=<n> ...) — the gate's own
# pairing of an image with the budget it must fit.
# ctest writes the name as a bracket argument ([=[name.size]=]) and quotes the
# rest, so the name starts after the bracket and the path ends at the quote.
SIZE_TEST = re.compile(r'add_test\(\s*\[=\[(?P<name>[^\]]+?)\.size\]=\][^\n]*?'
r'-DELF=(?P<elf>[^"\s]+)[^\n]*?-DLIMIT=(?P<limit>\d+)')
def avr_size() -> str:
for env in (ROOT / "../../toolchain").resolve().glob("avr-gcc-*/bin/avr-size"):
if env.is_file():
return str(env)
found = shutil.which("avr-size")
if not found:
sys.exit("no avr-size found (build the toolchain, or put it on PATH)")
return found
def preset_dirs() -> list[pathlib.Path]:
"""Build trees a configure preset still owns, newest-listed first."""
listing = subprocess.run(["cmake", "--list-presets"], cwd=ROOT, capture_output=True, text=True)
names = re.findall(r'^\s*"(.+)"$', listing.stdout, re.MULTILINE)
if not names:
sys.exit("cmake --list-presets returned nothing — run from a configured checkout")
return [d for d in (ROOT / "build" / n for n in names) if (d / "CTestTestfile.cmake").is_file()]
def measure(paths: list[str], tool: str) -> dict[str, int]:
""".text per ELF, in one avr-size call per batch."""
sizes: dict[str, int] = {}
for start in range(0, len(paths), 400):
batch = [p for p in paths[start:start + 400] if pathlib.Path(p).is_file()]
if not batch:
continue
out = subprocess.run([tool, *batch], capture_output=True, text=True).stdout
for line in out.splitlines()[1:]:
fields = line.split()
if len(fields) >= 6 and fields[0].isdigit():
sizes[fields[5]] = int(fields[0])
return sizes
def collect() -> dict[str, list[tuple[str, int, int]]]:
"""chip -> [(target, text, limit)], from every owned build tree."""
tool = avr_size()
found: dict[str, list[tuple[str, str, int]]] = {}
for tree in preset_dirs():
chip = tree.name.split("-")[0]
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
measured = {
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
key=lambda row: -row[1])
for chip, rows in sorted(found.items())
}
# A configured-but-unbuilt preset registers its tests with no images behind
# them; it is not a chip with nothing to say, it is a chip not built yet.
return {chip: rows for chip, rows in measured.items() if rows}
def cmd_max(args) -> int:
measured = collect()
if not measured:
sys.exit("nothing built — configure and build a preset first")
over = []
print(f"{'chip':<13} {'largest image':<34} {'.text':>6} {'budget':>7} headroom")
for chip, rows in measured.items():
name, text, limit = rows[0]
flag = "OVER" if text > limit else f"{limit - text:>5} B"
print(f"{chip:<13} {name:<34} {text:>6} {limit:>7} {flag}")
over += [(chip, n, t, l) for n, t, l in rows if t > l]
total = sum(len(rows) for rows in measured.values())
print(f"\n{total} images across {len(measured)} chips")
if over:
print("\nOVER BUDGET:")
for chip, name, text, limit in over:
print(f" {chip} {name}: {text} > {limit}")
return 1
tightest = min(((chip, n, t, l) for chip, rows in measured.items() for n, t, l in rows),
key=lambda row: row[3] - row[2])
chip, name, text, limit = tightest
print(f"tightest fit: {chip} {name}{text} of {limit}, {limit - text} B spare")
return 0
def cmd_check_readme(args) -> int:
"""The README's per-chip table, against the stock and autobaud builds."""
readme = (ROOT / "pureboot" / "README.md").read_text()
measured = collect()
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
readme, re.MULTILINE)
if not rows:
sys.exit("no size table found in pureboot/README.md")
bad = skipped = 0
for chips, stock_doc, auto_doc in rows:
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
built = {name: text for name, text, _ in measured.get(chip, [])}
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)):
if target not in built:
skipped += 1
continue
if built[target] != int(documented):
print(f" {chip:<12} {target:<18} README says {documented} B, built is {built[target]} B")
bad += 1
if bad:
print(f"\n{bad} row(s) stale — update pureboot/README.md")
return 1
print(f"README size table matches every built image ({len(rows)} rows"
+ (f", {skipped} not built" if skipped else "") + ")")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
subs = parser.add_subparsers(dest="cmd", required=True)
subs.add_parser("max", help="largest image per chip, and anything over budget")
subs.add_parser("check-readme", help="the README's size table against what is built")
args = parser.parse_args()
return {"max": cmd_max, "check-readme": cmd_check_readme}[args.cmd](args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off

302
tsb/tsb_policy.cpp Normal file
View File

@@ -0,0 +1,302 @@
// TinySafeBoot on libavr — the policy floor: pureboot's rules, measured.
//
// The full TinySafeBoot feature set — watchdog bail, one-wire half-duplex,
// config-page activation timeout, password gate, emergency erase, and
// config/flash/EEPROM read-write — under philosophy #5 exactly as pureboot
// obeys it: no assembly, no register variables; code, attributes, and flags
// only. Every lesson pureboot's development produced is applied — the
// library's half-duplex serial and startup entry, lean bring-up from reset
// state, one merged send loop over both memories, oracle-shaped loop bounds,
// locals threaded through noinline primitives, pureboot's codegen flags —
// and the result is 638 bytes: 198 below the idiomatic tier, and 126 above
// the 512 B boot section the tricks/asm tiers reach with the banned
// mechanisms (526/510). This tier exists to keep that number an artifact
// rather than a claim: the gap to 512 is the rent of policy-clean C++ —
// helpers that hold a cursor across rx()/tx() pay push/pop and argument
// threading where a global-register protocol pays nothing, and both
// control-flow merges tried (a parametrized paged session, a merged store
// loop) measured larger than the split cases they replaced. TSB's wire fixes
// the per-command loop shapes on the device, so pureboot 5's one-transfer-
// loop collapse has no purchase here.
//
// The wire protocol is strict request/response, which is what makes the
// shared line safe: the device drives it only between a received command and
// its reply, and releases it (the library's half-duplex choreography)
// whenever it waits.
#include <libavr/libavr.hpp>
using namespace avr::literals;
namespace spm = avr::spm;
namespace ee = avr::eeprom;
using dev = avr::device<{.clock = 16_MHz}>;
// One-wire: RX and TX share the line, exactly as the native-UART TSB expects.
using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct, .half_duplex = true}>;
inline constexpr serial_t serial{};
namespace tsb {
namespace {
// The loader is purely polled — it never enables interrupts — so every SPM and
// EEPROM lock folds to nothing under this posture.
constexpr auto off = avr::irq::guard_policy::unused;
// The handshake bytes, identical across every TSB host.
constexpr std::uint8_t confirm = '!';
constexpr std::uint8_t request = '?';
constexpr std::uint8_t knock = '@';
// Boot geometry for the 1 KB boot section (BOOTSZ=10); the page size and the
// flash/EEPROM extents are the chip database's to know. app_end is the config
// page (TSB's LASTPAGE), one page below the boot section.
constexpr std::uint16_t page = spm::page_bytes;
constexpr std::uint16_t boot_bytes = 1024;
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz).
constexpr std::uint8_t act_min = 16;
// Post-activation window: the host gets seconds, not milliseconds, mid-session.
constexpr std::uint8_t comm_window = 200;
// Firmware version stamp: YY*512 + MM*32 + DD, the encoding the host decodes.
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 27;
// The 16-byte device-info block, streamed out on activation.
// clang-format off
[[gnu::progmem]] constexpr std::uint8_t info[16] = {
'T', 'S', 'B',
build_date & 0xFF, build_date >> 8,
0xF3, // status: native-UART fixed-baud lineage
avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
page / 2, // page size in words
(app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words
eeprom_end & 0xFF, eeprom_end >> 8,
0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15)
};
// clang-format on
// The receive window, pre-floored where it is set. In .noinit: there is no
// crt to clear a .bss image, and run() stores it before the first receive.
[[gnu::section(".noinit")]] std::uint8_t window;
const std::uint8_t *flash_ptr(std::uint16_t addr)
{
return reinterpret_cast<const std::uint8_t *>(addr);
}
// Bounded byte receive: poll under nested countdowns, 0 on silence. The 0
// then falls through every compare — not a knock, not a confirm, not a
// command — so a silent host unwinds the loader to the application from
// anywhere, and a mid-session cable pull cannot wedge it. The line release on
// a direction change is the serial backend's.
[[gnu::noinline]] std::uint8_t rx()
{
std::uint16_t outer = static_cast<std::uint16_t>(window) << 8;
do {
std::uint8_t fine = 0;
do {
if (auto byte = serial.read())
return *byte;
} while (--fine);
} while (--outer);
return 0;
}
// One-wire transmit: the backend takes the line with a turn-around guard and
// holds it until the whole frame is out.
[[gnu::noinline]] void tx(std::uint8_t byte)
{
serial.write(byte);
}
// '?', then hand back the host's reply for the callers' one-byte compare.
[[gnu::noinline]] std::uint8_t rcnf()
{
tx(request);
return rx();
}
// The one send loop: the info block, the config page, application flash and
// EEPROM pages all stream through here.
[[gnu::noinline]] void send_block(bool eep, std::uint16_t at, std::uint8_t count)
{
do {
tx(eep ? ee::read(at) : avr::flash_load(flash_ptr(at)));
++at;
} while (--count);
}
// One EEPROM byte in — shared by the emergency wipe and the 'E' stream.
[[gnu::noinline]] void eeput(std::uint16_t at, std::uint8_t value)
{
ee::write<off>(at, value);
}
// Wait out a running SPM op, then re-open the RWW section — after every page
// op and before handing over, as the oracle does.
[[gnu::noinline]] void settle()
{
spm::wait();
spm::rww_enable<off>();
}
// One host page straight into the erased flash page at `at` — through the SPM
// word buffer (low byte then high), no SRAM staging — then committed. `at`
// names a page base, so the cursor's low byte reaching the boundary ends the
// walk.
[[gnu::noinline]] void store_flash_page(std::uint16_t at)
{
do {
std::uint8_t low = rx();
std::uint8_t high = rx();
spm::fill<off>(at, std::bit_cast<std::uint16_t>(std::array{low, high}));
at += 2;
} while (static_cast<std::uint8_t>(at) & (page - 1));
spm::write_page<off>(at - page);
settle();
}
extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0
[[noreturn]] void appjump()
{
settle();
tsb_app();
}
// Step one page down and erase it — the erase shared by the whole-app walk,
// the config rewrite and the emergency wipe; hands the stepped address back.
[[gnu::noinline]] std::uint16_t erase_below(std::uint16_t at)
{
at -= page;
spm::erase_page<off>(at);
settle();
return at;
}
// Erase the whole application, top-down like the oracle: the loop bound is a
// compare with zero, and the returned 0 is the address every caller wants
// next.
[[gnu::noinline]] std::uint16_t erase_application()
{
std::uint16_t at = app_end;
do {
at = erase_below(at);
} while (at != 0);
return at;
}
[[noreturn]] void run()
{
// A watchdog reset hands straight back to the application, as the
// reference loader does, rather than re-entering the bootloader.
if (avr::hw::mcusr::wdrf.test())
appjump();
// Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads
// 0, and the half-duplex write()/read() raise TXEN0/RXEN0 on first use —
// only the divisor low byte and U2X0 need a store. The solver still does
// the datasheet work; the asserts pin the reset-state assumptions.
{
constexpr auto sol = avr::uart::solve_baud(dev::clock, 115200_Bd);
static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
avr::hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(sol.ubrr));
avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
}
// Activation: 3×'@', each inside the config page's timeout window
// (floored so a corrupt page cannot lock the loader out); anything else —
// including silence — hands over.
window = avr::flash_load(flash_ptr(app_end + 2)) | act_min;
for (std::uint8_t k = 3; k; --k)
if (rx() != knock)
appjump();
window = comm_window;
// Password gate (config page from app_end+3, 0xff-terminated; a blank
// page is no password). A wrong byte blanks the comparison and drains the
// line forever, so a wrong password can never fall through; a 0 requests
// emergency erase behind two confirms. On pass the info block goes out;
// the emergency path skips it and drops into the command loop.
std::uint16_t at = app_end + 3;
std::uint8_t mask = 0xff;
for (;;) {
std::uint8_t expected = avr::flash_load(flash_ptr(at)) & mask;
++at;
if (expected == 0xff) {
send_block(false, reinterpret_cast<std::uint16_t>(&info[0]), sizeof info);
break;
}
std::uint8_t got = rx();
if (got == 0) {
if (mask == 0)
continue;
if (rcnf() != confirm || rcnf() != confirm)
appjump();
std::uint16_t a = erase_application();
do {
eeput(a, 0xff);
} while (++a <= eeprom_end);
erase_below(app_end + page);
break;
}
if (got != expected)
mask = 0;
}
for (;;) {
tx(confirm); // Mainloop ready
const std::uint8_t command = rx();
switch (command) {
case 'f': // read application flash, one page per host '!'
for (std::uint16_t a = 0; a < app_end; a += page) {
if (rx() != confirm)
break;
send_block(false, a, page);
}
break;
case 'e': // read EEPROM, one page per host '!', until the host stops
for (std::uint16_t a = 0;; a += page) {
if (rx() != confirm)
break;
send_block(true, a, page);
}
break;
case 'F': { // erase the application, then take pages behind '?'
std::uint16_t a = erase_application();
for (; rcnf() == confirm; a += page)
store_flash_page(a);
break;
}
case 'E': // take EEPROM pages behind '?', each write host-paced
for (std::uint16_t a = 0; rcnf() == confirm;) {
std::uint8_t count = page;
do {
eeput(a, rx());
++a;
} while (--count);
}
break;
case 'c': // read the config page
read_config:
send_block(false, app_end, page);
break;
case 'C': // replace the config page, then echo it back to verify
if (rcnf() != confirm)
break;
store_flash_page(erase_below(app_end + page));
goto read_config;
default: // 'q' or any other byte runs the application
appjump();
}
}
}
} // namespace
} // namespace tsb
// Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
// is laid first and does the one line of crt a crt-less image needs.
template struct avr::startup::entry<tsb::run>;

View File

@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off