diff --git a/CMakeLists.txt b/CMakeLists.txt index c94039c..4bac67e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,10 @@ endfunction() # tsb_pure — pure idiomatic libavr, one function per command, TU-local # (internal linkage), streaming (no SRAM page buffer): 836 B in # the 1 KB section. +# tsb_policy — the policy floor: pureboot's rules (no asm, no register +# variables) with every pureboot lesson applied. 638 B in the +# 1 KB section — the measured evidence that the 512 B fit is a +# property of the mechanisms philosophy #5 bans. # # add_tsb_variant( ) function(add_tsb_variant name bytes) @@ -124,8 +128,13 @@ endfunction() # chips build pureboot alone. if(LIBAVR_MCU STREQUAL "atmega328p") add_tsb_variant(tsb_asm 512) + add_tsb_variant(tsb_policy 1024) add_tsb_variant(tsb_pure 1024) add_tsb_variant(tsb_tricks 1024) + # The policy tier's floor is measured with the loop flags pureboot's size + # work found (a loader's loop bodies all contain calls); the other tiers + # keep the flag set their recorded floors were measured with — none. + target_compile_options(tsb_policy PRIVATE -fno-move-loop-invariants -fno-tree-ter) endif() # pureboot — the pure-constraint port (see pureboot/README.md): one source, diff --git a/tsb/tsb_policy.cpp b/tsb/tsb_policy.cpp new file mode 100644 index 0000000..fb061c9 --- /dev/null +++ b/tsb/tsb_policy.cpp @@ -0,0 +1,302 @@ +// TinySafeBoot on libavr — the policy floor: pureboot's rules, measured. +// +// The full TinySafeBoot feature set — watchdog bail, one-wire half-duplex, +// config-page activation timeout, password gate, emergency erase, and +// config/flash/EEPROM read-write — under philosophy #5 exactly as pureboot +// obeys it: no assembly, no register variables; code, attributes, and flags +// only. Every lesson pureboot's development produced is applied — the +// library's half-duplex serial and startup entry, lean bring-up from reset +// state, one merged send loop over both memories, oracle-shaped loop bounds, +// locals threaded through noinline primitives, pureboot's codegen flags — +// and the result is 638 bytes: 198 below the idiomatic tier, and 126 above +// the 512 B boot section the tricks/asm tiers reach with the banned +// mechanisms (526/510). This tier exists to keep that number an artifact +// rather than a claim: the gap to 512 is the rent of policy-clean C++ — +// helpers that hold a cursor across rx()/tx() pay push/pop and argument +// threading where a global-register protocol pays nothing, and both +// control-flow merges tried (a parametrized paged session, a merged store +// loop) measured larger than the split cases they replaced. TSB's wire fixes +// the per-command loop shapes on the device, so pureboot 5's one-transfer- +// loop collapse has no purchase here. +// +// The wire protocol is strict request/response, which is what makes the +// shared line safe: the device drives it only between a received command and +// its reply, and releases it (the library's half-duplex choreography) +// whenever it waits. + +#include + +using namespace avr::literals; +namespace spm = avr::spm; +namespace ee = avr::eeprom; + +using dev = avr::device<{.clock = 16_MHz}>; +// One-wire: RX and TX share the line, exactly as the native-UART TSB expects. +using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct, .half_duplex = true}>; +inline constexpr serial_t serial{}; + +namespace tsb { +namespace { + +// The loader is purely polled — it never enables interrupts — so every SPM and +// EEPROM lock folds to nothing under this posture. +constexpr auto off = avr::irq::guard_policy::unused; + +// The handshake bytes, identical across every TSB host. +constexpr std::uint8_t confirm = '!'; +constexpr std::uint8_t request = '?'; +constexpr std::uint8_t knock = '@'; + +// Boot geometry for the 1 KB boot section (BOOTSZ=10); the page size and the +// flash/EEPROM extents are the chip database's to know. app_end is the config +// page (TSB's LASTPAGE), one page below the boot section. +constexpr std::uint16_t page = spm::page_bytes; +constexpr std::uint16_t boot_bytes = 1024; +constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page; +constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; + +// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). +constexpr std::uint8_t act_min = 16; +// Post-activation window: the host gets seconds, not milliseconds, mid-session. +constexpr std::uint8_t comm_window = 200; + +// Firmware version stamp: YY*512 + MM*32 + DD, the encoding the host decodes. +constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 27; + +// The 16-byte device-info block, streamed out on activation. +// clang-format off +[[gnu::progmem]] constexpr std::uint8_t info[16] = { + 'T', 'S', 'B', + build_date & 0xFF, build_date >> 8, + 0xF3, // status: native-UART fixed-baud lineage + avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2], + page / 2, // page size in words + (app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words + eeprom_end & 0xFF, eeprom_end >> 8, + 0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15) +}; +// clang-format on + +// The receive window, pre-floored where it is set. In .noinit: there is no +// crt to clear a .bss image, and run() stores it before the first receive. +[[gnu::section(".noinit")]] std::uint8_t window; + +const std::uint8_t *flash_ptr(std::uint16_t addr) +{ + return reinterpret_cast(addr); +} + +// Bounded byte receive: poll under nested countdowns, 0 on silence. The 0 +// then falls through every compare — not a knock, not a confirm, not a +// command — so a silent host unwinds the loader to the application from +// anywhere, and a mid-session cable pull cannot wedge it. The line release on +// a direction change is the serial backend's. +[[gnu::noinline]] std::uint8_t rx() +{ + std::uint16_t outer = static_cast(window) << 8; + do { + std::uint8_t fine = 0; + do { + if (auto byte = serial.read()) + return *byte; + } while (--fine); + } while (--outer); + return 0; +} + +// One-wire transmit: the backend takes the line with a turn-around guard and +// holds it until the whole frame is out. +[[gnu::noinline]] void tx(std::uint8_t byte) +{ + serial.write(byte); +} + +// '?', then hand back the host's reply for the callers' one-byte compare. +[[gnu::noinline]] std::uint8_t rcnf() +{ + tx(request); + return rx(); +} + +// The one send loop: the info block, the config page, application flash and +// EEPROM pages all stream through here. +[[gnu::noinline]] void send_block(bool eep, std::uint16_t at, std::uint8_t count) +{ + do { + tx(eep ? ee::read(at) : avr::flash_load(flash_ptr(at))); + ++at; + } while (--count); +} + +// One EEPROM byte in — shared by the emergency wipe and the 'E' stream. +[[gnu::noinline]] void eeput(std::uint16_t at, std::uint8_t value) +{ + ee::write(at, value); +} + +// Wait out a running SPM op, then re-open the RWW section — after every page +// op and before handing over, as the oracle does. +[[gnu::noinline]] void settle() +{ + spm::wait(); + spm::rww_enable(); +} + +// One host page straight into the erased flash page at `at` — through the SPM +// word buffer (low byte then high), no SRAM staging — then committed. `at` +// names a page base, so the cursor's low byte reaching the boundary ends the +// walk. +[[gnu::noinline]] void store_flash_page(std::uint16_t at) +{ + do { + std::uint8_t low = rx(); + std::uint8_t high = rx(); + spm::fill(at, std::bit_cast(std::array{low, high})); + at += 2; + } while (static_cast(at) & (page - 1)); + spm::write_page(at - page); + settle(); +} + +extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0 + +[[noreturn]] void appjump() +{ + settle(); + tsb_app(); +} + +// Step one page down and erase it — the erase shared by the whole-app walk, +// the config rewrite and the emergency wipe; hands the stepped address back. +[[gnu::noinline]] std::uint16_t erase_below(std::uint16_t at) +{ + at -= page; + spm::erase_page(at); + settle(); + return at; +} + +// Erase the whole application, top-down like the oracle: the loop bound is a +// compare with zero, and the returned 0 is the address every caller wants +// next. +[[gnu::noinline]] std::uint16_t erase_application() +{ + std::uint16_t at = app_end; + do { + at = erase_below(at); + } while (at != 0); + return at; +} + +[[noreturn]] void run() +{ + // A watchdog reset hands straight back to the application, as the + // reference loader does, rather than re-entering the bootloader. + if (avr::hw::mcusr::wdrf.test()) + appjump(); + + // Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads + // 0, and the half-duplex write()/read() raise TXEN0/RXEN0 on first use — + // only the divisor low byte and U2X0 need a store. The solver still does + // the datasheet work; the asserts pin the reset-state assumptions. + { + constexpr auto sol = avr::uart::detail::solve_baud(dev::clock, 115200_Bd); + static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0"); + avr::hw::reg<"UBRR0">::write(static_cast(sol.ubrr)); + avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1)); + } + + // Activation: 3×'@', each inside the config page's timeout window + // (floored so a corrupt page cannot lock the loader out); anything else — + // including silence — hands over. + window = avr::flash_load(flash_ptr(app_end + 2)) | act_min; + for (std::uint8_t k = 3; k; --k) + if (rx() != knock) + appjump(); + window = comm_window; + + // Password gate (config page from app_end+3, 0xff-terminated; a blank + // page is no password). A wrong byte blanks the comparison and drains the + // line forever, so a wrong password can never fall through; a 0 requests + // emergency erase behind two confirms. On pass the info block goes out; + // the emergency path skips it and drops into the command loop. + std::uint16_t at = app_end + 3; + std::uint8_t mask = 0xff; + for (;;) { + std::uint8_t expected = avr::flash_load(flash_ptr(at)) & mask; + ++at; + if (expected == 0xff) { + send_block(false, reinterpret_cast(&info[0]), sizeof info); + break; + } + std::uint8_t got = rx(); + if (got == 0) { + if (mask == 0) + continue; + if (rcnf() != confirm || rcnf() != confirm) + appjump(); + std::uint16_t a = erase_application(); + do { + eeput(a, 0xff); + } while (++a <= eeprom_end); + erase_below(app_end + page); + break; + } + if (got != expected) + mask = 0; + } + + for (;;) { + tx(confirm); // Mainloop ready + const std::uint8_t command = rx(); + switch (command) { + case 'f': // read application flash, one page per host '!' + for (std::uint16_t a = 0; a < app_end; a += page) { + if (rx() != confirm) + break; + send_block(false, a, page); + } + break; + case 'e': // read EEPROM, one page per host '!', until the host stops + for (std::uint16_t a = 0;; a += page) { + if (rx() != confirm) + break; + send_block(true, a, page); + } + break; + case 'F': { // erase the application, then take pages behind '?' + std::uint16_t a = erase_application(); + for (; rcnf() == confirm; a += page) + store_flash_page(a); + break; + } + case 'E': // take EEPROM pages behind '?', each write host-paced + for (std::uint16_t a = 0; rcnf() == confirm;) { + std::uint8_t count = page; + do { + eeput(a, rx()); + ++a; + } while (--count); + } + break; + case 'c': // read the config page + read_config: + send_block(false, app_end, page); + break; + case 'C': // replace the config page, then echo it back to verify + if (rcnf() != confirm) + break; + store_flash_page(erase_below(app_end + page)); + goto read_config; + default: // 'q' or any other byte runs the application + appjump(); + } + } +} + +} // namespace +} // namespace tsb + +// Reset lands at the boot section base (BOOTRST): the entry stub in .vectors +// is laid first and does the one line of crt a crt-less image needs. +template struct avr::startup::entry;