cmake_minimum_required(VERSION 3.28) project(tsb_libavr LANGUAGES CXX) # 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 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) set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device) execute_process( COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts -o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c -lsimavr -lsimavrparts -lelf -lutil RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err) if(NOT _pbdev_res EQUAL 0) message(STATUS "pureboot_device not built (${_pbdev_err}) — protocol tests skipped") unset(PB_DEVICE) endif() if(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 -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() endif() # The ELF is only a container (symbols, section headers) and is never flashed — # and the host tool's load_image() dispatches on extension, so handing it one # would silently program the header bytes. Every loader image therefore gets # both flashable forms beside it at link time: .hex for avrdude, and .bin for # the host tool's raw path (which is what the reloc and update tests convert to # on the fly). .eeprom is dropped — EEPROM content is its own update. function(add_image_outputs name) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom $ $.hex COMMAND ${CMAKE_OBJCOPY} -O binary -R .eeprom $ $.bin) endfunction() # 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_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 — 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): 836 B in # the 1 KB section. # # 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} -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) add_image_outputs(${name}) 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() # 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, # fitting each chip's smallest boot sector. The geometry and the # pureboot_add_loader() deployment function live in pureboot/CMakeLists.txt — # the unit a downstream project consumes; everything below is this port's # own build: the stock loaders, their tests, and the size matrix. The # distinct binary dir keeps the `pureboot` target's output name free. add_subdirectory(pureboot pureboot-cmake) # The stock loader: the family-default deployment (crystal/RC clock, the # chip's natural link, default pins). The activation window stays a cache # variable — re-timing a deployed loader is a self-update with a re-timed # build. pureboot9 is that re-timed build, and what the update test installs. set(PUREBOOT_TIMEOUT 8 CACHE STRING "pureboot activation window, seconds") pureboot_add_loader(pureboot TIMEOUT ${PUREBOOT_TIMEOUT}) if(PROJECT_IS_TOP_LEVEL) get_target_property(_pb_stock_hz pureboot PUREBOOT_HZ) get_target_property(_pb_stock_baud pureboot PUREBOOT_BAUD) add_test(NAME pureboot.size COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) if(Python3_FOUND) add_test(NAME pureboot.pi COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py ${CMAKE_OBJDUMP} ${CMAKE_OBJCOPY} ${CMAKE_CXX_COMPILER} ${LIBAVR_MCU} $ ${CMAKE_BINARY_DIR}/CMakeFiles/pureboot.dir/pureboot/pureboot.cpp.obj ${PUREBOOT_BASE_HEX}) add_test(NAME pureboot.planner COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) endif() # The protocol test flashes this fixture through the loader with the real # host tool and expects its banner after the hand-over; a normally linked # application whose reset vector is what the tinies' surgery re-homes. if(DEFINED PB_DEVICE) add_executable(pbapp test/pbapp.cpp) target_link_libraries(pbapp PRIVATE libavr) add_custom_command(TARGET pbapp POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) add_test(NAME pureboot.protocol COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${PUREBOOT_EEPROM} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbtest-work) set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) # The position-independence acceptance test: the identical image, # installed one slot lower, must serve the full command set. add_test(NAME pureboot.reloc COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbreloc.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbreloc-work) set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180 ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}") # Entering the loader from a running application with no reset # between, over a page buffer the application dirtied — the case the # loader declines to guard and the host repairs. Hardware forbids the # state here (SPM runs only from the boot section); simavr does not, # which is what makes it constructible. if(LIBAVR_MCU STREQUAL "atmega328p") add_test(NAME pureboot.dirty COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbdirty.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbdirty-work) set_tests_properties(pureboot.dirty PROPERTIES TIMEOUT 180) endif() # Re-homing: a loader mistakenly programmed at address 0 (a raw .bin # handed to a programmer) or sitting in the staging slot must heal # into the canonical slot through the ordinary --update-loader flow. # Patched-vector behavior, so one representative chip carries it. if(LIBAVR_MCU STREQUAL "attiny85") add_test(NAME pureboot.rehome COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbrehome.py ${PB_DEVICE} $ $.bin ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbrehome-work) set_tests_properties(pureboot.rehome PROPERTIES TIMEOUT 180) endif() # The self-update end-to-end: the re-timed build (same source, only # the timeout differs — a byte-different image) replaces the resident # through --update-loader, with every power-fail phase rehearsed from # the runner's flash dumps. pureboot_add_loader(pureboot9 TIMEOUT 9) add_test(NAME pureboot.update COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbupdate.py ${PB_DEVICE} $ $ ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbupdate-work) set_tests_properties(pureboot.update PROPERTIES TIMEOUT 600 ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}") endif() # The size matrix: every configuration axis that could move the image # size — the serial backend (different code), the USART instance # (different registers), the clock (different constants), and the baud # through the shapes its bit timing takes — each combination must still # fit the chip's slot budget. Pins are size-neutral (port and bit are # immediate operands) and the timeout is a constant, so neither adds an # axis. The stock build is one point of this matrix and already has its # test. function(pureboot_size_variant name) pureboot_add_loader(${name} ${ARGN}) add_test(NAME ${name}.size COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) endfunction() # The autobaud loader: one clock-agnostic image per chip, so it has no # clock x baud axis of its own — the matrix below sweeps those for the # fixed-baud builds, and this one binary has to serve all of them at run # time. Size-tested against the same per-chip budget as every other variant. pureboot_add_loader(pureboot_autobaud SERIAL autobaud) add_test(NAME pureboot_autobaud.size COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) # One point of the exhaustive matrix, named from its resolved parameters # so the enumeration cannot collide with itself. Unreachable rates drop # out here rather than aborting the configure. function(pureboot_matrix_point hz baud link) if(link STREQUAL "software") pureboot_baud_feasible(${hz} ${baud} 1 _ok) set(_args SERIAL software) else() pureboot_baud_feasible(${hz} ${baud} 0 _ok) set(_args USART ${link}) endif() if(_ok) pureboot_size_variant(pbm_${hz}_${baud}_${link} CLOCK ${hz} BAUD ${baud} ${_args}) endif() endfunction() # Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and # the crystal the stock build assumes (the tiny13's ladder is its own RC # menu — it has no crystal option). if(LIBAVR_MCU MATCHES "^attiny13") set(_matrix_clocks 1200000 4800000 9600000) set(_full_clocks 128000 600000 1200000 4800000 9600000) else() set(_matrix_clocks 1000000 8000000 16000000) set(_full_clocks 128000 1000000 1843200 2000000 3686400 4000000 7372800 8000000 11059200 12000000 14745600 16000000 18432000 20000000) endif() # The exhaustive cross product: every clock a deployment plausibly runs # — the internal oscillators, the shipped CKDIV8 floor, the plain # crystals and the UART crystals — against every rate, against every # backend. Beyond the ladder the list carries the slow rates a # sub-megahertz oscillator is left with, which no ladder rate reaches # (16000 Bd is the only rate the 128 kHz oscillator holds exactly); at # the fast clocks those same rates also select the software UART's # 16-bit _delay_loop_2 bit spin (two words more setup at each of its five # sites), the largest image the space produces and a shape the ladder # default — always the *fastest* rate a clock reaches — never picks. # # Every chip runs the full cross product: the size-bearing classes (flash # addressing, hand-over shape, page size, USART inventory) are what make # the image differ, and a chip outside them is expected to match its class # — but "expected" is what a matrix is for, and the whole sweep is cheap # enough to run rather than reason about. PUREBOOT_FULL_MATRIX is what # selects it; the compact matrix below is the per-commit default. get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER) list(APPEND _full_bauds 16000 4800 2400 1200) if(DEFINED ENV{PUREBOOT_FULL_MATRIX}) foreach(_matrix_hz IN LISTS _full_clocks) foreach(_matrix_baud IN LISTS _full_bauds) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software) if(PUREBOOT_HAS_USART) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0) endif() if(PUREBOOT_HAS_USART1) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1) endif() endforeach() endforeach() else() foreach(_matrix_hz IN LISTS _matrix_clocks) math(EXPR _matrix_khz "${_matrix_hz} / 1000") if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz) pureboot_size_variant(pureboot_sw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL software) endif() if(PUREBOOT_HAS_USART AND NOT _matrix_hz EQUAL _pb_stock_hz) pureboot_size_variant(pureboot_hw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL hardware) endif() if(PUREBOOT_HAS_USART1 AND NOT _matrix_hz EQUAL _pb_stock_hz) pureboot_size_variant(pureboot_usart1_${_matrix_khz}k CLOCK ${_matrix_hz} USART 1) endif() endforeach() list(GET _matrix_clocks -1 _matrix_top_hz) pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software) endif() if(PUREBOOT_HAS_USART1) pureboot_size_variant(pureboot_usart1 USART 1) endif() # One configured deployment end to end — a real board's shape rather # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder # baud (9600). The full protocol suite runs against it, fixture # application included, over the runner's GPIO bridge — proving the # configuration plumbing produces a working loader, not just one that # fits. if(LIBAVR_MCU STREQUAL "atmega328p" AND DEFINED PB_DEVICE) pureboot_size_variant(pureboot_custom CLOCK 1000000 SERIAL software RX pb5 TX pb1) get_target_property(_custom_hz pureboot_custom PUREBOOT_HZ) get_target_property(_custom_baud pureboot_custom PUREBOOT_BAUD) get_target_property(_custom_link pureboot_custom PUREBOOT_LINK) add_executable(pbapp_custom test/pbapp.cpp) target_link_libraries(pbapp_custom PRIVATE libavr) target_compile_definitions(pbapp_custom PRIVATE PUREBOOT_CLOCK_HZ=${_custom_hz} PUREBOOT_BAUD=${_custom_baud} PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1) add_custom_command(TARGET pbapp_custom POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) add_test(NAME pureboot.custom COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_custom_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_custom_baud} ${PUREBOOT_EEPROM} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbcustom-work ${_custom_link}) set_tests_properties(pureboot.custom PROPERTIES TIMEOUT 180) endif() # The second USART, driven for real on one chip: instance selection is # compile-checked everywhere, but only a live session proves the loader # initialized and polls the USART it claims to. The fixture application # banners on the same instance. if(LIBAVR_MCU STREQUAL "atmega644a" AND DEFINED PB_DEVICE) get_target_property(_usart1_hz pureboot_usart1 PUREBOOT_HZ) get_target_property(_usart1_baud pureboot_usart1 PUREBOOT_BAUD) add_executable(pbapp_usart1 test/pbapp.cpp) target_link_libraries(pbapp_usart1 PRIVATE libavr) target_compile_definitions(pbapp_usart1 PRIVATE PUREBOOT_CLOCK_HZ=${_usart1_hz} PUREBOOT_BAUD=${_usart1_baud} PUREBOOT_USART=1) add_custom_command(TARGET pbapp_usart1 POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) add_test(NAME pureboot.usart1 COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_usart1_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_usart1_baud} ${PUREBOOT_EEPROM} $.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbusart1-work usart1) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) endif() # The autobaud variants driven end to end over the software-UART bridge (both # under review — pureboot/autobaud.md): the host sends the 0xC0 calibration # pulse, the loader times it, locks, and programs. Run on the near-flash 328P # and the word-addressed 1284P — the two flash-addressing classes — and each # at two clocks with the one binary, which is the clock-agnostic property # autobaud exists for (test/pbautobaud.py). The fixture application banners # over the same software link at the first clock's rate. if(LIBAVR_MCU MATCHES "^atmega(328p|1284p)$" AND DEFINED PB_DEVICE) add_executable(pbapp_autobaud test/pbapp.cpp) target_link_libraries(pbapp_autobaud PRIVATE libavr) target_compile_definitions(pbapp_autobaud PRIVATE PUREBOOT_CLOCK_HZ=1000000 PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1) add_custom_command(TARGET pbapp_autobaud POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) add_test(NAME pureboot.autobaud COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $.bin 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py ${CMAKE_BINARY_DIR}/pbautobaud-work) set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240) endif() endif()