diff --git a/CMakeLists.txt b/CMakeLists.txt index a1e813c..dbd3bb5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,18 +245,23 @@ if(PROJECT_IS_TOP_LEVEL) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) endfunction() - # The autobaud variants under review (pureboot/autobaud.md): one clock-agnostic - # image per chip, no clock × baud axis. Both are size-tested against the same - # per-chip budget on every chip — the decider is whether pure C++ fits 512, - # and the 1284 is the tight one (pure 508, register 512). + # The autobaud loader (pureboot/autobaud.md): one clock-agnostic image per + # chip, no clock × baud axis, size-tested against the same per-chip budget on + # every chip. + # + # Only the unified version is built. Its two predecessors — + # pureboot_autobaud_pure.cpp at 508 B and pureboot_autobaud_reg.cpp at 512 — + # had 4 B and 0 B of margin on the 1284P, and the fix for the activation hang + # costs ~22, which puts them at 530 and 534. Neither can ship, so the choice + # the branch existed to offer is settled by measurement rather than taste. + # The sources stay for the record; autobaud.md carries the numbers. function(pureboot_autobaud_variant name source) pureboot_add_autobaud(${name} ${source}) add_test(NAME ${name}.size COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) endfunction() - pureboot_autobaud_variant(pureboot_autobaud_pure pureboot_autobaud_pure.cpp) - pureboot_autobaud_variant(pureboot_autobaud_reg pureboot_autobaud_reg.cpp) + pureboot_autobaud_variant(pureboot_autobaud_uni pureboot_autobaud_uni.cpp) # One point of the exhaustive matrix, named from its resolved parameters # so the enumeration cannot collide with itself. Unreachable rates drop @@ -404,7 +409,7 @@ if(PROJECT_IS_TOP_LEVEL) add_custom_command(TARGET pbapp_autobaud POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) - foreach(_variant pure reg) + foreach(_variant uni) add_test(NAME pureboot.autobaud_${_variant} COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} diff --git a/pureboot/autobaud.md b/pureboot/autobaud.md index 3509777..1b4de02 100644 --- a/pureboot/autobaud.md +++ b/pureboot/autobaud.md @@ -1,6 +1,6 @@ -# pureboot autobaud — findings and the version decision +# pureboot autobaud — findings, and how the version question settled itself -The autobaud variant measures the host's bit timing **at runtime** from a +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, @@ -8,205 +8,244 @@ 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. +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 to make +## The decision, settled by measurement -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.** +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). -| | `pureboot_autobaud_pure.cpp` | `pureboot_autobaud_reg.cpp` | +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 | |---|---|---| -| 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 | 440–508 B | 452–512 B | +| `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 also take two shared, licensed simplifications (below): a slimmed info -block and a single-byte activation knock. +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 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. +## The unified loader -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 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. -## The mechanism +`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 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 1–16 MHz at 9600 (worst seen −2.8 % at 128 kHz/1200, still -inside UART tolerance). +### The protocol -**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*). +| 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 | -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. +`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. -## How the target was found — the hand-assembly floor +| 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 | -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. +Three things follow from that table that the loader never had: -**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: +- **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. -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 ~18–26 B (GCC re-derives the stack - slot and byteswaps). +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. -Measured on identical C++ (the same loader, unit in a register vs. RAM): +### Why the SPM space has to be one primitive -| unit home | 1284 size | +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 | |---|---| -| global register variable | 528 | -| RAM static | 560 | -| hand-asm (register + cheap anchor) | 506 | +| `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 | -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). +**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 pure lever — GPIOR +The route from a first attempt to the final loader, all on the 1284P: -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. +| 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** | -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. +Three of those steps are worth keeping as lessons: -### The ablation (1284, all pure except the GRV row) +- **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. -| 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** ✓ | — | +### What did not work -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. +- **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. -## Sizes — every chip, both versions +## The codegen coupling, still load-bearing -Both build and size-test green on all 37 (patched-vector budget 510, else 512): +`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. -| chip class | pure | register | budget | +## 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 | |---|---|---|---| -| ATtiny13/13A | 446 | 452 | 510 | -| ATtiny25/45/85 | 440–444 | 456–460 | 510 | -| ATmega8/8A | 472 | 476 | 512 | -| ATmega16/32 | 476 | 478 | 512 | -| ATmega48 family | 440 | 456 | 510 | -| ATmega88/168/328 | 462–466 | 476–478 | 512 | -| ATmega164/324/644 | 466 | 478 | 512 | -| **ATmega1284/P** | **508** | **512** | 512 | +| 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 | -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. +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**. -## The shared simplifications, and why each is licensed +## Host tool and simulation -- **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 +- **`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 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. + 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 -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. +- **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_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. +- `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, off-tree and gitignored; not sim-verified, a size reference only. + 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. diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 9307cd1..6ce42a9 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -30,10 +30,29 @@ VERSION = 3 # this tool's own version — free to drift from a loader's # map lives: every version so far speaks the same protocol, and one that # changes it becomes the new floor here. OLDEST_LOADER = 1 -NEWEST_LOADER = 4 +NEWEST_LOADER = 5 SLOT = 512 # the loader slot, on every chip RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops +# pureboot 5 replaced the four per-memory commands with one unified pair: 'G' +# reads and 'g' writes, both taking a selector byte, a 16-bit address and a +# count, over the spaces below. The loader carries one transfer loop instead of +# four bodies, which is what paid for RAM access and for the calibration fix. +UNIFIED_LOADER = 5 +SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 + +# A selector's high nibble is flash's third address byte, so a transfer names a +# byte address within a 64 KiB bank and never has to speak word addresses. No +# single transfer may cross a bank boundary — the host chunks to keep that true. +def selector(space, address): + return space | ((address >> 16) << 4) + + +# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its +# data byte to SPMCSR and fires the instruction at the selected flash address. +# Every part pureboot targets agrees on these encodings. +SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11 + # Calibration byte for the autobaud loader: 0xC0 is a start bit plus six zero # data bits — one low pulse of seven bit-times, which the loader times into its # per-bit unit. Sent at whatever baud the host chose; the loader locks to it. @@ -509,7 +528,58 @@ class Loader: count -= chunk return data + @property + def unified(self): + """pureboot 5 and later: one 'G'/'g' pair over selector-named spaces.""" + return self.info is not None and self.info.version >= UNIFIED_LOADER + + def _read_space(self, space, address, count): + """A run out of any space, chunked to 256 bytes and to bank bounds.""" + data = b"" + while count: + chunk = min(count, 256, 0x10000 - (address & 0xFFFF)) + head = bytes((ord("G"), selector(space, address), address & 0xFF, + (address >> 8) & 0xFF, chunk & 0xFF)) + data += self._command(head, chunk, 5.0) + address += chunk + count -= chunk + return data + + def _write_space(self, space, address, data, progress=None): + """A run into any space. Each byte is acked as its write begins — an + EEPROM cell and an SPM operation both need that pacing, and the ack is + what the loader sends in place of a completion status.""" + offset = 0 + while offset < len(data): + chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))] + head = bytes((ord("g"), selector(space, address), address & 0xFF, + (address >> 8) & 0xFF, len(chunk) & 0xFF)) + self.port.write(head) + for byte in chunk: + self.port.write(bytes((byte,))) + self._expect_prompt() + if progress: + progress.step() + self._expect_prompt() # the next command prompt + address += len(chunk) + offset += len(chunk) + + def spm(self, operation, address): + """One SPM operation at a flash address — the erase, write and RWW + re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here.""" + self._write_space(SP_SPM, address, bytes((operation,))) + + def read_ram(self, address, count): + """Data space: SRAM, and with it the register file and every I/O + register, which share the address space on AVR. New in pureboot 5.""" + return self._read_space(SP_RAM, address, count) + + def write_ram(self, address, data): + self._write_space(SP_RAM, address, data) + def read_flash(self, address, count): + if self.unified: + return self._read_space(SP_FLASH, address, count) if not self.info.word_flash: return self._stream_read("R", address, count) # Word-addressed wire: widen to even bounds and never let one read @@ -527,15 +597,32 @@ class Loader: return data[address - start : address - start + count] def read_eeprom(self, address, count): + if self.unified: + return self._read_space(SP_EEPROM, address, count) return self._stream_read("r", address, count) def write_page(self, address, data): assert len(data) == self.info.page and address % self.info.page == 0 + if self.unified: + # 'W' fills the page buffer and stops there; the erase and the write + # are host-issued SPM operations. Only a chip with a boot section + # has RWW to re-enable — on the others bit 4 of SPMCSR means + # something else entirely, so it must not be sent. + head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF)) + self._command(head + data, 0, 2.0) + self.spm(SPM_ERASE, address) + self.spm(SPM_WRITE, address) + if not self.info.patch_vector: + self.spm(SPM_RWWSRE, address) + return wire = address // (2 if self.info.word_flash else 1) head = bytes((ord("W"), wire & 0xFF, wire >> 8)) self._command(head + data, 0, 2.0) def write_eeprom(self, address, data, progress=None): + if self.unified: + self._write_space(SP_EEPROM, address, data, progress) + return offset = 0 while offset < len(data): chunk = data[offset : offset + 256] @@ -551,6 +638,8 @@ class Loader: offset += len(chunk) def read_fuses(self): + if self.unified: + return self._read_space(SP_FUSE, 0, 4) return self._command(b"F", 4, 2.0) def jump(self, word_address): @@ -1107,6 +1196,37 @@ def op_read_eeprom(loader, path): print(f"read EEPROM: {len(data)} B -> {path}") +def _require_unified(loader, what): + if not loader.unified: + raise Error(f"{what} needs pureboot {UNIFIED_LOADER} or later; this loader is {loader.info.version}") + + +def _peek_spec(spec): + """ADDR[:N] — addresses and counts in any Python integer base.""" + address, _, count = spec.partition(":") + return int(address, 0), int(count, 0) if count else 1 + + +def op_peek(loader, spec): + _require_unified(loader, "--peek") + address, count = _peek_spec(spec) + data = loader.read_ram(address, count) + for offset in range(0, len(data), 16): + row = data[offset : offset + 16] + text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row) + print(f"{address + offset:#06x} {row.hex(' '):<47} {text}") + + +def op_poke(loader, spec): + _require_unified(loader, "--poke") + address, _, payload = spec.partition(":") + if not payload: + raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef") + data = bytes.fromhex(payload.replace(" ", "")) + loader.write_ram(int(address, 0), data) + print(f"poke: {len(data)} B at {int(address, 0):#06x}") + + def op_fuses(loader): low, lock, extended, high = loader.read_fuses() print("fuses:") @@ -1158,6 +1278,10 @@ def main(): parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)") parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM") parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image") + parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, " + "I/O) — pureboot 5 and later") + parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space — " + "pureboot 5 and later") parser.add_argument("--force", action="store_true", help="override refusable safety checks") parser.add_argument("--stay", action="store_true", help="leave the loader in its session") parser.add_argument("-v", "--verbose", action="store_true", @@ -1209,6 +1333,10 @@ def main(): op_read_eeprom(loader, args.read_eeprom) if args.verify_eeprom: op_verify_eeprom(loader, args.verify_eeprom) + if args.poke: + op_poke(loader, args.poke) + if args.peek: + op_peek(loader, args.peek) if args.stay: print("loader stays in its session (reset to leave)") else: diff --git a/pureboot/pureboot_autobaud_pure.cpp b/pureboot/pureboot_autobaud_pure.cpp index b2b64c7..8a8d911 100644 --- a/pureboot/pureboot_autobaud_pure.cpp +++ b/pureboot/pureboot_autobaud_pure.cpp @@ -1,3 +1,9 @@ +// SUPERSEDED — kept for the record, not built. With the activation hang fixed +// (a lone calibration pulse used to wedge the loader, see autobaud.md) this +// version is 530 B on the 1284P against a 512 B slot. Its 4 B of margin was +// never spare capacity; it was the space the missing fix should have occupied. +// pureboot_autobaud_uni.cpp replaces it at 464 B with more features. +// // pureboot autobaud — the software-serial variant that measures the host's bit // timing at runtime, so one binary runs at any F_CPU: the image carries no // clock. THE PURE VERSION — no inline assembly, no global register variables, @@ -145,11 +151,12 @@ struct link { return u; } - static std::uint8_t rx() + // The eight data bits, entered just past the falling edge of the start bit. + // Split out of rx() so the activation path can wait for that edge under a + // budget while the command loop waits for it indefinitely. + static std::uint8_t sample() { const std::uint16_t unit = get_unit(); - while (in_t::read()) // await the start edge - ; _delay_loop_2(static_cast(unit + (unit >> 1))); // 1.5 bits to the LSB centre std::uint8_t value = 0; for (std::uint8_t bit = 0; bit < 8; ++bit) { @@ -161,6 +168,27 @@ struct link { return value; } + static std::uint8_t rx() + { + while (in_t::read()) // await the start edge + ; + return sample(); + } + + // A byte under the poll budget, for the activation knock. An expired budget + // returns 0, which is not the knock, so the caller falls back into the + // budgeted measure() — and a line that stays idle boots the application + // there. Without this the knock's edge wait was unbounded, so a single + // spurious calibration pulse (EMI, or a host that opens the port and never + // knocks) wedged the loader and the application never ran. + static std::uint8_t rx_bounded(__uint24 budget) + { + while (in_t::read()) + if (!--budget) + return 0; + return sample(); + } + static void tx(std::uint8_t value) { const std::uint16_t unit = get_unit(); @@ -311,7 +339,7 @@ void send_fuses() for (;;) { if (link::measure(autobaud_budget) == 0) run_app(); - if (link::rx() == 'p') + if (link::rx_bounded(autobaud_budget) == 'p') break; } diff --git a/pureboot/pureboot_autobaud_reg.cpp b/pureboot/pureboot_autobaud_reg.cpp index 3010bee..3a42790 100644 --- a/pureboot/pureboot_autobaud_reg.cpp +++ b/pureboot/pureboot_autobaud_reg.cpp @@ -1,3 +1,9 @@ +// SUPERSEDED — kept for the record, not built. With the activation hang fixed +// (a lone calibration pulse used to wedge the loader, see autobaud.md) this +// version is 534 B on the 1284P against a 512 B slot, so the global register +// variable it broke purity for buys nothing. pureboot_autobaud_uni.cpp replaces +// it at 464 B, strictly pure and with more features. +// // pureboot autobaud — the software-serial variant that measures the host's bit // timing at runtime, so one binary runs at any F_CPU: the image carries no // clock. THE REGISTER VERSION — keeps the running-slot write guard, at the cost @@ -111,10 +117,11 @@ struct link { return u; } - static std::uint8_t rx() + // The eight data bits, entered just past the falling edge of the start bit. + // Split out of rx() so the activation path can wait for that edge under a + // budget while the command loop waits for it indefinitely. + static std::uint8_t sample() { - while (in_t::read()) // await the start edge - ; _delay_loop_2(static_cast(g_unit + (g_unit >> 1))); // 1.5 bits to the LSB centre std::uint8_t value = 0; for (std::uint8_t bit = 0; bit < 8; ++bit) { @@ -126,6 +133,27 @@ struct link { return value; } + static std::uint8_t rx() + { + while (in_t::read()) // await the start edge + ; + return sample(); + } + + // A byte under the poll budget, for the activation knock. An expired budget + // returns 0, which is not the knock, so the caller falls back into the + // budgeted measure() — and a line that stays idle boots the application + // there. Without this the knock's edge wait was unbounded, so a single + // spurious calibration pulse (EMI, or a host that opens the port and never + // knocks) wedged the loader and the application never ran. + static std::uint8_t rx_bounded(__uint24 budget) + { + while (in_t::read()) + if (!--budget) + return 0; + return sample(); + } + static void tx(std::uint8_t value) { out_t::clear(); // start bit @@ -288,7 +316,7 @@ void send_fuses() for (;;) { if (link::measure(autobaud_budget) == 0) run_app(); - if (link::rx() == 'p') + if (link::rx_bounded(autobaud_budget) == 'p') break; } diff --git a/pureboot/pureboot_autobaud_uni.cpp b/pureboot/pureboot_autobaud_uni.cpp new file mode 100644 index 0000000..8cb97b5 --- /dev/null +++ b/pureboot/pureboot_autobaud_uni.cpp @@ -0,0 +1,411 @@ +// pureboot autobaud, the unified-primitive version — VARIANT A, an explicit +// space byte. One read command and one write command carry a space selector, so +// flash, EEPROM, RAM and the fuses share a single cursor, a single transfer loop +// and a single argument decode instead of one command body each. +// +// Strictly pure: no inline assembly, no global register variables, and no GPIOR +// either — the measured unit lives in a plain static, so the loader claims no +// chip resource an application might want. (PUREBOOT_UNIT_GPIOR=1 puts it back +// in the I/O scratch registers, kept only as a measurement axis.) +// +// Against pureboot_autobaud_pure.cpp this version: +// - adds RAM read and write, which the loader has never had. Because AVR maps +// the register file and the whole I/O space into the data address space, +// that one space also gives the host arbitrary peripheral access for free; +// - collapses 'R' (read flash), 'r' (read EEPROM), 'w' (write EEPROM) and 'F' +// (fuses) — four bodies, four loops — into 'G' and 'P' over four spaces; +// - fixes the activation hang: a lone calibration pulse used to leave the +// loader blocked forever in the knock's rx(), so a stray edge on an +// unattended device wedged it in the loader and the application never ran. +// +// Position independence is kept and is total: control flow is PC-relative, the +// wire carries addresses, and nothing anchors on the runtime address. + +#include + +#include + +using namespace avr::literals; +namespace spm = avr::spm; +namespace ee = avr::eeprom; +namespace hw = avr::hw; + +namespace pureboot { +namespace { + +// Purely polled: every interrupt guard folds to nothing. +constexpr auto off = avr::irq::guard_policy::unused; + +constexpr std::uint8_t ack = '+'; + +// Autobaud carries no clock, so PUREBOOT_CLOCK_HZ / PUREBOOT_BAUD are not +// consulted; only the software-UART pins are a deployment parameter. +#if !defined(PUREBOOT_RX) +#define PUREBOOT_RX pb0 +#endif +#if !defined(PUREBOOT_TX) +#define PUREBOOT_TX pb1 +#endif + +// The unit's home. A plain static by default — pureboot claims no GPIOR, so the +// application keeps both scratch registers. The GPIOR spelling is retained +// behind a macro purely so the two can be measured against each other. +#if !defined(PUREBOOT_UNIT_GPIOR) +#define PUREBOOT_UNIT_GPIOR 0 +#endif + +// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. +consteval std::int16_t wdrf_field() +{ + auto reg = std::string_view{hw::db.regs[static_cast(avr::power::detail::reset_reg())].name}; + return hw::db.field_index(reg, "WDRF"); +} + +// The loader owns the top 512 bytes; a staging copy goes in the slot below. +constexpr std::uint16_t slot_bytes = 512; +constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; +constexpr std::uint16_t page = spm::page_bytes; +constexpr bool boot_section = hw::curated::has_boot_section(); + +// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash +// addresses there are word addresses ('J' always was one). +constexpr bool word_flash = spm::flash_bytes > 65536; + +// The loader's one identity number (README.md); the slimmed info block carries +// it and the signature, and the host maps a version to its protocol. +constexpr std::uint8_t version = 5; + +// The activation window as a fixed poll budget: with no clock, whole seconds +// cannot be timed. A __uint24 (AVR's three-byte type) holds it — a fourth byte +// would cost two words at each countdown step for range never used. +#if !defined(PUREBOOT_AUTOBAUD_POLLS) +#define PUREBOOT_AUTOBAUD_POLLS 4000000 +#endif +constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; + +// The two SPM commands the loader still has to recognise by value, on the chips +// where it cannot issue a runtime one generically. Taken from the chip's own +// definitions rather than spelled 3 and 5 — though every part pureboot targets +// agrees on those, which is what lets the host send the raw SPMCSR byte. +constexpr std::uint8_t spm_erase = __BOOT_PAGE_ERASE; +constexpr std::uint8_t spm_write = __BOOT_PAGE_WRITE; + +// The spaces a transfer can name. Flash is 0 so it is the cheap default. +// +// sp_spm is the one that is not memory: a write there hands its data byte to +// SPMCSR and fires the instruction at the given flash address, so page erase, +// page write and RWW re-enable become host-issued commands instead of a +// hardcoded tail inside 'W'. The store side already owns an address, a data +// byte and an ack, so the whole sequence costs only the fused out/spm pair. It +// also lets the host reach every other SPM operation — lock bits included — +// which the loader previously had no way to expose. +enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_ram = 2, sp_fuse = 3, sp_spm = 4 }; + +// A transfer's selector byte is `space | bank << 4`: the low nibble names the +// space, the high nibble carries flash's third address byte (RAMPZ) on the +// chips that have one. Putting the bank here rather than widening the address +// keeps the shared cursor sixteen bits for every space — a three-byte cursor +// costs its extra increment on EEPROM and RAM reads too, which never need it. +// The host must not span a bank boundary in one transfer; it already chunks by +// page, so nothing it does today comes close. + +// .noinit, not .bss: the unit is always measured before it is read, so it needs +// no zeroing — and a zeroed .bss would drag in __do_clear_bss, 18 bytes of +// startup code for a variable that is written before its first use. +[[gnu::section(".noinit")]] std::uint16_t unit_backing; + +[[gnu::always_inline]] inline std::uint16_t get_unit() +{ +#if PUREBOOT_UNIT_GPIOR + return static_cast(hw::reg_impl::read() | + (hw::reg_impl::read() << 8)); +#else + return unit_backing; +#endif +} + +[[gnu::always_inline]] inline void put_unit(std::uint16_t u) +{ +#if PUREBOOT_UNIT_GPIOR + hw::reg_impl::write(static_cast(u)); + hw::reg_impl::write(static_cast(u >> 8)); +#else + unit_backing = u; +#endif +} + +// The autobaud software link: bit-banged with cycle-counted delays like the +// fixed-baud software backend, but the per-bit delay is the measured unit, not +// a consteval constant. rx/tx load the unit once into a local, so each bit +// spins from a register with no per-bit reload. +struct link { + using in_t = avr::io::input; + using out_t = avr::io::output; + + static void init() + { + avr::init(); + out_t::set(); // idle high + } + + // Time the calibration pulse into the unit. The host sends 0xC0 — a start + // bit plus six zero data bits are one low pulse of seven bit-times — and the + // counted poll loop is 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. Waits for the start edge + // under the poll budget; 0 (returned, and stored) means the budget expired. + // + // The shape of the counting loop is load-bearing, not incidental: the >> 2 + // is exact only while the pulse's bit-count equals the loop's cycles per + // iteration. Both are 7 here (sbis 1 + rjmp 2 + adiw 2 + rjmp 2). Reshaping + // this loop silently changes the lock; test/pbautobaud.py is what pins it. + static std::uint16_t measure(__uint24 budget) + { + while (in_t::read()) + if (!--budget) + return 0; + std::uint16_t count = 0; + while (!in_t::read()) + ++count; + const std::uint16_t u = static_cast(count >> 2); + put_unit(u); + return u; + } + + // The eight data bits, entered just past the falling edge of the start bit. + // Split out of rx() so the activation path can wait for that edge under a + // budget while the command loop waits for it indefinitely. + static std::uint8_t sample() + { + const std::uint16_t unit = get_unit(); + _delay_loop_2(static_cast(unit + (unit >> 1))); // 1.5 bits to the LSB centre + std::uint8_t value = 0; + for (std::uint8_t bit = 0; bit < 8; ++bit) { + value >>= 1; + if (in_t::read()) + value |= 0x80; + _delay_loop_2(unit); + } + return value; + } + + static std::uint8_t rx() + { + while (in_t::read()) // await the start edge + ; + return sample(); + } + + // A byte under the poll budget, for the activation knock. An expired budget + // returns 0, which is not the knock, so the caller falls back into the + // budgeted measure() — and a line that stays idle boots the application + // there. That is the whole hang fix: no wait during activation is unbounded, + // so a stray calibration pulse can no longer wedge the loader. + static std::uint8_t rx_bounded(__uint24 budget) + { + while (in_t::read()) + if (!--budget) + return 0; + return sample(); + } + + static void tx(std::uint8_t value) + { + const std::uint16_t unit = get_unit(); + out_t::clear(); // start bit + _delay_loop_2(unit); + for (std::uint8_t bit = 0; bit < 8; ++bit) { + out_t::write(value & 1); + value >>= 1; + _delay_loop_2(unit); + } + out_t::set(); // stop bit + _delay_loop_2(unit); + } + + static void drain() + { + // The software transmitter returns only after the stop bit. + } +}; + +extern "C" [[noreturn]] void pureboot_app(); + +[[gnu::noipa, noreturn]] void jump(void (*target)()) +{ + target(); + __builtin_unreachable(); +} + +[[gnu::noinline, noreturn]] void run_app() +{ + jump(pureboot_app); +} + +// Inlined: read across a call, the first byte strands in a call-saved register +// the caller has to push and pop. +[[gnu::always_inline]] inline std::uint16_t rx16() +{ + std::uint16_t low = link::rx(); + return static_cast(low | (link::rx() << 8)); +} + +[[gnu::always_inline]] inline std::uint16_t word_of(std::array pair) +{ + return std::bit_cast(pair); +} + +[[gnu::noinline]] void tx_ack() +{ + link::tx(ack); +} + +// One byte out of any space. The four accessors share the cursor, the loop and +// the call site — the whole point of the unified commands — so each costs only +// its own instruction rather than a body, a loop and a dispatch arm. +[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, std::uint8_t bank, std::uint16_t at) +{ + if (space == sp_eeprom) + return ee::read(at); + if (space == sp_ram) + return *reinterpret_cast(at); + if (space == sp_fuse) + return spm::read_fuse(static_cast(at)); + if constexpr (word_flash) + return avr::flash_load_far((static_cast(bank) << 16) | at); + else + return avr::flash_load(reinterpret_cast(at)); +} + +// One byte into a writable space. Flash is not one of them — it arrives a page +// at a time through 'W' — and the fuses are not writable at all. +[[gnu::always_inline]] inline void store(std::uint8_t space, [[maybe_unused]] std::uint8_t bank, std::uint16_t at, + std::uint8_t value) +{ + if (space == sp_ram) { + *reinterpret_cast(at) = value; + return; + } + if (space == sp_spm) { + // The fused store-and-fire: SPMCSR takes the data byte and the SPM + // issues against Z in the same unscheduled pair the hardware's + // four-cycle window demands, which is exactly why this is one + // primitive and not a poke of SPMCSR followed by a poke of anything + // else. A host cannot hit that window across a serial link. + // The preprocessor rather than `if constexpr` only because + // spm::detail::page_command does not exist at all where there is no + // RAMPZ, and a discarded constexpr branch outside a template is still + // name-checked. A generic spm::command() in libavr would let the + // runtime command through on every chip and retire the dispatch below. +#if defined(RAMPZ) + spm::detail::page_command(value, (static_cast(bank) << 16) | at); +#else + if (value == spm_erase) + spm::erase_page(at); + else if (value == spm_write) + spm::write_page(at); + else if constexpr (boot_section) + spm::rww_enable(); +#endif + if constexpr (boot_section) + spm::wait(); + return; + } + ee::write(at, value); +} + +// One page into the SPM buffer, and only that: the erase, the write and the RWW +// re-enable that used to follow are now three host-issued writes to sp_spm, +// which reach the same fused out/spm pair through the store path's own address +// and data. No running-slot write guard: the host guarantees it never targets +// the loader's own slot (licensed — README.md). +void program_flash([[maybe_unused]] std::uint8_t bank, std::uint16_t at) +{ + std::uint16_t z = at & ~static_cast(page - 1); + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); +#if defined(RAMPZ) + spm::fill((static_cast(bank) << 16) | z, word_of({low, high})); +#else + spm::fill(z, word_of({low, high})); +#endif + z += 2; + } while (static_cast(z) & (page - 1)); +} + +[[noreturn]] void run() +{ + if (hw::field_impl::test()) + run_app(); + + link::init(); + + // Measure the calibration pulse into the unit, then take one 'p' knock — + // both under the poll budget. An expired budget (no host) boots the + // application; anything but 'p', including the knock timing out, re-measures + // and so returns to the budgeted wait that boots it. + for (;;) { + if (link::measure(autobaud_budget) == 0) + run_app(); + if (link::rx_bounded(autobaud_budget) == 'p') + break; + } + + for (;;) { + ee::wait(); + tx_ack(); + const std::uint8_t command = link::rx(); + switch (command) { + case 'J': { // jump to a wire word address: hand-over and staging transfer + auto target = reinterpret_cast(rx16()); + tx_ack(); + link::drain(); + jump(target); + } + case 'b': // chip identity: version then the three signature bytes + link::tx(version); + link::tx(hw::db.signature[0]); + link::tx(hw::db.signature[1]); + link::tx(hw::db.signature[2]); + break; + case 'W': // program one flash page: sel8, addr16, then page bytes + case 'G': // read: sel8, addr16, n8 (0 = 256) + case 'g': { // write: sel8, addr16, n8, then n bytes each acked + // The unified transfer. One decode, one cursor, one loop for every + // space and both directions — the four command bodies this replaces + // each carried their own copy of all three. Read and write are the + // same letter in the two cases, so the direction is bit 5 of the + // command and the loop tests it with a one-word skip. 'W' joins the + // same selector-and-address decode rather than keeping a word + // address of its own, which makes flash addressing uniform across + // every command that names it and costs nothing to share. + const std::uint8_t sel = link::rx(); + const std::uint8_t space = sel & 0x0f; + const std::uint8_t bank = static_cast(sel >> 4); + std::uint16_t at = rx16(); + if (command == 'W') { + program_flash(bank, at); + break; + } + std::uint8_t count = link::rx(); + do { + if (command & 0x20) { + store(space, bank, at, link::rx()); + tx_ack(); + } else + link::tx(load(space, bank, at)); + ++at; + } while (--count); + break; + } + default: // unknown bytes are ignored; the loop re-acks + break; + } + } +} + +} // namespace +} // namespace pureboot + +template struct avr::startup::entry; diff --git a/test/pbautobaud.py b/test/pbautobaud.py index bbfbee6..7ab532c 100644 --- a/test/pbautobaud.py +++ b/test/pbautobaud.py @@ -17,6 +17,7 @@ baked in. import os import sys +import time def fail(message): @@ -74,13 +75,53 @@ def main(): fail(f"{label}: EEPROM read-back mismatch") if hand_over: + # Regression: a calibration pulse with no knock behind it must + # not wedge the loader. The knock's edge wait used to be + # unbudgeted, so one stray low pulse — EMI, or a host that opens + # the port and never knocks — held the loader forever and the + # application never ran. The whole activation is bounded now, so + # the window closes and the app boots; the banner is the proof. + # (The pause lets the loader reach its measurement loop, so the + # pulse is genuinely seen and the test cannot pass vacuously.) + device.reset() + port = pb.Port(device.pty, baud) + try: + time.sleep(0.2) + port.write(bytes((pb.CALIBRATE,))) + # Accumulate rather than match exactly: the reset leaves the + # idle line a framing artefact ahead of the banner, which is + # noise here — the question is only whether the app ran. + seen = b"" + deadline = time.monotonic() + 180.0 + while b"APP" not in seen and time.monotonic() < deadline: + seen += port.read_available(1.0) + if b"APP" not in seen: + fail(f"{label}: lone calibration pulse wedged the loader — app never bannered, saw {seen!r}") + print(f" {label}: lone calibration pulse does not wedge the loader") + finally: + port.close() + device.reset() port = pb.Port(device.pty, baud) try: loader = pb.Loader(port) live = loader.connect_autobaud(15) - if live.version != pb.NEWEST_LOADER: + if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER: fail(f"{label}: loader reports pureboot {live.version}") + if loader.unified: + # pureboot 5's data space. 0x0200 is clear of the + # loader's own .noinit unit at the bottom of SRAM and of + # the stack at the top. Reading it back over the same + # locked link proves both directions of the new space. + probe = bytes(range(0x30, 0x40)) + loader.write_ram(0x0200, probe) + if loader.read_ram(0x0200, len(probe)) != probe: + fail(f"{label}: RAM round-trip mismatch") + # The register file and the I/O space share the data + # address space on AVR, so the same command reaches a + # peripheral register. SPMCSR reads back as idle here. + verbose_ram = loader.read_ram(0x0200, 4) + print(f" {label}: RAM read/write ok ({verbose_ram.hex()})") loader.run_application() banner = port.read_exact(3, 5.0) if banner != b"APP": diff --git a/test/pbtest.py b/test/pbtest.py index 69d1c6f..75189c8 100644 --- a/test/pbtest.py +++ b/test/pbtest.py @@ -100,11 +100,14 @@ def main(): try: loader = pb.Loader(port) live = loader.connect(15) - # The loader built from this tree and the tool beside it must - # agree on where the version numbering stands: a bump the tool - # was never told about is a loader it would refuse to speak to. - if live.version != pb.NEWEST_LOADER: - fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}") + # The loader built from this tree must report a version the tool + # beside it speaks — a bump the tool was never told about is a + # loader it would refuse to talk to. Not equality with the newest: + # the tool now spans two loader generations, the fixed-baud one + # here and the unified autobaud loader that follows it. + if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER: + fail(f"loader reports pureboot {live.version}, the tool speaks " + f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}") # A W addressed inside a page rather than at its base must still # consume exactly one page and prompt. The loader's own slot is