From 3a0d3790ee5b6270afb191bcb146a86df6011f9a Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 19 Jul 2026 16:47:21 +0200 Subject: [PATCH] tsb: pure and tricks tiers reach full oracle feature parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tiers gain the features the asm tier already carries — one-wire half-duplex (via libavr's new .half_duplex), the config-page activation timeout, and emergency erase (password \0 + double-confirm wipes flash, EEPROM and the config page) — on top of the watchdog bail, password gate and config/flash/EEPROM read-write they already had. pure stays idiomatic (flash_table info block, one function per command) at 950 B; tricks keeps its compiler trickery (call-saved global-register page walk, unified runtime-flag paths pinned noinline/noclone, streaming stores, arithmetic command decode) at 808 B. Both byte-identical across generated and reflect modes; the size gradient across the three tiers is now 502 / 808 / 950 B. Co-Authored-By: Claude Opus 4.8 (1M context) --- tsb/tsb_pure.cpp | 135 +++++++++++++++++++++++-------------- tsb/tsb_tricks.cpp | 161 ++++++++++++++++++++++++++------------------- 2 files changed, 180 insertions(+), 116 deletions(-) diff --git a/tsb/tsb_pure.cpp b/tsb/tsb_pure.cpp index b26cb64..692ee6f 100644 --- a/tsb/tsb_pure.cpp +++ b/tsb/tsb_pure.cpp @@ -1,17 +1,14 @@ // TinySafeBoot on libavr — tier 1: pure, idiomatic C++. // -// A ≤512-byte serial flash bootloader for the ATmega328P boot section, -// reimplementing the TinySafeBoot wire protocol (native-UART fixed-baud -// lineage) on libavr. This variant is written for clarity: well-factored -// functions, no compiler-specific size hacks, no inline assembly. The only -// attribute is the one the task inherently needs — the naked reset entry that -// stands in for the absent C runtime; the flash-resident info block is a libavr -// flash_table. -// -// The structure follows the hand-written reference: one SRAM page buffer that -// every page transfer shares, separate flash/EEPROM leaf routines (so nothing -// is duplicated by constant propagation), and the polled `unused` interrupt -// posture so every SPM/EEPROM lock folds away. +// A serial flash bootloader for the ATmega328P boot section, reimplementing the +// TinySafeBoot native-UART fixed-baud protocol on libavr with the full feature +// set of the hand-written oracle: a watchdog-reset bail, one-wire half-duplex, +// a config-page activation timeout, the password gate, emergency erase, and +// config/flash/EEPROM read-write. This variant is written for clarity — +// well-factored functions, no compiler-specific size hacks, no inline assembly. +// The one-wire wiring, the flash-resident info block and every SPM/EEPROM lock +// are libavr's to handle; the only attribute is the naked reset entry that +// stands in for the absent C runtime. #include @@ -22,7 +19,8 @@ namespace spm = avr::spm; namespace ee = avr::eeprom; using dev = avr::device<{.clock = 16_MHz}>; -using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct}>; +// 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 { @@ -31,15 +29,15 @@ namespace tsb { // EEPROM lock folds to nothing under this posture. constexpr auto off = avr::irq::guard_policy::unused; -// The two handshake bytes, identical across every TSB host. +// 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 ATmega328P 512-byte boot section (BOOTSZ=11). The page -// size, flash and EEPROM extents are the chip database's to know. app_end is -// both the first byte the loader protects and the config page (the LASTPAGE -// holding app-jump vector, timeout and password), one page below the boot -// section. +// 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 (the LASTPAGE holding the app-jump vector, activation timeout and +// password), 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; @@ -49,8 +47,7 @@ constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; // The 16-byte device-info block the host reads on activation. A flash_table -// keeps it in progmem with no .data image (there is no crt to copy one) and -// reads it back through LPM. +// keeps it in progmem with no .data image (there is no crt to copy one). // clang-format off inline constexpr std::array info_data = { 'T', 'S', 'B', @@ -66,15 +63,14 @@ inline constexpr std::array info_data = { using info = avr::flash_table; // One page staged in SRAM. Scratch that is always filled before it is read, so -// it lives in .noinit — no startup clear (there is no crt to run one) and no -// bytes in .text, which is the only thing the boot-section budget counts. +// it lives in .noinit — no startup clear (there is no crt) and no .text bytes. [[gnu::section(".noinit")]] std::uint8_t buffer[page]; +// Blocking byte read/write over the one-wire line: read() releases the line to +// the receiver, write() takes it and holds it until the frame is out. std::uint8_t rx() { - for (;;) - if (auto byte = serial.read()) - return *byte; + return serial.read_blocking(); } void tx(std::uint8_t byte) @@ -130,6 +126,16 @@ void write_eeprom_page(std::uint16_t addr) ee::write(addr + i, buffer[i]); } +// Erase the whole application, one page at a time (unwritten pages stay erased). +void erase_application() +{ + for (std::uint16_t a = 0; a < app_end; a += page) { + spm::erase_page(a); + spm::wait(); + } + spm::rww_enable(); +} + // Run the application: reset vector at 0x0000. Any non-command byte, a wrong // password, or an idle programmer port lands here. [[noreturn]] void appjump() @@ -160,19 +166,15 @@ void read_eeprom() } } -// 'F': erase the whole application first (unwritten pages stay erased), then -// take pages the host offers behind '?'. +// 'F': erase the whole application first, then take pages the host offers +// behind '?'. void write_flash() { - for (std::uint16_t a = 0; a < app_end; a += page) { - spm::erase_page(a); - spm::wait(); - } + erase_application(); for (std::uint16_t a = 0; request_confirm(); a += page) { get_page(); write_flash_page(a); } - spm::rww_enable(); } // 'E': take pages the host offers behind '?' into EEPROM. @@ -197,36 +199,71 @@ void write_config() send_flash(app_end, page); } +// Emergency erase: wipe the application flash, the EEPROM and the config page. +// Reachable only from the password gate (a wrong byte can never reach it), so a +// blank config still leaves the loader recoverable. +void emergency_erase() +{ + erase_application(); + for (std::uint16_t a = 0; a <= eeprom_end; ++a) + ee::write(a, 0xff); + spm::erase_page(app_end); + spm::wait(); + spm::rww_enable(); +} + +// The password gate. The config page holds the password at app_end+3, +// terminated by 0xff (a blank page means no password). A byte of 0 requests +// emergency erase; a wrong byte hangs the loader, still draining the line, so a +// wrong password can never fall through to the erase. +enum class gate : std::uint8_t { pass, emergency }; + +gate password_gate() +{ + for (const std::uint8_t *pw = flash_ptr(app_end + 3);; ++pw) { + std::uint8_t expected = avr::flash_load(pw); + if (expected == 0xff) + return gate::pass; + std::uint8_t got = rx(); + if (got == 0) + return gate::emergency; + if (got != expected) + for (;;) + rx(); + } +} + [[noreturn]] void run() { - // A bootloader may be entered by a watchdog reset; the reference loader - // hands straight back to the application in that case rather than 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(); avr::init(); - // Activation: the host knocks three '@'. With no programmer attached the - // port stays idle, so a bounded wait boots the application instead of - // hanging forever. + // Activation: the host knocks three '@' inside a window whose length is the + // config page's timeout byte (floored so a corrupt page can never lock the + // loader out). An idle port times out and boots the application. + std::uint32_t idle = static_cast(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16; std::uint8_t knocks = 0; - std::uint32_t idle = 4000000; while (knocks < 3) { if (auto byte = serial.read()) - knocks = *byte == '@' ? knocks + 1 : 0; + knocks = *byte == knock ? knocks + 1 : 0; else if (--idle == 0) appjump(); } - // Password gate: the config page holds it at app_end+3, terminated by 0xff. - // A blank page (0xff there) means no password. A wrong byte hangs the loader - // silently, as TSB does. - for (const std::uint8_t *pw = flash_ptr(app_end + 3); avr::flash_load(pw) != 0xff; ++pw) - if (rx() != avr::flash_load(pw)) - for (;;) { - } - - send_flash(reinterpret_cast(info::storage.data()), info::size()); + switch (password_gate()) { + case gate::pass: + send_flash(reinterpret_cast(info::storage.data()), info::size()); + break; + case gate::emergency: + if (!request_confirm() || !request_confirm()) + appjump(); + emergency_erase(); + break; + } for (;;) { tx(confirm); // Mainloop ready diff --git a/tsb/tsb_tricks.cpp b/tsb/tsb_tricks.cpp index d6f1c90..69ad059 100644 --- a/tsb/tsb_tricks.cpp +++ b/tsb/tsb_tricks.cpp @@ -1,12 +1,15 @@ // TinySafeBoot on libavr — tier 2: C++ with compiler trickery. // -// Same protocol and libavr surface as the pure variant (tsb_pure.cpp), but the -// readable one-handler-per-command shape is traded for size: flash and EEPROM -// share a single code path selected by a *runtime* flag decoded from the -// command byte, so the compiler cannot constant-propagate it into two clones. -// Attributes pin that sharing down (noinline/noclone) and the hot page pointer -// and byte counter are pinned to call-saved registers to erase the prologue -// push/pop that C++ function decomposition otherwise pays. No inline assembly. +// Same protocol, libavr surface and full feature set as the pure variant +// (tsb_pure.cpp) — watchdog bail, one-wire, config-page timeout, password gate, +// emergency erase, config/flash/EEPROM read-write — but the readable +// one-handler-per-command shape is traded for size. Flash and EEPROM share a +// single code path selected by a *runtime* flag decoded from the command byte, +// so the compiler cannot constant-propagate it into two clones; attributes +// (noinline/noclone) pin that sharing down; the hot page address and byte +// counter live in call-saved global registers to erase the prologue push/pop +// that C++ function decomposition otherwise pays; and pages stream straight to +// SPM/EEPROM with no SRAM staging. No inline assembly. #include @@ -17,7 +20,7 @@ namespace spm = avr::spm; namespace ee = avr::eeprom; using dev = avr::device<{.clock = 16_MHz}>; -using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct}>; +using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct, .half_duplex = true}>; inline constexpr serial_t serial{}; namespace tsb { @@ -26,6 +29,7 @@ constexpr auto off = avr::irq::guard_policy::unused; constexpr std::uint8_t confirm = '!'; constexpr std::uint8_t request = '?'; +constexpr std::uint8_t knock = '@'; constexpr std::uint16_t page = spm::page_bytes; constexpr std::uint16_t boot_bytes = 1024; @@ -47,21 +51,16 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; }; // clang-format on -[[gnu::section(".noinit")]] std::uint8_t buffer[page]; - // The hot page walk lives in call-saved global registers, TSB-style: g_addr is // the running flash/EEPROM byte address, g_cnt the byte countdown. Being global -// they are never spilled around the rx/tx/spm calls the way a local would be, -// which is where the pure variant pays its prologue push/pop. r4-r7 are -// call-saved, so the library's UART/SPM helpers preserve them across calls. +// they are never spilled around the rx/tx/spm calls the way a local would be — +// r4-r7 are call-saved, so the library's UART and SPM helpers preserve them. register std::uint16_t g_addr asm("r4"); register std::uint8_t g_cnt asm("r6"); std::uint8_t rx() { - for (;;) - if (auto byte = serial.read()) - return *byte; + return serial.read_blocking(); } void tx(std::uint8_t byte) @@ -74,13 +73,8 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) return reinterpret_cast(addr); } -// One page transfer, memory selected at run time. noinline + noclone keep it a -// single shared body: the `flash` flag arrives from the command byte, so the -// optimiser cannot split it back into a flash copy and an EEPROM copy. All of -// these walk g_addr / g_cnt, set by the caller. - -// Stream g_cnt bytes to the host from flash (LPM) or EEPROM, advancing g_addr -// so a caller can send consecutive pages without re-seeding it. +// Stream g_cnt bytes to the host from flash (LPM) or EEPROM, memory chosen at +// run time so the optimiser cannot split the loop into two clones. [[gnu::noinline, gnu::noclone]] void send(bool flash) { do { @@ -89,36 +83,30 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) } while (--g_cnt); } -// Take one page from the host into the SRAM buffer. -[[gnu::noinline]] void get_page() -{ - g_cnt = 0; - do { - buffer[g_cnt] = rx(); - } while (++g_cnt != page); -} - [[gnu::noinline]] bool request_confirm() { tx(request); return rx() == confirm; } -// Program the SRAM buffer into the already-erased page at g_addr (flash) or into -// EEPROM. g_addr is left on the page base for the caller to advance. -[[gnu::noinline, gnu::noclone]] void write_page(bool flash) +// Stream one page straight from the host into the already-erased flash page at +// g_addr (SPM word buffer, low byte then high) or into EEPROM — no SRAM staging, +// so receive and store are one loop. The memory is a run-time flag. +[[gnu::noinline, gnu::noclone]] void store_page(bool flash) { g_cnt = 0; if (flash) { do { - spm::fill(g_addr + g_cnt, static_cast(buffer[g_cnt] | (buffer[g_cnt + 1] << 8))); + std::uint8_t lo = rx(); + std::uint8_t hi = rx(); + spm::fill(g_addr + g_cnt, static_cast(lo | (hi << 8))); g_cnt += 2; } while (g_cnt != page); spm::write_page(g_addr); spm::wait(); } else { do { - ee::write(g_addr + g_cnt, buffer[g_cnt]); + ee::write(g_addr + g_cnt, rx()); } while (++g_cnt != page); } } @@ -130,6 +118,18 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) __builtin_unreachable(); } +// Erase the whole application, one page at a time. +[[gnu::noinline]] void erase_application() +{ + g_addr = 0; + do { + spm::erase_page(g_addr); + spm::wait(); + g_addr += page; + } while (g_addr < app_end); + spm::rww_enable(); +} + // 'f'/'e': stream memory back one page per host '!'. send advances g_addr, so // flash self-terminates at the application boundary; EEPROM runs until the host // stops. @@ -150,22 +150,13 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) // the host offers behind '?'. [[gnu::noinline]] void write_mem(bool flash) { - if (flash) { - g_addr = 0; - do { - spm::erase_page(g_addr); - spm::wait(); - g_addr += page; - } while (g_addr < app_end); - } + if (flash) + erase_application(); g_addr = 0; while (request_confirm()) { - get_page(); - write_page(flash); + store_page(flash); g_addr += page; } - if (flash) - spm::rww_enable(); } // 'C': replace the config page, then echo it back for the host to verify. @@ -173,46 +164,82 @@ void write_config() { if (!request_confirm()) return; - get_page(); g_addr = app_end; spm::erase_page(g_addr); spm::wait(); - write_page(true); + store_page(true); spm::rww_enable(); + g_addr = app_end; g_cnt = page; - send(true); // g_addr is still app_end + send(true); +} + +// Emergency erase: wipe the application flash, the EEPROM and the config page. +[[gnu::noinline]] void emergency_erase() +{ + erase_application(); + g_addr = 0; + do { + ee::write(g_addr, 0xff); + } while (++g_addr <= eeprom_end); + spm::erase_page(app_end); + spm::wait(); + spm::rww_enable(); +} + +// The password gate. A byte of 0 requests emergency erase; a wrong byte hangs +// the loader (still draining the line), so it can never fall through to erase. +enum class gate : std::uint8_t { pass, emergency }; + +[[gnu::noinline]] gate password_gate() +{ + for (const std::uint8_t *pw = flash_ptr(app_end + 3);; ++pw) { + std::uint8_t expected = avr::flash_load(pw); + if (expected == 0xff) + return gate::pass; + std::uint8_t got = rx(); + if (got == 0) + return gate::emergency; + if (got != expected) + for (;;) + rx(); + } } [[noreturn]] void run() { - if (avr::hw::mcusr::read() & avr::hw::mcusr::wdrf(1).value) + if (avr::hw::mcusr::wdrf.test()) appjump(); avr::init(); + std::uint32_t idle = static_cast(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16; std::uint8_t knocks = 0; - std::uint32_t idle = 4000000; while (knocks < 3) { if (auto byte = serial.read()) - knocks = *byte == '@' ? knocks + 1 : 0; + knocks = *byte == knock ? knocks + 1 : 0; else if (--idle == 0) appjump(); } - for (const std::uint8_t *pw = flash_ptr(app_end + 3); avr::flash_load(pw) != 0xff; ++pw) - if (rx() != avr::flash_load(pw)) - for (;;) { - } - - g_addr = reinterpret_cast(&info[0]); - g_cnt = sizeof(info); - send(true); + switch (password_gate()) { + case gate::pass: + g_addr = reinterpret_cast(&info[0]); + g_cnt = sizeof(info); + send(true); + break; + case gate::emergency: + if (!request_confirm() || !request_confirm()) + appjump(); + emergency_erase(); + break; + } for (;;) { - tx(confirm); - // Decode the command arithmetically so `flash`/`write` stay runtime - // values: bit 5 is the case bit (upper = write), and the folded-lower - // letter picks the memory. A single unified path serves f/F/e/E. + tx(confirm); // Mainloop ready + // Decode the command arithmetically so flash/write stay run-time values: + // bit 5 is the case bit (upper = write), the folded-lower letter picks the + // memory. A single unified path serves f/F/e/E. std::uint8_t cmd = rx(); std::uint8_t lower = cmd | 0x20; bool write = (cmd & 0x20) == 0;