diff --git a/CMakeLists.txt b/CMakeLists.txt index dfb7382..d1a35c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,24 +44,29 @@ endif() # its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled # loader has no use for the crt or the vector table. The naked entry sits in # .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section -# size; the linker section-start and the source's boot_bytes agree. +# size; the linker section-start and the source's boot_bytes agree. tsb_app is +# the application's reset vector, pinned to 0 here so the loaders jump to a +# named function; --pmem-wrap-around lets relaxation turn that absolute jump +# into the wrapped rjmp AVR's modulo-flash PC actually executes. # All three implement the full oracle feature set (see oracle/README.md): # watchdog bail, one-wire half-duplex, config-page activation timeout, password # gate, emergency erase, config/flash/EEPROM read-write. They differ only in how, # and the size gradient is the cost of that "how" — see dev/lessons.md. -# tsb_asm — dense inline asm for the loader core, libavr for every geometry/ -# baud/info constant and the stack bring-up: 498 B in the 512 B -# section, beating the hand-written oracle (500 B) at its own -# feature set. The core is asm because C++ cannot reach it under -# 512 (the tricks tier is the proof). -# tsb_tricks — compiler trickery, no asm: the hot page pointer is walked in Y -# (adiw), flash/EEPROM paths share one runtime-flag body, every -# single-call handler is always_inline'd into the no-prologue -# reset entry, pages stream straight to SPM/EEPROM, and the -# bring-up is the two reset-non-default registers only. 666 B in -# the 1 KB section (BOOTSZ=10). +# tsb_asm — the tricks tier's C++ with exactly two routines in asm (the +# bounded rx and the page-store loop — the two whose remaining +# cost is the C ABI itself): 510 B in the 512 B section the +# hand-written 500 B oracle occupies. Everything else, from +# bring-up to dispatch, is C++ on libavr. +# tsb_tricks — no asm at all: the whole-loader register allocation lives in +# global register variables (Y walks the page pointer), every +# helper is a tiny noinline primitive placed by the +# global-register store rules, pages stream straight to +# SPM/EEPROM, and the bring-up is the two reset-non-default +# registers only. 526 B in the 1 KB section (BOOTSZ=10) — 14 +# over the oracle's section, from 168 over at this tier's first +# floor. # tsb_pure — pure idiomatic libavr, one function per command, TU-local -# (internal linkage), streaming (no SRAM page buffer): 842 B in +# (internal linkage), streaming (no SRAM page buffer): 836 B in # the 1 KB section. # # add_tsb_variant( ) @@ -70,7 +75,8 @@ function(add_tsb_variant name bytes) math(EXPR base_hex "${base_dec}" OUTPUT_FORMAT HEXADECIMAL) add_executable(${name} tsb/${name}.cpp) target_link_libraries(${name} PRIVATE libavr) - target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${base_hex}) + target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${base_hex} + -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) if(PROJECT_IS_TOP_LEVEL) add_test(NAME ${name}.size diff --git a/oracle/README.md b/oracle/README.md index 1935cac..7943e61 100644 --- a/oracle/README.md +++ b/oracle/README.md @@ -38,9 +38,11 @@ avra -I /usr/share/avra tsb-fixedbaud.asm # after uncommenting .include "m328P ``` **500 bytes with every feature** — the proof that ≤512 B and full feature parity -are simultaneously reachable. The port's `tsb_asm` tier matches this bar; `tsb_pure` -and `tsb_tricks` implement the same protocol at larger sizes in the 1 KB section, -trading bytes for readability. +are simultaneously reachable. The port's `tsb_asm` tier meets the same bar at +510 B in the same 512 B section, written in C++ on libavr except the two +routines whose remaining cost is the calling convention itself (the bounded rx +and the page-store loop); `tsb_tricks` needs no assembly at all at 526 B, and +`tsb_pure` stays fully idiomatic at 836 B, both in the 1 KB section. The oracle targets 20 MHz / 33333 baud; the port targets 16 MHz / 115200 baud (what the simavr protocol test drives). Baud and geometry differ, code size and diff --git a/tsb/tsb_asm.cpp b/tsb/tsb_asm.cpp index 7136d13..7cf105c 100644 --- a/tsb/tsb_asm.cpp +++ b/tsb/tsb_asm.cpp @@ -1,54 +1,73 @@ -// TinySafeBoot on libavr — tier 3: full feature parity in ≤512 B. +// TinySafeBoot on libavr — tier 3: full feature parity in the 512-byte boot +// section, in C++ except where the C ABI itself is the cost. // // The complete TinySafeBoot feature set — watchdog-reset bail, one-wire // half-duplex UART, a config-page activation timeout, the password gate, -// emergency erase, and config/flash/EEPROM read-write — reimplemented for the -// 512-byte ATmega328P boot section. Matching the hand-written assembly oracle's -// size and features at once is only reachable at assembly density, so the loader -// body is one cohesive inline-asm routine. libavr still does the datasheet work: -// every geometry, baud and info-block constant below is computed by the library, -// never hand-entered, and the loader references them as assembler immediates. +// emergency erase, and config/flash/EEPROM read-write — at 510 bytes in the +// 512-byte BOOTSZ=11 section the hand-written oracle occupies (500 B). This tier used to be one +// monolithic inline-asm routine; it is now the tricks tier's C++ (same +// register protocol, same structure — see tsb_tricks.cpp, including the +// global-register miscompile rules) with exactly two routines kept in +// assembly, the two whose remaining cost *is* the calling convention: // -// The wire protocol is strict request/response, which makes the one-wire -// turn-around safe: the device owns the line whenever it drives a byte and -// releases it (RX-only) whenever it waits for one. +// rx the bounded receive: C++ must re-floor the timeout window on every +// call (the global-register-store miscompile) and split it across +// call-saved registers; the asm keeps the oracle's X-register nested +// countdown. +// store the page-store loop: C++ cannot hold the receive byte pair and the +// walked Z pointer across the rx calls without call-saved staging +// (push/pop + a Y→Z copy per word); the asm calls rx knowing exactly +// which registers it touches and walks Z live across the whole page. +// +// Everything else — bring-up, activation, password gate, emergency erase, +// dispatch, every SPM/EEPROM/flash primitive, every geometry/baud/info +// constant — is C++ on libavr, and the two asm routines splice into the same +// global-register protocol the C++ uses (g_addr in Y, g_cnt in r16, g_window +// in r7, g_receiving in r6), so calls cross the boundary with no marshalling. +// +// 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 (RXEN0 only) whenever it waits. #include -#include // __SPM_ENABLE and the SPM page-op bit names -#include // SFR addresses / bit numbers for the boot entry +#include // SP / RAMEND for the crt-free boot entry, SFR addresses for the asm routines using namespace avr::literals; namespace spm = avr::spm; +namespace ee = avr::eeprom; +namespace hw = avr::hw; namespace tsb { +namespace { -// Boot geometry — the chip database's to know, not ours. -constexpr std::uint16_t page = spm::page_bytes; // 128 -constexpr std::uint16_t boot_bytes = 512; // BOOTSZ=11 -constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page; // config page base -constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; - -// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. -constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); -static_assert(baud.u2x && baud.ubrr < 256, "asm bring-up writes UBRR0L only, with U2X0"); - -// Activation window: the config page's timeout byte, floored so a corrupt page -// can never lock the loader out (at least the clock rate in MHz → ~0.5 s here). -constexpr std::uint8_t act_min = 16; -// Post-activation communication timeout (~several seconds); the loader bails to -// the application if the host falls silent mid-session. -constexpr std::uint8_t comm_timeout = 200; +// 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; constexpr std::uint8_t confirm = '!'; constexpr std::uint8_t request = '?'; constexpr std::uint8_t knock = '@'; -constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; +// Boot geometry for the 512 B boot section (BOOTSZ=11); 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 = 512; +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; -// The 16-byte device-info block, LPM-read on activation. A plain progmem array: -// the loader streams it straight out with LPM, so a flash_table wrapper would -// add nothing here. +// 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; + +constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; + +// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. +constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); + +// The 16-byte device-info block, streamed out on activation. // clang-format off [[gnu::progmem]] constexpr std::uint8_t info[16] = { 'T', 'S', 'B', @@ -62,296 +81,306 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; }; // clang-format on +register std::uint16_t g_addr asm("r28"); +register std::uint8_t g_cnt asm("r16"); +register std::uint8_t g_window asm("r7"); +register std::uint8_t g_receiving asm("r6"); + +const std::uint8_t *flash_ptr(std::uint16_t addr) +{ + return reinterpret_cast(addr); +} + +// Bounded byte receive (asm 1 of 2): release the one-wire line on a direction +// change, poll RXC0 under the oracle's nested X-register countdown seeded from +// g_window (floored against lockout), byte or 0-on-silence in r24. Z survives +// — the property the store's word loop rides on. +[[gnu::noinline, gnu::noclone]] std::uint8_t rx() +{ + std::uint8_t byte; + asm volatile(" tst %[dir] \n\t" // already receiving? keep the line released + " brne 1f \n\t" + " ldi %[b], 0x10 \n\t" // RXEN0 alone: release the line and listen + " sts %[ucsr0b], %[b] \n\t" + " ser %[b] \n\t" + " mov %[dir], %[b] \n\t" + "1: mov r27, %[to] \n\t" // outer countdown high byte = window + " ori r27, %[actmin] \n\t" // lockout-proof floor + " clr r26 \n\t" + "2: ser %[b] \n\t" + "3: lds %[b], %[ucsr0a] \n\t" + " sbrc %[b], 7 \n\t" // RXC0 + " rjmp 4f \n\t" + " dec %[b] \n\t" + " brne 3b \n\t" + " sbiw r26, 1 \n\t" + " brcc 2b \n\t" + " clr %[b] \n\t" // silence → 0, which no compare accepts + " rjmp 5f \n\t" + "4: lds %[b], %[udr0] \n\t" + "5: \n\t" + : [b] "=&d"(byte), [dir] "+r"(g_receiving) + : [to] "r"(g_window), [actmin] "M"(act_min), [ucsr0a] "n"(_SFR_MEM_ADDR(UCSR0A)), + [ucsr0b] "n"(_SFR_MEM_ADDR(UCSR0B)), [udr0] "n"(_SFR_MEM_ADDR(UDR0)) + : "r26", "r27", "cc"); + return byte; +} + +// One-wire transmit: take the line (TXEN0 alone) on a direction change with a +// turn-around guard, put the byte out, hold the line until the whole frame is +// out (TXC0, not UDRE0), W1C TXC0 by storing the sampled status back (keeps +// U2X0). Plain C++ — it compiles *smaller* than the oracle's routine. +[[gnu::noinline, gnu::noclone]] void tx(std::uint8_t byte) +{ + if (g_receiving) { + g_receiving = 0; + hw::ucsr0b::write(hw::ucsr0b::txen0(1)); + for (std::uint8_t guard = 46; guard; --guard) + ; + } + hw::udr0::write(byte); + std::uint8_t status; + do { + status = hw::ucsr0a::read(); + } while (!(status & hw::ucsr0a::txc0(1).value)); + hw::ucsr0a::write(status); +} + +// '?', then hand back the host's reply for the callers' one-byte compare. +[[gnu::noinline, gnu::noclone]] std::uint8_t rcnf() +{ + tx(request); + return rx(); +} + +// One flash byte ← [g_addr++] (the advance right before ret — the +// global-register rule, see tsb_tricks.cpp). +[[gnu::noinline, gnu::noclone]] std::uint8_t sflash() +{ + std::uint8_t byte = avr::flash_load(flash_ptr(g_addr)); + ++g_addr; + return byte; +} + +// One EEPROM byte ← [g_addr++]. +[[gnu::noinline, gnu::noclone]] std::uint8_t eerd() +{ + std::uint8_t byte = ee::read(g_addr); + ++g_addr; + return byte; +} + +// One EEPROM byte → [g_addr++]. +[[gnu::noinline, gnu::noclone]] void eewr(std::uint8_t byte) +{ + ee::write(g_addr, byte); + ++g_addr; +} + +// Stream g_cnt flash bytes from g_addr to the host. +[[gnu::noinline, gnu::noclone]] void sendf() +{ + do { + tx(sflash()); + } while (--g_cnt); +} + +// 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, gnu::noclone]] void settle() +{ + spm::wait(); + spm::rww_enable(); +} + +extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0 + +[[noreturn]] void appjump() +{ + settle(); + tsb_app(); +} + +// Step g_addr one page down and erase that page (the decrement lives here — +// the global-register rule). +[[gnu::noinline, gnu::noclone]] void erase_below() +{ + g_addr -= page; + spm::erase_page(g_addr); + settle(); +} + +// Erase the whole application, top-down like the oracle: the loop bound is a +// compare with zero, and g_addr = 0 is handed back for free. +[[gnu::noinline, gnu::noclone]] void erase_application() +{ + g_addr = app_end; + do { + erase_below(); + } while (g_addr != 0); +} + +// Stream one host page into the erased flash page at g_addr (asm 2 of 2): the +// word pair stages in r0:r1 straight from rx (whose register set is known — +// the cross-call liveness C++ cannot express), Z walks the page and PGWRT +// programs it. g_addr is left at the next page base. +[[gnu::noinline, gnu::noclone]] void store_flash() +{ + asm volatile(" movw r30, r28 \n\t" // Z = page base; rx leaves Z live + " ldi r20, %[words] \n\t" + "1: rcall %x[rx] \n\t" + " mov r0, r24 \n\t" // word low byte + " rcall %x[rx] \n\t" + " mov r1, r24 \n\t" // word high byte + " ldi r24, 0x01 \n\t" // SPMEN: buffer the word at Z + " out %[spmcsr], r24 \n\t" + " spm \n\t" + " clr r1 \n\t" + " adiw r30, 2 \n\t" + " dec r20 \n\t" + " brne 1b \n\t" + " movw %[base], r30 \n\t" // g_addr = the next page base + " subi r30, %[pagelo] \n\t" // Z back to this page's base + " sbci r31, %[pagehi] \n\t" + " ldi r24, 0x05 \n\t" // PGWRT | SPMEN: program the page + " out %[spmcsr], r24 \n\t" + " spm \n\t" + : [base] "+r"(g_addr) + : [rx] "i"(&rx), [spmcsr] "I"(_SFR_IO_ADDR(SPMCSR)), [words] "M"(page / 2), [pagelo] "M"(page & 0xff), + [pagehi] "M"(page >> 8) + : "r0", "r1", "r20", "r24", "r26", "r27", "r30", "r31", "cc", "memory"); + settle(); +} + +[[noreturn, gnu::noinline]] void run() +{ + // A watchdog reset hands straight back to the application, as the + // reference loader does, rather than re-entering the bootloader. + if (hw::mcusr::wdrf.test()) + appjump(); + + // Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads + // 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low + // byte and U2X0 need a store. The library still does the datasheet work. + static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0"); + hw::reg<"UBRR0">::write(static_cast(baud.ubrr)); + hw::ucsr0a::write(hw::ucsr0a::u2x0(1)); + // General-purpose registers are undefined at power-on (no crt zeroes them); + // the direction latch must start "not receiving" so the first rx() enables + // the receiver. The reference loader clears its shadow register for the + // same reason. + g_receiving = 0; + + // Activation: 3×'@', each inside the config page's timeout window (rx + // floors it so a corrupt page cannot lock the loader out); anything else — + // including silence — hands over. + g_window = avr::flash_load(flash_ptr(app_end + 2)); + for (std::uint8_t k = 3; k; --k) + if (rx() != knock) + appjump(); + g_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. + g_addr = app_end + 3; + std::uint8_t mask = 0xff; + for (;;) { + std::uint8_t expected = avr::flash_load(flash_ptr(g_addr)) & mask; + ++g_addr; + if (expected == 0xff) { + g_addr = reinterpret_cast(&info[0]); + g_cnt = sizeof(info); + sendf(); + break; + } + std::uint8_t got = rx(); + if (got == 0) { + if (mask == 0) + continue; + if (rcnf() != confirm || rcnf() != confirm) + appjump(); + erase_application(); // leaves g_addr = 0 for the EEPROM walk + do { + eewr(0xff); + } while (g_addr <= eeprom_end); + g_addr = app_end + page; + erase_below(); + break; + } + if (got != expected) + mask = 0; + } + + for (;;) { + tx(confirm); // Mainloop ready + g_addr = 0; + switch (rx()) { + case 'f': // read application flash, one page per host '!' + for (;;) { + if (rx() != confirm) + break; + g_cnt = page; + sendf(); + if (g_addr >= app_end) + break; + } + break; + case 'F': // erase the application, then take pages behind '?' + erase_application(); // leaves g_addr = 0, the write start + while (rcnf() == confirm) + store_flash(); + break; + case 'e': // read EEPROM, one page per host '!', until the host stops + for (;;) { + if (rx() != confirm) + break; + g_cnt = page; + do { + tx(eerd()); + } while (--g_cnt); + } + break; + case 'E': // take EEPROM pages behind '?' + while (rcnf() == confirm) { + g_cnt = page; + do { + eewr(rx()); + } while (--g_cnt); + } + break; + case 'c': // read the config page + read_config: + g_addr = app_end; + g_cnt = page; + sendf(); + break; + case 'C': // replace the config page, then echo it back to verify + if (rcnf() != confirm) + break; + g_addr = app_end + page; + erase_below(); // leaves g_addr = app_end, the store target + store_flash(); + goto read_config; + default: // 'q' or any other byte runs the application + appjump(); + } + } +} + +} // namespace } // namespace tsb -// Reset lands here: BOOTRST vectors to the boot base, .vectors is laid first, and -// no crt runs. The stack pointer is the one piece of bring-up that reads as -// plainly in C++ as in assembler, so it is; the dense loader body follows. +// Reset lands here: BOOTRST vectors to the boot section base and .vectors is +// laid first, so this is the first instruction executed. No crt ran, so set +// the stack pointer before anything is called. extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry() { SP = RAMEND; - asm volatile( - // --- bring-up ------------------------------------------------------ - " in r16, %[mcusr] \n\t" // watchdog reset → hand straight back - " sbrc r16, 3 \n\t" // MCUSR bit 3 = WDRF - " rjmp 9f \n\t" // 9: = appjump (drains SPM, jumps to 0) - " ldi r16, %[ubrr] \n\t" // fixed baud, UBRR0L only - " sts %[ubrr0l], r16 \n\t" - " ldi r16, 0x02 \n\t" // 1<(); } -// Run the application: reset vector at 0x0000. Any non-command byte, a wrong -// password, or an idle programmer port lands here. +// The application's reset vector; the linker pins it to 0x0000 (--defsym). +extern "C" [[noreturn]] void tsb_app(); + +// Run the application. Any non-command byte, a wrong password, or an idle +// programmer port lands here. [[noreturn]] void appjump() { spm::wait(); // make sure any pending SPM finished before handing over - reinterpret_cast(0)(); - __builtin_unreachable(); + tsb_app(); } // 'f': stream the application flash back, one page per host '!'. Self-terminates @@ -295,5 +301,8 @@ gate password_gate() extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry() { SP = RAMEND; + // The one line of crt this loader needs: compiled code assumes + // __zero_reg__ (r1) is 0, and power-on registers are undefined. + asm volatile("clr __zero_reg__"); tsb::run(); } diff --git a/tsb/tsb_tricks.cpp b/tsb/tsb_tricks.cpp index 21ede3c..53b0dcc 100644 --- a/tsb/tsb_tricks.cpp +++ b/tsb/tsb_tricks.cpp @@ -1,19 +1,33 @@ -// TinySafeBoot on libavr — tier 2: C++ with compiler trickery. +// TinySafeBoot on libavr — tier 2: C++ with compiler trickery, no 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 genuinely-shared sharing down, while every handler -// that is called only once is `always_inline`d into run() — which, being -// [[noreturn]] and entered from the naked reset vector, pays no prologue/epilogue, -// so their push/pop of call-saved registers vanishes. The hot page address lives -// in the Y register (call-saved and adiw-able) so it is walked, not respilled; -// pages stream straight to SPM/EEPROM with no SRAM staging; and the bring-up -// writes only the two registers that are not already at their reset value. No -// inline assembly. +// 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 — in pure C++, 526 bytes: 14 over the 512-byte +// boot section the hand-written oracle fits, from 168 over at this tier's first +// floor. The structure mirrors the oracle's: a handful of tiny noinline +// primitives sharing one whole-loader register allocation, expressed as global +// register variables so no helper ever saves, spills, or reloads any of it. +// +// The register protocol (all call-saved, so calls preserve them by ABI): +// Y (r28:r29) g_addr the walked flash/EEPROM address — adiw-able +// r16 g_cnt byte countdown of the running block — ldi-able +// r7 g_window rx timeout, roughly 30 ms units at 16 MHz +// r6 g_receiving one-wire direction latch, cleared at bring-up +// (power-on registers are undefined) +// +// GCC 16.1 miscompiles stores into global register variables: an update whose +// remaining uses all hide inside callees is deleted whenever a CALL follows it +// before any jump/ret (the backend's liveness walk lumps fixed registers with +// call-clobbered ones — minimal repro in libavr's +// local/scratch/probes/gcc-avr-globalreg-repro.cpp, lessons.md entry). Every +// g_* update below therefore sits where a *local* read or a jump/ret follows +// it — the helpers advance g_addr immediately before returning, and rx() +// re-floors the window on every call instead of storing the floored value +// once. The layout is load-bearing; do not "simplify" it. +// +// 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 (RXEN0 only) whenever it waits. #include @@ -22,281 +36,328 @@ using namespace avr::literals; 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, .half_duplex = true}>; -inline constexpr serial_t serial{}; +namespace hw = avr::hw; 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; 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; -constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; +// 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; + +constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); +// 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, - 0x1E, 0x95, 0x0F, - page / 2, + 0xF3, // status: native-UART fixed-baud lineage + 0x1E, 0x95, 0x0F, // ATmega328P signature + page / 2, // page size in words (app_end / 2) & 0xFF, (app_end / 2) >> 8, eeprom_end & 0xFF, eeprom_end >> 8, 0xAA, 0xAA, }; // clang-format on -// 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 — -// the library's UART and SPM helpers preserve the call-saved registers. g_addr -// takes Y (r28:r29): call-saved like any low pair, but also adiw-able and -// directly immediate-arithmetic-able, so advancing it is one instruction where a -// low pair would need a copy into a high register first. register std::uint16_t g_addr asm("r28"); -register std::uint8_t g_cnt asm("r6"); - -std::uint8_t rx() -{ - return serial.read_blocking(); -} - -void tx(std::uint8_t byte) -{ - serial.write(byte); -} +register std::uint8_t g_cnt asm("r16"); +register std::uint8_t g_window asm("r7"); +register std::uint8_t g_receiving asm("r6"); const std::uint8_t *flash_ptr(std::uint16_t addr) { return reinterpret_cast(addr); } -// 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) +// Bounded byte receive, the oracle's shape: release the one-wire line on a +// direction change, poll RXC0 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. +[[gnu::noinline, gnu::noclone]] std::uint8_t rx() +{ + if (!g_receiving) { + g_receiving = 1; + hw::ucsr0b::write(hw::ucsr0b::rxen0(1)); // RXEN0 alone: release and listen + } + // act_min ORs in here, per call, not once into g_window at setup — the + // one placement the global-register-store miscompile cannot delete. + std::uint16_t outer = static_cast(g_window | act_min) << 8; + do { + std::uint8_t fine = 0; + do { + auto status = hw::ucsr0a::read(); + if (status & hw::ucsr0a::rxc0(1).value) + return hw::udr0::read(); + } while (--fine); + } while (--outer); + return 0; +} + +// One-wire transmit: take the line (TXEN0 alone — the receiver must be off +// while driving) on a direction change, with a turn-around guard so a shorted +// peer can switch first; then hold the line until the whole frame is out +// (TXC0, not UDRE0 — the stop bit must be on the wire before a caller may +// release the line), and W1C TXC0 by storing the sampled status back, which +// keeps U2X0. +[[gnu::noinline, gnu::noclone]] void tx(std::uint8_t byte) +{ + if (g_receiving) { + g_receiving = 0; + hw::ucsr0b::write(hw::ucsr0b::txen0(1)); + for (std::uint8_t guard = 46; guard; --guard) + ; + } + hw::udr0::write(byte); + std::uint8_t status; + do { + status = hw::ucsr0a::read(); + } while (!(status & hw::ucsr0a::txc0(1).value)); + hw::ucsr0a::write(status); +} + +// '?', then hand back the host's reply for the callers' one-byte compare. +[[gnu::noinline, gnu::noclone]] std::uint8_t rcnf() +{ + tx(request); + return rx(); +} + +// One flash byte ← [g_addr++] (the advance right before ret — see header). +[[gnu::noinline, gnu::noclone]] std::uint8_t sflash() +{ + std::uint8_t byte = avr::flash_load(flash_ptr(g_addr)); + ++g_addr; + return byte; +} + +// One EEPROM byte ← [g_addr++]. +[[gnu::noinline, gnu::noclone]] std::uint8_t eerd() +{ + std::uint8_t byte = ee::read(g_addr); + ++g_addr; + return byte; +} + +// One EEPROM byte → [g_addr++]. +[[gnu::noinline, gnu::noclone]] void eewr(std::uint8_t byte) +{ + ee::write(g_addr, byte); + ++g_addr; +} + +// Stream g_cnt flash bytes from g_addr to the host. +[[gnu::noinline, gnu::noclone]] void sendf() { do { - tx(flash ? avr::flash_load(flash_ptr(g_addr)) : ee::read(g_addr)); - ++g_addr; + tx(sflash()); } while (--g_cnt); } -[[gnu::noinline]] bool request_confirm() -{ - tx(request); - return rx() == confirm; -} - -// 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. -// g_addr walks the page (adiw on Y) and is left at the next page base, so the -// caller advances nothing. write_page needs the base, recovered as g_addr-page. -[[gnu::noinline, gnu::noclone]] void store_page(bool flash) -{ - if (flash) { - g_cnt = page / 2; - do { - std::uint8_t lo = rx(); - std::uint8_t hi = rx(); - spm::fill(g_addr, static_cast(lo | (hi << 8))); - g_addr += 2; - } while (--g_cnt); - spm::write_page(g_addr - page); - spm::wait(); - } else { - g_cnt = page; - do { - ee::write(g_addr, rx()); - ++g_addr; - } while (--g_cnt); - } -} - -[[noreturn]] void appjump() +// 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, gnu::noclone]] void settle() { spm::wait(); - reinterpret_cast(0)(); - __builtin_unreachable(); -} - -// One flash page erased and waited out. Shared, so the SPM erase command -// sequence is emitted once for the whole-app erase, the config page and the -// emergency wipe rather than three times. -[[gnu::noinline]] void erase1(std::uint16_t addr) -{ - spm::erase_page(addr); - spm::wait(); -} - -// Re-enable RWW flash reads after programming, shared for the same reason. -[[gnu::noinline]] void rww() -{ spm::rww_enable(); } -// Erase the whole application, one page at a time. -[[gnu::noinline]] void erase_application() +extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0 + +[[noreturn]] void appjump() { - g_addr = 0; - do { - erase1(g_addr); - g_addr += page; - } while (g_addr < app_end); - rww(); + settle(); + tsb_app(); } -// '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. -[[gnu::always_inline]] inline void read_mem(bool flash) +// Step g_addr one page down and erase that page. The decrement lives in here, +// before the erase's own use of it, not in the caller's loop where a following +// call would get it deleted (see header). +[[gnu::noinline, gnu::noclone]] void erase_below() { - g_addr = 0; - for (;;) { - if (rx() != confirm) - return; - g_cnt = page; - send(flash); - if (flash && g_addr >= app_end) - return; - } + g_addr -= page; + spm::erase_page(g_addr); + settle(); } -// 'F'/'E': flash erases the whole application first, then both take the pages -// the host offers behind '?'. -[[gnu::always_inline]] inline void write_mem(bool flash) +// Erase the whole application, top-down like the oracle: the loop bound is a +// compare with zero, and g_addr = 0 — the value every caller wants next — is +// handed back for free. +[[gnu::noinline, gnu::noclone]] void erase_application() { - if (flash) - erase_application(); - g_addr = 0; - while (request_confirm()) - store_page(flash); -} - -// 'C': replace the config page, then echo it back for the host to verify. -[[gnu::always_inline]] inline void write_config() -{ - if (!request_confirm()) - return; g_addr = app_end; - erase1(g_addr); - store_page(true); - rww(); - g_addr = app_end; - g_cnt = page; - send(true); -} - -// Emergency erase: wipe the application flash, the EEPROM and the config page. -[[gnu::always_inline]] inline void emergency_erase() -{ - erase_application(); - g_addr = 0; do { - ee::write(g_addr, 0xff); - } while (++g_addr <= eeprom_end); - erase1(app_end); - rww(); + erase_below(); + } while (g_addr != 0); } -// 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::always_inline]] inline gate password_gate() +// Stream one host page into the erased flash page at g_addr (SPM word buffer, +// low byte then high) — no SRAM staging, receive and program are one loop. +// g_addr is left at the next page base. +[[gnu::noinline, gnu::noclone]] void store_flash() { - 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(); - } + g_cnt = page / 2; + do { + std::uint16_t word = rx(); + word |= static_cast(rx()) << 8; + spm::fill(g_addr, word); + g_addr += 2; + } while (--g_cnt); + spm::write_page(g_addr - page); + settle(); } -[[noreturn]] void run() +[[noreturn, gnu::noinline]] void run() { - if (avr::hw::mcusr::wdrf.test()) + // A watchdog reset hands straight back to the application, as the + // reference loader does, rather than re-entering the bootloader. + if (hw::mcusr::wdrf.test()) appjump(); - // Lean bring-up: at reset UCSR0C already reads 8N1 and UBRR0H reads 0, and - // read()/write() enable RXEN0/TXEN0 on first use, so only the baud divisor low - // byte (it fits 8 bits — see the static_assert) and U2X0 must be written. The - // library surface still does the datasheet work. + // Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads + // 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low + // byte and U2X0 need a store. The library still does the datasheet work. static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0"); - avr::hw::reg<"UBRR0">::write(static_cast(baud.ubrr)); - avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1)); + hw::reg<"UBRR0">::write(static_cast(baud.ubrr)); + hw::ucsr0a::write(hw::ucsr0a::u2x0(1)); + // General-purpose registers are undefined at power-on (no crt zeroes them); + // the direction latch must start "not receiving" so the first rx() enables + // the receiver. The reference loader clears its shadow register for the + // same reason. + g_receiving = 0; - __uint24 idle = static_cast<__uint24>(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16; - std::uint8_t knocks = 0; - while (knocks < 3) { - if (auto byte = serial.read()) - knocks = *byte == knock ? knocks + 1 : 0; - else if (--idle == 0) + // Activation: 3×'@', each inside the config page's timeout window (rx + // floors it so a corrupt page cannot lock the loader out); anything else — + // including silence — hands over. + g_window = avr::flash_load(flash_ptr(app_end + 2)); + for (std::uint8_t k = 3; k; --k) + if (rx() != knock) appjump(); - } + g_window = comm_window; - 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; + // 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. + g_addr = app_end + 3; + std::uint8_t mask = 0xff; + for (;;) { + std::uint8_t expected = avr::flash_load(flash_ptr(g_addr)) & mask; + ++g_addr; + if (expected == 0xff) { + g_addr = reinterpret_cast(&info[0]); + g_cnt = sizeof(info); + sendf(); + break; + } + std::uint8_t got = rx(); + if (got == 0) { + if (mask == 0) + continue; + if (rcnf() != confirm || rcnf() != confirm) + appjump(); + erase_application(); // leaves g_addr = 0 for the EEPROM walk + do { + eewr(0xff); + } while (g_addr <= eeprom_end); + g_addr = app_end + page; + erase_below(); + break; + } + if (got != expected) + mask = 0; } for (;;) { 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; - if (lower == 'f' || lower == 'e') { - bool flash = lower == 'f'; - if (write) - write_mem(flash); - else - read_mem(flash); - } else if (lower == 'c') { - if (write) { - write_config(); - } else { - g_addr = app_end; + g_addr = 0; + switch (rx()) { + case 'f': // read application flash, one page per host '!' + for (;;) { + if (rx() != confirm) + break; g_cnt = page; - send(true); + sendf(); + if (g_addr >= app_end) + break; } - } else { + break; + case 'F': // erase the application, then take pages behind '?' + erase_application(); // leaves g_addr = 0, the write start + while (rcnf() == confirm) + store_flash(); + break; + case 'e': // read EEPROM, one page per host '!', until the host stops + for (;;) { + if (rx() != confirm) + break; + g_cnt = page; + do { + tx(eerd()); + } while (--g_cnt); + } + break; + case 'E': // take EEPROM pages behind '?' + while (rcnf() == confirm) { + g_cnt = page; + do { + eewr(rx()); + } while (--g_cnt); + } + break; + case 'c': // read the config page + read_config: + g_addr = app_end; + g_cnt = page; + sendf(); + break; + case 'C': // replace the config page, then echo it back to verify + if (rcnf() != confirm) + break; + g_addr = app_end + page; + erase_below(); // leaves g_addr = app_end, the store target + store_flash(); + goto read_config; + default: // 'q' or any other byte runs the application appjump(); } } } +} // namespace } // namespace tsb +// Reset lands here: BOOTRST vectors to the boot section base and .vectors is +// laid first, so this is the first instruction executed. No crt ran, so set +// the stack pointer before anything is called. extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry() { SP = RAMEND; + // The one line of crt this loader needs: compiled code assumes + // __zero_reg__ (r1) is 0, and power-on registers are undefined. + asm volatile("clr __zero_reg__"); tsb::run(); }