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

@@ -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,19 +66,19 @@ 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,
"--eeprom", ee_path, "--stay")
for needed in ("device: signature", "fuses:", "verify:", "stays"):
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
"--eeprom", ee_path, "--stay")
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,
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay")
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);