pureboot: every deployment axis is a build parameter

Clock, baud, serial backend (hardware USART 0/1 or the software UART on
any pins) and the activation window all resolve through one CMake
function, pureboot_add_loader() in pureboot/CMakeLists.txt — the unit a
downstream project consumes. The default baud is the fastest standard
rate within 2.5 % (the same best-divisor search libavr's solver runs),
gated on software builds by the polled receiver's 100-cycles-a-bit
floor; every explicit pick is re-checked by the compile's static asserts.

The size matrix builds each axis that can move the image — backend x
clock ladder x USART instance, per chip — against the slot budget, and
two nondefault deployments run the whole protocol suite live: the 328P
on its shipped 1 MHz fuses over software serial on TX=PB1/RX=PB5
(pureboot.custom), and the 644A over USART1 (pureboot.usart1). The sim
runner takes -l to bridge any link, paces a fully quiet bridge toward
real time (a free-running 8 M-cycle window loses the reset-race knock),
and the fixture application speaks the deployment it is built for.

The loader itself shed bytes on the way: the return-address high byte
spelled through byteswap (the double swap folds to the one-byte pick),
the info-block address composed instead of bit_cast, and libavr's new
polled-UART helpers replacing the port's uart::detail reaches. Every
combination fits: 458-506 B across the megas' whole matrix, 470-484 B
on the tinies, 556-562 B in the 1284s' 1 KiB slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:42:57 +02:00
parent 76f4576fe0
commit a977e507f3
8 changed files with 743 additions and 338 deletions

View File

@@ -126,154 +126,31 @@ if(LIBAVR_MCU STREQUAL "atmega328p")
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.
#
# The image is position-independent (check_pi.py asserts the two link-time
# facts that make it so), and on the tinies its budget is 510, not 512: the
# slot's last word is the trampoline the host composes — the resident slot's
# holds the application entry, and a staging copy's holds the jump through
# which it reaches the loader it installed. The activation window is a
# compile-time constant; a different PUREBOOT_TIMEOUT builds the re-timed
# binary a self-update then installs.
# 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")
# Per-family geometry. The boot-sectioned megas run the loader from the
# hardware boot section and boot the application at word 0; the tinies and
# the boot-section-less m48s get the trampoline surgery. All megas assume a
# 16 MHz crystal at 115200 Bd; the tinies their internal RC at 57600 Bd over
# the software UART. --pmem-wrap-around models AVR's modulo-flash PC where
# the flash is big enough to need it (an rjmp reaches all of 4 KiB by
# itself).
if(LIBAVR_MCU MATCHES "^attiny13a?$")
set(_pb_flash 1024)
set(_pb_wrap "")
set(_pb_page 32)
set(_pb_hz 9600000)
set(_pb_baud 57600)
set(_pb_eeprom 64)
elseif(LIBAVR_MCU STREQUAL "attiny25")
set(_pb_flash 2048)
set(_pb_wrap "")
set(_pb_page 32)
set(_pb_hz 8000000)
set(_pb_baud 57600)
set(_pb_eeprom 128)
elseif(LIBAVR_MCU STREQUAL "attiny45")
set(_pb_flash 4096)
set(_pb_wrap "")
set(_pb_page 64)
set(_pb_hz 8000000)
set(_pb_baud 57600)
set(_pb_eeprom 256)
elseif(LIBAVR_MCU STREQUAL "attiny85")
set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
set(_pb_page 64)
set(_pb_hz 8000000)
set(_pb_baud 57600)
set(_pb_eeprom 512)
elseif(LIBAVR_MCU MATCHES "^atmega48(a|p|pa)?$")
set(_pb_flash 4096)
set(_pb_wrap "")
set(_pb_page 64)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 256)
elseif(LIBAVR_MCU MATCHES "^atmega8a?$" OR LIBAVR_MCU MATCHES "^atmega88(a|p|pa)?$")
set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
set(_pb_page 64)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 512)
elseif(LIBAVR_MCU MATCHES "^atmega16a?$" OR LIBAVR_MCU MATCHES "^atmega168(a|p|pa)?$" OR
LIBAVR_MCU MATCHES "^atmega164(a|p|pa)$")
set(_pb_flash 16384)
set(_pb_wrap -Wl,--pmem-wrap-around=16k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 512)
elseif(LIBAVR_MCU MATCHES "^atmega32a?$" OR LIBAVR_MCU MATCHES "^atmega328p?$" OR
LIBAVR_MCU MATCHES "^atmega324(a|p|pa)$")
set(_pb_flash 32768)
set(_pb_wrap -Wl,--pmem-wrap-around=32k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 1024)
elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
# 64 KiB is exactly the 16-bit byte space: plain LPM reaches everything,
# and the smallest boot section (1 KiB) holds the loader and its staging
# slot together (see pureboot/README.md).
set(_pb_flash 65536)
set(_pb_wrap -Wl,--pmem-wrap-around=64k)
set(_pb_page 256)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 2048)
elseif(LIBAVR_MCU MATCHES "^atmega1284p?$")
# 128 KiB: wire flash addresses are word addresses, reads go through
# ELPM, and the PC's modulo wrap exceeds what --pmem-wrap-around models.
# The slot is 1 KiB — this chip's own smallest boot sector; the far
# machinery cannot fit 512 B (see pureboot/README.md).
set(_pb_flash 131072)
set(_pb_wrap "")
set(_pb_page 256)
set(_pb_hz 16000000)
set(_pb_baud 115200)
set(_pb_eeprom 4096)
set(_pb_slot 1024)
set(_pb_limit 1024)
else()
message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}")
endif()
if(NOT DEFINED _pb_slot)
set(_pb_slot 512)
endif()
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
# Patched-vector chips hand over through the trampoline word below the slot,
# which is also the slot's own last word — their budget is slot 2.
if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
set(_pb_app 0)
if(NOT DEFINED _pb_limit)
set(_pb_limit ${_pb_slot})
endif()
else()
math(EXPR _pb_app "${_pb_base} - 2")
math(EXPR _pb_limit "${_pb_slot} - 2")
endif()
# simavr names its cores after the base dies; the A revisions run on them
# (the 644PA on the 644P core).
set(_pb_sim_mcu ${LIBAVR_MCU})
if(LIBAVR_MCU MATCHES "^atmega(8|16|32|48|88|164|168|644)a$")
string(REGEX REPLACE "a$" "" _pb_sim_mcu ${LIBAVR_MCU})
elseif(LIBAVR_MCU STREQUAL "atmega644pa")
set(_pb_sim_mcu atmega644p)
endif()
add_executable(pureboot pureboot/pureboot.cpp)
target_link_libraries(pureboot PRIVATE libavr)
target_compile_definitions(pureboot PRIVATE PUREBOOT_TIMEOUT=${PUREBOOT_TIMEOUT})
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} $<TARGET_FILE:pureboot>)
add_image_outputs(pureboot)
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=$<TARGET_FILE:pureboot>
-DLIMIT=${_pb_limit} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
-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_NM} $<TARGET_FILE:pureboot> ${_pb_base_hex})
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${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)
@@ -289,9 +166,9 @@ if(PROJECT_IS_TOP_LEVEL)
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:pbapp> $<TARGET_FILE:pbapp>.bin)
add_test(NAME pureboot.protocol
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
${PB_DEVICE} $<TARGET_FILE:pureboot> ${_pb_sim_mcu} ${_pb_hz} ${_pb_base_hex}
${_pb_page} ${_pb_baud} ${_pb_eeprom} $<TARGET_FILE:pbapp>.bin
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${PUREBOOT_EEPROM}
$<TARGET_FILE:pbapp>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbtest-work)
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
@@ -299,43 +176,127 @@ if(PROJECT_IS_TOP_LEVEL)
# 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} $<TARGET_FILE:pureboot> ${_pb_sim_mcu} ${_pb_hz} ${_pb_base_hex}
${_pb_page} ${_pb_baud} ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${PB_DEVICE} $<TARGET_FILE:pureboot> ${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}")
# Re-homing: a loader mistakenly programmed at address 0 (a raw .bin
# handed to a programmer) must heal into the canonical slot through
# the ordinary --update-loader flow. Patched-vector behavior, so one
# representative chip carries it.
# 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} $<TARGET_FILE:pureboot> $<TARGET_FILE:pureboot9>.bin ${_pb_sim_mcu}
${_pb_hz} ${_pb_base_hex} ${_pb_page} ${_pb_baud} $<TARGET_FILE:pbapp>.bin
${PB_DEVICE} $<TARGET_FILE:pureboot> $<TARGET_FILE:pureboot9>.bin
${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE}
${_pb_stock_baud} $<TARGET_FILE:pbapp>.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
# PUREBOOT_TIMEOUT differs — a byte-different image) replaces the
# resident through --update-loader, with every power-fail phase
# rehearsed from the runner's flash dumps.
add_executable(pureboot9 pureboot/pureboot.cpp)
target_link_libraries(pureboot9 PRIVATE libavr)
target_compile_definitions(pureboot9 PRIVATE PUREBOOT_TIMEOUT=9)
target_link_options(pureboot9 PRIVATE -nostartfiles -Wl,--section-start=.text=${_pb_base_hex}
-Wl,--defsym=pureboot_app=${_pb_app} ${_pb_wrap})
add_image_outputs(pureboot9)
# 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} $<TARGET_FILE:pureboot> $<TARGET_FILE:pureboot9> ${_pb_sim_mcu}
${_pb_hz} ${_pb_base_hex} ${_pb_page} ${_pb_baud} $<TARGET_FILE:pbapp>.bin
${PB_DEVICE} $<TARGET_FILE:pureboot> $<TARGET_FILE:pureboot9>
${PUREBOOT_SIM_MCU} ${_pb_stock_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE}
${_pb_stock_baud} $<TARGET_FILE:pbapp>.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 clock and its ladder
# baud (different constants and divisor shapes), the USART instance
# (different register class) — 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=$<TARGET_FILE:${name}>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
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)
else()
set(_matrix_clocks 1000000 8000000 16000000)
endif()
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()
endforeach()
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
$<TARGET_FILE:pbapp_custom> $<TARGET_FILE:pbapp_custom>.bin)
add_test(NAME pureboot.custom
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
${PB_DEVICE} $<TARGET_FILE:pureboot_custom> ${PUREBOOT_SIM_MCU} ${_custom_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_custom_baud} ${PUREBOOT_EEPROM}
$<TARGET_FILE:pbapp_custom>.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
$<TARGET_FILE:pbapp_usart1> $<TARGET_FILE:pbapp_usart1>.bin)
add_test(NAME pureboot.usart1
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
${PB_DEVICE} $<TARGET_FILE:pureboot_usart1> ${PUREBOOT_SIM_MCU} ${_usart1_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_usart1_baud} ${PUREBOOT_EEPROM}
$<TARGET_FILE:pbapp_usart1>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbusart1-work usart1)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif()
endif()

310
pureboot/CMakeLists.txt Normal file
View File

@@ -0,0 +1,310 @@
# pureboot as a consumable CMake unit: the per-chip geometry, the default
# baud ladder, and pureboot_add_loader() — the one way a loader target is
# created, both by this port's own build and by a downstream project. A
# downstream project brings its usual libavr setup (the `libavr` target and
# the LIBAVR_MCU toolchain preset), adds this directory, and states its
# deployment:
#
# add_subdirectory(bootloader/pureboot)
# pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
#
# Every argument is optional — CLOCK defaults to the family assumption
# below, BAUD to the fastest standard rate the clock reaches within 2.5 %
# (the ladder), SERIAL to the chip's hardware USART where it has one
# (`hardware`/`software` force a backend, USART 1 picks the second
# instance), RX/TX to pb0/pb1 for the software UART, TIMEOUT to 8 s.
# Infeasible picks fail the build by name: libavr's baud-error and
# software-UART cycle-floor static asserts re-check whatever is passed.
# Per-family geometry: flash/page/EEPROM sizes and the linker wrap the PC
# modulo needs, the loader slot (each chip's smallest boot sector — 1 KiB on
# the word-addressed 1284s), and the deployment defaults (crystal assumption
# on the megas, calibrated RC on the tinies). The USART flags mirror the
# hardware inventory the loader's own static asserts check (the plain 644 is
# the x4 family's one single-USART die, Atmel-2593).
set(_pb_has_usart 1)
set(_pb_has_usart1 0)
if(LIBAVR_MCU MATCHES "^attiny13a?$")
set(_pb_flash 1024)
set(_pb_wrap "")
set(_pb_page 32)
set(_pb_hz 9600000)
set(_pb_eeprom 64)
set(_pb_has_usart 0)
elseif(LIBAVR_MCU STREQUAL "attiny25")
set(_pb_flash 2048)
set(_pb_wrap "")
set(_pb_page 32)
set(_pb_hz 8000000)
set(_pb_eeprom 128)
set(_pb_has_usart 0)
elseif(LIBAVR_MCU STREQUAL "attiny45")
set(_pb_flash 4096)
set(_pb_wrap "")
set(_pb_page 64)
set(_pb_hz 8000000)
set(_pb_eeprom 256)
set(_pb_has_usart 0)
elseif(LIBAVR_MCU STREQUAL "attiny85")
set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
set(_pb_page 64)
set(_pb_hz 8000000)
set(_pb_eeprom 512)
set(_pb_has_usart 0)
elseif(LIBAVR_MCU MATCHES "^atmega48(a|p|pa)?$")
set(_pb_flash 4096)
set(_pb_wrap "")
set(_pb_page 64)
set(_pb_hz 16000000)
set(_pb_eeprom 256)
elseif(LIBAVR_MCU MATCHES "^atmega8a?$" OR LIBAVR_MCU MATCHES "^atmega88(a|p|pa)?$")
set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
set(_pb_page 64)
set(_pb_hz 16000000)
set(_pb_eeprom 512)
elseif(LIBAVR_MCU MATCHES "^atmega16a?$" OR LIBAVR_MCU MATCHES "^atmega168(a|p|pa)?$")
set(_pb_flash 16384)
set(_pb_wrap -Wl,--pmem-wrap-around=16k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_eeprom 512)
elseif(LIBAVR_MCU MATCHES "^atmega164(a|p|pa)$")
set(_pb_flash 16384)
set(_pb_wrap -Wl,--pmem-wrap-around=16k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_eeprom 512)
set(_pb_has_usart1 1)
elseif(LIBAVR_MCU MATCHES "^atmega32a?$" OR LIBAVR_MCU MATCHES "^atmega328p?$")
set(_pb_flash 32768)
set(_pb_wrap -Wl,--pmem-wrap-around=32k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_eeprom 1024)
elseif(LIBAVR_MCU MATCHES "^atmega324(a|p|pa)$")
set(_pb_flash 32768)
set(_pb_wrap -Wl,--pmem-wrap-around=32k)
set(_pb_page 128)
set(_pb_hz 16000000)
set(_pb_eeprom 1024)
set(_pb_has_usart1 1)
elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
# 64 KiB is exactly the 16-bit byte space: plain LPM reaches everything,
# and the smallest boot section (1 KiB) holds the loader and its staging
# slot together (see README.md). The plain 644 is the family's one
# single-USART die.
set(_pb_flash 65536)
set(_pb_wrap -Wl,--pmem-wrap-around=64k)
set(_pb_page 256)
set(_pb_hz 16000000)
set(_pb_eeprom 2048)
if(NOT LIBAVR_MCU STREQUAL "atmega644")
set(_pb_has_usart1 1)
endif()
elseif(LIBAVR_MCU MATCHES "^atmega1284p?$")
# 128 KiB: wire flash addresses are word addresses, reads go through
# ELPM, and the PC's modulo wrap exceeds what --pmem-wrap-around models.
# The slot is 1 KiB — this chip's own smallest boot sector; the far
# machinery cannot fit 512 B (see README.md).
set(_pb_flash 131072)
set(_pb_wrap "")
set(_pb_page 256)
set(_pb_hz 16000000)
set(_pb_eeprom 4096)
set(_pb_slot 1024)
set(_pb_limit 1024)
set(_pb_has_usart1 1)
else()
message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}")
endif()
if(NOT DEFINED _pb_slot)
set(_pb_slot 512)
endif()
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
# Patched-vector chips hand over through the trampoline word below the slot,
# which is also the slot's own last word — their budget is slot 2.
if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
set(_pb_app 0)
if(NOT DEFINED _pb_limit)
set(_pb_limit ${_pb_slot})
endif()
else()
math(EXPR _pb_app "${_pb_base} - 2")
math(EXPR _pb_limit "${_pb_slot} - 2")
endif()
# simavr names its cores after the base dies; the A revisions run on them
# (the 644PA on the 644P core).
set(_pb_sim_mcu ${LIBAVR_MCU})
if(LIBAVR_MCU MATCHES "^atmega(8|16|32|48|88|164|168|644)a$")
string(REGEX REPLACE "a$" "" _pb_sim_mcu ${LIBAVR_MCU})
elseif(LIBAVR_MCU STREQUAL "atmega644pa")
set(_pb_sim_mcu atmega644p)
endif()
# The function runs in its caller's scope, so everything it needs crosses
# scopes as global properties.
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
set_property(GLOBAL PROPERTY PUREBOOT_APP ${_pb_app})
set_property(GLOBAL PROPERTY PUREBOOT_WRAP "${_pb_wrap}")
set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1})
# The port's own build (tests, the size matrix) reads the geometry from the
# parent scope; a downstream consumer gets the same variables for free.
set(PUREBOOT_BASE_HEX ${_pb_base_hex} PARENT_SCOPE)
set(PUREBOOT_PAGE ${_pb_page} PARENT_SCOPE)
set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE)
set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
# The fastest standard rate the clock reaches within 2.5 % — the same
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a
# default never trips the compile-time error it is checked against. A
# software build additionally requires the polled receiver's 100-cycles-a-bit
# floor (its own static assert): at low clocks the U2X divisor still reaches
# rates the bit-banged sampler cannot, so the backend gates the ladder.
function(pureboot_default_baud clock software outvar)
foreach(baud 115200 57600 38400 19200 9600)
math(EXPR _cycles "${clock} / ${baud}")
if(software AND _cycles LESS 100)
continue()
endif()
foreach(divisor 8 16)
math(EXPR _step "${divisor} * ${baud}")
math(EXPR _n "(${clock} + ${_step} / 2) / ${_step}")
if(_n LESS 1 OR _n GREATER 4096)
continue()
endif()
math(EXPR _actual "${clock} / (${divisor} * ${_n})")
math(EXPR _delta "${_actual} - ${baud}")
if(_delta LESS 0)
math(EXPR _delta "-(${_delta})")
endif()
math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
if(_error_bp LESS_EQUAL 250)
set(${outvar} ${baud} PARENT_SCOPE)
return()
endif()
endforeach()
endforeach()
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 %")
endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>])
#
# Creates the loader target plus its flashable images (<name>.hex for a
# programmer, <name>.bin for --update-loader) and stamps the resolved
# deployment on the target: the PUREBOOT_HZ, PUREBOOT_BAUD and PUREBOOT_LINK
# properties (the link as usart0/usart1/sw:<RX>,<TX> — what a test harness
# needs to speak to the build).
function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS)
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
endif()
get_property(_hz GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ)
get_property(_base_hex GLOBAL PROPERTY PUREBOOT_BASE_HEX)
get_property(_app GLOBAL PROPERTY PUREBOOT_APP)
get_property(_wrap GLOBAL PROPERTY PUREBOOT_WRAP)
get_property(_usart GLOBAL PROPERTY PUREBOOT_HAS_USART)
get_property(_usart1 GLOBAL PROPERTY PUREBOOT_HAS_USART1)
if(NOT PB_CLOCK)
set(PB_CLOCK ${_hz})
endif()
if(NOT PB_TIMEOUT)
set(PB_TIMEOUT 8)
endif()
if(NOT PB_SERIAL)
set(PB_SERIAL auto)
endif()
if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software")
endif()
if(DEFINED PB_USART)
set(PB_SERIAL hardware)
elseif(PB_SERIAL STREQUAL "hardware")
set(PB_USART 0)
endif()
set(_serial_defines "")
if(PB_SERIAL STREQUAL "hardware")
if(PB_USART EQUAL 1 AND NOT _usart1)
message(FATAL_ERROR "pureboot_add_loader(${name}): ${LIBAVR_MCU} has no USART1")
elseif(NOT _usart)
message(FATAL_ERROR "pureboot_add_loader(${name}): ${LIBAVR_MCU} has no hardware USART")
endif()
set(_serial_defines PUREBOOT_USART=${PB_USART})
set(_link usart${PB_USART})
else()
if(PB_SERIAL STREQUAL "auto")
if(_usart AND (PB_RX OR PB_TX))
message(WARNING "pureboot_add_loader(${name}): RX/TX apply to the software UART, "
"which auto does not pick on ${LIBAVR_MCU} — SERIAL software to force it")
endif()
if(_usart)
set(_link usart0)
else()
set(PB_SERIAL software)
endif()
endif()
if(PB_SERIAL STREQUAL "software")
if(NOT PB_RX)
set(PB_RX pb0)
endif()
if(NOT PB_TX)
set(PB_TX pb1)
endif()
foreach(_pin ${PB_RX} ${PB_TX})
if(NOT _pin MATCHES "^p[a-h][0-7]$")
message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1")
endif()
endforeach()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
# The link spec a test harness drives a GPIO bridge with: sw:<RX>,<TX>
# as the port letter and bit, the loader's own pin naming upcased.
string(SUBSTRING ${PB_RX} 1 2 _rx_pin)
string(SUBSTRING ${PB_TX} 1 2 _tx_pin)
string(TOUPPER "sw:${_rx_pin},${_tx_pin}" _link)
string(REPLACE "SW" "sw" _link ${_link})
endif()
endif()
if(NOT PB_BAUD)
if(PB_SERIAL STREQUAL "software")
pureboot_default_baud(${PB_CLOCK} 1 PB_BAUD)
else()
pureboot_default_baud(${PB_CLOCK} 0 PB_BAUD)
endif()
endif()
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines})
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr)
target_compile_definitions(${name} PRIVATE ${_defines})
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
# The ELF is a container (symbols, section headers), never flashed; the
# flashable forms sit beside it: .hex for a programmer, .bin (the slot's
# bare bytes) for the host tool's raw path and --update-loader.
add_custom_command(TARGET ${name} POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.hex
COMMAND ${CMAKE_OBJCOPY} -O binary -R .eeprom
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin)
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
PUREBOOT_LINK ${_link})
endfunction()

View File

@@ -3,16 +3,18 @@
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 — all 37 —
fitting each chip's smallest boot sector**: 512 bytes everywhere — 488 B on
the tiny13s, 498502 B on the tiny25/45/85, 466504 B across the megas
(474 B on the boot-section-less m48s, 498 B on the 644s) — except the
ATmega1284/1284P, whose smallest boot sector is 1 KiB and whose far-flash
machinery (ELPM reads, RAMPZ page commands, word-addressed wire) lands at
558 B in a 1 KiB slot: the 512-byte figure is a hardware boundary those
chips simply do not have, and no implementation of this feature set fits it
there. The device speaks primitives; every composite — verify, erase,
reset-vector surgery, updating the loader itself — lives in the host tool
(`pureboot.py`).
fitting each chip's smallest boot sector**: 512 bytes everywhere — 470 B on
the tiny13s, ~484 B on the tiny25/45/85, 458506 B across the megas and
every configured variant of them (the m8's software-serial build is the
fattest) — except the ATmega1284/1284P, whose smallest boot sector is 1 KiB
and whose far-flash machinery (ELPM reads, RAMPZ page commands,
word-addressed wire) lands at 556562 B in a 1 KiB slot: the 512-byte
figure is a hardware boundary those chips simply do not have, and no
implementation of this feature set fits it there. Clock, baud, serial
backend and pins are per-build configuration (below); the size matrix in
the test suite holds every combination inside its slot. The device speaks
primitives; every composite — verify, erase, reset-vector surgery, updating
the loader itself — lives in the host tool (`pureboot.py`).
The image is **position-independent**: control flow is PC-relative, the
read/write paths take wire addresses, the write guard protects the slot the
@@ -26,16 +28,63 @@ jumps into it, and lets it rewrite the resident. The slot is 512 bytes
minimum); on the tinies the budget is 510, not 512: a slot's last word
belongs to the host-managed trampoline (below).
## Configuration
Every deployment axis is a build parameter, resolved by the CMake function
`pureboot_add_loader()` (in `pureboot/CMakeLists.txt`) — the one way a
loader target is created, by this repo's own build and by a downstream
project alike:
| Argument | Meaning | Default |
|---|---|---|
| `CLOCK <hz>` | the clock the board runs | 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s |
| `BAUD <bd>` | the wire rate | the ladder below |
| `SERIAL auto\|hardware\|software` | the link backend | `auto`: the hardware USART where the chip has one |
| `USART <n>` | the USART instance (x4 megas carry two) | 0 |
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 |
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock
reaches within 2.5 % — the same U2X-included divisor search libavr's baud
solver runs — and on a software build additionally within the polled
receiver's 100-cycles-a-bit floor. 16 MHz lands 115200, 8 MHz 57600,
1 MHz 9600. Whatever is picked or overridden is re-checked in the compile:
an infeasible clock/baud/backend combination, or a USART the chip does not
have, fails with a named static assert.
A downstream project brings its usual libavr setup (the `libavr` target,
the chip via the `LIBAVR_MCU` toolchain preset), consumes this directory,
and states its deployment — for example an ATmega328P on its shipped
1 MHz fuses with the software UART on hand-picked pins:
```cmake
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main)
FetchContent_MakeAvailable(bootloader)
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)
pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
```
The function emits the ELF plus `myboot.hex` (the programmer artifact) and
`myboot.bin` (the self-update image), prints the size, and stamps the
resolved deployment on the target as the `PUREBOOT_HZ`, `PUREBOOT_BAUD`
and `PUREBOOT_LINK` properties — what a flashing script or test harness
needs to speak to the build. This exact example deployment runs the full
protocol suite in CI (`pureboot.custom`).
## Link
The stock builds assume the family's natural deployment; any axis moves
per build (above).
| Chip | Serial | Baud | Clock assumed |
|---|---|---|---|
| every ATmega | the hardware USART (USART0), RXD/TXD per pinout | 115200 8N1 | 16 MHz crystal |
| ATtiny25/45/85 | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 8 MHz internal RC |
| ATtiny13/13A | 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.
The software-UART RX pin has its pull-up enabled; TX idles high. All
multi-byte quantities on the wire are little-endian.
## Activation
@@ -50,9 +99,10 @@ The host then has one activation window per awaited byte to knock: `p` then
awaited again (line noise cannot lock the loader, only delay it). A window
expiring with an idle line boots the application.
The window length is a compile-time constant — 8 s by default, another value
via the `PUREBOOT_TIMEOUT` CMake cache variable — so the whole EEPROM belongs
to the application; pureboot never uses it for its own state. Re-timing a
The window length is a compile-time constant — 8 s by default, another
value via `pureboot_add_loader(... TIMEOUT <s>)` (the stock target keeps
the `PUREBOOT_TIMEOUT` cache variable) — so the whole EEPROM belongs to
the application; pureboot never uses it for its own state. Re-timing a
deployed loader is a self-update with a re-timed build (below).
## Session
@@ -237,11 +287,35 @@ read-back unless `--no-verify`; images are raw binary, or Intel HEX by
extension. `--force` overrides the refusable safety checks (today: flashing
application data into a mega's reset walk region).
Readouts come one fact per line: `--info` prints the decoded info block
field by field, `--fuses` each fuse byte on its own line — plus, on a
boot-sectioned mega, the decoded meaning (where the BOOTSZ section starts,
what BOOTRST does to reset). Transfers that take wire time — programming,
reading, erasing, verifying, the update phases — draw a transient progress
bar on stderr when it is a tty; logs and pipes see only the summary lines.
`-v`/`--verbose` adds the decisions as they happen: knock counts, the
programming plan (vector-surgery targets, skipped blank pages), update
state handling and per-phase page counts.
## Tests
Per chip preset, `ctest` runs:
`tools/check.sh` runs every chip's workflow (`tools/check.sh --full` adds
the reflect-mode builds of libavr's spot set; `tools/make_presets.py`
regenerates the presets). Per chip preset, `ctest` runs:
- `pureboot.size` — the 510-byte (tinies) / 512-byte (mega) budget;
- `pureboot_*.size` — the size matrix: the serial backends × the clock
ladder (1/8/16 MHz; the t13s' own RC menu), plus the USART1 build on the
x4 chips — every configuration axis that could move the image, each
variant against the same slot budget (pins are immediate operands and the
timeout is a constant: size-neutral);
- `pureboot.custom` (328P) — the configured-deployment acceptance test: the
1 MHz software-serial TX=PB1/RX=PB5 build from the configuration example
drives the full protocol suite through the runner's GPIO bridge, fixture
application included;
- `pureboot.usart1` (644A) — the same protocol suite over the second
hardware USART: instance selection is compile-checked everywhere, but
only a live session proves the loader polls the USART it claims;
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`
in the image, the info block within its first 256 bytes;
- `pureboot.planner` — the host tool's pure logic: programming orders and
@@ -249,9 +323,10 @@ Per chip preset, `ctest` runs:
boot-fuse decode, and the update preflight's error/warning matrix over
synthetic fuse bytes;
- `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c`the mega's USART as a pty; on the tinies a
cycle-timed GPIO⇄pty bridge for the software UART, plus the SPM/NVM module
simavr's tiny cores lack) driven by the real host tool through
(`test/pureboot_device.c`a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, selected with `-l` to match
the loader's link; plus the SPM/NVM module simavr's tiny cores lack)
driven by the real host tool through
knock-from-reset, program + verify of both memories, session reconnect, an
external reset through the patched vector, and the hand-over to a fixture
application whose banner proves the launch — cross-checked against the
@@ -265,6 +340,6 @@ Per chip preset, `ctest` runs:
from its flash dump, and a re-run must complete the update with the
application intact throughout.
`size`, `pi`, and `planner` are host logic and run anywhere; the three
`size`, `pi`, and `planner` are host logic and run anywhere; the
simulator-driven targets need simavr and a pty, so they are POSIX-only —
on Windows the tool is exercised against real hardware.

View File

@@ -38,22 +38,19 @@ constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t ack = '+';
// Per-chip personality: the clocks the dogfood boards run (16 MHz crystal on
// the mega, calibrated RC on the tinies). The device signature comes straight
// from the chip database (avr::hw::db.signature) — compile-time data is the
// only universal source, since the tiny13A cannot even read its signature row
// from code.
consteval avr::hertz_t clock()
{
auto name = std::string_view{avr::hw::db.name};
if (name.starts_with("ATtiny13"))
return 9.6_MHz;
if (name.starts_with("ATtiny"))
return 8_MHz;
return 16_MHz;
}
// Per-deployment personality, passed in by the build — pureboot_add_loader()
// (the CMake function next to this file) resolves the defaults: the clock the
// board actually runs, the wire baud, the serial backend and its pins. The
// device signature needs no configuring — it comes from the chip database
// (avr::hw::db.signature), the only universal source, since the tiny13A
// cannot even read its signature row from code.
#if !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() (README.md)"
#endif
using dev = avr::device<{.clock = clock()}>;
using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
@@ -118,34 +115,43 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)),
};
// 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; drain() holds until
// the last transmitted frame is fully on the wire (the jump hand-over must
// not let the target's re-init clip the ack).
template <avr::hertz_t C>
consteval std::int16_t rxc_field()
{
return avr::uart::detail::ufield<'0', "UCSR#A", "RXC#">();
}
// The serial link. PUREBOOT_USART forces a hardware USART instance,
// PUREBOOT_SOFT_SERIAL the polled software UART (no vector — the table
// belongs to the application) on PUREBOOT_RX/PUREBOOT_TX; with neither, the
// chip's first USART where it has one and the software UART 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; drain() holds until the last
// transmitted frame is fully on the wire (the jump hand-over must not 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_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
template <avr::hertz_t C>
consteval std::int16_t txc_field()
// Whether the chip carries the selected USART: the suffixed instance name,
// or — for instance 0 — the classic megas' un-numbered block.
consteval bool usart_exists()
{
return avr::uart::detail::ufield<'0', "UCSR#A", "TXC#">();
}
template <avr::hertz_t C>
consteval std::int16_t status_reg()
{
return avr::uart::detail::ureg<'0', "UCSR#A">();
const char name[] = {'U', 'S', 'A', 'R', 'T', usart_digit};
if (avr::hw::db.has_instance(std::string_view{name, sizeof(name)}))
return true;
return usart_digit == '0' && avr::hw::db.has_instance("USART");
}
template <avr::hertz_t C>
struct hardware_link {
using uart = avr::uart::usart0<C, {.baud = 115200_Bd, .max_baud_error = 2.5_pct}>;
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6).
@@ -158,7 +164,7 @@ struct hardware_link {
static bool pending()
{
return avr::hw::field_impl<rxc_field<C>()>::test();
return uart::rx_ready();
}
static std::uint8_t rx()
@@ -173,22 +179,14 @@ struct hardware_link {
static void drain()
{
// write() leaves the byte draining behind it. Clear a stale TXC0
// first (W1C by writing the sampled status back — the store a hand
// assembler writes, keeping U2X0), then wait for the fresh
// completion; with a byte still ahead in the shifter TXC0 cannot
// re-set until the last pending byte has fully left.
using status = avr::hw::reg_impl<status_reg<C>()>;
status::write(status::read());
while (!avr::hw::field_impl<txc_field<C>()>::test()) {
}
uart::drain();
}
};
template <avr::hertz_t C>
struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::pb0, 57600_Bd>;
using tx_t = avr::uart::software_tx<C, avr::pb1, 57600_Bd>;
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, wire_baud>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, wire_baud>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6).
@@ -201,7 +199,7 @@ struct software_link {
static bool pending()
{
return !avr::io::input<avr::pb0>::read(); // a start bit has begun
return rx_t::start_pending();
}
static std::uint8_t rx()
@@ -220,8 +218,14 @@ struct software_link {
}
};
using link = std::conditional_t<avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART"),
hardware_link<dev::clock>, software_link<dev::clock>>;
#if defined(PUREBOOT_USART)
static_assert(usart_exists(), "PUREBOOT_USART selects a hardware USART this chip does not have");
using link = hardware_link<dev::clock>;
#elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock>;
#else
using link = std::conditional_t<usart_exists(), hardware_link<dev::clock>, software_link<dev::clock>>;
#endif
// The application's entry, an absolute address the linker pins (--defsym in
// CMakeLists.txt): 0x0000 on the mega (word 0 stays the application's own
@@ -417,10 +421,13 @@ void send_fuses()
// return address is a word address, whose high byte is the 256-word slot
// index — on byte-addressed chips doubled back into byte terms.
// program_flash refuses this one slot and the info block is addressed
// from it, so both follow wherever the code was flashed.
// from it, so both follow wherever the code was flashed. The high byte is
// spelled as byteswap's low byte: the builtin's value is itself built by
// swapping the two stacked bytes, and the double swap folds to the single
// byte pick a hand assembler writes — `>> 8` leaves the swap materialized.
const std::uint16_t ra_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
const std::uint8_t slot_high =
word_flash ? static_cast<std::uint8_t>(ra_words >> 8) & 0xfe : static_cast<std::uint8_t>((ra_words >> 8) << 1);
const std::uint8_t ra_high = static_cast<std::uint8_t>(std::byteswap(ra_words));
const std::uint8_t slot_high = word_flash ? ra_high & 0xfe : static_cast<std::uint8_t>(ra_high << 1);
// 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.
@@ -439,13 +446,13 @@ void send_fuses()
// asserts it), and slots are 512-aligned — so the low byte of its
// link address (in wire units: bytes, or words on the large
// chips) is its offset in any slot, and the high byte of its
// runtime address is the running slot's. Built as a byte pair so
// no absolute address is ever materialized.
// runtime address is the running slot's. Composed from the two
// bytes — the high half is runtime data, so no absolute address
// is ever materialized.
const auto link_low = reinterpret_cast<std::uint16_t>(info_data.data());
const std::uint8_t low =
word_flash ? static_cast<std::uint8_t>(link_low >> 1) : static_cast<std::uint8_t>(link_low);
send_flash(std::bit_cast<std::uint16_t>(std::array{low, slot_high}),
static_cast<std::uint8_t>(info_data.size()));
send_flash(static_cast<std::uint16_t>(low | (slot_high << 8)), static_cast<std::uint8_t>(info_data.size()));
break;
}
case 'J': { // jump to a wire word address: hand-over and staging transfer

View File

@@ -4,11 +4,16 @@
// surgery, actually launched it. Linked normally (crt, vectors at 0); on
// the tinies its reset vector is the rjmp the host re-homes.
//
// On the mega it then listens, and an 'L' makes it jump into the resident
// loader — the application-owned loader entry a BOOTRST-unprogrammed mega
// relies on (reset always boots the application there), exercised by the
// self-update tests. The tinies idle: reset reaches their loader through
// the patched vector, so the application owes it nothing.
// On the hardware-USART link it then listens, and an 'L' makes it jump into
// the resident loader — the application-owned loader entry a
// BOOTRST-unprogrammed mega relies on (reset always boots the application
// there), exercised by the self-update tests. The software link idles:
// reset reaches those loaders through the patched vector (or the runner
// models BOOTRST), so the application owes them nothing.
//
// The fixture speaks the deployment its loader was built for: the same
// PUREBOOT_* defines configure it, and without them it assumes the stock
// deployment (the crystal/RC clock table below, the chip's natural link).
#include <libavr/libavr.hpp>
using namespace avr::literals;
@@ -17,19 +22,44 @@ namespace {
consteval avr::hertz_t clock()
{
#if defined(PUREBOOT_CLOCK_HZ)
return avr::hertz_t{PUREBOOT_CLOCK_HZ};
#else
auto name = std::string_view{avr::hw::db.name};
if (name.starts_with("ATtiny13"))
return 9.6_MHz;
if (name.starts_with("ATtiny"))
return 8_MHz;
return 16_MHz;
#endif
}
#if !defined(PUREBOOT_TX)
#define PUREBOOT_TX pb1
#endif
#if !defined(PUREBOOT_USART)
#define PUREBOOT_USART 0
#endif
consteval bool use_hardware()
{
#if defined(PUREBOOT_SOFT_SERIAL)
return false;
#else
return avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART");
#endif
}
using dev = avr::device<{.clock = clock()}>;
template <avr::hertz_t C, bool Hardware = avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART")>
template <avr::hertz_t C, bool Hardware = use_hardware()>
struct link {
using tx_t = avr::uart::usart0<C, {.baud = 115200_Bd, .max_baud_error = 2.5_pct}>;
#if defined(PUREBOOT_BAUD)
static constexpr avr::baud_t baud{PUREBOOT_BAUD};
#else
static constexpr avr::baud_t baud{115200};
#endif
using tx_t = avr::uart::usart<'0' + PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
static void tx(char c)
{
tx_t::write(static_cast<std::uint8_t>(c));
@@ -47,7 +77,12 @@ struct link {
template <avr::hertz_t C>
struct link<C, false> {
using tx_t = avr::uart::software_tx<C, avr::pb1, 57600_Bd>;
#if defined(PUREBOOT_BAUD)
static constexpr avr::baud_t baud{PUREBOOT_BAUD};
#else
static constexpr avr::baud_t baud{57600};
#endif
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud>;
static void tx(char c)
{
tx_t::write(static_cast<std::uint8_t>(c));

View File

@@ -8,8 +8,11 @@ import subprocess
class Device:
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None):
cmd = [binary, elf, mcu, hz, base_hex, str(page), str(baud), dump]
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None):
cmd = [binary]
if link:
cmd += ["-l", link]
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None:
# Chips without a hardware boot section — the tinies and the
# m48s — reset to address 0 like silicon; the boot-sectioned

View File

@@ -5,15 +5,15 @@ through flash + EEPROM + fuse + hand-over scenarios, and cross-check
the tool's view against the simulator's ground-truth memory dumps.
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <eeprom_size> <app_bin> <tool_py> <workdir>
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link]
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...) for a
loader built off the chip's natural serial default.
Exits 0 if every scenario passes.
"""
import os
import signal
import subprocess
import sys
import time
def fail(message):
@@ -33,53 +33,14 @@ def rjmp_decode(word, at, flash_words):
return (at + 1 + offset) % flash_words
class Device:
def __init__(self, binary, elf, mcu, hz, base, page, baud, dump):
self.proc = subprocess.Popen(
[binary, elf, mcu, hz, base, str(page), str(baud), 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("PB_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()
def run_tool(tool, pty, baud, *args):
result = subprocess.run(
[sys.executable, tool, "--port", pty, "--baud", str(baud), "--wait", "20", *args],
capture_output=True,
text=True,
timeout=120,
)
print(result.stdout, end="")
if result.returncode != 0:
fail(f"tool exited {result.returncode}: {result.stderr.strip()}")
return result.stdout
def main():
(device_bin, elf, mcu, hz, base_hex, page, baud, eeprom_size, app_bin, tool, workdir) = sys.argv[1:]
args = sys.argv[1:]
link = args.pop() if len(args) == 12 else None
(device_bin, elf, mcu, hz, base_hex, page, baud, eeprom_size, app_bin, tool, workdir) = args
base, page, baud, eeprom_size = int(base_hex, 0), int(page), int(baud), int(eeprom_size)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
@@ -105,18 +66,18 @@ def main():
+ bytes([flags])
)
device = Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
# Session 1: knock from reset, identify, program everything, stay.
out = run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
"--eeprom", ee_path, "--stay")
for needed in ("device: signature", "fuses:", "verify:", "stays"):
for needed in ("signature", "fuses", "verify:", "stays"):
if needed not in out:
fail(f"session 1 output lacks {needed!r}")
# Session 2: reconnect into the live session, verify, dump, hand over
# is deferred — the pty must be reopened for the APP banner first.
out = run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay")
if out.count("verify:") != 2:
fail("session 2 did not verify both memories")
@@ -136,7 +97,7 @@ def main():
# runner resets them to address 0 like silicon) or BOOTRST (mega).
# The loader must answer a fresh knock, and the 'J' hand-over must
# land in the application, which banners on the same link.
device.proc.send_signal(signal.SIGUSR1)
device.reset()
port = pb.Port(device.pty, baud)
try:
loader = pb.Loader(port)

View File

@@ -1,12 +1,16 @@
// simavr "device" for the pureboot protocol tests, all three chips. Loads
// the boot-linked ELF at the loader base, starts execution there (BOOTRST /
// the patched vector are not what is under test), and exposes the loader's
// simavr "device" for the pureboot protocol tests, every chip. Loads the
// boot-linked ELF at the loader base, starts execution there (BOOTRST / the
// patched vector are not what is under test), and exposes the loader's
// serial link as a pty for the real host tool:
//
// - Megas: the hardware USART through simavr's uart_pty.
// - Tinies: an 8N1 bridge between a pty and the GPIO software UART
// (drives PB0, the loader's RX; decodes PB1, its TX), timed against the
// simulated cycle counter.
// - Hardware USART builds: simavr's uart_pty on the selected instance.
// - Software UART builds: an 8N1 bridge between a pty and the GPIO pins,
// timed against the simulated cycle counter (drives the loader's RX,
// decodes its TX).
//
// The link follows the chip's natural default (USART0 on the megas, the
// software UART on PB0/PB1 elsewhere) unless -l overrides it: `-l usart1`
// for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins.
//
// simavr's tiny cores decode the SPM opcode but attach no NVM module — SPM
// is a silent no-op (the mega's boot section has one, avr_flash). The
@@ -38,11 +42,31 @@
static avr_t *avr;
static uart_pty_t uart_pty;
static int use_uart_pty;
static int link_software;
static char uart_digit = '0';
static char sw_rx_port = 'B', sw_tx_port = 'B';
static int sw_rx_bit = 0, sw_tx_bit = 1;
static const char *dump_path;
static uint32_t reset_pc;
static volatile sig_atomic_t reset_requested;
static int parse_link(const char *spec)
{
if (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) {
link_software = 0;
uart_digit = spec[5];
return 0;
}
if (strncmp(spec, "sw", 2) == 0) {
link_software = 1;
if (spec[2] == '\0')
return 0;
if (sscanf(spec + 2, ":%c%d,%c%d", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit) == 4)
return 0;
}
return -1;
}
// simavr 1.6's avr_flash PGERS handler erases spm_pagesize bytes starting at
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly) —
// hardware ignores the in-page bits (§26.8.1), so an erase issued with Z
@@ -273,29 +297,42 @@ static void finish(int sig)
}
}
}
if (use_uart_pty)
if (!link_software)
uart_pty_stop(&uart_pty);
_exit(0);
}
int main(int argc, char *argv[])
{
if (argc < 8 || argc > 10) {
int link_given = 0;
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) {
if (opt != 'l' || parse_link(optarg) != 0) {
fprintf(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)\n");
return 2;
}
link_given = 1;
}
int args = argc - optind;
if (args < 7 || args > 9) {
fprintf(stderr,
"usage: %s <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
"usage: %s [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
" [reset_hex] [resume_flash]\n"
" reset_hex: reset vector (default: base on the mega, 0 on the tinies)\n"
" -l link: usart0 | usart1 | sw[:B0,B1] (RX,TX); default: the chip's own\n"
" reset_hex: reset vector (default: base with a boot section, else 0)\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" run's dump, for power-fail resume tests\n",
argv[0]);
return 2;
}
argv += optind - 1; // argv[1] is the ELF again, whatever was parsed
const char *mcu_name = argv[2];
uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0);
unsigned page = (unsigned)atoi(argv[5]);
unsigned baud = (unsigned)atoi(argv[6]);
dump_path = argv[7];
use_uart_pty = strncmp(mcu_name, "atmega", 6) == 0; // every mega links over its hardware USART
int is_mega = strncmp(mcu_name, "atmega", 6) == 0;
if (!link_given)
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
avr = avr_make_mcu_by_name(mcu_name);
if (!avr) {
@@ -306,7 +343,7 @@ int main(int argc, char *argv[])
avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0);
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
if (argc > 9) {
if (args > 8) {
// Resume: the full flash image of an interrupted prior run.
FILE *f = fopen(argv[9], "rb");
if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
@@ -327,8 +364,8 @@ int main(int argc, char *argv[])
// and the boot-section-less m48s reset to word 0 like silicon — erased
// flash walks up into the loader, and after the host's surgery the
// patched vector routes there.
int boot_section = use_uart_pty && strncmp(mcu_name, "atmega48", 8) != 0;
reset_pc = argc > 8 ? (uint32_t)strtoul(argv[8], NULL, 0) : (boot_section ? base : 0);
int boot_section = is_mega && strncmp(mcu_name, "atmega48", 8) != 0;
reset_pc = args > 7 ? (uint32_t)strtoul(argv[8], NULL, 0) : (boot_section ? base : 0);
avr->pc = reset_pc;
avr->codeend = avr->flashend;
@@ -341,27 +378,34 @@ int main(int argc, char *argv[])
avr_ioctl(avr, AVR_IOCTL_EEPROM_SET, &seed);
}
if (use_uart_pty) {
// The megas carry simavr's avr_flash module (and its two gaps the wrap
// above fixes); the tinies get the NVM module simavr lacks. Which serial
// bridge runs is the link's business, not the chip class's.
if (is_mega) {
fix_mega_flash_erase();
// POLL_SLEEP paces an idle-polling loader in host real time (a
// no-hardware CPU-saving hack); clear it so cycles run free.
uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &flags);
uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, '0');
printf("PB_PTY %s\n", uart_pty.pty.slavename);
} else {
nvm.page = page;
memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
nvm.io.kind = "tiny_nvm";
nvm.io.ioctl = nvm_ioctl;
avr_register_io(avr, &nvm.io);
}
if (!link_software) {
// POLL_SLEEP paces an idle-polling loader in host real time (a
// no-hardware CPU-saving hack); clear it so cycles run free.
uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit);
printf("PB_PTY %s\n", uart_pty.pty.slavename);
} else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ('B'), 0);
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ('B'), 1), tx_hook, NULL);
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), (unsigned)sw_rx_bit);
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), (unsigned)sw_tx_bit), tx_hook,
NULL);
avr_raise_irq(rx_pin, 1); // idle line
int slave;
@@ -389,18 +433,27 @@ int main(int argc, char *argv[])
reset_requested = 0;
avr_reset(avr);
avr->pc = reset_pc;
if (use_uart_pty) { // reset restores the pacing hack; re-clear it
if (!link_software) { // reset restores the pacing hack; re-clear it
uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &flags);
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &flags);
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
} else {
bridge_reset();
}
}
if (!use_uart_pty && ++since_poll >= 2000) {
if (link_software && ++since_poll >= 2000) {
since_poll = 0;
poll_pty();
// An unthrottled idle simulation runs the activation window out
// from under the host's real-time knock cadence: a 1 MHz build's
// 8 s window is 8 M cycles — tens of wall milliseconds — so a
// first knock lost to an in-flight reset misses the window
// entirely. Pace the simulation only while the bridge is fully
// quiet (nothing decoding, nothing queued); transfers keep full
// speed, and a quiet window stretches toward real time.
if (!rx_active && !tx_active && rx_head == rx_tail)
usleep(200);
}
}
finish(0);