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

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