diff --git a/CMakeLists.txt b/CMakeLists.txt index d1a35c2..64938c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,13 +19,13 @@ if(PROJECT_IS_TOP_LEVEL) add_compile_options(-Werror) # warnings are errors for the port's own code enable_testing() - # The behavioral test drives the real TinySafeBoot wire protocol over a - # simavr pty (as the host tools do) and actually flashes the device. The - # runner is a host program built at configure time against libsimavr; if it - # or Python is missing, only the size tests run. + # The behavioral tests drive the real wire protocols over a simavr pty + # (as the host tools do) and actually flash the device. The runners are + # host programs built at configure time against libsimavr; if they or + # Python are missing, only the size tests run. find_program(_host_cc NAMES cc gcc) find_package(Python3 COMPONENTS Interpreter) - if(_host_cc AND Python3_FOUND) + if(_host_cc AND Python3_FOUND AND LIBAVR_MCU STREQUAL "atmega328p") set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) execute_process( COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts @@ -90,6 +90,47 @@ function(add_tsb_variant name bytes) endif() endfunction() -add_tsb_variant(tsb_asm 512) -add_tsb_variant(tsb_pure 1024) -add_tsb_variant(tsb_tricks 1024) +# The tsb tiers reimplement the ATmega328P-only reference protocol; the other +# chips build pureboot alone. +if(LIBAVR_MCU STREQUAL "atmega328p") + add_tsb_variant(tsb_asm 512) + add_tsb_variant(tsb_pure 1024) + add_tsb_variant(tsb_tricks 1024) +endif() + +# pureboot — the pure-constraint port (see pureboot/README.md): one source, +# no inline assembly, no global register variables, every libavr chip, 512 +# bytes each. The loader owns the top 512 bytes of flash on every chip; the +# application entry symbol is address 0 on the mega (reset re-vectors to the +# loader through BOOTRST, so word 0 stays the application's own vector) and +# the trampoline word just below the loader on the tinies (host-side vector +# surgery points it at the application). --pmem-wrap-around models AVR's +# modulo-flash PC where the flash is big enough to need it. +if(LIBAVR_MCU STREQUAL "attiny13a") + set(_pb_flash 1024) + set(_pb_wrap "") +elseif(LIBAVR_MCU STREQUAL "attiny85") + set(_pb_flash 8192) + set(_pb_wrap -Wl,--pmem-wrap-around=8k) +else() + set(_pb_flash 32768) + set(_pb_wrap -Wl,--pmem-wrap-around=32k) +endif() +math(EXPR _pb_base "${_pb_flash} - 512") +math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL) +if(LIBAVR_MCU STREQUAL "atmega328p") + set(_pb_app 0) +else() + math(EXPR _pb_app "${_pb_base} - 2") +endif() + +add_executable(pureboot pureboot/pureboot.cpp) +target_link_libraries(pureboot PRIVATE libavr) +target_link_options(pureboot PRIVATE -nostartfiles -Wl,--section-start=.text=${_pb_base_hex} + -Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap}) +add_custom_command(TARGET pureboot POST_BUILD COMMAND ${CMAKE_SIZE} $) +if(PROJECT_IS_TOP_LEVEL) + add_test(NAME pureboot.size + COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ + -DLIMIT=512 -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 67e1aef..49bc2e3 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -22,11 +22,35 @@ "name": "atmega328p-reflect", "inherits": "base", "cacheVariables": { "LIBAVR_MCU": "atmega328p", "LIBAVR_REFLECT": "ON" } + }, + { + "name": "attiny85-generated", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "attiny85", "LIBAVR_REFLECT": "OFF" } + }, + { + "name": "attiny85-reflect", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "attiny85", "LIBAVR_REFLECT": "ON" } + }, + { + "name": "attiny13a-generated", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "attiny13a", "LIBAVR_REFLECT": "OFF" } + }, + { + "name": "attiny13a-reflect", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "attiny13a", "LIBAVR_REFLECT": "ON" } } ], "buildPresets": [ { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated" }, - { "name": "atmega328p-reflect", "configurePreset": "atmega328p-reflect" } + { "name": "atmega328p-reflect", "configurePreset": "atmega328p-reflect" }, + { "name": "attiny85-generated", "configurePreset": "attiny85-generated" }, + { "name": "attiny85-reflect", "configurePreset": "attiny85-reflect" }, + { "name": "attiny13a-generated", "configurePreset": "attiny13a-generated" }, + { "name": "attiny13a-reflect", "configurePreset": "attiny13a-reflect" } ], "workflowPresets": [ { @@ -36,9 +60,27 @@ { "type": "build", "name": "atmega328p-generated" }, { "type": "test", "name": "atmega328p-generated" } ] + }, + { + "name": "attiny85-generated", + "steps": [ + { "type": "configure", "name": "attiny85-generated" }, + { "type": "build", "name": "attiny85-generated" }, + { "type": "test", "name": "attiny85-generated" } + ] + }, + { + "name": "attiny13a-generated", + "steps": [ + { "type": "configure", "name": "attiny13a-generated" }, + { "type": "build", "name": "attiny13a-generated" }, + { "type": "test", "name": "attiny13a-generated" } + ] } ], "testPresets": [ - { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated", "output": { "outputOnFailure": true } } + { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated", "output": { "outputOnFailure": true } }, + { "name": "attiny85-generated", "configurePreset": "attiny85-generated", "output": { "outputOnFailure": true } }, + { "name": "attiny13a-generated", "configurePreset": "attiny13a-generated", "output": { "outputOnFailure": true } } ] } diff --git a/pureboot/README.md b/pureboot/README.md new file mode 100644 index 0000000..4a74cc5 --- /dev/null +++ b/pureboot/README.md @@ -0,0 +1,96 @@ +# pureboot + +A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by +constraint: one C++ source, no inline assembly, no global register variables +(attributes allowed), built for every chip libavr targets, **512 bytes on +each** — 490 B on the ATtiny13A, 510 B on the ATtiny85, 484 B on the +ATmega328P. The device speaks primitives; every composite — verify, erase, +reset-vector surgery, timeout configuration — lives in the host tool +(`pureboot.py`). + +## Link + +| Chip | Serial | Baud | Clock assumed | +|---|---|---|---| +| ATmega328P | USART0, RXD/TXD = PD0/PD1 | 115200 8N1 | 16 MHz crystal | +| ATtiny85 | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 8 MHz internal RC | +| ATtiny13A | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 9.6 MHz internal RC | + +The tiny RX pin has its pull-up enabled; TX idles high. All multi-byte +quantities on the wire are little-endian. + +## Activation + +Reset enters the loader (BOOTRST on the mega, the patched reset vector on the +tinies) — except a watchdog reset, which hands straight to the application +(the application owns its watchdog; it must clear WDRF itself, which also +releases the WDRF-forced WDE). + +The host then has one activation window per awaited byte to knock: `p` then +`b`. Each awaited byte gets a fresh window; any other byte is discarded and +awaited again (line noise cannot lock the loader, only delay it). A window +expiring with an idle line boots the application. + +The window length in seconds is the **last EEPROM cell** (address +`eeprom_size - 1`); `0x00` and the erased `0xff` both mean the 4 s default, +so a full EEPROM erase resets the timeout rather than maxing it. The host +changes it with the ordinary EEPROM-write command. + +## Session + +After the knock the loader stays in its command loop until `G` or a reset. +Before reading each command it waits for any pending EEPROM write to finish +and sends the prompt `+` (0x2b) — the prompt is therefore also the completion +ack of the previous command. A session is: await `+`, send a command, read +its reply, repeat. + +| Cmd | Arguments | Reply | +|---|---|---| +| `b` | — | the 12-byte info block | +| `R` | addr16, n8 | n flash bytes (n = 0 means 256) | +| `W` | addr16, then one page of data | — (completion = next prompt) | +| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) | +| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun | +| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse | +| `G` | — | `+`, then the application runs | +| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) | + +`W` streams exactly one SPM page (size from the info block) into the buffer, +then erases and programs; the address must be page-aligned. Pages inside the +loader's own 512 bytes are drained but never programmed — a broken host +cannot brick the chip. `w` is host-paced: send the next byte only after the +previous byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip without an +extended fuse byte (the ATtiny13A) that slot carries no meaning. Fuse *writing* does not +exist: SPM reaches flash (and, on the mega, lock bits) only — fuse bytes are +external-programming territory by hardware. + +The info block (`b`): + +| Offset | Content | +|---|---| +| 0–2 | `'P'`, `'B'`, protocol version (1) | +| 3–5 | device signature | +| 6 | SPM page size in bytes | +| 7–8 | loader base — application flash ends here | +| 9–10 | EEPROM size | +| 11 | bit 0 set: host must patch the reset vector (no hardware boot section) | + +Composites are the host's job: verify = read back and compare, erase = +write `0xff` (per page for flash, per byte for EEPROM), timeout = EEPROM +write to the last cell. + +## Deployment + +**ATmega328P**: program the loader at 0x7e00 with an external programmer; +fuses BOOTSZ = 11 (256 words) and BOOTRST programmed. Applications are +flashed unmodified — reset re-vectors to the loader in hardware, word 0 +stays the application's own reset vector, and `G` jumps to 0. + +**Tinies** (no boot section): program the loader at `flash - 512`; erased +flash below it walks up into the loader, so a virgin chip activates. When +flashing an application the host performs reset-vector surgery: the +application's own `rjmp` target is re-encoded as a trampoline `rjmp` in the +word just below the loader (`base - 2`, where `G` jumps), and word 0 is +rewritten to `rjmp` to the loader base. Every other vector stays the +application's. Page 0 is written last, so an interrupted flash leaves word 0 +erased and the chip still falls through to the loader on the next reset. diff --git a/pureboot/pureboot.cpp b/pureboot/pureboot.cpp new file mode 100644 index 0000000..21e0f98 --- /dev/null +++ b/pureboot/pureboot.cpp @@ -0,0 +1,326 @@ +// pureboot — a serial bootloader on libavr, pure by constraint: one C++ +// source with no inline assembly and no global register variables, built for +// every chip libavr targets, 512 bytes on each. The device speaks primitives +// — read/program flash, read/write EEPROM, fuse bytes, an info block, run — +// and everything composite (verify, erase, reset-vector surgery on the +// tinies, timeout configuration) lives in the host tool. Protocol reference: +// README.md next to this file. +// +// Entry: reset lands in avr::startup::entry below (BOOTRST on the mega; the +// patched reset vector — or erased flash walking up into the loader — on the +// tinies). A watchdog reset hands straight to the application. Otherwise the +// host has one activation window — EEPROM's last cell, in seconds — to knock +// ("pb"); an idle line boots the application. A session then stays in the +// command loop until 'G' hands over or the chip resets. + +#include + +using namespace avr::literals; +namespace spm = avr::spm; +namespace ee = avr::eeprom; + +namespace pureboot { +namespace { + +// Purely polled — interrupts stay off, every guard folds to nothing. +constexpr auto off = avr::irq::guard_policy::unused; + +constexpr std::uint8_t ack = '+'; + +// Per-chip personality, from the chip database: the clocks the dogfood +// boards run (16 MHz crystal on the mega, calibrated RC on the tinies) and +// the device signature (compile-time data — the tiny13A cannot even read its +// signature row from code). +consteval avr::hertz_t clock() +{ + if (avr::hw::db.name == "ATtiny13A") + return 9.6_MHz; + if (avr::hw::db.name == "ATtiny85") + return 8_MHz; + return 16_MHz; +} + +consteval std::array signature() +{ + if (avr::hw::db.name == "ATtiny13A") + return {0x1e, 0x90, 0x07}; + if (avr::hw::db.name == "ATtiny85") + return {0x1e, 0x93, 0x0b}; + return {0x1e, 0x95, 0x0f}; +} + +using dev = avr::device<{.clock = clock()}>; + +// Geometry: the loader owns the top 512 bytes of flash; the byte below it is +// the trampoline word (the application's relocated reset vector) on chips +// without a hardware boot section. The RWWSRE bit marks a separate boot +// section — on classic AVR the two capabilities coincide. +constexpr std::uint16_t boot_bytes = 512; +constexpr std::uint16_t base = static_cast(spm::flash_bytes - boot_bytes); +constexpr std::uint16_t page = spm::page_bytes; +constexpr bool boot_section = avr::hw::db.field_index("SPMCSR", "RWWSRE") >= 0; + +// The activation timeout lives in EEPROM's last cell, in seconds; the host +// rewrites it with the ordinary EEPROM-write command. An unprogrammed cell — +// 0x00 or the erased 0xff — means the 4 s default: a stray value can never +// floor the window to nothing and lock the loader out, and erasing the whole +// EEPROM resets the timeout instead of maxing it to 255 s. +constexpr std::uint16_t timeout_cell = avr::hw::db.mem.eeprom_size - 1; +constexpr std::uint8_t default_seconds = 4; + +// The 12-byte info block the host reads with the 'b' command; flash-resident +// (there is no crt to copy a .data image). +inline constexpr std::array info_data = { + 'P', + 'B', + 1, // magic, protocol version + signature()[0], + signature()[1], + signature()[2], + static_cast(page), + base & 0xff, + base >> 8, // app flash ends here; loader base + avr::hw::db.mem.eeprom_size & 0xff, + avr::hw::db.mem.eeprom_size >> 8, + boot_section ? 0 : 1, // bit 0: host must patch the reset vector (no hardware boot section) +}; +using info = avr::flash_table; + +// The serial link: the hardware USART where the chip has one, the polled +// software UART (no vector — the table belongs to the application) on PB0/PB1 +// elsewhere. Both are class templates on the clock so only the selected +// backend is ever instantiated. pending() is the cheap line test the +// activation window polls; rx() then picks the byte up. +template +consteval std::int16_t rxc_field() +{ + return avr::hw::db.field_index("UCSR0A", "RXC0"); +} + +template +struct hardware_link { + using uart = avr::uart::usart0; + + static void init() + { + avr::init(); + } + + static bool pending() + { + return avr::hw::field_impl()>::test(); + } + + static std::uint8_t rx() + { + return uart::read_blocking(); + } + + static void tx(std::uint8_t byte) + { + uart::write(byte); + } +}; + +template +struct software_link { + using rx_t = avr::uart::software_rx_polled; + using tx_t = avr::uart::software_tx; + + static void init() + { + avr::init(); + } + + static bool pending() + { + return !avr::io::input::read(); // a start bit has begun + } + + static std::uint8_t rx() + { + return rx_t::template read_blocking(); + } + + static void tx(std::uint8_t byte) + { + tx_t::template write(byte); + } +}; + +using link = std::conditional_t, software_link>; + +// The application's entry: the linker pins pureboot_app to 0x0000 on the +// mega (reset re-vectors here through BOOTRST, so address 0 stays the +// application's own vector) and to the trampoline word at base - 2 on the +// tinies (--defsym in CMakeLists.txt). +extern "C" [[noreturn]] void pureboot_app(); + +[[noreturn]] void run_app() +{ + pureboot_app(); +} + +// One activation tick is 65536 pending() polls — a pin (or flag) test plus a +// 16-bit countdown, about 8 cycles. Whole-second precision is all the +// timeout cell promises; the seconds count stays a loop bound (a runtime +// multiply would drag libgcc's __mulhi3 into the MUL-less tinies). +consteval std::uint16_t ticks_per_second() +{ + return static_cast(dev::clock.hz / (65536ull * 8u)); +} +static_assert(ticks_per_second() >= 1); + +bool pending_before(std::uint8_t seconds) +{ + do { + std::uint16_t ticks = ticks_per_second(); + do { + std::uint16_t spins = 0; // wraps first, so 65536 polls per tick + do { + if (link::pending()) + return true; + } while (--spins); + } while (--ticks); + } while (--seconds); + return false; +} + +// A knock byte under the activation deadline: an idle line means no host is +// there, and the application runs. +std::uint8_t rx_deadline(std::uint8_t seconds) +{ + if (!pending_before(seconds)) + run_app(); + return link::rx(); +} + +std::uint16_t rx16() +{ + std::uint8_t low = link::rx(); + return static_cast(low | (link::rx() << 8)); +} + +const std::uint8_t *flash_ptr(std::uint16_t address) +{ + return reinterpret_cast(address); +} + +// The streamers take the count in the wire's 8-bit form: 0 means 256. +void send_flash(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(avr::flash_load(flash_ptr(address++))); + while (--count); +} + +void send_eeprom(std::uint16_t address, std::uint8_t count) +{ + do + link::tx(ee::read(address++)); + while (--count); +} + +// EEPROM write, host-paced: each ack goes out once the byte's write has +// begun, so the next byte arrives while it completes and the following +// write's own ready-wait sees an idle line. Nothing is ever missed, on +// either serial backend, without a buffer. +void store_eeprom(std::uint16_t address, std::uint8_t count) +{ + do { + ee::write(address++, link::rx()); + link::tx(ack); + } while (--count); +} + +// One flash page: stream the bytes into the SPM buffer as little-endian +// words, then erase and program. Addresses in the loader's own 512 bytes +// are drained but never programmed — a broken host cannot brick the chip. +// On the mega the RWW section is re-enabled so reads work immediately. +void program_flash(std::uint16_t address) +{ + for (std::uint16_t i = 0; i < page; i += 2) { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill(address + i, static_cast(low | (high << 8))); + } + if (address < base) { + spm::erase_page(address); + spm::wait(); + spm::write_page(address); + spm::wait(); + if constexpr (boot_section) + spm::rww_enable(); + } +} + +// The four fuse/lock bytes in the hardware's own Z order: low, lock, +// extended, high. Writing fuses is not a thing self-programming can do on +// AVR — SPM reaches flash (and boot lock bits) only. +void send_fuses() +{ + for (std::uint8_t which = 0; which < 4; ++which) + link::tx(spm::read_fuse(static_cast(which))); +} + +[[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::mcusr::wdrf.test()) + run_app(); + + link::init(); + + std::uint8_t seconds = ee::read(timeout_cell); + if (seconds == 0 || seconds == 0xff) + seconds = default_seconds; + + // The knock: 'p' then 'b', each under a fresh window; any other byte is + // line noise and waits again. Falling out of a window runs the app. + while (rx_deadline(seconds) != 'p' || rx_deadline(seconds) != 'b') { + } + + for (;;) { + // No prompt while an EEPROM write runs: a pending write blocks SPM + // and fuse reads (§26.2.1), and the ack tells the host all is done. + ee::wait(); + link::tx(ack); + switch (link::rx()) { + case 'b': // info block + send_flash(reinterpret_cast(info::storage.data()), info::size()); + break; + case 'R': { // read flash: addr16, n8 (0 = 256) + std::uint16_t address = rx16(); + send_flash(address, link::rx()); + break; + } + case 'W': // program one flash page: addr16, page bytes + program_flash(rx16()); + break; + case 'r': { // read EEPROM: addr16, n8 + std::uint16_t address = rx16(); + send_eeprom(address, link::rx()); + break; + } + case 'w': { // write EEPROM: addr16, n8, then n bytes each acked + std::uint16_t address = rx16(); + store_eeprom(address, link::rx()); + break; + } + case 'F': // fuse and lock bytes + send_fuses(); + break; + case 'G': // hand over to the application + link::tx(ack); + run_app(); + default: // unknown bytes are ignored; the loop re-acks + break; + } + } +} + +} // namespace +} // namespace pureboot + +template struct avr::startup::entry;