55 Commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:47 +02:00
3899e8ef40 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
0ddc65ca28 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
142de8775d 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
a285347e5f 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
2cc540c6a1 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
14efef96cc 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
eecf9673b9 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
c78bf2841a 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
f9eeae5069 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
01e0169ac5 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
98a3be3c2e 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
11707e0b06 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
c763ca856a 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
a5b42bc02f 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
8f106b636d 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
6f3eb06233 pureboot: a version number for the loader, not for the protocol
The info block's third byte was a protocol version that never moved in the
loader's lifetime. It is the pureboot version now, and this change is
version 2: the one number that says what a deployed loader is. Every loader
already in the field answers 1.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:42:57 +02:00
a977e507f3 pureboot: every deployment axis is a build parameter
Clock, baud, serial backend (hardware USART 0/1 or the software UART on
any pins) and the activation window all resolve through one CMake
function, pureboot_add_loader() in pureboot/CMakeLists.txt — the unit a
downstream project consumes. The default baud is the fastest standard
rate within 2.5 % (the same best-divisor search libavr's solver runs),
gated on software builds by the polled receiver's 100-cycles-a-bit
floor; every explicit pick is re-checked by the compile's static asserts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3
.gitmodules vendored
View File

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

View File

@@ -2,19 +2,18 @@ cmake_minimum_required(VERSION 3.28)
project(tsb_libavr LANGUAGES CXX) project(tsb_libavr LANGUAGES CXX)
# libavr rides as the pinned submodule; LIBAVR_ROOT (cache or environment) # libavr from a local checkout (LIBAVR_ROOT) or the forge; the toolchain file
# overrides it for tandem development against a working tree. The toolchain # comes from the same checkout via CMakePresets.json.
# file comes from the submodule via CMakePresets.json either way. include(FetchContent)
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(LIBAVR_ROOT)
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr) FetchContent_Declare(libavr SOURCE_DIR ${LIBAVR_ROOT})
else()
FetchContent_Declare(libavr GIT_REPOSITORY git@git.blackmark.me:avr/libavr.git GIT_TAG main)
endif() endif()
if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt) FetchContent_MakeAvailable(libavr)
message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} — run: git submodule update --init libavr")
endif()
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
@@ -22,17 +21,15 @@ 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 (C++23 — what # host programs built at configure time against libsimavr; if they or
# the distribution's compiler speaks in full); if they or Python are # Python are missing, only the size tests run.
# missing, only the size tests run. find_program(_host_cc NAMES cc gcc)
find_program(_host_cxx NAMES c++ g++)
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(_host_cxx AND Python3_FOUND) if(_host_cc 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_cxx} -std=c++23 -Wall -Wextra -O2 COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts
-I/usr/include/simavr -I/usr/include/simavr/parts -o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c
-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,9 +39,8 @@ 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_cxx} -std=c++23 -Wall -Wextra -O2 COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts
-I/usr/include/simavr -I/usr/include/simavr/parts -o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c
-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)
@@ -69,18 +65,16 @@ 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 variants that trade # The TinySafeBoot protocol reimplemented on libavr in three 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 entry sits in # loader has no use for the crt or the vector table. The naked entry sits in
# .vectors, laid first, and runs — avr::startup::entry on the policy tier, # .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section
# the experiment tiers' own naked stubs elsewhere, each documented in its # size; the linker section-start and the source's boot_bytes agree. tsb_app is
# 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 four implement the full oracle feature set (see oracle/README.md): # All three 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.
@@ -100,10 +94,6 @@ 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)
@@ -131,13 +121,8 @@ 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,
@@ -172,18 +157,6 @@ if(PROJECT_IS_TOP_LEVEL)
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
@@ -262,12 +235,12 @@ if(PROJECT_IS_TOP_LEVEL)
# 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 USART instance # size — the serial backend (different code), the USART instance
# (different registers), the clock (different constants), the baud # (different registers), the clock (different constants), and the baud
# through the shapes its bit timing takes, and the pins through the one # through the shapes its bit timing takes — each combination must still
# thing they decide (whether a bit-banged link has to release the USART # fit the chip's slot budget. Pins are size-neutral (port and bit are
# that owns them) — each combination must still fit the chip's slot # immediate operands) and the timeout is a constant, so neither adds an
# budget. The timeout is a constant and adds no axis. The stock build is # axis. The stock build is one point of this matrix and already has its
# one point of this matrix and already has its test. # 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
@@ -283,33 +256,20 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot_autobaud.size add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud> COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -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 # One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. `pins` is empty for the # so the enumeration cannot collide with itself. Unreachable rates drop
# default pair, or the index of the USART whose own pins a bit-banged # out here rather than aborting the configure.
# link sits on. Unreachable rates drop out here rather than aborting the function(pureboot_matrix_point hz baud link)
# configure.
function(pureboot_matrix_point hz baud link pins)
set(_name pbm_${hz}_${baud}_${link})
if(link STREQUAL "software") if(link STREQUAL "software")
pureboot_baud_feasible(${hz} ${baud} 1 _ok) pureboot_baud_feasible(${hz} ${baud} 1 _ok)
set(_args SERIAL software) 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() else()
pureboot_baud_feasible(${hz} ${baud} 0 _ok) pureboot_baud_feasible(${hz} ${baud} 0 _ok)
set(_args USART ${link}) set(_args USART ${link})
endif() endif()
if(_ok) if(_ok)
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args}) pureboot_size_variant(pbm_${hz}_${baud}_${link} CLOCK ${hz} BAUD ${baud} ${_args})
endif() endif()
endfunction() endfunction()
@@ -347,14 +307,12 @@ if(PROJECT_IS_TOP_LEVEL)
if(DEFINED ENV{PUREBOOT_FULL_MATRIX}) if(DEFINED ENV{PUREBOOT_FULL_MATRIX})
foreach(_matrix_hz IN LISTS _full_clocks) foreach(_matrix_hz IN LISTS _full_clocks)
foreach(_matrix_baud IN LISTS _full_bauds) foreach(_matrix_baud IN LISTS _full_bauds)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "") pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software)
if(PUREBOOT_HAS_USART) if(PUREBOOT_HAS_USART)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
endif() endif()
if(PUREBOOT_HAS_USART1) if(PUREBOOT_HAS_USART1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "")
endif() endif()
endforeach() endforeach()
endforeach() endforeach()
@@ -373,74 +331,11 @@ if(PROJECT_IS_TOP_LEVEL)
endforeach() endforeach()
list(GET _matrix_clocks -1 _matrix_top_hz) list(GET _matrix_clocks -1 _matrix_top_hz)
pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software) 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()
if(PUREBOOT_HAS_USART1)
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() 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
@@ -469,33 +364,6 @@ 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
@@ -519,9 +387,9 @@ if(PROJECT_IS_TOP_LEVEL)
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 autobaud variants driven end to end over the software-UART bridge (both
# the host sends the 0xC0 calibration pulse, the loader times it, locks, # under review — pureboot/autobaud.md): the host sends the 0xC0 calibration
# and programs. Run on the near-flash 328P # 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 # 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 # at two clocks with the one binary, which is the clock-agnostic property
# autobaud exists for (test/pbautobaud.py). The fixture application banners # autobaud exists for (test/pbautobaud.py). The fixture application banners

View File

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

View File

@@ -1,77 +0,0 @@
# 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`.

View File

@@ -1,28 +0,0 @@
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

@@ -1,118 +0,0 @@
<?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>

View File

@@ -1,112 +0,0 @@
<?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>

1
libavr

Submodule libavr deleted from 911a87538f

View File

@@ -116,17 +116,6 @@ else()
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})
@@ -136,19 +125,6 @@ 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})
@@ -157,8 +133,6 @@ 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.
@@ -168,14 +142,9 @@ 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 rates a default may pick, fastest first. # The rates a default may pick, fastest first.
set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600) set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
@@ -226,28 +195,20 @@ endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>] # pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>] # [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>]) # [RX <pin>] [TX <pin>] [TIMEOUT <s>])
# #
# The loader target plus its flashable images (<name>.hex for a programmer, # The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the # <name>.bin for --update-loader). The resolved deployment is stamped on the
# target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled # target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
# usart0, usart1, or sw:<RX>,<TX> with a trailing @<n> where those pins are a # usart0, usart1 or sw:<RX>,<TX>) — what a test harness speaks to it with.
# 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 # 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, # 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 # 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, # PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
# not what it was built for. # 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;OSCCAL" "" ${ARGN}) cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${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()
@@ -314,21 +275,11 @@ function(pureboot_add_loader name)
else() else()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
endif() endif()
# sw:<RX>,<TX> as port letter and bit, upcased — with @<n> where # sw:<RX>,<TX> as port letter and bit, upcased.
# 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)
@@ -346,13 +297,6 @@ function(pureboot_add_loader name)
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines}) ${_serial_defines})
endif() 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)

View File

@@ -28,34 +28,31 @@ it carries the calibration machinery and no clock at all.
| Chip | Flash | Loader at | Link | Stock | Autobaud | | Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 390 B | 460 B | | ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 402 B | 472 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 394 B | 464 B | | ATtiny25 † | 2 KiB | 0x0600 | software | 406 B | 476 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 398 B | 468 B | | ATtiny45 † | 4 KiB | 0x0e00 | software | 410 B | 480 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 398 B | 468 B | | ATtiny85 † | 8 KiB | 0x1e00 | software | 410 B | 480 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 360 B | 474 B | | ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 372 B | 486 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 362 B | 480 B | | ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 374 B | 490 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 362 B | 480 B | | ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 374 B | 490 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 388 B | 464 B | | ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 400 B | 476 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 398 B | 474 B | | ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 410 B | 486 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B | | ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B | | ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B | | ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B | | ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 394 B | 474 B | | ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 406 B | 484 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 420 B | 500 B | | ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 432 B | 510 B |
† No hardware boot section: the host patches the reset vector, and the budget † 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. 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 The tightest fit in the whole space is the 1284s' autobaud build, 510 of its
USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone 512 — they alone carry the far-flash machinery (ELPM reads, RAMPZ page
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud commands) and autobaud alone carries the calibration loop. Everything else has
alone carries the calibration loop, a bit-banged link on a USART's pins alone 20 B of headroom or more. The flash bank riding in a transfer's selector byte
has to release it (below), and the trim adds its one register write. Without keeps even those chips' addressing the same 16-bit form every other chip uses,
the trim that build is 504; on the default pins, 500. The flash bank riding which is why they are no longer the outlier they were.
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 The software UART enables the RX pull-up; TX idles high. All multi-byte wire
quantities are little-endian. quantities are little-endian.
@@ -74,7 +71,6 @@ repo's build and by a downstream project alike:
| `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
@@ -83,13 +79,6 @@ receiver's 100-cycles-a-bit floor. Whatever is picked or overridden is
re-checked in the compile: an infeasible combination, or a USART the chip does re-checked in the compile: an infeasible combination, or a USART the chip does
not have, fails with a named static assert. not have, fails with a named static assert.
Putting a bit-banged link on a USART's own pins is a supported deployment, and
the usual one where a board's USB bridge is wired to RXD/TXD: the link's `init`
clears that USART's `UCSRnB` first, because while its `TXEN` is set the USART —
not the port register — owns the TX pin, and a loader entered from an
application that left it enabled would receive and obey while answering nothing
(§20.2). It costs four bytes, and only on those pins.
`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing `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 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 binary per chip serves every clock and every rate. It is for the deployments
@@ -101,37 +90,15 @@ needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). (`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 A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the 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 software UART on hand-picked pins, say:
(the tags name them; this repo pins its own libavr the same way), where
FetchContent tracks whatever `main` is:
```cmake ```cmake
# git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main)
add_subdirectory(bootloader/pureboot pureboot) FetchContent_MakeAvailable(bootloader)
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)
``` ```
@@ -289,17 +256,7 @@ Two generations exist. **1 through 4** speak one session — a 12-byte info bloc
from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses). 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 **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 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 deployed pureboot 4 stays drivable and self-updatable to 5.
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 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 version: the data space, the host-issued SPM operations and the fuses now share
@@ -356,18 +313,6 @@ 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 on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it
into the top slot from there (`pureboot.rehome`). 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
@@ -375,28 +320,6 @@ 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. bytes as a raw binary, or the Intel HEX the build emits beside it.
One thing the image cannot tell the host: **which link it speaks.** The update
works by entering copies of the *new* image (steps 3 and 4 below), so a build
made for another baud or another backend answers on that one and not on the
session's — and 512 bytes of position-independent code carry no header to read
it from. Where the new image's link differs, name it:
```sh
# a 57600 fixed-baud resident, replaced by an autobaud build
pureboot.py --port … --baud 57600 --update-loader ab.bin --staged-autobaud
# …or by a 38400 build of the same backend
pureboot.py --port … --baud 57600 --update-loader sw38400.bin --staged-baud 38400
```
The host retunes on the open port, so no DTR pulse resets the copy it is talking
to. Omit them against a changed link and the update stops after installing the
staging copy, saying so and naming this as the cause.
An `OSCCAL`-baked image is a link change in effect even at an unchanged rate
on paper: the staging copy shifts the physical clock the moment its `run()`
starts, and from then on speaks exactly what it was built for. Declare it
like any other link change — `--staged-baud` with the new build's rate.
The preflight refuses an image built for another chip: the stamp every pureboot 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 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 both. Die revisions share their base signature and geometry, so their images
@@ -417,15 +340,10 @@ are interchangeable — as the silicon is.
content, and the state file is discarded. content, and the state file is discarded.
Every phase is idempotent and keyed off the actual flash state, so 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 — with one the same command after any interruption resumes and completes. The state file
qualification, which is the link again: from step 2 on, the copy the re-run has carries the only bytes not recoverable from the device; losing it mid-update
to reach is the *new* image, so a resumed run needs the same `--staged-*` as the still completes the update, and the staging region comes back by reflashing
first one. On a patched-vector part step 3 also re-aims word 0 at the staging the application. A boot-sectioned mega needs its fuses for the preflight — read
copy, so after that point a reset reaches the new image's link and **only** that
one; a re-run on the resident's link finds nothing at all. The state file carries
the only bytes not recoverable from the device; losing it mid-update still
completes the update, and the staging region comes back by reflashing the
application. A boot-sectioned mega needs its fuses for the preflight — read
from the device, or supplied with `--assume-fuses` where reading is impossible from the device, or supplied with `--assume-fuses` where reading is impossible
(simulators). (simulators).
@@ -453,40 +371,13 @@ application data into a mega's reset walk region.
`--autobaud` opens with the calibration pulse instead of the plain knock, for a `--autobaud` opens with the calibration pulse instead of the plain knock, for a
loader built `SERIAL autobaud`; the rest of the session is identical, at loader built `SERIAL autobaud`; the rest of the session is identical, at
whatever `--baud` the host chose. Its `--info` adds the **measured clock** whatever `--baud` the host chose.
the loader's bit-period unit, decoded and multiplied by the session rate —
which is the number an `OSCCAL` bake or a fixed-baud build for the part is
held against; `--clock <hz>` states the drift against a nominal.
`--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
window — reset the target as each probe announces itself (a board with DTR
wired to reset is pulsed by the probe's own port-open). A loader
off-frequency answers at its oscillator's ratio, and the report gives the
found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it.
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) — `--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 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 register. Reading an I/O register can have side effects (reading UDR clears its
flags), which is the caller's business to know. 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 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 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 plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr
@@ -495,11 +386,8 @@ counts, the programming plan, update state handling and per-phase page counts.
## Tests ## Tests
libavr rides as the `libavr/` submodule (`git submodule update --init libavr`); `tools/check.sh` runs every chip's workflow (`--full` adds the reflect-mode
`LIBAVR_ROOT` (cache or environment) overrides it for tandem development builds of libavr's spot set; `tools/make_presets.py` regenerates the presets).
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: Per chip preset, `ctest` runs:
- `pureboot.size` — the 510-byte (patched-vector) / 512-byte budget; - `pureboot.size` — the 510-byte (patched-vector) / 512-byte budget;
@@ -509,18 +397,11 @@ Per chip preset, `ctest` runs:
the fastest clock — where a software UART's per-bit spin outgrows its the fastest clock — where a software UART's per-bit spin outgrows its
one-register delay loop and takes the 16-bit one. That is the largest image one-register delay loop and takes the 16-bit one. That is the largest image
the configuration space produces, and a shape the ladder default (always the the configuration space produces, and a shape the ladder default (always the
*fastest* rate a clock reaches) never picks. Pins are an axis for one reason *fastest* rate a clock reaches) never picks. Pins are immediate operands and
only, and it is enough: a bit-banged link on a USART's own pins has to the timeout is a constant: neither is an axis;
release that USART, so `pureboot_{sw,autobaud}_on_usart{0,1}` build there
too. The timeout is a constant and is no axis;
- `pureboot_autobaud.size` — the clock-free build, which has no clock or baud - `pureboot_autobaud.size` — the clock-free build, which has no clock or baud
axis of its own: one binary per chip has to serve every point the matrix axis of its own: one binary per chip has to serve every point the matrix
below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock below sweeps;
shape and on the tightest image in the space (autobaud on a USART's own
pins), holding both of the trim write's addressing encodings to the budget;
- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
object and sits exactly at ram_start, where `--info` reads it: wire
contract, not layout accident;
- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product - `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
replacing that compact matrix, on **every** chip: every plausible oscillator replacing that compact matrix, on **every** chip: every plausible oscillator
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) × (the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×
@@ -531,27 +412,12 @@ Per chip preset, `ctest` runs:
flash-resident section but `.text`, and the image byte-identical when linked flash-resident section but `.text`, and the image byte-identical when linked
at a different base — which is position independence itself rather than a at a different base — which is position independence itself rather than a
proxy for it; 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 - `pureboot.planner` — the host tool's pure logic: programming orders and their
recovery properties, the surgery, the staging composition, the boot-fuse recovery properties, the surgery, the staging composition, the boot-fuse
decode, the update preflight over synthetic fuse bytes, and the repairing decode, the update preflight over synthetic fuse bytes, and the repairing
verify against a fake device; 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.cpp`: a hardware USART as a pty, or a cycle-timed (`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's
tiny cores lack) driven by the real host tool through knock-from-reset, tiny cores lack) driven by the real host tool through knock-from-reset,
program + verify of both memories, session reconnect, an external reset program + verify of both memories, session reconnect, an external reset
@@ -568,12 +434,6 @@ Per chip preset, `ctest` runs:
- `pureboot.usart1` (644A) — the same suite over the second hardware USART: - `pureboot.usart1` (644A) — the same suite over the second hardware USART:
instance selection is compile-checked everywhere, but only a live session instance selection is compile-checked everywhere, but only a live session
proves the loader polls the USART it claims; proves the loader polls the USART it claims;
- `pureboot.mute` (328P) — a software link on USART0's own pins, entered from an
application that handed over with that USART still enabled: the loader must
still answer, which it does only because it releases it. The pin ownership is
the runner's, not simavr's — simavr wires a USART through IRQs and never takes
the pin from the port, so without that model the state under test could not
arise at all;
- `pureboot.dirty` (328P) — entering the loader from a running application over - `pureboot.dirty` (328P) — entering the loader from a running application over
an SPM buffer it deliberately dirtied, the case the loader declines to guard: 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 a bare verify must see the corruption and the repairing verify must fix it in
@@ -582,58 +442,12 @@ Per chip preset, `ctest` runs:
- `pureboot.update` — the full `--update-loader` flow, then every power-fail - `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 phase: the device is killed mid-write, restarted from its flash dump, and a
re-run must complete the update with the application intact; 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 - `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
bridge: the calibration handshake, a flash + EEPROM + fuse round trip against 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 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 same binary again at double the clock, which is the property the backend
exists for. The measured clock `--info` prints is asserted against the exists for. A lone calibration pulse with no knock behind it must still let
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. the application boot, so no wait in activation can be unbounded.
`size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run `size`, `pi` and `planner` are host logic and run anywhere; the
anywhere; the simulator-driven targets need simavr and a pty, so they are simulator-driven targets need simavr and a pty, so they are POSIX-only.
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

@@ -38,6 +38,13 @@ 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 #endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
{
auto reg = std::string_view{avr::hw::db.regs[static_cast<std::size_t>(avr::power::detail::reset_reg())].name};
return avr::hw::db.field_index(reg, "WDRF");
}
// The loader owns the top 512 bytes; a staging copy goes in the slot below. // The loader owns the top 512 bytes; a staging copy goes in the slot below.
// Chips without a hardware boot section — the tinies and the m48s, whose SPM // Chips without a hardware boot section — the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 §26) — keep the application's relocated // runs from anywhere (Atmel-8271 §26) — keep the application's relocated
@@ -65,17 +72,9 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
#endif #endif
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// A build may bake a measured oscillator trim (README.md: the RC-oscillator
// deployment answer); the byte is applied at the top of run(). Orthogonal to
// the serial backend — an autobaud build may carry it for the application's
// benefit alone.
#if defined(PUREBOOT_OSCCAL)
static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
#endif
// The loader's one identity number. The protocol carries none of its own — // 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). // a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 6; constexpr std::uint8_t version = 5;
// The image's identity stamp, for the host tool rather than for the wire: an // 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 // update image is a bare 512-byte slot, and without this nothing in it says
@@ -167,6 +166,27 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0'; constexpr char usart_digit = '0';
#endif #endif
// Release a hardware USART the application may have left enabled onto a
// bit-banged link's pins. A software transmitter drives its TX pin through the
// port register, but while that USART's TXEN is set the USART owns the pin and
// the port write does nothing — the loader would receive and obey yet never
// answer. Writing UCSRnB zero hands the pin back to the port. Guarded on the
// pin actually being a USART's TXD, so a link on non-USART pins emits nothing.
template <char Inst, avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usart_on()
{
if constexpr (avr::uart::has_usart<Inst>())
if constexpr (avr::uart::detail::usart_pin<Inst>("TXD") == Tx)
avr::hw::reg_impl<avr::uart::detail::ureg<Inst, "UCSR#B">()>::write(0);
}
template <avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usarts_on()
{
release_usart_on<'0', Tx>();
release_usart_on<'1', Tx>();
}
template <avr::hertz_t C, avr::baud_t B> template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
@@ -213,6 +233,7 @@ struct software_link {
static void init() static void init()
{ {
avr::init<rx_t, tx_t>(); avr::init<rx_t, tx_t>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static bool pending() static bool pending()
@@ -246,6 +267,7 @@ struct autobaud_link {
static void init() static void init()
{ {
avr::init<uart>(); avr::init<uart>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static std::uint8_t rx() static std::uint8_t rx()
@@ -321,14 +343,9 @@ 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()
{ {
window_t polls = window_polls(); std::uint32_t polls = window_polls();
do { do {
if (link::pending()) if (link::pending())
return true; return true;
@@ -460,15 +477,9 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
[[noreturn]] void run() [[noreturn]] void run()
{ {
#if defined(PUREBOOT_OSCCAL)
// The build's oscillator trim, ahead of everything — the WDRF bail
// included — so every path out of reset, the watchdog hand-over to the
// 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 // A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF — no activation window in its way. // on until it clears WDRF — no activation window in its way.
if (avr::power::peek_reset_cause().watchdog) if (avr::hw::field_impl<wdrf_field()>::test())
run_app(); run_app();
link::init(); link::init();

View File

@@ -20,21 +20,17 @@ if os.name == "nt":
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
else: else:
import array
import fcntl
import select import select
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 6 # this tool's own version — free to drift from a loader's VERSION = 5 # this tool's own version — free to drift from a loader's
# The loader versions this tool can drive. A pureboot version implies its wire # The loader versions this tool speaks. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that # protocol, which carries no number of its own, so this window is where that
# map lives: the tool keeps a decoder for every generation in it (14 speak # map lives: every version so far speaks the same protocol, and one that
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying # changes it becomes the new floor here.
# builds and changes nothing on the wire), and a version it has no decoder
# for moves the floor.
OLDEST_LOADER = 1 OLDEST_LOADER = 1
NEWEST_LOADER = 6 NEWEST_LOADER = 5
SLOT = 512 # the loader slot, on every chip SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
@@ -45,13 +41,6 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
UNIFIED_LOADER = 5 UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
# as delay-loop counts: (bit cycles UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# floored — the spin granule and per-bit overhead of libavr's software UART.
# --info undoes the encoding to report the true clock, which therefore sits
# within one granule below it.
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
# A selector's high nibble is the flash bank — the address bits above the 16-bit # A selector's high nibble is the flash bank — the address bits above the 16-bit
# wire address — so a transfer names a byte address within one 64 KiB bank and # wire address — so a transfer names a byte address within one 64 KiB bank and
# no command has to speak word addresses. No single transfer may cross a bank # no command has to speak word addresses. No single transfer may cross a bank
@@ -77,36 +66,31 @@ CALIBRATE = 0xC0
# from its chip database at build time). Die revisions that share a signature # from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon. # share this row, as they share the silicon.
CHIP_GEOMETRY = { CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector, ram_start) # signature : (flash, page, eeprom, patch_vector)
# ram_start is where SRAM begins in data space: the classic megas and the (0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
# tinies keep it right after the plain I/O registers (0x60), the x8/x4 (0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
# generations past their extended I/O file (0x100). An autobaud loader's (0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45
# measured bit period lives at exactly ram_start (its only RAM object; (0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85
# the loader's own build pins the layout), which is what --info reads. (0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A (0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25 (0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45 (0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85 (0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A (0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA (0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A (0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A (0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA (0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A (0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A (0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA (0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA (0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A (0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A (0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P (0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328 (0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P (0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA (0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
} }
VERBOSE = False VERBOSE = False
@@ -160,60 +144,23 @@ class Progress:
class PosixPort: class PosixPort:
"""A raw serial port with deadline-based reads, over termios. A rate with """A raw serial port with deadline-based reads, over termios."""
no B-constant — the off-nominal probes `--scan` walks — goes through
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
by name."""
# The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed
# word offsets: four flag words, then a line-discipline byte and 19
# control chars padded to word 9 (include/uapi/asm-generic/termbits.h).
_TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B
_BOTHER, _CBAUD = 0o010000, 0o010017
_ISPEED, _OSPEED = 9, 10
@staticmethod
def _speed(baud):
return getattr(termios, f"B{baud}", None)
def _set_arbitrary(self, baud):
buf = array.array("i", [0] * (self._OSPEED + 1))
try:
fcntl.ioctl(self.fd, self._TCGETS2, buf, True)
buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER
buf[self._ISPEED] = buf[self._OSPEED] = baud
fcntl.ioctl(self.fd, self._TCSETS2, buf)
except OSError:
raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None
def _apply_baud(self, attrs, baud):
speed = self._speed(baud)
attrs[4] = attrs[5] = speed if speed is not None else termios.B38400
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
if speed is None:
self._set_arbitrary(baud)
self.baud = baud
def __init__(self, path, baud): def __init__(self, path, baud):
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY) self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
attrs = termios.tcgetattr(self.fd)
attrs[0] = 0 # iflag
attrs[1] = 0 # oflag
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
attrs[3] = 0 # lflag
try: try:
attrs = termios.tcgetattr(self.fd) speed = getattr(termios, f"B{baud}")
attrs[0] = 0 # iflag except AttributeError:
attrs[1] = 0 # oflag raise Error(f"unsupported baud rate {baud}") from None
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag attrs[4] = attrs[5] = speed
attrs[3] = 0 # lflag attrs[6][termios.VMIN] = 0
attrs[6][termios.VMIN] = 0 attrs[6][termios.VTIME] = 0
attrs[6][termios.VTIME] = 0 termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
self._apply_baud(attrs, baud)
except BaseException:
os.close(self.fd)
raise
def set_baud(self, baud):
"""Retune the port without closing it — the fd stays open, so no DTR
pulse and no reset. That matters: the only caller is mid-session with a
loader copy that a reset would throw away."""
self._apply_baud(termios.tcgetattr(self.fd), baud)
def close(self): def close(self):
os.close(self.fd) os.close(self.fd)
@@ -342,7 +289,6 @@ if os.name == "nt":
# timeout would otherwise stay at the driver's default — which # timeout would otherwise stay at the driver's default — which
# may be "wait forever" — until the first read. # may be "wait forever" — until the first read.
self._deadline(_GAP_MS, 1000) self._deadline(_GAP_MS, 1000)
self.baud = baud
except Error: except Error:
# An open port outlives the exception otherwise, and a COM # An open port outlives the exception otherwise, and a COM
# handle is exclusive: the next attempt would meet its own # handle is exclusive: the next attempt would meet its own
@@ -350,21 +296,6 @@ if os.name == "nt":
self.close() self.close()
raise raise
def set_baud(self, baud):
"""Retune the port on its live handle — SetCommState only, so the
handle is never reopened and DTR never drops. That matters: the only
caller is mid-session with a loader copy a reset would throw away."""
if baud < 50:
raise Error(f"unsupported baud rate {baud}")
dcb = _DCB()
dcb.DCBlength = ctypes.sizeof(_DCB)
if not _k32.GetCommState(self.handle, ctypes.byref(dcb)):
_fail("cannot read the port state")
dcb.BaudRate = baud
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
_fail(f"cannot retune the port to {baud} baud")
self.baud = baud
def close(self): def close(self):
_k32.CloseHandle(self.handle) _k32.CloseHandle(self.handle)
@@ -450,7 +381,7 @@ class Info:
if geometry is None: if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature) sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it") raise Error(f"unknown signature {sig} — this tool has no geometry for it")
flash, page, eeprom, patch, _ = geometry flash, page, eeprom, patch = geometry
base = flash - SLOT base = flash - SLOT
word_flash = flash > 0x10000 word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base wire_base = base // 2 if word_flash else base
@@ -486,11 +417,6 @@ class Info:
# The hand-over target as 'J' takes it: the trampoline below the # The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware. # loader, or word 0 where BOOTRST re-vectors reset in hardware.
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0 self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
# Where SRAM begins, from the signature — None only for a chip this
# tool has no geometry row for, which the wire-block path (v14)
# permits where from_identity refuses.
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
self.ram = geometry[4] if geometry else None
def describe(self): def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature) sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -531,10 +457,6 @@ class Loader:
# Set once a session is established over an autobaud link, so a # Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked. # re-entry after 'J' repeats the handshake that worked.
self.autobaud = False self.autobaud = False
# The link this session is speaking. It moves when the host follows a
# staging copy built for another one (enter_copy).
self.baud = getattr(port, "baud", None)
self._link_declared = False
def _read_identity(self): def _read_identity(self):
"""The 'b' reply, in either of the two layouts a loader may send. """The 'b' reply, in either of the two layouts a loader may send.
@@ -557,7 +479,6 @@ class Loader:
drain absorbs whatever they produced.""" drain absorbs whatever they produced."""
deadline = time.monotonic() + wait deadline = time.monotonic() + wait
knocks = 0 knocks = 0
refusal = None
while True: while True:
self.port.flush_input() self.port.flush_input()
self.port.write(knock) self.port.write(knock)
@@ -579,18 +500,12 @@ class Loader:
except Error as failed: except Error as failed:
if "pureboot" in str(failed): if "pureboot" in str(failed):
raise raise
# A malformed or unknown identity is retried as noise, but
# it was an answer: if nothing better ever arrives, naming
# it beats reporting silence.
refusal = failed
self.info = None self.info = None
if self.info is not None: if self.info is not None:
self._expect_prompt() self._expect_prompt()
verbose(f"loader answered {what} {knocks}; identity read") verbose(f"loader answered {what} {knocks}; identity read")
return self.info return self.info
if time.monotonic() > deadline: if time.monotonic() > deadline:
if refusal is not None:
raise Error(f"no usable answer — the last identity reply failed: {refusal}")
raise Error("no answer — reset the device within its activation window") raise Error("no answer — reset the device within its activation window")
def connect(self, wait): def connect(self, wait):
@@ -749,41 +664,12 @@ class Loader:
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8))) self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt() self._expect_prompt()
def enter_copy(self, byte_address, wait, link=None): def enter_copy(self, byte_address, wait):
"""Jump into the loader copy at `byte_address` and knock it — a slot """Jump into the loader copy at `byte_address` and knock it — a slot
base is that copy's entry stub, so it can only land there. base is that copy's entry stub, so it can only land there."""
autobaud = self.autobaud
`link` is that copy's own `(baud, autobaud)`, for when it is not this
session's. A staging copy *is* the new image, so it speaks the rate and
backend it was built for; the host has to be told which, because 512
bytes of position-independent code carry no header to read it from.
Retuning goes through the open port, so no DTR pulse resets the copy that
is now running — and the session keeps the new link afterwards, since
every later jump lands in the same image.
"""
baud, autobaud = link if link is not None else (self.baud, self.autobaud)
if link is not None:
self._link_declared = True
self.jump(byte_address // 2) self.jump(byte_address // 2)
if baud is not None and baud != self.baud: return self.connect_autobaud(wait) if autobaud else self.connect(wait)
self.port.set_baud(baud)
self.baud = baud
self.autobaud = autobaud
try:
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
except Error as unheard:
if self._link_declared:
raise
# The bare activation timeout sends the operator to look at wiring,
# while on a patched-vector part the application region is already
# gone. Name the one cause that fits: the copy answers on its own
# link, not the resident's.
raise Error(
f"the copy at {byte_address:#06x} did not answer on this session's "
f"link ({baud} Bd, {'autobaud' if autobaud else 'fixed baud'}). An "
f"image built for another baud or backend speaks that one instead — "
f"say which with --staged-baud / --staged-autobaud"
) from unheard
def run_application(self): def run_application(self):
self.jump(self.info.app_entry_word) self.jump(self.info.app_entry_word)
@@ -1131,16 +1017,10 @@ def patch_word0(loader, page0, target_base):
return bytes(patched) return bytes(patched)
def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=None): def op_update_loader(loader, wait, path, state_path, fuse_bytes):
"""Replace the resident loader with `path`, using the loader as its own """Replace the resident loader with `path`, using the loader as its own
staging loader. Every phase is idempotent and keyed off the flash state, staging loader. Every phase is idempotent and keyed off the flash state,
so a re-run resumes; the state file carries what the staging slot held. so a re-run resumes; the state file carries what the staging slot held."""
`staged_link` is the new image's own `(baud, autobaud)` where it differs from
this session's — the copies the host enters *are* that image, so they answer
on its link and not the resident's. Note what this does to the idempotence
above: once the staging copy is installed, the resumable state is only
reachable on the new link, so a re-run has to name it too."""
info = loader.info info = loader.info
image = loader_image(path) image = loader_image(path)
for warning in update_preflight(image, info, fuse_bytes): for warning in update_preflight(image, info, fuse_bytes):
@@ -1185,7 +1065,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
# routes through the resident, word 0 is re-aimed at the staging copy for # routes through the resident, word 0 is re-aimed at the staging copy for
# the rewrite, so a power loss mid-rewrite still resets into a loader. # the rewrite, so a power loss mid-rewrite still resets into a loader.
verbose(f"entering the staging copy at {info.stage:#06x}") verbose(f"entering the staging copy at {info.stage:#06x}")
loader.enter_copy(info.stage, wait, link=staged_link) loader.enter_copy(info.stage, wait)
redirect = info.patch_vector and info.stage != 0 redirect = info.patch_vector and info.stage != 0
if redirect: if redirect:
verbose("word 0 re-aimed at the staging copy for the rewrite") verbose("word 0 re-aimed at the staging copy for the rewrite")
@@ -1403,62 +1283,6 @@ def op_fuses(loader):
return fuse_bytes return fuse_bytes
def scan_ratios():
"""The probe walk, in percent of the built rate: the built rate itself
first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near
its trim is the common case, and each probe costs a reset."""
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
def scan_rate(baud, pct):
return round(baud * (100 + pct) / 100)
def scan_report(baud, pct, version, clock=None):
"""The findings, one per line: the found rate is the session workaround,
its ratio to the built rate is the oscillator's offset, and the fixes are
the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build."""
rate = scan_rate(baud, pct)
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}",
f" session --baud {rate}"]
if clock:
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
if pct:
direction = "lower" if pct > 0 else "higher"
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
"or the autobaud build")
else:
lines.append(" fix none — the built rate answers; check the earlier wiring instead")
return lines
def op_scan(port_path, baud, wait, clock=None):
"""A fixed-baud loader whose oscillator drifted still answers — at the
drifted ratio, since its rate scales with its clock. One probe per
activation window, and with an application resident the window opens
exactly once per reset, so each probe announces itself and expects a
fresh reset before knocking."""
for pct in scan_ratios():
rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
try:
port = Port(port_path, rate)
except Error as unmakeable:
print(f"scan: {rate} Bd skipped — {unmakeable}")
continue
try:
info = Loader(port).connect(wait)
except Error:
continue
finally:
port.close()
for line in scan_report(baud, pct, info.version, clock):
print(line)
return
raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the "
"autobaud build, which has no rate to miss (README.md)")
# -------------------------------------------------------------------- cli --- # -------------------------------------------------------------------- cli ---
@@ -1474,25 +1298,10 @@ def main():
parser.add_argument("--autobaud", action="store_true", parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single " help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)") "knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--scan", action="store_true",
help="walk ±10%% around --baud for a fixed-baud loader gone silent — one "
"reset per probe, standalone (README.md: deployment)")
parser.add_argument("--clock", type=int, metavar="HZ",
help="the clock the loader was built for — lets --scan and an autobaud "
"--info state drift in absolute terms")
parser.add_argument("--info", action="store_true", help="print the device info block") parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes") parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary") parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
parser.add_argument("--state", metavar="FILE", help="update state file (default: FILE.pbstate)") parser.add_argument("--state", metavar="FILE", help="update state file (default: FILE.pbstate)")
# The update enters the staging copy, which is the new image and so speaks
# the link *it* was built for. Nothing in the image says which, so where it
# differs from this session's these name it and the host follows.
parser.add_argument("--staged-baud", metavar="BD", type=int,
help="the baud the --update-loader image was built for, where it "
"differs from --baud")
parser.add_argument("--staged-autobaud", action=argparse.BooleanOptionalAction, default=None,
help="whether that image is an autobaud build, where it differs "
"from --autobaud")
parser.add_argument("--assume-fuses", metavar="HEX8", help="fuse bytes low,lock,ext,high as 8 hex digits " parser.add_argument("--assume-fuses", metavar="HEX8", help="fuse bytes low,lock,ext,high as 8 hex digits "
"(overrides reading them — e.g. under a simulator that cannot)") "(overrides reading them — e.g. under a simulator that cannot)")
parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash") parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash")
@@ -1526,12 +1335,6 @@ def main():
except (ValueError, AssertionError): except (ValueError, AssertionError):
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
if args.scan:
if args.autobaud:
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
op_scan(args.port, args.baud, args.wait, args.clock)
return
port = Port(args.port, args.baud) port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted") verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try: try:
@@ -1541,18 +1344,6 @@ def main():
print("device:") print("device:")
for line in info.lines(): for line in info.lines():
print(f" {line}") print(f" {line}")
if args.autobaud and info.ram is not None:
# The whole of the loader's RAM is the measured bit period at
# ram_start; decoded and times the rate this session drives,
# that is the true clock — the number to hold an OSCCAL bake
# or a fixed-baud build against (README.md: deployment). The
# autobaud identity path refuses unknown signatures, so ram is
# always known here; the guard states that dependency.
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
fuse_bytes = fuse_override fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader) read = op_fuses(loader)
@@ -1560,14 +1351,7 @@ def main():
fuse_bytes = read fuse_bytes = read
if args.update_loader: if args.update_loader:
state = args.state or args.update_loader + ".pbstate" state = args.state or args.update_loader + ".pbstate"
staged_link = None op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes)
if args.staged_baud is not None or args.staged_autobaud is not None:
staged_link = (
args.staged_baud if args.staged_baud is not None else args.baud,
args.staged_autobaud if args.staged_autobaud is not None else args.autobaud,
)
op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes,
staged_link)
if args.flash: if args.flash:
op_flash(loader, args.flash, args.erase_flash, not args.no_verify, fuse_bytes, args.force) op_flash(loader, args.flash, args.erase_flash, not args.no_verify, fuse_bytes, args.force)
elif args.erase_flash: elif args.erase_flash:
@@ -1600,7 +1384,7 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
try: try:
main() main()
except (Error, OSError) as error: except Error as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
sys.exit(1) sys.exit(1)
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -1,23 +0,0 @@
# 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,95 +7,77 @@
// 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 <csignal> #include <signal.h>
#include <cstdint> #include <stdint.h>
#include <cstdio> #include <stdio.h>
#include <cstdlib> #include <stdlib.h>
#include <cstring> #include <string.h>
#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"
}
namespace { static avr_t *avr;
static uart_pty_t uart_pty;
static const char *dump_path;
avr_t *avr; static void finish(int sig)
uart_pty_t uart_pty;
const char *dump_path;
[[noreturn]] void finish(int)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
std::FILE *f = std::fopen(dump_path, "wb"); FILE *f = fopen(dump_path, "wb");
if (f) { if (f) {
std::fwrite(avr->flash, 1, avr->flashend + 1, f); fwrite(avr->flash, 1, avr->flashend + 1, f);
std::fclose(f); 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) {
std::println(stderr, "usage: {} <tsb.elf> <boot_base_hex> [flash_dump.bin]", argv[0]); fprintf(stderr, "usage: %s <tsb.elf> <boot_base_hex> [flash_dump.bin]\n", argv[0]);
return 2; return 2;
} }
auto boot_base = static_cast<std::uint32_t>(std::strtoul(argv[2], nullptr, 0)); uint32_t boot_base = (uint32_t)strtoul(argv[2], NULL, 0);
dump_path = argc >= 4 ? argv[3] : nullptr; dump_path = argc >= 4 ? argv[3] : NULL;
avr = avr_make_mcu_by_name("atmega328p"); avr = avr_make_mcu_by_name("atmega328p");
if (!avr) { if (!avr) {
std::println(stderr, "device: no ATmega328P core"); fprintf(stderr, "device: no ATmega328P core\n");
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.
std::memset(avr->flash, 0xff, avr->flashend + 1); 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{}; elf_firmware_t fw = {0};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
std::println(stderr, "device: cannot read {}", argv[1]); fprintf(stderr, "device: cannot read %s\n", argv[1]);
return 1; return 1;
} }
// An image that runs past flash end cannot execute on hardware, and a memcpy(avr->flash + boot_base, fw.flash, fw.flashsize);
// 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 = std::getenv("TSB_CONFIG"); const char *cfg = getenv("TSB_CONFIG");
if (cfg) { if (cfg) {
std::uint32_t app_end = boot_base - 128; // config page sits directly below the boot code 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] = static_cast<std::uint8_t>(std::strtoul(b, nullptr, 16)); avr->flash[app_end + i / 2] = (uint8_t)strtoul(b, NULL, 16);
} }
} }
@@ -104,18 +86,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.
std::uint32_t uflags = 0; 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');
std::println("TSB_PTY {}", uart_pty.pty.slavename); printf("TSB_PTY %s\n", uart_pty.pty.slavename);
std::fflush(stdout); fflush(stdout);
std::signal(SIGTERM, finish); signal(SIGTERM, finish);
std::signal(SIGINT, finish); signal(SIGINT, finish);
for (;;) { for (;;) {
int state = avr_run(avr); int state = avr_run(avr);
@@ -123,4 +105,5 @@ int main(int argc, char *argv[])
break; break;
} }
finish(0); finish(0);
return 0;
} }

View File

@@ -11,10 +11,6 @@
// 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).
@@ -68,29 +64,16 @@ 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()
{ {
#if defined(PUREBOOT_HANDOVER) // 'L' hands back to the loader in the top slot — 512 bytes on every
// Hand back at once, with this USART still enabled — the state that // chip. The jump takes a word address, which is what makes the
// leaves a bit-banged loader on its pins mute unless the loader // >64 KiB chips' entry reachable through a 16-bit pointer at all.
// releases it. Unconditional because there is no command wire to constexpr std::uint32_t slot = 512;
// 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')
enter_loader(); reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
// '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') {
@@ -99,7 +82,6 @@ struct link {
tx('D'); tx('D');
} }
} }
#endif
} }
}; };
@@ -117,27 +99,8 @@ 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
} }
}; };
@@ -146,13 +109,8 @@ 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();
} }

View File

@@ -16,7 +16,6 @@ baked in.
""" """
import os import os
import re
import sys import sys
import time import time
@@ -59,23 +58,11 @@ def main():
try: try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse # The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it. # and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz), out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses",
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay") "--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"): for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out: if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{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 both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin") read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin") read_eeprom = os.path.join(workdir, f"re_{label}.bin")
@@ -154,45 +141,13 @@ def main():
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified" print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
+ (", hand-over ok" if hand_over else "")) + (", 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 # 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 # 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 # 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.) # 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, app_baud, "clock-a", hand_over=True)
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False) round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks")
# 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__": if __name__ == "__main__":

View File

@@ -1,73 +0,0 @@
#!/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()

View File

@@ -1,46 +0,0 @@
#!/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

@@ -10,9 +10,7 @@
// //
// 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
@@ -23,22 +21,16 @@
// //
// 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"
@@ -47,39 +39,30 @@ extern "C" {
#include "sim_elf.h" #include "sim_elf.h"
#include "sim_io.h" #include "sim_io.h"
#include "uart_pty.h" #include "uart_pty.h"
}
namespace { static avr_t *avr;
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;
avr_t *avr; static int parse_link(const char *spec)
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 (spec == "usart0" || spec == "usart1") { if (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) {
link_software = false; link_software = 0;
uart_digit = spec[5]; uart_digit = spec[5];
return 0; return 0;
} }
if (spec.starts_with("sw")) { if (strncmp(spec, "sw", 2) == 0) {
link_software = true; link_software = 1;
if (spec.size() == 2) if (spec[2] == '\0')
return 0; return 0;
char owner = 0; if (sscanf(spec + 2, ":%c%d,%c%d", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit) == 4)
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;
} }
@@ -97,19 +80,19 @@ int parse_link(std::string_view 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.
avr_flash_t *mega_flash; static avr_flash_t *mega_flash;
int (*mega_flash_ioctl)(avr_io_t *io, std::uint32_t ctl, void *param); static int (*mega_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) static int fixed_flash_ioctl(avr_io_t *io, 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)) {
auto z = static_cast<std::uint16_t>(io->avr->data[30] | (io->avr->data[31] << 8)); uint16_t z = (uint16_t)(io->avr->data[30] | (io->avr->data[31] << 8));
auto masked = static_cast<std::uint16_t>(z & ~(mega_flash->spm_pagesize - 1)); uint16_t masked = (uint16_t)(z & ~(mega_flash->spm_pagesize - 1));
io->avr->data[30] = static_cast<std::uint8_t>(masked); io->avr->data[30] = (uint8_t)masked;
io->avr->data[31] = static_cast<std::uint8_t>(masked >> 8); io->avr->data[31] = (uint8_t)(masked >> 8);
int result = mega_flash_ioctl(io, ctl, param); int result = mega_flash_ioctl(io, ctl, param);
io->avr->data[30] = static_cast<std::uint8_t>(z); io->avr->data[30] = (uint8_t)z;
io->avr->data[31] = static_cast<std::uint8_t>(z >> 8); io->avr->data[31] = (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) &&
@@ -124,44 +107,46 @@ int fixed_flash_ioctl(avr_io_t *io, std::uint32_t ctl, void *param)
return mega_flash_ioctl(io, ctl, param); return mega_flash_ioctl(io, ctl, param);
} }
void fix_mega_flash_erase() static void fix_mega_flash_erase(void)
{ {
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 && std::string_view{io->kind} == "flash") { if (io->kind && strcmp(io->kind, "flash") == 0) {
mega_flash = reinterpret_cast<avr_flash_t *>(io); mega_flash = (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;
} }
} }
std::println(stderr, "device: no flash module to fix — SPM page erases may misalign"); fprintf(stderr, "device: no flash module to fix — SPM page erases may misalign\n");
} }
void request_reset(int) static void request_reset(int sig)
{ {
(void)sig;
reset_requested = 1; reset_requested = 1;
} }
// ------------------------------------------------------------- tiny NVM --- // ------------------------------------------------------------- tiny NVM ---
struct tiny_nvm_t { typedef struct {
avr_io_t io; avr_io_t io;
std::uint8_t buffer[128]; uint8_t buffer[128];
std::uint8_t used[128]; // a buffer word loads once until erased — like silicon uint8_t used[128]; // a buffer word loads once until erased — like silicon
unsigned page; unsigned page;
}; } tiny_nvm_t;
tiny_nvm_t nvm; static tiny_nvm_t nvm;
int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *) static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
{ {
(void)param;
if (ctl != AVR_IOCTL_FLASH_SPM) if (ctl != AVR_IOCTL_FLASH_SPM)
return -1; return -1;
auto *n = reinterpret_cast<tiny_nvm_t *>(io); tiny_nvm_t *n = (tiny_nvm_t *)io;
avr_t *mcu = io->avr; avr_t *mcu = io->avr;
std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
auto z = static_cast<std::uint16_t>(mcu->data[30] | (mcu->data[31] << 8)); uint16_t z = (uint16_t)(mcu->data[30] | (mcu->data[31] << 8));
std::uint32_t page_base = static_cast<std::uint32_t>(z & ~(n->page - 1)) % (mcu->flashend + 1); uint32_t page_base = (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
@@ -170,109 +155,73 @@ int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
n->used[offset] = 1; n->used[offset] = 1;
} }
} else if (command == 0x03) { // PGERS } else if (command == 0x03) { // PGERS
std::memset(mcu->flash + page_base, 0xff, n->page); 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];
std::memset(n->buffer, 0xff, n->page); memset(n->buffer, 0xff, n->page);
std::memset(n->used, 0, n->page); memset(n->used, 0, n->page);
} else if (command == 0x11) { // CTPB } else if (command == 0x11) { // CTPB
std::memset(n->buffer, 0xff, n->page); memset(n->buffer, 0xff, n->page);
std::memset(n->used, 0, n->page); memset(n->used, 0, n->page);
} }
mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly
return 0; return 0;
} }
// ----------------------------------------------------------- GPIO bridge --- // ----------------------------------------------------------- GPIO bridge ---
int pty_master = -1; static int pty_master = -1;
avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty static avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
avr_cycle_count_t bit_cycles; static avr_cycle_count_t bit_cycles;
int tx_level = 1, tx_active, tx_bit; static int tx_level = 1, tx_active, tx_bit;
std::uint8_t tx_shift; static uint8_t tx_shift;
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *) static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param)
{ {
(void)mcu;
(void)param;
if (tx_bit < 8) { if (tx_bit < 8) {
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; return when + bit_cycles;
// The byte is delivered at the stop bit's sampling point (9.5 bit /* The byte is not delivered until its stop bit has passed. A real
// times), where a hardware receiver raises its RXC — not sooner: a * receiver cannot answer sooner, and a host that did would put its
// host answering before the stop bit would put its start bit on the * start bit on the wire while the device is still driving the stop
// wire while the device is still driving, which the device, * bit which the device, transmitting, is not watching for. */
// 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)
std::println(stderr, "device: pty write lost a byte"); fprintf(stderr, "device: pty write lost a byte\n");
tx_active = 0; tx_active = 0;
return 0; return 0;
} }
// A USART owns its TxD pin whenever its transmitter is enabled, and the port static void tx_hook(avr_irq_t *irq, uint32_t value, void *param)
// 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()
{ {
return tx_owner && avr_regbit_get(avr, tx_owner->txen); (void)irq;
} (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, nullptr); avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, NULL);
} }
tx_level = level; tx_level = level;
} }
std::uint8_t rx_queue[8192]; static uint8_t rx_queue[8192];
unsigned rx_head, rx_tail; // ring: head = next to send static unsigned rx_head, rx_tail; // ring: head = next to send
int rx_active, rx_bit; static int rx_active, rx_bit;
std::uint8_t rx_byte; static uint8_t rx_byte;
void rx_start_next(); static void rx_start_next(void);
avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *) static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param)
{ {
(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++;
@@ -288,7 +237,7 @@ avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
return 0; return 0;
} }
void rx_start_next() static void rx_start_next(void)
{ {
if (rx_active || rx_head == rx_tail) if (rx_active || rx_head == rx_tail)
return; return;
@@ -297,7 +246,7 @@ void rx_start_next()
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, nullptr); avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL);
} }
// A reset abandons whatever the bridge was mid-transfer: bytes still queued // A reset abandons whatever the bridge was mid-transfer: bytes still queued
@@ -307,10 +256,10 @@ void rx_start_next()
// 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.
void bridge_reset() static void bridge_reset(void)
{ {
avr_cycle_timer_cancel(avr, tx_sample, nullptr); avr_cycle_timer_cancel(avr, tx_sample, NULL);
avr_cycle_timer_cancel(avr, rx_step, nullptr); avr_cycle_timer_cancel(avr, rx_step, NULL);
rx_head = rx_tail = 0; rx_head = rx_tail = 0;
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
@@ -318,9 +267,9 @@ void bridge_reset()
avr_raise_irq(rx_pin, 1); // idle line avr_raise_irq(rx_pin, 1); // idle line
} }
void poll_pty() static void poll_pty(void)
{ {
std::uint8_t chunk[256]; 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);
@@ -335,22 +284,23 @@ void poll_pty()
// ------------------------------------------------------------------ main --- // ------------------------------------------------------------------ main ---
[[noreturn]] void finish(int) static void finish(int sig)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
std::FILE *f = std::fopen(dump_path, "wb"); FILE *f = fopen(dump_path, "wb");
if (f) { if (f) {
std::fwrite(avr->flash, 1, avr->flashend + 1, f); fwrite(avr->flash, 1, avr->flashend + 1, f);
std::fclose(f); fclose(f);
} }
avr_eeprom_desc_t ee = {.ee = nullptr, .offset = 0, .size = 0}; avr_eeprom_desc_t ee = {.ee = NULL, .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];
std::snprintf(path, sizeof(path), "%s.eeprom", dump_path); snprintf(path, sizeof(path), "%s.eeprom", dump_path);
f = std::fopen(path, "wb"); f = fopen(path, "wb");
if (f) { if (f) {
std::fwrite(ee.ee, 1, ee.size, f); fwrite(ee.ee, 1, ee.size, f);
std::fclose(f); fclose(f);
} }
} }
} }
@@ -359,87 +309,76 @@ void poll_pty()
_exit(0); _exit(0);
} }
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
bool link_given = false; int link_given = 0;
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) {
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)"); fprintf(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)\n");
return 2; return 2;
} }
link_given = true; link_given = 1;
} }
int args = argc - optind; int args = argc - optind;
if (args < 7 || args > 9) { if (args < 7 || args > 9) {
std::print(stderr, fprintf(stderr,
"usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>" "usage: %s [-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[@0]] (RX,TX, then the USART owning\n" " -l link: usart0 | usart1 | sw[:B0,B1] (RX,TX); default: the chip's own\n"
" them); default: the chip's own\n" " reset_hex: reset vector (default: base with a boot section, else 0)\n"
" reset_hex: reset vector (default: base with a boot section, else 0)\n" " resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n" " run's dump, for power-fail resume tests\n",
" run's dump, for power-fail resume tests\n", argv[0]);
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 std::string_view mcu_name = argv[2]; const char *mcu_name = argv[2];
auto base = static_cast<std::uint32_t>(std::strtoul(argv[4], nullptr, 0)); uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0);
auto page = static_cast<unsigned>(std::atoi(argv[5])); unsigned page = (unsigned)atoi(argv[5]);
auto baud = static_cast<unsigned>(std::atoi(argv[6])); unsigned baud = (unsigned)atoi(argv[6]);
dump_path = argv[7]; dump_path = argv[7];
const bool is_mega = mcu_name.starts_with("atmega"); int is_mega = strncmp(mcu_name, "atmega", 6) == 0;
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.data()); avr = avr_make_mcu_by_name(mcu_name);
if (!avr) { if (!avr) {
std::println(stderr, "device: no {} core", mcu_name); fprintf(stderr, "device: no %s core\n", mcu_name);
return 1; return 1;
} }
avr_init(avr); avr_init(avr);
avr->frequency = static_cast<std::uint32_t>(std::strtoul(argv[3], nullptr, 0)); avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0);
std::memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased 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.
std::FILE *f = std::fopen(argv[9], "rb"); FILE *f = fopen(argv[9], "rb");
if (!f || std::fread(avr->flash, 1, avr->flashend + 1, f) == 0) { if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
std::println(stderr, "device: cannot read {}", argv[9]); fprintf(stderr, "device: cannot read %s\n", argv[9]);
return 1; return 1;
} }
std::fclose(f); fclose(f);
} else { } else {
elf_firmware_t fw{}; elf_firmware_t fw = {0};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
std::println(stderr, "device: cannot read {}", argv[1]); fprintf(stderr, "device: cannot read %s\n", argv[1]);
return 1; return 1;
} }
// An image past flash end would smash the simulator's heap and turn memcpy(avr->flash + base, fw.flash, fw.flashsize);
// 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.
const bool boot_section = is_mega && !mcu_name.starts_with("atmega48"); int boot_section = is_mega && strncmp(mcu_name, "atmega48", 8) != 0;
reset_pc = args > 7 ? static_cast<std::uint32_t>(std::strtoul(argv[8], nullptr, 0)) : (boot_section ? base : 0); reset_pc = args > 7 ? (uint32_t)strtoul(argv[8], NULL, 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).
std::uint8_t blank[1024]; uint8_t blank[1024];
std::memset(blank, 0xff, sizeof(blank)); 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;
@@ -453,7 +392,7 @@ int main(int argc, char *argv[])
fix_mega_flash_erase(); fix_mega_flash_erase();
} else { } else {
nvm.page = page; nvm.page = page;
std::memset(nvm.buffer, 0xff, sizeof(nvm.buffer)); 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);
@@ -462,38 +401,35 @@ 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.
std::uint32_t flags = 0; 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);
std::println("PB_PTY {}", uart_pty.pty.slavename); printf("PB_PTY %s\n", 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
if (sw_tx_owner) rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), (unsigned)sw_rx_bit);
find_tx_owner(); avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), (unsigned)sw_tx_bit), tx_hook,
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), static_cast<unsigned>(sw_rx_bit)); NULL);
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, nullptr, &raw, nullptr) != 0) { if (openpty(&pty_master, &slave, NULL, &raw, NULL) != 0) {
std::println(stderr, "device: openpty failed"); fprintf(stderr, "device: openpty failed\n");
return 1; return 1;
} }
fcntl(pty_master, F_SETFL, O_NONBLOCK); fcntl(pty_master, F_SETFL, O_NONBLOCK);
std::println("PB_PTY {}", ttyname(slave)); printf("PB_PTY %s\n", ttyname(slave));
} }
std::fflush(stdout); fflush(stdout);
std::signal(SIGTERM, finish); signal(SIGTERM, finish);
std::signal(SIGINT, finish); signal(SIGINT, finish);
std::signal(SIGUSR1, request_reset); // an external reset line, for the tests signal(SIGUSR1, request_reset); // an external reset line, for the tests
long since_poll = 0; long since_poll = 0;
for (;;) { for (;;) {
@@ -505,13 +441,12 @@ 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
std::uint32_t flags = 0; 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) {
@@ -529,4 +464,5 @@ int main(int argc, char *argv[])
} }
} }
finish(0); finish(0);
return 0;
} }

View File

@@ -8,8 +8,7 @@ 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 prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects. control that a well-behaved loader still connects.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset Stdlib only; run with `python test/test_handshake.py`. Not yet wired into ctest.
beside pureboot.planner.
""" """
import importlib.util import importlib.util
import pathlib import pathlib

View File

@@ -1,71 +0,0 @@
#!/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()

View File

@@ -1,167 +0,0 @@
#!/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

@@ -3,8 +3,8 @@
# 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) and swaps the compact size matrix for the exhaustive # full matrix) and swaps the compact size matrix for the exhaustive
# clock × baud × backend cross product. libavr resolves from the `libavr/` # clock × baud × backend cross product. LIBAVR_ROOT must point at the libavr
# submodule; LIBAVR_ROOT overrides it for a working tree. # checkout.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -36,10 +36,4 @@ 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,14 +7,11 @@ 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 — or with --check, which Run from the repo root: tools/make_presets.py
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",
@@ -44,7 +41,7 @@ def main():
"hidden": True, "hidden": True,
"generator": "Ninja", "generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}", "binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake", "toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
@@ -75,9 +72,6 @@ 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,
@@ -85,19 +79,12 @@ 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:
f.write(rendered) json.dump(presets, f, indent=1)
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__":
sys.exit(main()) main()

View File

@@ -1,247 +0,0 @@
#!/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)

View File

@@ -1,427 +0,0 @@
#!/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)

View File

@@ -1,160 +0,0 @@
#!/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::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::detail::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

View File

@@ -1,302 +0,0 @@
// 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::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::detail::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