From eecf9673b9434977e2d56e5296a48215fb5b4196 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Thu, 23 Jul 2026 22:15:38 +0200 Subject: [PATCH] pureboot: autobaud variant, two versions for review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 13 + pureboot/CMakeLists.txt | 35 +++ pureboot/autobaud.md | 193 +++++++++++++++ pureboot/pureboot_autobaud_pure.cpp | 368 ++++++++++++++++++++++++++++ pureboot/pureboot_autobaud_reg.cpp | 343 ++++++++++++++++++++++++++ 5 files changed, 952 insertions(+) create mode 100644 pureboot/autobaud.md create mode 100644 pureboot/pureboot_autobaud_pure.cpp create mode 100644 pureboot/pureboot_autobaud_reg.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 882367f..1a1e774 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,19 @@ 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). + 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) + # One point of the exhaustive matrix, named from its resolved parameters # so the enumeration cannot collide with itself. Unreachable rates drop # out here rather than aborting the configure. diff --git a/pureboot/CMakeLists.txt b/pureboot/CMakeLists.txt index 03462ed..610cb0a 100644 --- a/pureboot/CMakeLists.txt +++ b/pureboot/CMakeLists.txt @@ -310,3 +310,38 @@ function(pureboot_add_loader name) set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD} PUREBOOT_LINK ${_link}) endfunction() + +# pureboot_add_autobaud( [RX ] [TX ]) +# +# An autobaud software-serial loader from (pureboot_autobaud_*.cpp). +# Autobaud measures the host's bit timing at runtime, so the image carries no +# clock and no baud — one binary per chip runs at any F_CPU. Same per-chip +# geometry, link and codegen flags as pureboot_add_loader(); only the clock and +# baud axes fall away. Two source files are under review (autobaud.md): +# pureboot_autobaud_pure.cpp and pureboot_autobaud_reg.cpp. +function(pureboot_add_autobaud name source) + cmake_parse_arguments(PB "" "RX;TX" "" ${ARGN}) + if(NOT PB_RX) + set(PB_RX pb0) + endif() + if(NOT PB_TX) + set(PB_TX pb1) + endif() + foreach(_pin ${PB_RX} ${PB_TX}) + if(NOT _pin MATCHES "^p[a-h][0-7]$") + message(FATAL_ERROR "pureboot_add_autobaud(${name}): pin '${_pin}' is not of the form pb1") + endif() + endforeach() + get_property(_base_hex GLOBAL PROPERTY PUREBOOT_BASE_HEX) + get_property(_app GLOBAL PROPERTY PUREBOOT_APP) + get_property(_wrap GLOBAL PROPERTY PUREBOOT_WRAP) + + add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/${source}) + target_link_libraries(${name} PRIVATE libavr) + target_compile_definitions(${name} PRIVATE PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) + target_compile_options(${name} PRIVATE + -fno-ivopts -fira-algorithm=priority -fno-move-loop-invariants -fno-tree-ter -fno-split-wide-types) + target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} + -Wl,--defsym=pureboot_app=${_app} ${_wrap}) + add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) +endfunction() diff --git a/pureboot/autobaud.md b/pureboot/autobaud.md new file mode 100644 index 0000000..97ac05e --- /dev/null +++ b/pureboot/autobaud.md @@ -0,0 +1,193 @@ +# 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 | 440–508 B | 452–512 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 1–16 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 ~18–26 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 | 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 | + +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. + +## What remains (either version) + +The decider — does pure C++ fit 512 on every chip — is answered (yes). Not yet +done, and required before shipping: + +1. **Sim-validate the lock.** Run the chosen variant over the GPIO⇄pty bridge at + ≥3 exact F_CPU (1/8/16 MHz, the tiny13's 9.6 MHz): confirm it measures the + right unit and completes a flash + verify. This test is also what pins the + codegen-coupled calibration constant (0xC0 vs the 7-cycle loop) against a + toolchain bump. +2. **Host tool + protocol.** `pureboot.py` learns the calibration handshake + (send the 0xC0 lead, then knock at the locked rate), the slim info format + (derive geometry from the signature), and — for the pure version — that the + loader carries no write guard. README protocol section updated. +3. **Real-hardware acceptance.** Autobaud exists for what a cycle-exact simulator + cannot produce: a real RC oscillator at ±10 % with drift and jitter. 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). + +## Files + +- `pureboot_autobaud_pure.cpp` — the pure version (GPIOR/RAM unit, no guard). +- `pureboot_autobaud_reg.cpp` — the register version (GRV unit, guard kept). +- `CMakeLists.txt` — `pureboot_add_autobaud()` builds either; the port's build + 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. diff --git a/pureboot/pureboot_autobaud_pure.cpp b/pureboot/pureboot_autobaud_pure.cpp new file mode 100644 index 0000000..b2b64c7 --- /dev/null +++ b/pureboot/pureboot_autobaud_pure.cpp @@ -0,0 +1,368 @@ +// 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, +// exactly the constraints the fixed-baud loader keeps. +// +// Two source files exist for review (autobaud.md): +// this one, pure, and pureboot_autobaud_reg.cpp, which keeps the running-slot +// write guard at the cost of one global register variable. They differ only in +// where the measured unit lives and whether the guard is present. +// +// What this version trades to fit 512 B in pure C++ (the 1284 at 508), each +// licensed by "the host guarantees safety" (README.md) and the owner's approval +// to simplify the info block: +// - the measured per-bit unit lives in the two general-purpose I/O scratch +// registers (GPIOR) where the chip has them, in a static otherwise — +// reached through libavr's named register surface, so no asm and no global +// register variable; the loader stays pure; +// - the info block is slimmed to the version and the signature — the chip's +// identity — from which the host derives page size, loader base, EEPROM +// size and the addressing flags via its own chip database; +// - no running-slot write guard: the host never programs the loader's own +// slot, and a broken host bricking the target is the host's bug; +// - a single-byte activation knock: the calibration pulse already proves a +// host is present. +// +// Position independence is kept and is in fact total here: control flow is +// PC-relative, the wire carries addresses, and with the slimmed info block and +// no write guard nothing anchors on the runtime address at all. + +#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 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 = 4; + +// 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 measured per-bit delay (in _delay_loop_2 four-cycle iterations). It lives +// in the two adjacent general-purpose I/O scratch registers (GPIOR1:GPIOR2) +// where the chip has them — in/out reach them in one word where a static's +// lds/sts take two, and there is no .bss to clear — and in a plain static +// otherwise (the t13, m8 and m16/32 have no GPIOR). Both are pure: the named +// register surface, no inline asm, no global register variable. +constexpr bool have_gpior = hw::db.reg_index("GPIOR1") >= 0 && hw::db.reg_index("GPIOR2") >= 0; + +std::uint16_t unit_backing; + +template +[[gnu::always_inline]] inline std::uint16_t get_unit() +{ + if constexpr (Gpior) + return static_cast(hw::reg_impl::read() | + (hw::reg_impl::read() << 8)); + else + return unit_backing; +} + +template +[[gnu::always_inline]] inline void put_unit(std::uint16_t u) +{ + if constexpr (Gpior) { + hw::reg_impl::write(static_cast(u)); + hw::reg_impl::write(static_cast(u >> 8)); + } else + unit_backing = u; +} + +// 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. + 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; + } + + static std::uint8_t rx() + { + 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) { + value >>= 1; + if (in_t::read()) + value |= 0x80; + _delay_loop_2(unit); + } + return value; + } + + 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); +} + +[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(avr::flash_load(reinterpret_cast(address++))); + while (--count); +} + +[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count) +{ + std::uint8_t rampz = static_cast(address >> 15); + std::uint16_t z = static_cast(address << 1); + do { + link::tx(avr::flash_load_far((static_cast(rampz) << 16) | z)); + if (++z == 0) + ++rampz; + } while (--count); +} + +[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count) +{ + if constexpr (word_flash) + send_flash_far(address, count); + else + send_flash_near(address, count); +} + +[[gnu::noinline]] void tx_ack() +{ + link::tx(ack); +} + +void send_eeprom(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(ee::read(address++)); + while (--count); +} + +void store_eeprom(std::uint16_t address, std::uint8_t count) +{ + do { + ee::write(address++, link::rx()); + tx_ack(); + } while (--count); +} + +// One page into the SPM buffer, then erase and program. No running-slot write +// guard: the host guarantees it never targets the loader's own slot (the pure +// version's one dropped safety net, licensed — README.md). +void program_flash(std::uint16_t wire_address) +{ + spm::flash_address_t address; + if constexpr (word_flash) { + const std::uint8_t rampz = static_cast(wire_address >> 15); + const std::uint16_t z0 = static_cast(wire_address << 1) & ~static_cast(page - 1); + std::uint16_t z = z0; + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill((static_cast(rampz) << 16) | z, word_of({low, high})); + z += 2; + } while (static_cast(z)); + address = (static_cast(rampz) << 16) | z0; + } else { + address = static_cast(wire_address & ~static_cast(page - 1)); + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill(address, word_of({low, high})); + address += 2; + } while (static_cast(address) & (page - 1)); + address -= 2; // back inside the page — erase and write ignore the word bits + } + spm::erase_page(address); + if constexpr (boot_section) + spm::wait(); + spm::write_page(address); + if constexpr (boot_section) + spm::wait(); + if constexpr (boot_section) + spm::rww_enable(); +} + +void send_fuses() +{ + std::uint8_t which = 0; + do + link::tx(spm::read_fuse(static_cast(which))); + while (++which != 4); +} + +[[noreturn]] void run() +{ + if (hw::field_impl::test()) + run_app(); + + link::init(); + + // Measure the calibration pulse into the unit, then take one 'p' knock. An + // expired budget (no host) boots the application; a pulse that decodes to + // anything but 'p' re-measures. + for (;;) { + if (link::measure(autobaud_budget) == 0) + run_app(); + if (link::rx() == '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 'R': // read flash: addr16, n8 (0 = 256) + case 'r': // read EEPROM: addr16, n8 + case 'w': { // write EEPROM: addr16, n8, then n bytes each acked + // One address-and-count path for the three, so the flash streamer + // keeps a single call site and inlines into this never-returning + // loop — its cursor then lives in the loop's own call-saved + // registers instead of being saved and restored around a call + // (the call-site-count lesson, autobaud.md). + const std::uint16_t address = rx16(); + const std::uint8_t count = link::rx(); + if (command == 'r') + send_eeprom(address, count); + else if (command == 'w') + store_eeprom(address, count); + else + send_flash(address, count); + break; + } + case 'W': // program one flash page: addr16, page bytes + program_flash(rx16()); + break; + case 'F': // fuse and lock bytes + send_fuses(); + break; + default: // unknown bytes are ignored; the loop re-acks + break; + } + } +} + +} // namespace +} // namespace pureboot + +template struct avr::startup::entry; diff --git a/pureboot/pureboot_autobaud_reg.cpp b/pureboot/pureboot_autobaud_reg.cpp new file mode 100644 index 0000000..3010bee --- /dev/null +++ b/pureboot/pureboot_autobaud_reg.cpp @@ -0,0 +1,343 @@ +// 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 +// of one global register variable (r4) holding the measured unit. That variable +// is pureboot's single, deliberate break from its no-global-register-variable +// rule, present only in this variant; everything else stays pure C++. +// +// Two source files exist for review (autobaud.md): +// pureboot_autobaud_pure.cpp, fully pure but dropping the write guard, and this +// one. They differ only in where the measured unit lives (a call-saved register +// here, GPIOR/RAM there) and whether the guard is present. +// +// The register buys ~32 B over a RAM home — an outlined rx/tx reads it with one +// move where a static costs an lds — and that is what lets the write guard stay +// while the image still fits 512 B (the 1284 at 512, exactly). The unit is +// written once through a noinline setter so the store lands immediately before a +// ret: GCC otherwise deletes a global-register store whose only readers are +// callees (autobaud.md, upstream bug 6). +// +// Simplifications shared with the pure version, each licensed: a slimmed info +// block (version + signature; the host derives geometry from its chip database) +// and a single-byte activation knock (the calibration pulse already proves a +// host). Position independence is kept: control flow is PC-relative and the +// write guard anchors on the runtime return address, as the fixed-baud loader. + +#include + +#include + +using namespace avr::literals; +namespace spm = avr::spm; +namespace ee = avr::eeprom; +namespace hw = avr::hw; + +// The measured per-bit delay (in _delay_loop_2 four-cycle iterations) in a +// call-saved register that the serial callees read directly, so no wire path +// threads it and rx/tx reach it with a move, not a load. Written only through +// set_unit() below. +register std::uint16_t g_unit asm("r4"); + +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; only the software-UART pins are a parameter. +#if !defined(PUREBOOT_RX) +#define PUREBOOT_RX pb0 +#endif +#if !defined(PUREBOOT_TX) +#define PUREBOOT_TX pb1 +#endif + +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"); +} + +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(); +constexpr bool word_flash = spm::flash_bytes > 65536; + +constexpr std::uint8_t version = 4; + +#if !defined(PUREBOOT_AUTOBAUD_POLLS) +#define PUREBOOT_AUTOBAUD_POLLS 4000000 +#endif +constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; + +// The one store into g_unit, isolated so it lands right before the ret: a +// global-register store whose only later readers are callees is dropped +// otherwise (autobaud.md, upstream bug 6). +[[gnu::noinline]] void set_unit(std::uint16_t v) +{ + g_unit = v; +} + +// The autobaud software link: bit-banged with cycle-counted delays, but the +// per-bit delay is g_unit, measured from the host's calibration pulse. +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 g_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 count >> 2 is the bit + // period in _delay_loop_2's four-cycle iterations. 0 means the budget + // expired. + 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); + set_unit(u); + return u; + } + + static std::uint8_t rx() + { + 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) { + value >>= 1; + if (in_t::read()) + value |= 0x80; + _delay_loop_2(g_unit); + } + return value; + } + + static void tx(std::uint8_t value) + { + out_t::clear(); // start bit + _delay_loop_2(g_unit); + for (std::uint8_t bit = 0; bit < 8; ++bit) { + out_t::write(value & 1); + value >>= 1; + _delay_loop_2(g_unit); + } + out_t::set(); // stop bit + _delay_loop_2(g_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); +} + +[[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); +} + +[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(avr::flash_load(reinterpret_cast(address++))); + while (--count); +} + +[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count) +{ + std::uint8_t rampz = static_cast(address >> 15); + std::uint16_t z = static_cast(address << 1); + do { + link::tx(avr::flash_load_far((static_cast(rampz) << 16) | z)); + if (++z == 0) + ++rampz; + } while (--count); +} + +[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count) +{ + if constexpr (word_flash) + send_flash_far(address, count); + else + send_flash_near(address, count); +} + +[[gnu::noinline]] void tx_ack() +{ + link::tx(ack); +} + +void send_eeprom(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(ee::read(address++)); + while (--count); +} + +void store_eeprom(std::uint16_t address, std::uint8_t count) +{ + do { + ee::write(address++, link::rx()); + tx_ack(); + } while (--count); +} + +// One page into the SPM buffer, then erase and program — except the slot this +// code is running in (`slot_high`, from run()), which is drained and left +// alone. A broken host therefore cannot brick the running loader, and a copy one +// slot lower may rewrite the resident one. +void program_flash(std::uint16_t wire_address, std::uint8_t slot_high) +{ + spm::flash_address_t address; + std::uint8_t page_high; + if constexpr (word_flash) { + const std::uint8_t rampz = static_cast(wire_address >> 15); + const std::uint16_t z0 = static_cast(wire_address << 1) & ~static_cast(page - 1); + std::uint16_t z = z0; + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill((static_cast(rampz) << 16) | z, word_of({low, high})); + z += 2; + } while (static_cast(z)); + address = (static_cast(rampz) << 16) | z0; + page_high = static_cast(wire_address >> 8); + } else { + address = static_cast(wire_address & ~static_cast(page - 1)); + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill(address, word_of({low, high})); + address += 2; + } while (static_cast(address) & (page - 1)); + address -= 2; // back inside the page — erase and write ignore the word bits + page_high = static_cast(address >> 8) & 0xfe; + } + if (page_high != slot_high) { + spm::erase_page(address); + if constexpr (boot_section) + spm::wait(); + spm::write_page(address); + if constexpr (boot_section) + spm::wait(); + } + if constexpr (boot_section) + spm::rww_enable(); +} + +void send_fuses() +{ + std::uint8_t which = 0; + do + link::tx(spm::read_fuse(static_cast(which))); + while (++which != 4); +} + +[[noreturn]] void run() +{ + if (hw::field_impl::test()) + run_app(); + + link::init(); + + // The high byte of the slot this copy runs at, which the write guard + // follows: the return address is a word address, so its high byte is the + // 256-word slot index, doubled back into byte terms on a byte-addressed + // chip. Taken as byteswap's low byte — the builtin already swaps the two + // stacked bytes, and the double swap folds away. + const std::uint16_t ra_words = reinterpret_cast(__builtin_return_address(0)); + const std::uint8_t ra_high = static_cast(std::byteswap(ra_words)); + const std::uint8_t slot_high = word_flash ? ra_high : static_cast(ra_high << 1); + + // Measure the calibration pulse into g_unit, then take one 'p' knock. An + // expired budget (no host) boots the application; a pulse that decodes to + // anything but 'p' re-measures. + for (;;) { + if (link::measure(autobaud_budget) == 0) + run_app(); + if (link::rx() == '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 'R': // read flash: addr16, n8 (0 = 256) + case 'r': // read EEPROM: addr16, n8 + case 'w': { // write EEPROM: addr16, n8, then n bytes each acked + // One address-and-count path for the three, so the flash streamer + // keeps a single call site and inlines into this never-returning + // loop (autobaud.md). + const std::uint16_t address = rx16(); + const std::uint8_t count = link::rx(); + if (command == 'r') + send_eeprom(address, count); + else if (command == 'w') + store_eeprom(address, count); + else + send_flash(address, count); + break; + } + case 'W': // program one flash page: addr16, page bytes + program_flash(rx16(), slot_high); + break; + case 'F': // fuse and lock bytes + send_fuses(); + break; + default: // unknown bytes are ignored; the loop re-acks + break; + } + } +} + +} // namespace +} // namespace pureboot + +template struct avr::startup::entry;