// pureboot — a serial bootloader on libavr: one C++ source, no inline // assembly, no global register variables, 512 bytes on every chip libavr // targets. The device speaks primitives; every composite (verify, erase, // reset-vector surgery, self-update) lives in the host tool. Protocol, // deployment and configuration: README.md next to this file. // // The image is position-independent — PC-relative control flow, wire // addresses in, the write guard and the info block both anchored on the // runtime return address — so the identical binary runs from any slot. That // is what makes a copy one slot below able to rewrite the resident one, and // every change here has to keep it (test/check_pi.py). #include using namespace avr::literals; namespace spm = avr::spm; namespace ee = avr::eeprom; namespace pureboot { namespace { // Purely polled: every interrupt guard folds to nothing. constexpr auto off = avr::irq::guard_policy::unused; constexpr std::uint8_t ack = '+'; // Deployment parameters come from the build (pureboot_add_loader()). The // signature is not one of them: the chip database is the only universal // source — a tiny13A cannot read its own signature row from code. An autobaud // build carries no clock and no baud at all; it measures both. #if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)) #error \ "PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)" #endif #if !defined(PUREBOOT_AUTOBAUD) using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>; constexpr avr::baud_t wire_baud{PUREBOOT_BAUD}; #endif // The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. consteval std::int16_t wdrf_field() { auto reg = std::string_view{avr::hw::db.regs[static_cast(avr::power::detail::reset_reg())].name}; return avr::hw::db.field_index(reg, "WDRF"); } // The loader owns the top 512 bytes; a staging copy goes in the slot below. // Chips without a hardware boot section — the tinies and the m48s, whose SPM // runs from anywhere (Atmel-8271 §26) — keep the application's relocated // reset vector in the word under the slot. constexpr std::uint16_t slot_bytes = 512; constexpr std::uint16_t page = spm::page_bytes; constexpr bool boot_section = avr::hw::curated::has_boot_section(); // Past 64 KiB one bank of flash does not cover the chip, so a transfer's // selector byte carries the bank and the wire address stays a byte address // within it. 'J' is the exception: it is a word address everywhere, because // that is what the hardware's own jump takes. constexpr bool banked_flash = spm::flash_bytes > 65536; // A compile-time window, so the whole EEPROM belongs to the application; // re-timing a deployed loader is a self-update with a re-timed build. An // autobaud build has no clock to convert seconds against and counts polls. #if !defined(PUREBOOT_TIMEOUT) #define PUREBOOT_TIMEOUT 8 #endif constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; #if !defined(PUREBOOT_AUTOBAUD_POLLS) #define PUREBOOT_AUTOBAUD_POLLS 4000000 #endif constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; // The loader's one identity number. The protocol carries none of its own — // a version implies it, and the host tool holds that map (README.md). constexpr std::uint8_t version = 5; // The image's identity stamp, for the host tool rather than for the wire: an // update image is a bare 512-byte slot, and without this nothing in it says // which chip it was built for. The tool refuses to install an image whose // stamp does not match the device — flashing a foreign loader bricks the // target, and the loader itself cannot check what has already replaced it. // // Never read from flash by the loader — 'b' answers out of this array, but at // constant indices, so those fold to immediates and no runtime address of it // is ever formed. `used` keeps the compiler from dropping the copy the host // needs and `retain` keeps --gc-sections from collecting it. // clang-format off [[gnu::used, gnu::retain, gnu::section(".text.stamp")]] inline constexpr std::uint8_t identity_stamp[]{ 'P', 'B', // the magic the host scans an image for version, // and from here on, exactly what 'b' answers avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2], }; // clang-format on // Where the identity proper starts: past the magic the host scans for. constexpr std::uint8_t stamp_identity = 2; // The address spaces a transfer can name, in a selector byte's low nibble. // Flash is 0 so it is the cheapest to select. // // spm_ops is the one that is not memory: a write there hands its byte to // SPMCSR and fires the instruction at the transfer's address, which is how // page erase, page write and RWW re-enable reach the wire without the loader // carrying a command for each. The hardware's four-cycle store-to-SPM window // is why this is one fused primitive and not a poke of SPMCSR — no host can // hit that window across a serial link. enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 }; // A selector's high nibble is the flash bank — the address bits above the // 16-bit wire address, RAMPZ on the chips that have one. Keeping it here // rather than widening the wire address is what lets one 16-bit cursor serve // every space: a 24-bit cursor would pay its extra byte on EEPROM and data // reads that can never need it. [[gnu::always_inline]] inline std::uint8_t space_of(std::uint8_t selector) { return selector & 0x0f; } [[gnu::always_inline]] inline std::uint8_t bank_of(std::uint8_t selector) { return static_cast(selector >> 4); } // The slot a flash address falls in, as one byte. A slot is half as many words // as bytes, so the word address's high byte is exactly this index — which is // what lets the write guard compare a single byte, and what the running copy's // own return address yields for free. constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes); constexpr std::uint8_t bank_shift = 16 - slot_shift; [[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at) { const auto within = static_cast(at >> slot_shift); if constexpr (banked_flash) return static_cast((bank << bank_shift) | within); else return within; } // The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL / // PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The // software receiver is the polled one: the vector table belongs to the // application. Templates on the clock, so only the selected backend // instantiates. pending() is the cheap line test the activation window polls; // drain() holds until the last frame is off the wire, so a hand-over cannot // let the target's re-init clip the ack. #if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART) #error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends" #endif #if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART) #error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART" #endif #if !defined(PUREBOOT_RX) #define PUREBOOT_RX pb0 #endif #if !defined(PUREBOOT_TX) #define PUREBOOT_TX pb1 #endif #if defined(PUREBOOT_USART) constexpr char usart_digit = '0' + PUREBOOT_USART; #else constexpr char usart_digit = '0'; #endif // Release a hardware USART the application may have left enabled onto a // bit-banged link's pins. A software transmitter drives its TX pin through the // port register, but while that USART's TXEN is set the USART owns the pin and // the port write does nothing — the loader would receive and obey yet never // answer. Writing UCSRnB zero hands the pin back to the port. Guarded on the // pin actually being a USART's TXD, so a link on non-USART pins emits nothing. template [[gnu::always_inline]] inline void release_usart_on() { if constexpr (avr::uart::has_usart()) if constexpr (avr::uart::detail::usart_pin("TXD") == Tx) avr::hw::reg_impl()>::write(0); } template [[gnu::always_inline]] inline void release_usarts_on() { release_usart_on<'0', Tx>(); release_usart_on<'1', Tx>(); } template struct hardware_link { using uart = avr::uart::usart; // The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // sbiw + sbci + sbci + brne (6). static constexpr std::uint8_t poll_cycles = 10; static void init() { avr::init(); } static bool pending() { return uart::rx_ready(); } static std::uint8_t rx() { return uart::read_blocking(); } static void tx(std::uint8_t byte) { uart::write(byte); } static void drain() { uart::drain(); } }; template struct software_link { using rx_t = avr::uart::software_rx_polled; using tx_t = avr::uart::software_tx; // The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // sbci + brne (6). static constexpr std::uint8_t poll_cycles = 8; static void init() { avr::init(); release_usarts_on(); } static bool pending() { return rx_t::start_pending(); } static std::uint8_t rx() { return rx_t::template read_blocking(); } static void tx(std::uint8_t byte) { tx_t::template write(byte); } static void drain() { // The software transmitter returns only after the stop bit. } }; // The clock-free link: the bit period is measured from the host's calibration // pulse instead of derived from a clock, so one image serves every F_CPU and // every rate. Activation differs in kind from the other two — there is no // clock to time a window against — so this backend brings its own, below. struct autobaud_link { using uart = avr::uart::software_autobaud; static void init() { avr::init(); release_usarts_on(); } static std::uint8_t rx() { return uart::template read(); } static void tx(std::uint8_t byte) { uart::template write(byte); } static void drain() { uart::drain(); } }; #if defined(PUREBOOT_AUTOBAUD) using link = autobaud_link; #elif defined(PUREBOOT_USART) static_assert(avr::uart::has_usart(), "PUREBOOT_USART selects a hardware USART this chip does not have"); using link = hardware_link; #elif defined(PUREBOOT_SOFT_SERIAL) using link = software_link; #else using link = std::conditional_t(), hardware_link, software_link>; #endif // The application's entry, pinned by the linker (--defsym): word 0 on a // boot-sectioned mega, the trampoline at base − 2 elsewhere. Reaching it must // not depend on where this copy runs, so the jump goes through a pointer, and // [[gnu::noipa]] keeps the constant from folding back into a relative call. 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); } // Activation: a bounded wait for the host, then the knock. Both forms boot the // application when the window closes on an idle line, and both bound *every* // wait — a knock awaited without a deadline would let one stray edge hold an // unattended device in the loader forever. #if defined(PUREBOOT_AUTOBAUD) // The window is a fixed poll budget: with no clock, whole seconds cannot be // timed. A uint24_t holds it — a fourth byte would cost two words at every // countdown step for range never used. void await_host() { for (;;) { if (!link::uart::calibrate(autobaud_budget)) run_app(); // The calibration pulse has already proven a host is there, so one // byte activates. A knock that never arrives falls back to calibrate(), // whose own budget then boots the application. if (link::uart::template read(autobaud_budget) == 'p') return; } } #else // The window as one 32-bit countdown, divided by the backend's counted // poll-loop cycles. Whole seconds is all it promises. consteval std::uint32_t window_polls() { return timeout_seconds * static_cast(dev::clock.hz / link::poll_cycles); } bool pending_before_deadline() { std::uint32_t polls = window_polls(); do { if (link::pending()) return true; } while (--polls); return false; } // A knock byte under the deadline: an idle window means no host, so the // application runs. std::uint8_t rx_deadline() { if (!pending_before_deadline()) run_app(); return link::rx(); } void await_host() { // 'p' then 'b', each under a fresh window; anything else is line noise. while (rx_deadline() != 'p' || rx_deadline() != 'b') { } } #endif // 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)); } // The wire's byte pair as the word it is — AVR is little-endian too, so the // cast is the identity a shift-and-or spelling makes the compiler rediscover. // Callers read into named variables first: the wire order is a sequence of // reads, not an argument order. [[gnu::always_inline]] inline std::uint16_t word_of(std::array pair) { return std::bit_cast(pair); } // Out of line: several sites send it, and a call is shorter than a // load-immediate at each. [[gnu::noinline]] void tx_ack() { link::tx(ack); } // A wire address and its selector's bank as the flash address they name. [[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at) { if constexpr (banked_flash) return (static_cast(bank) << 16) | at; else return at; } // One byte out of any space. Every accessor shares the transfer's cursor, its // loop and its call site, so a space costs only its own instruction rather // than a body, a loop and a dispatch arm of its own. [[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank, std::uint16_t at) { if (space == sp_eeprom) return ee::read(at); if (space == sp_data) return *reinterpret_cast(at); if (space == sp_fuse) return spm::read_fuse(static_cast(at)); if constexpr (banked_flash) return avr::flash_load_far(flash_address(bank, at)); else return avr::flash_load(reinterpret_cast(at)); } // One byte into a writable space. Flash is not one of them — it arrives a // page at a time through 'W' and is committed through sp_spm — and the fuses // are not writable at all: SPM reaches flash and boot lock bits only. [[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value, std::uint8_t slot_high) { if (space == sp_data) { *reinterpret_cast(at) = value; return; } if (space == sp_spm) { // The running-slot write guard. An SPM command aimed at the slot this // code executes from is dropped, so a broken host cannot brick the // running loader — while a copy one slot lower may still rewrite the // resident one, which is what a self-update is. Guarding the commit // rather than the page fill covers erase and write both, and leaves a // refused page's words in the buffer: harmless, since the next page // write auto-erases it (§26.2.1). if (slot_of(bank, at) != slot_high) spm::command(value, flash_address(bank, at)); // Only a boot-sectioned mega runs on while its RWW section programs; // everywhere else the CPU halts through erase and write, so the wait // is already over by the time it returns. if constexpr (boot_section) spm::wait(); return; } // Host-paced: the ack goes out once the write has begun, so the next byte // arrives while it completes and nothing is missed without a buffer. ee::write(at, value); } // One page into the SPM buffer, and only that: the erase and the write that // commit it are host-issued sp_spm stores, which reach the same fused // store-and-SPM pair through the transfer path's own address and data. // // Nothing discards the buffer first: it is write-once per word (§26.2.1), so // filling over a refused page or an application's leavings programs stale // words — but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so // that write clears the condition and the host's read-back rewrites the page. void fill_page(std::uint8_t bank, std::uint16_t at) { // The address names a page, so its in-page bits are dropped and the walk // starts at the page base; the low byte of the cursor is the whole in-page // offset, since a page is aligned and never crosses a bank. std::uint16_t z = at & ~static_cast(page - 1); do { std::uint8_t low = link::rx(); std::uint8_t high = link::rx(); spm::fill(flash_address(bank, z), word_of({low, high})); z += 2; } while (static_cast(z) & (page - 1)); } [[noreturn]] void run() { // A watchdog reset belongs to the application, whose watchdog stays forced // on until it clears WDRF — no activation window in its way. if (avr::hw::field_impl::test()) run_app(); link::init(); // The slot this copy runs in, which the write guard follows: the return // address is a word address and a slot is half as many words as bytes, so // its high byte is the slot index outright. No absolute address is ever // formed, so the image stays position-independent. const auto return_words = reinterpret_cast(__builtin_return_address(0)); const auto slot_high = static_cast(return_words >> 8); await_host(); for (;;) { // No prompt while an EEPROM write runs: it blocks SPM and fuse reads // (§26.2.1), and the prompt is the previous command's completion ack. 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': // identity: the version, then the three signature bytes // Straight out of the stamp, so the wire and the image can never // disagree about what this loader is. The indices are constant and // the array is constexpr, so these are immediates, not flash reads: // nothing here needs the stamp's runtime address. for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at) link::tx(identity_stamp[at]); break; case 'W': // fill one flash page buffer: sel8, addr16, then page bytes case 'G': // read: sel8, addr16, n8 (0 = 256) case 'g': { // write: sel8, addr16, n8, then n bytes, each acked // One decode, one cursor and one loop for every space and both // directions: a command per memory would carry a copy of all three // each. 'W' joins the same decode rather than keeping an address // form of its own, so flash addressing is uniform across every // command that names it. const std::uint8_t selector = link::rx(); const std::uint8_t space = space_of(selector); const std::uint8_t bank = bank_of(selector); std::uint16_t at = rx16(); if (command == 'W') { fill_page(bank, at); break; } std::uint8_t count = link::rx(); do { // Read and write are one letter apart in case, so the direction // is a single bit and the loop picks it with a one-word skip. if (command & 0x20) { store(space, bank, at, link::rx(), slot_high); tx_ack(); } else link::tx(load(space, bank, at)); ++at; } while (--count); break; } default: // unknown bytes are ignored; the loop re-acks break; } } } } // namespace } // namespace pureboot template struct avr::startup::entry;