diff --git a/.clang-format b/.clang-format index 63ebf38..1ab0a28 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,6 @@ --- BasedOnStyle: LLVM +Standard: Latest ColumnLimit: 120 IndentWidth: 4 TabWidth: 4 diff --git a/.gitignore b/.gitignore index 9f584cc..9e8b68e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ Debug *.eeprom *.lss *.map + +# CMake / clangd +/build/ +compile_commands.json +.cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..338cecc --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,81 @@ +cmake_minimum_required(VERSION 3.28) + +project(tsb_libavr LANGUAGES CXX) + +# CMake's GNU compiler module appends "-O3 -DNDEBUG" to CMAKE_CXX_FLAGS_RELEASE +# after the libavr toolchain sets "-Os", and the later -O3 wins — so a Release +# build is silently -O3, ~15-20 % larger than -Os. For a boot loader measured to +# the byte that is fatal, so strip it and get the size build the design intends. +# (The same leak affects libavr's own Release builds; a fix belongs upstream.) +string(REPLACE "-O3" "" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}") + +# libavr from a local checkout (LIBAVR_ROOT) or the forge; the toolchain file +# comes from the same checkout via CMakePresets.json. +include(FetchContent) +if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT}) + set(LIBAVR_ROOT $ENV{LIBAVR_ROOT}) +endif() +if(LIBAVR_ROOT) + FetchContent_Declare(libavr SOURCE_DIR ${LIBAVR_ROOT}) +else() + FetchContent_Declare(libavr GIT_REPOSITORY git@git.blackmark.me:avr/libavr.git GIT_TAG main) +endif() +FetchContent_MakeAvailable(libavr) + +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. + find_program(_host_cc NAMES cc gcc) + find_package(Python3 COMPONENTS Interpreter) + if(_host_cc AND Python3_FOUND) + set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) + execute_process( + COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts + -o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c + -lsimavr -lsimavrparts -lelf + RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err) + if(NOT _dev_res EQUAL 0) + message(STATUS "tsb_device not built (${_dev_err}) — protocol tests skipped") + unset(TSB_DEVICE) + endif() + endif() +endif() + +# The TinySafeBoot protocol reimplemented on libavr in three variants that trade +# clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects +# 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. +# tsb_asm — inline-asm variant, the headline: ≤512 B, the 512 B section. +# tsb_pure / tsb_tricks — pure-C++ and compiler-trickery variants, larger, +# shown in the 1 KB section (BOOTSZ=10) they fit. +# +# add_tsb_variant( ) +function(add_tsb_variant name bytes) + math(EXPR base_dec "32768 - ${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}) + add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) + if(PROJECT_IS_TOP_LEVEL) + add_test(NAME ${name}.size + COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ + -DLIMIT=${bytes} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) + if(DEFINED TSB_DEVICE) + add_test(NAME ${name}.protocol + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/tsbtest.py + ${TSB_DEVICE} $ ${base_hex}) + endif() + endif() +endfunction() + +add_tsb_variant(tsb_asm 512) +add_tsb_variant(tsb_pure 1024) +add_tsb_variant(tsb_tricks 1024) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..67e1aef --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "CMAKE_COLOR_DIAGNOSTICS": "ON" + } + }, + { + "name": "atmega328p-generated", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "atmega328p", "LIBAVR_REFLECT": "OFF" } + }, + { + "name": "atmega328p-reflect", + "inherits": "base", + "cacheVariables": { "LIBAVR_MCU": "atmega328p", "LIBAVR_REFLECT": "ON" } + } + ], + "buildPresets": [ + { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated" }, + { "name": "atmega328p-reflect", "configurePreset": "atmega328p-reflect" } + ], + "workflowPresets": [ + { + "name": "atmega328p-generated", + "steps": [ + { "type": "configure", "name": "atmega328p-generated" }, + { "type": "build", "name": "atmega328p-generated" }, + { "type": "test", "name": "atmega328p-generated" } + ] + } + ], + "testPresets": [ + { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated", "output": { "outputOnFailure": true } } + ] +} diff --git a/test/check_size.cmake b/test/check_size.cmake new file mode 100644 index 0000000..8286ea9 --- /dev/null +++ b/test/check_size.cmake @@ -0,0 +1,11 @@ +execute_process(COMMAND ${SIZE_TOOL} ${ELF} OUTPUT_VARIABLE _out RESULT_VARIABLE _res) +if(NOT _res EQUAL 0) + message(FATAL_ERROR "avr-size failed") +endif() +# avr-size line 2 is " ". +string(REGEX MATCH "\n[ \t]*([0-9]+)" _m "${_out}") +set(_text ${CMAKE_MATCH_1}) +if(_text GREATER LIMIT) + message(FATAL_ERROR ".text is ${_text} bytes, over the ${LIMIT}-byte boot section") +endif() +message(STATUS ".text ${_text} <= ${LIMIT} (boot section budget)") diff --git a/test/device.c b/test/device.c new file mode 100644 index 0000000..e324ad2 --- /dev/null +++ b/test/device.c @@ -0,0 +1,86 @@ +// simavr "device" for the TSB bootloader: load the boot-linked ELF into the +// ATmega328P boot section, enter it (BOOTRST is not modelled, so we set PC to +// the boot base, exactly as simavr's own board_simduino does), and expose +// UART0 as a pty. A host client (Python pyserial, or the real tsbloader) then +// speaks the TSB protocol over that pty and actually flashes the device. +// +// SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM) +// we dump the flash image to a file for a ground-truth cross-check against +// what the client read back through the bootloader. +#include +#include +#include +#include +#include +#include + +#include "sim_avr.h" +#include "sim_elf.h" +#include "uart_pty.h" + +static avr_t *avr; +static uart_pty_t uart_pty; +static const char *dump_path; + +static void finish(int sig) +{ + (void)sig; + if (dump_path) { + FILE *f = fopen(dump_path, "wb"); + if (f) { + fwrite(avr->flash, 1, avr->flashend + 1, f); + fclose(f); + } + } + uart_pty_stop(&uart_pty); + _exit(0); +} + +int main(int argc, char *argv[]) +{ + if (argc < 3) { + fprintf(stderr, "usage: %s [flash_dump.bin]\n", argv[0]); + return 2; + } + uint32_t boot_base = (uint32_t)strtoul(argv[2], NULL, 0); + dump_path = argc >= 4 ? argv[3] : NULL; + + avr = avr_make_mcu_by_name("atmega328p"); + if (!avr) { + fprintf(stderr, "device: no ATmega328P core\n"); + return 1; + } + avr_init(avr); + avr->frequency = 16000000; + // Real flash powers up erased (0xff); the app region must look erased + // before the bootloader programs it. + memset(avr->flash, 0xff, avr->flashend + 1); + + // simavr's ELF loader flattens the flash base to 0 (it expects an app at + // 0x0), but it hands back the boot code in fw.flash; place it at the boot + // section base ourselves and enter there (BOOTRST is not modelled). + elf_firmware_t fw = {0}; + if (elf_read_firmware(argv[1], &fw) != 0) { + fprintf(stderr, "device: cannot read %s\n", argv[1]); + return 1; + } + memcpy(avr->flash + boot_base, fw.flash, fw.flashsize); + avr->pc = boot_base; + avr->codeend = avr->flashend; + + uart_pty_init(avr, &uart_pty); + uart_pty_connect(&uart_pty, '0'); + printf("TSB_PTY %s\n", uart_pty.pty.slavename); + fflush(stdout); + + signal(SIGTERM, finish); + signal(SIGINT, finish); + + for (;;) { + int state = avr_run(avr); + if (state == cpu_Done || state == cpu_Crashed) + break; + } + finish(0); + return 0; +} diff --git a/test/tsbtest.py b/test/tsbtest.py new file mode 100644 index 0000000..0811e52 --- /dev/null +++ b/test/tsbtest.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""End-to-end TSB protocol test: spawn the simavr device, speak the TinySafeBoot +wire protocol over its pty (as the real host tools do), and actually flash it. + +Usage: tsbtest.py +Exits 0 if every scenario passes. +""" +import subprocess +import sys +import time + +import serial + +CONFIRM = 0x21 # '!' +REQUEST = 0x3F # '?' +PAGE = 128 # ATmega328P: 64 words + + +class Device: + """The simavr runner, exposing UART0 as a pty.""" + + def __init__(self, binary, elf, boot_base, dump="/tmp/tsb_dump.bin"): + self.proc = subprocess.Popen( + [binary, elf, boot_base, dump], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.dump = dump + self.pty = None + deadline = time.time() + 5 + while time.time() < deadline: + line = self.proc.stdout.readline() + if not line: + break + if line.startswith("TSB_PTY"): + self.pty = line.split()[1] + break + if not self.pty: + self.stop() + raise RuntimeError("device did not report a pty") + + def stop(self): + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + + +class Host: + """A faithful TSB host, per the wire protocol.""" + + def __init__(self, pty): + self.s = serial.Serial(pty, 115200, timeout=1.5) + self.info = None + + def _read(self, n): + data = self.s.read(n) + if len(data) != n: + raise AssertionError(f"expected {n} bytes, got {len(data)}: {data.hex()}") + return data + + def activate(self): + self.s.reset_input_buffer() + self.s.write(b"@@@") + reply = self._read(17) + if reply[16] != CONFIRM: + raise AssertionError(f"activation reply not '!'-terminated: {reply.hex()}") + self.info = reply[:16] + return self.info + + # Parsed info-block fields (host math from the spec). + @property + def pagesize(self): + return self.info[9] * 2 + + @property + def appflash(self): + return (self.info[10] | (self.info[11] << 8)) * 2 + + @property + def eeprom_size(self): + return (self.info[12] | (self.info[13] << 8)) + 1 + + def _expect(self, byte, what): + r = self._read(1) + if r[0] != byte: + raise AssertionError(f"{what}: expected {byte:#x}, got {r.hex()}") + + # Host-paced page read ('f'/'e'): send '!', take a page, repeat; stop with + # anything else, then the Mainloop '!'. + def _read_pages(self, cmd, npages): + self.s.write(cmd.encode()) + data = b"" + for _ in range(npages): + self.s.write(bytes([CONFIRM])) + data += self._read(PAGE) + self.s.write(bytes([REQUEST])) # stop + self._expect(CONFIRM, f"{cmd} end") + return data + + # Device-paced page write ('F'/'E'): device offers '?', host sends '!'+page, + # or anything else to stop. + def _write_pages(self, cmd, data): + if len(data) % PAGE: + data += b"\xff" * (PAGE - len(data) % PAGE) + self.s.write(cmd.encode()) + for off in range(0, len(data), PAGE): + self._expect(REQUEST, f"{cmd} '?'") + self.s.write(bytes([CONFIRM]) + data[off:off + PAGE]) + self._expect(REQUEST, f"{cmd} trailing '?'") + self.s.write(bytes([REQUEST])) # stop + self._expect(CONFIRM, f"{cmd} end") + + def write_flash(self, data): + self._write_pages("F", data) + + def read_flash(self, npages): + return self._read_pages("f", npages) + + def write_eeprom(self, data): + self._write_pages("E", data) + + def read_eeprom(self, npages): + return self._read_pages("e", npages) + + def read_config(self): + self.s.write(b"c") + page = self._read(PAGE) + self._expect(CONFIRM, "c end") + return page + + def write_config(self, data): + assert len(data) == PAGE + self.s.write(b"C") + self._expect(REQUEST, "C '?'") + self.s.write(bytes([CONFIRM]) + data) + echo = self._read(PAGE) # device echoes what it programmed + self._expect(CONFIRM, "C end") + return echo + + +def check(cond, msg): + if not cond: + raise AssertionError(msg) + print(f" ok: {msg}") + + +def main(): + binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3] + dev = Device(binary, elf, boot_base) + failures = [] + try: + host = Host(dev.pty) + + # --- activation --- + info = host.activate() + check(info[0:3] == b"TSB", f"magic 'TSB' (got {info[0:3]!r})") + check(info[6:9] == bytes([0x1E, 0x95, 0x0F]), f"signature 1E 95 0F (got {info[6:9].hex()})") + check(info[14] == info[15], f"device-type bytes 14==15 (got {info[14]:#x},{info[15]:#x})") + check(host.pagesize == PAGE, f"page size {PAGE} (got {host.pagesize})") + check(host.eeprom_size == 1024, f"eeprom size 1024 (got {host.eeprom_size})") + print(f" info: {info.hex()} appflash={host.appflash} eeprom={host.eeprom_size}") + + # --- flash write / read round-trip (actual self-programming) --- + app = bytes(range(256)) # two pages of known data + host.write_flash(app) + back = host.read_flash(2) + check(back == app, f"flash round-trip 2 pages ({'match' if back == app else 'MISMATCH'})") + + # --- EEPROM write / read round-trip --- + edata = bytes((i * 7) & 0xFF for i in range(PAGE)) + host.write_eeprom(edata) + eback = host.read_eeprom(1) + check(eback == edata, "eeprom round-trip 1 page") + + # --- config page write / read round-trip --- + cfg = bytes([0x00, 0x00, 0x40]) + b"\xff" * (PAGE - 3) # appjump 0, timeout 0x40, no password + echo = host.write_config(cfg) + check(echo == cfg, "config write echoes the programmed page") + check(host.read_config() == cfg, "config read-back matches") + + except AssertionError as e: + failures.append(str(e)) + print(f" FAIL: {e}") + finally: + dev.stop() + + if failures: + print(f"FAILED ({len(failures)})") + return 1 + print("ALL PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tsb/main.cpp b/tsb/main.cpp deleted file mode 100644 index fb6b242..0000000 --- a/tsb/main.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "clock.hpp" - -#include "uart/uart.hpp" - -constexpr auto READ_FLASH_CMD = 'f'; -constexpr auto WRITE_FLASH_CMD = 'F'; -constexpr auto READ_EEPROM_CMD = 'e'; -constexpr auto WRITE_EEPROM_CMD = 'E'; -constexpr auto READ_USERDATA_CMD = 'c'; -constexpr auto WRITE_USERDATA_CMD = 'C'; -constexpr auto REQUEST_CMD = '?'; -constexpr auto CONFIRM_CMD = '!'; -constexpr auto AUTO_BAUDING_CMD = '@'; - -using uart_interface = uart::Hardware0, uart::Driven::BLOCKING>; - -enum class State { - WAITING, - ACTIVE, -}; - -struct DeviceInfo { - char name[3] = {'T', 'S', 'B'}; - uint8_t date[2] = {0x1a, 0x1f}; - uint8_t status = 0xf0; - uint8_t signature[3] = {0x1e, 0x95, 0x0f}; - uint8_t pageSize = 0x40; - uint16_t flashSize = 0x3ec0; - uint16_t eepromSize = 0x03ff; -}; - -struct UserData { - uint16_t jumpAddress = 0xAAAA; - uint8_t timeout = 0x21; -}; - -static inline void sendDeviceInfo() -{ - uart::Uart serial; - - constexpr DeviceInfo deviceInfo; - constexpr UserData userData; - - for (uint8_t i = 0; i < sizeof(deviceInfo); ++i) { - serial.txByte(*(reinterpret_cast(&deviceInfo) + i)); - } - - for (uint8_t i = 0; i < sizeof(userData); ++i) { - serial.txByte(*(reinterpret_cast(&userData) + i)); - } -} - -static uint8_t g_lastPage[128] = {}; - -static inline void sendUserData() -{ - uart::Uart serial; - - for (uint8_t i = 0; i < sizeof(g_lastPage); ++i) { - serial.txByte(*(reinterpret_cast(&g_lastPage) + i)); - } -} - -static inline void sendConfirm() -{ - uart::Uart serial; - serial.txByte(CONFIRM_CMD); -} - -int main() -{ - uart::Uart serial; - serial.init(); - - State state = State::WAITING; - - uint8_t receivedByte = 0; - uint8_t autoBaudingCounter = 0; - - while (true) { - if (serial.rxByte(receivedByte)) { - if (state == State::WAITING) { - if (receivedByte == AUTO_BAUDING_CMD) { - ++autoBaudingCounter; - } - if (autoBaudingCounter == 3) { - autoBaudingCounter = 0; - state = State::ACTIVE; - sendDeviceInfo(); - } - } else if (state == State::ACTIVE) { - if (receivedByte == READ_USERDATA_CMD) { - sendUserData(); - sendConfirm(); - state = State::WAITING; - } - } - } - } - - return 0; -} diff --git a/tsb/tsb_asm.cpp b/tsb/tsb_asm.cpp new file mode 100644 index 0000000..02e2054 --- /dev/null +++ b/tsb/tsb_asm.cpp @@ -0,0 +1,297 @@ +// TinySafeBoot on libavr — tier 3: C++ with minimal inline assembly. +// +// The tier-2 structure (unified runtime-flag paths, global-register page walk) +// with its hottest primitives — the UART poll/read/write and the SPM word/page +// stores — written as small, self-contained inline-asm sequences. Everything +// above them (command dispatch, activation, the page loops) stays C++. This is +// the ≤512-byte boot-section deliverable. + +#include + +#include // SP / RAMEND for the crt-free boot entry + +using namespace avr::literals; +namespace spm = avr::spm; +namespace ee = avr::eeprom; + +namespace tsb { + +constexpr auto off = avr::irq::guard_policy::unused; + +constexpr std::uint8_t confirm = '!'; +constexpr std::uint8_t request = '?'; + +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; + +constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; + +// 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, + (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, +// which is where the pure variant pays its prologue push/pop. r4-r7 are +// call-saved, so the library's SPM helpers preserve them across calls. +register std::uint16_t g_addr asm("r4"); +register std::uint8_t g_cnt asm("r6"); + +// rx/tx carry fixed assembler names so the hand-rolled loops can `rcall` them; +// noinline keeps every caller funneling through the one shared copy (rx also +// preserves Z/r0, which the store/send loops rely on across the call). +[[gnu::used, gnu::noinline]] std::uint8_t rx() asm("tsb_rx"); +[[gnu::used, gnu::noinline]] void tx(std::uint8_t) asm("tsb_tx"); + +// Blocking receive: spin on RXC0, then take UDR0. The driver's read() returns a +// std::optional for non-blocking use; a bootloader only ever blocks, so the tight +// poll drops the option's has-value plumbing. +std::uint8_t rx() +{ + std::uint8_t byte; + asm volatile("%=: lds %0, %[sra] \n\t" + " sbrs %0, %[rxc] \n\t" + " rjmp %=b \n\t" + " lds %0, %[udr] \n\t" + : "=&r"(byte) + : [sra] "n"(_SFR_MEM_ADDR(UCSR0A)), [rxc] "I"(RXC0), [udr] "n"(_SFR_MEM_ADDR(UDR0))); + return byte; +} + +// Blocking transmit: spin on UDRE0, then store UDR0. +void tx(std::uint8_t byte) +{ + asm volatile("%=: lds __tmp_reg__, %[sra] \n\t" + " sbrs __tmp_reg__, %[udre] \n\t" + " rjmp %=b \n\t" + " sts %[udr], %[b] \n\t" + : + : [sra] "n"(_SFR_MEM_ADDR(UCSR0A)), [udre] "I"(UDRE0), [udr] "n"(_SFR_MEM_ADDR(UDR0)), [b] "r"(byte)); +} + +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. GCC's unified loop +// (one body, per-byte memory branch) is already smaller than a split asm pair, +// so this one stays C++. +[[gnu::noinline, gnu::noclone]] void send(bool flash) +{ + do { + tx(flash ? avr::flash_load(flash_ptr(g_addr)) : ee::read(g_addr)); + ++g_addr; + } while (--g_cnt); +} + +[[gnu::noinline]] bool request_confirm() +{ + tx(request); + return rx() == confirm; +} + +// Stream one page from the host straight into the already-erased flash page at +// g_addr (SPM word buffer, low byte then high) or into EEPROM — no SRAM staging, +// so the receive and the store are one loop instead of two. The flash fill is +// hand-rolled asm: Z the flash word address, the word received into r0:r1 via +// the tiny rcall'd rx (which preserves Z), then committed by avr-libc. +[[gnu::noinline, gnu::noclone]] void store_page(bool flash) +{ + if (flash) { + std::uint8_t words = page / 2; + asm volatile(" movw r30, %[base] \n\t" + "%=: rcall tsb_rx \n\t" + " mov r0, r24 \n\t" + " rcall tsb_rx \n\t" + " mov r1, r24 \n\t" + " ldi r25, %[fill] \n\t" + " out %[spmcsr], r25 \n\t" + " spm \n\t" + " clr r1 \n\t" + " adiw r30, 2 \n\t" + " dec %[words] \n\t" + " brne %=b \n\t" + : [words] "+d"(words) + : [base] "r"(g_addr), [fill] "M"(_BV(__SPM_ENABLE)), [spmcsr] "I"(_SFR_IO_ADDR(SPMCSR)) + : "r24", "r25", "r30", "r31", "memory"); + spm::write_page(g_addr); + spm::wait(); + } else { + // EEPROM: rx each byte straight into the cell array, X the running + // address. The tight EEMPE→EEPE strobe replaces the library's wider + // atomic write (which the interrupt-driven queue and split modes need). + std::uint8_t cnt = page; + asm volatile( + " movw r26, %[a] \n\t" + "%=: rcall tsb_rx \n\t" + "0: sbic %[eecr], %[eepe] \n\t" + " rjmp 0b \n\t" + " out %[eedr], r24 \n\t" + " out %[earl], r26 \n\t" + " out %[earh], r27 \n\t" + " sbi %[eecr], %[eempe] \n\t" + " sbi %[eecr], %[eepe] \n\t" + " adiw r26, 1 \n\t" + " dec %[c] \n\t" + " brne %=b \n\t" + : [c] "+d"(cnt) + : [a] "r"(g_addr), [eecr] "I"(_SFR_IO_ADDR(EECR)), [eedr] "I"(_SFR_IO_ADDR(EEDR)), + [earl] "I"(_SFR_IO_ADDR(EEARL)), [earh] "I"(_SFR_IO_ADDR(EEARH)), [eepe] "I"(EEPE), [eempe] "I"(EEMPE) + : "r24", "r26", "r27"); + } +} + +[[noreturn]] void appjump() +{ + spm::wait(); + asm volatile("jmp 0"); // hand over to the application reset vector at 0x0000 + __builtin_unreachable(); +} + +// '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::noinline]] void read_mem(bool flash) +{ + g_addr = 0; + for (;;) { + if (rx() != confirm) + return; + g_cnt = page; + send(flash); + if (flash && g_addr >= app_end) + return; + } +} + +// 'F'/'E': flash erases the whole application first, then both take the pages +// the host offers behind '?'. +[[gnu::noinline]] void write_mem(bool flash) +{ + if (flash) { + // Erase every application page [0, app_end) with Z the running byte + // address and the busy-wait inline — avoids the Y juggling GCC needs to + // step the non-adiw'able global address, and its prologue push/pop. + asm volatile(" clr r30 \n\t" + " clr r31 \n\t" + "%=: ldi r25, %[ers] \n\t" + " out %[spmcsr], r25 \n\t" + " spm \n\t" + "0: in r25, %[spmcsr] \n\t" + " sbrc r25, 0 \n\t" + " rjmp 0b \n\t" + " subi r30, 0x80 \n\t" + " sbci r31, 0xFF \n\t" + " cpi r30, lo8(%[end]) \n\t" + " ldi r25, hi8(%[end]) \n\t" + " cpc r31, r25 \n\t" + " brlo %=b \n\t" + : + : [ers] "M"(_BV(PGERS) | _BV(__SPM_ENABLE)), [spmcsr] "I"(_SFR_IO_ADDR(SPMCSR)), [end] "i"(app_end) + : "r25", "r30", "r31"); + } + g_addr = 0; + while (request_confirm()) { + 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. +void write_config() +{ + if (!request_confirm()) + return; + g_addr = app_end; + spm::erase_page(g_addr); + spm::wait(); + store_page(true); + spm::rww_enable(); + g_cnt = page; + send(true); // g_addr is still app_end +} + +[[noreturn]] void run() +{ + // Minimal 115200 8N1 bring-up: 8N1 is the UCSR0C reset value, so only U2X0, + // UBRR0 (16 at 16 MHz → 2.1 % error) and the RX/TX enables need writing — the + // driver's avr::init also programs UCSR0C. + avr::hw::reg<"UCSR0A">::write(avr::hw::field<"UCSR0A", "U2X0">{}(1).value); + avr::hw::reg<"UBRR0">::write16(16); + avr::hw::reg<"UCSR0B">::write(static_cast(avr::hw::field<"UCSR0B", "RXEN0">{}(1).value | + avr::hw::field<"UCSR0B", "TXEN0">{}(1).value)); + + // The password gate that the canonical loader carries (compare host bytes + // against the config page, hang on mismatch) is dropped here: it is optional + // (a blank config page means no password, the usual case) and its ~26 bytes + // are what a C++ build cannot spare inside the 512-byte boot section. Tiers 1 + // and 2 keep it; this asm variant trades it for the size budget. + std::uint8_t knocks = 0; + std::uint16_t idle = 0xFFFF; + while (knocks < 3) { + if (avr::hw::reg<"UCSR0A">::read() & avr::hw::field<"UCSR0A", "RXC0">{}(1).value) + knocks = avr::hw::reg<"UDR0">::read() == '@' ? knocks + 1 : 0; + else if (--idle == 0) + appjump(); + } + + g_addr = reinterpret_cast(&info[0]); + g_cnt = sizeof(info); + send(true); + + 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. + 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_cnt = page; + send(true); + } + } else { + appjump(); + } + } +} + +} // namespace tsb + +extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry() +{ + SP = RAMEND; + tsb::run(); +} diff --git a/tsb/tsb_pure.cpp b/tsb/tsb_pure.cpp new file mode 100644 index 0000000..c93b8fe --- /dev/null +++ b/tsb/tsb_pure.cpp @@ -0,0 +1,266 @@ +// 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 +// attributes are the ones the task inherently needs — [[gnu::progmem]] for the +// flash-resident info block and the naked reset entry that stands in for the +// absent C runtime. +// +// 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. + +#include + +#include // SP / RAMEND for the crt-free boot entry + +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}>; +inline constexpr serial_t serial{}; + +namespace tsb { + +// The loader is purely polled — it never enables interrupts — so every SPM and +// EEPROM lock folds to nothing under this posture. +constexpr auto off = avr::irq::guard_policy::unused; + +// The two handshake bytes, identical across every TSB host. +constexpr std::uint8_t confirm = '!'; +constexpr std::uint8_t request = '?'; + +// 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. +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; + +// Firmware version stamp: YY*512 + MM*32 + DD, the encoding the host decodes. +constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 19; + +// The 16-byte device-info block the host reads on activation. Flash-resident so +// it needs no .data (there is no crt to copy it). +// clang-format off +[[gnu::progmem]] constexpr std::uint8_t info[16] = { + 'T', 'S', 'B', + build_date & 0xFF, build_date >> 8, + 0xF3, // status byte (native-UART fixed-baud lineage) + 0x1E, 0x95, 0x0F, // ATmega328P signature + page / 2, // page size in words + (app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words + eeprom_end & 0xFF, eeprom_end >> 8, + 0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15) +}; +// clang-format on + +// 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. +[[gnu::section(".noinit")]] std::uint8_t buffer[page]; + +std::uint8_t rx() +{ + for (;;) + if (auto byte = serial.read()) + return *byte; +} + +void tx(std::uint8_t byte) +{ + serial.write(byte); +} + +const std::uint8_t *flash_ptr(std::uint16_t addr) +{ + return reinterpret_cast(addr); +} + +// Stream `count` bytes to the host, from flash (LPM) or from EEPROM. +void send_flash(std::uint16_t addr, std::uint16_t count) +{ + while (count--) + tx(avr::flash_load(flash_ptr(addr++))); +} + +void send_eeprom(std::uint16_t addr, std::uint16_t count) +{ + while (count--) + tx(ee::read(addr++)); +} + +// Take one page from the host into the SRAM buffer. +void get_page() +{ + for (std::uint16_t i = 0; i < page; ++i) + buffer[i] = rx(); +} + +// Prompt the host with '?' and report whether it answered '!'. +bool request_confirm() +{ + tx(request); + return rx() == confirm; +} + +// Program the SRAM buffer into one already-erased flash page (low byte then +// high, as the SPM word buffer wants). +void write_flash_page(std::uint16_t addr) +{ + for (std::uint16_t i = 0; i < page; i += 2) + spm::fill(addr + i, static_cast(buffer[i] | (buffer[i + 1] << 8))); + spm::write_page(addr); + spm::wait(); +} + +// Write the SRAM buffer into EEPROM byte by byte. +void write_eeprom_page(std::uint16_t addr) +{ + for (std::uint16_t i = 0; i < page; ++i) + ee::write(addr + i, buffer[i]); +} + +// Run the application: reset vector at 0x0000. 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(); +} + +// 'f': stream the application flash back, one page per host '!'. Self-terminates +// at the application boundary; the host normally stops earlier with a non-'!'. +void read_flash() +{ + for (std::uint16_t a = 0; a < app_end; a += page) { + if (rx() != confirm) + return; + send_flash(a, page); + } +} + +// 'e': stream EEPROM back, one page per host '!', until the host stops. +void read_eeprom() +{ + for (std::uint16_t a = 0;; a += page) { + if (rx() != confirm) + return; + send_eeprom(a, page); + } +} + +// 'F': erase the whole application first (unwritten pages stay erased), 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(); + } + 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. +void write_eeprom() +{ + for (std::uint16_t a = 0; request_confirm(); a += page) { + get_page(); + write_eeprom_page(a); + } +} + +// 'C': replace the config page, then echo it back for the host to verify. +void write_config() +{ + if (!request_confirm()) + return; + get_page(); + spm::erase_page(app_end); + spm::wait(); + write_flash_page(app_end); + spm::rww_enable(); + send_flash(app_end, page); +} + +[[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. + if (avr::hw::reg<"MCUSR">::read() & avr::hw::field<"MCUSR", "WDRF">{}(1).value) + 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. + std::uint8_t knocks = 0; + std::uint32_t idle = 4000000; + while (knocks < 3) { + if (auto byte = serial.read()) + knocks = *byte == '@' ? 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[0]), sizeof(info)); + + for (;;) { + tx(confirm); // Mainloop ready + switch (rx()) { + case 'f': + read_flash(); + break; + case 'F': + write_flash(); + break; + case 'e': + read_eeprom(); + break; + case 'E': + write_eeprom(); + break; + case 'c': + send_flash(app_end, page); + break; + case 'C': + write_config(); + break; + default: + appjump(); // 'q' or any other byte runs the application + } + } +} + +} // 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; + tsb::run(); +} diff --git a/tsb/tsb_tricks.cpp b/tsb/tsb_tricks.cpp new file mode 100644 index 0000000..35e7913 --- /dev/null +++ b/tsb/tsb_tricks.cpp @@ -0,0 +1,245 @@ +// 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. + +#include + +#include // SP / RAMEND for the crt-free boot entry + +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}>; +inline constexpr serial_t serial{}; + +namespace tsb { + +constexpr auto off = avr::irq::guard_policy::unused; + +constexpr std::uint8_t confirm = '!'; +constexpr std::uint8_t request = '?'; + +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; + +// 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, + (app_end / 2) & 0xFF, (app_end / 2) >> 8, + eeprom_end & 0xFF, eeprom_end >> 8, + 0xAA, 0xAA, +}; +// 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. +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; +} + +void tx(std::uint8_t byte) +{ + serial.write(byte); +} + +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. +[[gnu::noinline, gnu::noclone]] void send(bool flash) +{ + do { + tx(flash ? avr::flash_load(flash_ptr(g_addr)) : ee::read(g_addr)); + ++g_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) +{ + g_cnt = 0; + if (flash) { + do { + spm::fill(g_addr + g_cnt, static_cast(buffer[g_cnt] | (buffer[g_cnt + 1] << 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]); + } while (++g_cnt != page); + } +} + +[[noreturn]] void appjump() +{ + spm::wait(); + reinterpret_cast(0)(); + __builtin_unreachable(); +} + +// '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::noinline]] void read_mem(bool flash) +{ + g_addr = 0; + for (;;) { + if (rx() != confirm) + return; + g_cnt = page; + send(flash); + if (flash && g_addr >= app_end) + return; + } +} + +// 'F'/'E': flash erases the whole application first, then both take the pages +// 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); + } + g_addr = 0; + while (request_confirm()) { + get_page(); + write_page(flash); + g_addr += page; + } + if (flash) + spm::rww_enable(); +} + +// 'C': replace the config page, then echo it back for the host to verify. +void write_config() +{ + if (!request_confirm()) + return; + get_page(); + g_addr = app_end; + spm::erase_page(g_addr); + spm::wait(); + write_page(true); + spm::rww_enable(); + g_cnt = page; + send(true); // g_addr is still app_end +} + +[[noreturn]] void run() +{ + if (avr::hw::reg<"MCUSR">::read() & avr::hw::field<"MCUSR", "WDRF">{}(1).value) + appjump(); + + avr::init(); + + std::uint8_t knocks = 0; + std::uint32_t idle = 4000000; + while (knocks < 3) { + if (auto byte = serial.read()) + knocks = *byte == '@' ? 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); + + 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. + 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_cnt = page; + send(true); + } + } else { + appjump(); + } + } +} + +} // namespace tsb + +extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry() +{ + SP = RAMEND; + tsb::run(); +}