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>
252 lines
13 KiB
Markdown
252 lines
13 KiB
Markdown
# pureboot autobaud — findings, and how the version question settled itself
|
|
|
|
The autobaud loader 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 the two versions that
|
|
were under review are both dead, and what the loader looks like now.
|
|
|
|
## The decision, settled by measurement
|
|
|
|
Two autobaud loaders were built for review, differing in one tradeoff — the
|
|
running-slot write guard against strict purity. `pureboot_autobaud_pure.cpp`
|
|
landed at **508 B** on the 1284P (4 B spare) and `pureboot_autobaud_reg.cpp` at
|
|
**512** (zero spare).
|
|
|
|
Then hardware testing found a defect that neither could absorb.
|
|
|
|
**A single spurious calibration pulse wedged the loader.** `run()` budgeted only
|
|
the start-edge wait inside `measure()`; the `rx()` that read the knock behind it
|
|
was unbudgeted and blocked forever. One stray low pulse on an unattended device
|
|
— EMI, or a host that opens the port and never knocks — held the loader in its
|
|
activation loop and the application never ran. On a field device that is a hang,
|
|
not a hiccup, and it is exactly the deployment autobaud is for.
|
|
|
|
The fix is to 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 a line that stays idle boots the application there. It costs about 22 bytes.
|
|
|
|
| 1284P, with the activation fix | size | 512 B budget |
|
|
|---|---|---|
|
|
| `pureboot_autobaud_pure.cpp` | 530 | **over by 18** |
|
|
| `pureboot_autobaud_reg.cpp` | 534 | **over by 22** |
|
|
| `pureboot_autobaud_uni.cpp` | **464** | **48 B spare** |
|
|
|
|
Both candidates were unshippable, and the margin they were competing over was
|
|
never real — it was the space the missing fix should have occupied. So the
|
|
choice is not between them. It is the third loader below, which fits with room
|
|
to spare *and* carries features neither had. The two sources stay in the tree
|
|
for the record; only the unified one is built.
|
|
|
|
## The unified loader
|
|
|
|
The insight that paid was the one that had already paid once: **merging command
|
|
bodies removes cost that moving them around only redistributes.** Folding `R`,
|
|
`r` and `w` into a single address-and-count path had been worth 14 B earlier.
|
|
Pushed further — one read command and one write command over *named spaces* —
|
|
it is worth far more, because four transfer loops collapse into one.
|
|
|
|
`pureboot_autobaud_uni.cpp` is pureboot 5. It is strictly pure: no inline
|
|
assembly, no global register variable, and **no GPIOR either** — the measured
|
|
unit lives in a plain static, so the loader claims no chip resource an
|
|
application might want, and the GPIOR-versus-static question disappears along
|
|
with the chips that have no GPIOR.
|
|
|
|
### The protocol
|
|
|
|
| command | arguments | |
|
|
|---|---|---|
|
|
| `b` | — | version, then the three signature bytes |
|
|
| `J` | addr16 | ack, then jump (word address) |
|
|
| `W` | sel8, addr16, page bytes | fill the flash page buffer |
|
|
| `G` | sel8, addr16, n8 | read n bytes (0 means 256) |
|
|
| `g` | sel8, addr16, n8, then n bytes | write, each byte acked |
|
|
|
|
`sel` is `space | bank << 4`. The low nibble names the space; the high nibble is
|
|
flash's third address byte, so every transfer speaks a **byte** address inside a
|
|
64 KiB bank and no command has to carry word addresses. The host must not span a
|
|
bank boundary in one transfer — it already chunks by page, so nothing it does
|
|
comes close.
|
|
|
|
| space | | |
|
|
|---|---|---|
|
|
| 0 | flash | `lpm`/`elpm` |
|
|
| 1 | EEPROM | |
|
|
| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR |
|
|
| 3 | fuse and lock | |
|
|
| 4 | SPM | write-only: the data byte goes to SPMCSR and fires the instruction at the selected address |
|
|
|
|
Three things follow from that table that the loader never had:
|
|
|
|
- **RAM read and write**, the missing feature. In a per-command design it would
|
|
have cost a fresh dispatch arm and a fresh loop, ~30 B, on a loader with 4 B
|
|
spare. As one more space on a shared loop it is a single `ld`/`st`. It also
|
|
hands the host arbitrary **I/O register access** for free, because AVR maps
|
|
the peripherals into the same address space.
|
|
- **Host-driven SPM.** `W` used to end with a hardcoded erase, write and RWW
|
|
re-enable — 42 B. Those are now three writes to the SPM space, reusing the
|
|
store path's own address, data byte and ack. The host pays three extra
|
|
round-trips per page (18 wire bytes against 256 of data) and gains the ability
|
|
to issue *any* SPM operation, lock bits included.
|
|
- **`W` on the same footing as everything else.** It takes the same selector and
|
|
the same byte address instead of a word address of its own, which made flash
|
|
addressing uniform across the protocol *and* was 20 B cheaper than keeping its
|
|
private convention.
|
|
|
|
Read and write are `G` and `g` — the same letter, one bit apart — so the
|
|
transfer loop picks its direction with a one-word skip rather than a compare.
|
|
|
|
### Why the SPM space has to be one primitive
|
|
|
|
It is tempting to go further and expose a generic "poke this I/O register", from
|
|
which the host could drive SPM itself. The hardware forbids it: `out SPMCSR, x`
|
|
and the `spm` that follows must issue within four cycles, and EEPROM's
|
|
EEMPE→EEPE window is the same shape. A host cannot hit a four-cycle window
|
|
across a serial link. **Atomicity is the floor, and the atomic unit must be
|
|
resident.** That is the real limit on how low-level a bootloader's primitives
|
|
can go — not the byte count.
|
|
|
|
## Where the bytes went
|
|
|
|
The starting point was the 508 B pure build, disassembled and attributed:
|
|
|
|
| phase | bytes |
|
|
|---|---|
|
|
| `link::rx` + `link::tx` (bit-banged UART) | 102 |
|
|
| autobaud measure + knock | 62 |
|
|
| reset vector, pin init, WDRF check, jump, ack, EEPROM wait | 50 |
|
|
| command loop head + dispatch tree | 42 |
|
|
| `W` program flash page | 98 |
|
|
| `R` / `r` / `w` / `F` bodies, plus the shared address decode | 122 |
|
|
| `b` info, `J` jump | 32 |
|
|
|
|
**208 B — 41% — is physical layer and activation**, which no protocol change can
|
|
touch. The command bodies were the entire addressable surface, and they were
|
|
four copies of one idea.
|
|
|
|
The route from a first attempt to the final loader, all on the 1284P:
|
|
|
|
| step | size |
|
|
|---|---|
|
|
| unified `G`/`P`, `load`/`store` outlined, `__uint24` cursor | 600 |
|
|
| …`load`/`store` inlined; unit in `.noinit`, not `.bss` | 534 |
|
|
| …16-bit cursor with the bank in the selector; direction as a command bit | 510 |
|
|
| …erase/write/RWW moved out to the SPM space | 484 |
|
|
| …`W` sharing the selector-and-address decode | **464** |
|
|
|
|
Three of those steps are worth keeping as lessons:
|
|
|
|
- **Outlining `load`/`store` cost more than the four bodies they replaced.** As
|
|
functions they were 110 B against the 106 B of inline bodies — the AVR ABI's
|
|
argument marshalling plus prologue ate the entire saving. Inlined into the one
|
|
shared loop they cost only their own instructions. The call-site lesson cuts
|
|
both ways: *merging* call sites pays, *creating* one does not.
|
|
- **A `.bss` static drags in `__do_clear_bss`** — 18 B of startup code to zero a
|
|
variable that is always measured before it is read. `.noinit` is correct here
|
|
and free.
|
|
- **A three-byte cursor taxes every space.** Widening the shared cursor so flash
|
|
could reach past 64 KiB put an extra increment on EEPROM and RAM reads that
|
|
never need it. Moving the bank into the selector byte kept the cursor at
|
|
sixteen bits and cost nothing on the wire.
|
|
|
|
### What did not work
|
|
|
|
- **Encoding the space in the command byte** (so dispatch becomes masking rather
|
|
than a compare tree) cannot carry the bank's four bits alongside a space. The
|
|
cheap half of the idea survived as the direction bit; the rest lost to the
|
|
selector byte, which is also more extensible.
|
|
- **A generic primitive interpreter** — a loader with no logic at all, driven
|
|
entirely by the host — is not reachable on AVR. Harvard architecture means the
|
|
program counter cannot fetch from data space, so the classic "upload a flash
|
|
algorithm into RAM and jump to it" bootstrap is impossible, and on every
|
|
boot-sectioned part SPM only takes effect from the boot section anyway. What
|
|
remains is a fixed primitive set: still a protocol, still logic, only at a
|
|
different granularity. debugWIRE reaches that design point only because its
|
|
interpreter is *in silicon*; it costs the loader nothing because it is not in
|
|
the loader.
|
|
- Below 512 B the saved bytes are largely unspendable on the boot-sectioned
|
|
chips: the 328P's smallest boot section is exactly 512 B, and the 1284P's is
|
|
1024 B, of which pureboot already occupies only the top half. The margin
|
|
matters as headroom for correctness fixes — as this defect showed — not as
|
|
flash returned to the application. On the patch-vector parts, which have no
|
|
boot section, it *is* returned: on the ATtiny13 the loader is 43% of a 1 KiB
|
|
part, and every byte is real.
|
|
|
|
## The codegen coupling, still load-bearing
|
|
|
|
`count >> 2` is exact only because the calibration pulse's bit-count (7, from
|
|
the 0xC0 byte) equals the poll loop's cycles per iteration (7 — `sbis` 1,
|
|
`rjmp` 2, `adiw` 2, `rjmp` 2). The loop shape survived every restructuring here,
|
|
verified in the disassembly, but a toolchain bump that reshapes it would break
|
|
the lock silently. `test/pbautobaud.py` is what pins it: a wrong unit fails the
|
|
flash verify.
|
|
|
|
## Sizes — every chip
|
|
|
|
Budget 510 B on the patch-vector parts, 512 elsewhere. The 1284P is no longer
|
|
the tight one: the bank nibble made far flash *cheaper* than the near-flash
|
|
arithmetic it replaced.
|
|
|
|
| size | chips | budget | spare |
|
|
|---|---|---|---|
|
|
| 444 | ATtiny13, 13A | 510 | 66 |
|
|
| 448 | ATmega48, 48A, 48P, 48PA; ATtiny25 | 510 | 62 |
|
|
| 452 | ATtiny45, 85 | 510 | 58 |
|
|
| 460 | ATmega644, 644A, 644P, 644PA | 512 | 52 |
|
|
| 464 | **ATmega1284, 1284P**; ATmega8, 8A, 88, 88A, 88P, 88PA | 512 | 48 |
|
|
| 466 | ATmega16, 16A, 32, 32A; 164A/P/PA, 168/A/P/PA, 324A/P/PA, 328, 328P | 512 | 46 |
|
|
|
|
All 37 chips build and size-test green, plus the 12-preset reflect spot set
|
|
(guidance rule 4 — the reflect matrix is never run in full), which matches its
|
|
generated counterpart byte for byte on every chip in the set. Worst case across
|
|
the whole set is **466 B, 46 under budget**.
|
|
|
|
## Host tool and simulation
|
|
|
|
- **`pureboot.py`** speaks both generations. `Info.version >= 5` selects the
|
|
unified path; everything below it keeps the four-command protocol, so the
|
|
fixed-baud loader is untouched. `--autobaud` sends the 0xC0 pulse and one
|
|
knock, then derives full geometry from the signature. New: `--peek ADDR[:N]`
|
|
and `--poke ADDR:HEX` reach the data space.
|
|
- **`test/pbautobaud.py`** drives the loader over the GPIO⇄pty software-UART
|
|
bridge through the calibration handshake, a flash + EEPROM + fuse round-trip
|
|
cross-checked against the simulator's own memory, a RAM read/write round-trip,
|
|
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.
|
|
- It also **pins the activation hang**: the test sends a lone calibration pulse
|
|
with no knock behind it and requires the application to boot. Against the
|
|
unfixed loader that assertion never returns.
|
|
|
|
## What remains
|
|
|
|
- **Real-hardware acceptance.** A cycle-exact simulator cannot produce what
|
|
autobaud exists for: 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. Drive an internal-oscillator ATtiny at a fixed host baud and
|
|
confirm lock plus a full flash and verify.
|
|
- **A generic `spm::command()` in libavr.** The SPM space issues a runtime
|
|
command through `spm::detail::page_command` where RAMPZ exists, and falls back
|
|
to a dispatch over the known operations where it does not — the one
|
|
preprocessor branch in the file. A two-line library addition would make it
|
|
uniform and save a few bytes on the 36 non-RAMPZ chips, none of which are
|
|
tight.
|
|
- **Retire or revive the two dead variants.** They are kept only as the record
|
|
of the measurement; nothing builds them.
|
|
|
|
## Files
|
|
|
|
- `pureboot_autobaud_uni.cpp` — the loader. pureboot 5.
|
|
- `pureboot_autobaud_pure.cpp`, `pureboot_autobaud_reg.cpp` — superseded, not
|
|
built; 530 and 534 B on the 1284P once the activation hang is fixed.
|
|
- `pureboot.py` — `--autobaud`, the unified transfer path, `--peek`/`--poke`.
|
|
- `test/pbautobaud.py` — the end-to-end sim test and the hang regression.
|
|
- `local/scratch/autobaud/floor_1284.S` (libavr checkout) — the hand-asm floor
|
|
probe at 506 B, off-tree and gitignored; a size reference only. The unified
|
|
loader is 42 B under it, with features the probe never had.
|