Files
bootloader/pureboot/autobaud.md
BlackMark 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

11 KiB
Raw Blame History

pureboot autobaud — findings and the version decision

The autobaud variant measures the host's bit timing at runtime from a calibration pulse, so the image carries no clock: one clock-agnostic binary per chip runs at any F_CPU and locks onto whatever baud the host sends. It exists for the software-serial deployments — the RC-oscillator parts (the tinies, internal-oscillator megas) whose exact clock is uncertain and drifts, so today each needs a per-clock build. Autobaud erases that axis. That is the win — deployment, not bytes.

This document records how the fit was established, why there are two source files under review, and what remains before either can ship.

The decision to make

Two complete autobaud loaders sit side by side for review. They implement the identical wire protocol and the identical command set; they differ in exactly two things, and the choice between them is a single tradeoff — the running-slot write guard vs. strict purity.

pureboot_autobaud_pure.cpp pureboot_autobaud_reg.cpp
measured unit lives in GPIOR I/O scratch regs (RAM where absent) one global register variable (r4)
running-slot write guard dropped kept
purity (no asm, no GRV) yes one GRV — the single break
ATmega1284P size 508 B (4 B spare) 512 B (exact)
all 37 chips 440508 B 452512 B

Both also take two shared, licensed simplifications (below): a slimmed info block and a single-byte activation knock.

The pure version keeps pureboot's defining constraint — "one C++ source, no inline assembly, no global register variables" (README.md) — intact, and pays for it by dropping the write guard, which the host compensates for. The register version keeps the guard by pinning the measured unit to a call-saved register, which is only expressible as a global register variable — the one thing the purity rule forbids.

Recommendation deferred to the owner. The pure version aligns with the stated constraints (purity mandated; relaxing host-guaranteed safety licensed); the register version keeps every feature at the cost of the purity claim and leaves the 1284 with zero margin.

The mechanism

The host sends calibration byte 0xC0 — a start bit plus six zero data bits form a single low pulse of seven bit-times. The loader counts that pulse in a poll loop the compiler emits at seven cycles an iteration, so the count is the pulse length in cycles ÷ 7 × 7 = one bit period in cycles, and count >> 2 is that period in _delay_loop_2's four-cycle iterations — the delay unit fed to every rx/tx bit. Numerically sound: the per-bit delay tracks the true period to < 0.2 % from 116 MHz at 9600 (worst seen 2.8 % at 128 kHz/1200, still inside UART tolerance).

The catch — codegen coupling. count >> 2 is exact only because the calibration pulse's bit-count (7) equals the poll loop's cycles/iter (7). That couples the on-wire calibration byte to what the compiler emits; a toolchain bump that reshapes the loop breaks it silently. This must be pinned by a sim timing test that fails on drift (see What remains).

The activation window is a fixed poll budget (PUREBOOT_AUTOBAUD_POLLS, default 4,000,000), not timeout × clock — with no clock, whole seconds cannot be timed. A __uint24 holds it; a std::uint32_t's fourth byte would cost two words at each countdown step.

How the target was found — the hand-assembly floor

Pure C++ first came out 560 B on the 1284 (full info + guard). To learn whether that was a hard floor or C++ overhead, a hand-optimal assembly loader was written with the full v4 protocol (all commands, the full 12-byte info block, the write guard, position independence) plus autobaud — local/scratch/autobaud/ in the libavr checkout, off-tree, a size probe.

The hand-asm floor is 506 B — it fits 512 with the full info block and the guard, no concessions. That proved a fit was possible and gave a target. The hand-asm achieves it two ways pure C++ cannot express directly:

  1. The measured unit in a call-saved register (Y). rx/tx/spin read it directly — no argument, no movw. In C++ an outlined rx/tx can only receive the unit as a parameter (a movw at each of ~19 call sites, +38 B) or read it from RAM (an lds), unless it is a global register variable. That register is worth ~32 B and is exactly the impurity at issue.
  2. A cheap PC-relative slot anchor (rcall .+0; pop; pop, ~10 B) where the C++ __builtin_return_address(0) costs ~1826 B (GCC re-derives the stack slot and byteswaps).

Measured on identical C++ (the same loader, unit in a register vs. RAM):

unit home 1284 size
global register variable 528
RAM static 560
hand-asm (register + cheap anchor) 506

So even with a GRV, C++ carries ~22 B over hand-asm; with RAM, ~54 B. The compiler flag axis was spent (the tuned -f set is optimal; -fipa-ra, -fipa-icf, -ftree-tail-merge, -flto, -fwhole-program all gave nothing — GCC's AVR ABI passes arguments in fixed registers regardless).

The pure lever — GPIOR

The register cost is the RAM home's lds/sts (two words each) plus .bss and its __do_clear_bss. The general-purpose I/O scratch registers (GPIOR1:GPIOR2, adjacent) are reached by in/out — one word — through libavr's named register surface: no inline asm, no global register variable, fully pure. Storing the unit there sidesteps .bss and halves each access. Where a chip has no GPIOR (the t13, m8, m16/32) the pure version falls back to a plain static; those chips have ample headroom.

GPIOR alone did not close the gap (the write guard's slot_high machinery is the bulk). The pure version fits by combining GPIOR storage with the licensed simplifications; the register version fits by pinning the unit and so affording the guard.

The ablation (1284, all pure except the GRV row)

config GPIOR unit GRV unit
full info + guard + 2-knock 550 528
slim info + guard + 2-knock 540 518
slim info + guard + 1-knock 512
slim info + no guard + 2-knock 514
slim info + no guard + 1-knock 508

The pivotal cost is the write guard's slot_high (__builtin_return_address, ~26 B), removable only when the info block is slim (so nothing else needs slot_high) and the guard is gone. That is why the pure version drops the guard and the register version keeps it.

Sizes — every chip, both versions

Both build and size-test green on all 37 (patched-vector budget 510, else 512):

chip class pure register budget
ATtiny13/13A 446 452 510
ATtiny25/45/85 440444 456460 510
ATmega8/8A 472 476 512
ATmega16/32 476 478 512
ATmega48 family 440 456 510
ATmega88/168/328 462466 476478 512
ATmega164/324/644 466 478 512
ATmega1284/P 508 512 512

The 1284 is the tight one — the far-flash machinery (ELPM/RAMPZ, word-addressed wire) it alone carries. The pureboot_autobaud_*.size tests run per chip in the port's build.

The shared simplifications, and why each is licensed

  • Slimmed info block. 'b' returns the version and the three signature bytes — the loader's identity and the chip's — as immediates, instead of the full 12-byte block read from flash. The host derives page size, loader base, EEPROM size and the addressing flags from the signature via its own chip database. This drops the flash-resident table and, with the guard also gone in the pure version, the entire slot_high anchor. Owner-approved: "keep version + chip type, simplify the rest; the host derives geometry."
  • Single-byte knock. One 'p' activates; the calibration pulse has already proven a host is present. (Fixed-baud pureboot needs p+b because it has no prior proof.)
  • No running-slot write guard (pure version only). The guard stops a buggy host from programming the loader's own slot; the host is guaranteed never to do so (it knows the loader's location). Licensed by "the host guarantees safety" (README.md). Self-update still works — it is the safety net against host bugs that is lost, not a functional path.

Host tool and simulation — done

Both are wired and green, for both variants.

  • Host tool. pureboot.py --autobaud sends the 0xC0 calibration pulse and a single knock at the host's chosen baud, then reads the slimmed info block and derives the full geometry from the signature (AUTOBAUD_GEOMETRY, a table over every chip pureboot targets). The rest of the tool — flash, EEPROM, fuses, hand-over, verify — is unchanged: once connected, the derived Info presents the same geometry the fixed-baud path reads off the wire. The pure version's dropped write guard is host-transparent (the tool already never targets the running slot).
  • Sim test (test/pbautobaud.py, pureboot.autobaud_pure / pureboot.autobaud_reg). Drives each variant over the GPIO⇄pty software-UART bridge through the calibration handshake, a flash + EEPROM + fuse round-trip cross-checked against the simulator's ground-truth memory, and a hand-over to the fixture application — then repeats at double the F_CPU with the same binary, which is the clock-agnostic property autobaud exists for. Run on the near-flash 328P and the word-addressed 1284P (both flash-addressing classes). Because the lock fails a flash/verify if the measured unit is wrong, this test also pins the codegen-coupled calibration constant (0xC0 against the 7-cycle poll loop): a toolchain bump that reshaped the loop would fail it.

What remains

The decider is answered (pure C++ fits 512 on every chip) and both variants pass in simulation. One item remains before shipping the chosen variant:

  • Real-hardware acceptance. Autobaud exists for what a cycle-exact simulator cannot produce: a real RC oscillator at ±10 % with drift and jitter. simavr proves the arithmetic and the fit at exact clocks; only silicon proves the feature does its job. Drive an internal-oscillator ATtiny from the host at a fixed baud; confirm lock plus a full flash + verify (Windows environment, hardware present — confirm first). Deferred until the variant is chosen.

Files

  • pureboot_autobaud_pure.cpp — the pure version (GPIOR/RAM unit, no guard).
  • pureboot_autobaud_reg.cpp — the register version (GRV unit, guard kept).
  • pureboot.py--autobaud: the calibration handshake, slim info, and the signature→geometry table both variants rely on.
  • test/pbautobaud.py — the end-to-end sim test; CMakeLists.txt runs it for both variants on the 328P and 1284P, and pureboot_add_autobaud() size-tests both on every chip.
  • local/scratch/autobaud/floor_1284.S (libavr checkout) — the hand-asm floor probe, off-tree and gitignored; not sim-verified, a size reference only.