pureboot: the device — one pure C++ source, 512 bytes, every chip

No inline assembly, no global register variables; libavr does the
datasheet work. The device speaks primitives — flash read/page-program,
EEPROM read/write, fuse read, info block, EEPROM-resident activation
timeout, hand-over — and verify, erase, reset-vector surgery, and
timeout configuration live in the host tool. 490 B on the ATtiny13A,
510 B on the ATtiny85, 484 B on the ATmega328P, each linked into the
top 512 bytes of flash; per-chip size tests gate all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 05:33:28 +02:00
parent 74d8b92885
commit 47990349a4
4 changed files with 515 additions and 10 deletions

View File

@@ -19,13 +19,13 @@ if(PROJECT_IS_TOP_LEVEL)
add_compile_options(-Werror) # warnings are errors for the port's own code add_compile_options(-Werror) # warnings are errors for the port's own code
enable_testing() enable_testing()
# The behavioral test drives the real TinySafeBoot wire protocol over a # The behavioral tests drive the real wire protocols over a simavr pty
# simavr pty (as the host tools do) and actually flashes the device. The # (as the host tools do) and actually flash the device. The runners are
# runner is a host program built at configure time against libsimavr; if it # host programs built at configure time against libsimavr; if they or
# or Python is missing, only the size tests run. # Python are missing, only the size tests run.
find_program(_host_cc NAMES cc gcc) find_program(_host_cc NAMES cc gcc)
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(_host_cc AND Python3_FOUND) if(_host_cc AND Python3_FOUND AND LIBAVR_MCU STREQUAL "atmega328p")
set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device)
execute_process( execute_process(
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts
@@ -90,6 +90,47 @@ function(add_tsb_variant name bytes)
endif() endif()
endfunction() endfunction()
# The tsb tiers reimplement the ATmega328P-only reference protocol; the other
# chips build pureboot alone.
if(LIBAVR_MCU STREQUAL "atmega328p")
add_tsb_variant(tsb_asm 512) add_tsb_variant(tsb_asm 512)
add_tsb_variant(tsb_pure 1024) add_tsb_variant(tsb_pure 1024)
add_tsb_variant(tsb_tricks 1024) add_tsb_variant(tsb_tricks 1024)
endif()
# pureboot — the pure-constraint port (see pureboot/README.md): one source,
# no inline assembly, no global register variables, every libavr chip, 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.
if(LIBAVR_MCU STREQUAL "attiny13a")
set(_pb_flash 1024)
set(_pb_wrap "")
elseif(LIBAVR_MCU STREQUAL "attiny85")
set(_pb_flash 8192)
set(_pb_wrap -Wl,--pmem-wrap-around=8k)
else()
set(_pb_flash 32768)
set(_pb_wrap -Wl,--pmem-wrap-around=32k)
endif()
math(EXPR _pb_base "${_pb_flash} - 512")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
if(LIBAVR_MCU STREQUAL "atmega328p")
set(_pb_app 0)
else()
math(EXPR _pb_app "${_pb_base} - 2")
endif()
add_executable(pureboot pureboot/pureboot.cpp)
target_link_libraries(pureboot PRIVATE libavr)
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>)
if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot>
-DLIMIT=512 -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
endif()

View File

@@ -22,11 +22,35 @@
"name": "atmega328p-reflect", "name": "atmega328p-reflect",
"inherits": "base", "inherits": "base",
"cacheVariables": { "LIBAVR_MCU": "atmega328p", "LIBAVR_REFLECT": "ON" } "cacheVariables": { "LIBAVR_MCU": "atmega328p", "LIBAVR_REFLECT": "ON" }
},
{
"name": "attiny85-generated",
"inherits": "base",
"cacheVariables": { "LIBAVR_MCU": "attiny85", "LIBAVR_REFLECT": "OFF" }
},
{
"name": "attiny85-reflect",
"inherits": "base",
"cacheVariables": { "LIBAVR_MCU": "attiny85", "LIBAVR_REFLECT": "ON" }
},
{
"name": "attiny13a-generated",
"inherits": "base",
"cacheVariables": { "LIBAVR_MCU": "attiny13a", "LIBAVR_REFLECT": "OFF" }
},
{
"name": "attiny13a-reflect",
"inherits": "base",
"cacheVariables": { "LIBAVR_MCU": "attiny13a", "LIBAVR_REFLECT": "ON" }
} }
], ],
"buildPresets": [ "buildPresets": [
{ "name": "atmega328p-generated", "configurePreset": "atmega328p-generated" }, { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated" },
{ "name": "atmega328p-reflect", "configurePreset": "atmega328p-reflect" } { "name": "atmega328p-reflect", "configurePreset": "atmega328p-reflect" },
{ "name": "attiny85-generated", "configurePreset": "attiny85-generated" },
{ "name": "attiny85-reflect", "configurePreset": "attiny85-reflect" },
{ "name": "attiny13a-generated", "configurePreset": "attiny13a-generated" },
{ "name": "attiny13a-reflect", "configurePreset": "attiny13a-reflect" }
], ],
"workflowPresets": [ "workflowPresets": [
{ {
@@ -36,9 +60,27 @@
{ "type": "build", "name": "atmega328p-generated" }, { "type": "build", "name": "atmega328p-generated" },
{ "type": "test", "name": "atmega328p-generated" } { "type": "test", "name": "atmega328p-generated" }
] ]
},
{
"name": "attiny85-generated",
"steps": [
{ "type": "configure", "name": "attiny85-generated" },
{ "type": "build", "name": "attiny85-generated" },
{ "type": "test", "name": "attiny85-generated" }
]
},
{
"name": "attiny13a-generated",
"steps": [
{ "type": "configure", "name": "attiny13a-generated" },
{ "type": "build", "name": "attiny13a-generated" },
{ "type": "test", "name": "attiny13a-generated" }
]
} }
], ],
"testPresets": [ "testPresets": [
{ "name": "atmega328p-generated", "configurePreset": "atmega328p-generated", "output": { "outputOnFailure": true } } { "name": "atmega328p-generated", "configurePreset": "atmega328p-generated", "output": { "outputOnFailure": true } },
{ "name": "attiny85-generated", "configurePreset": "attiny85-generated", "output": { "outputOnFailure": true } },
{ "name": "attiny13a-generated", "configurePreset": "attiny13a-generated", "output": { "outputOnFailure": true } }
] ]
} }

96
pureboot/README.md Normal file
View File

@@ -0,0 +1,96 @@
# pureboot
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, **512 bytes on
each** — 490 B on the ATtiny13A, 510 B on the ATtiny85, 484 B on the
ATmega328P. The device speaks primitives; every composite — verify, erase,
reset-vector surgery, timeout configuration — lives in the host tool
(`pureboot.py`).
## Link
| Chip | Serial | Baud | Clock assumed |
|---|---|---|---|
| ATmega328P | USART0, RXD/TXD = PD0/PD1 | 115200 8N1 | 16 MHz crystal |
| ATtiny85 | software UART, RX = PB0, TX = PB1 | 57600 8N1 | 8 MHz internal RC |
| ATtiny13A | 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.
## Activation
Reset enters the loader (BOOTRST on the mega, the patched reset vector on the
tinies) — except a watchdog reset, which hands straight to the application
(the application owns its watchdog; it must clear WDRF itself, which also
releases the WDRF-forced WDE).
The host then has one activation window per awaited byte to knock: `p` then
`b`. Each awaited byte gets a fresh window; any other byte is discarded and
awaited again (line noise cannot lock the loader, only delay it). A window
expiring with an idle line boots the application.
The window length in seconds is the **last EEPROM cell** (address
`eeprom_size - 1`); `0x00` and the erased `0xff` both mean the 4 s default,
so a full EEPROM erase resets the timeout rather than maxing it. The host
changes it with the ordinary EEPROM-write command.
## Session
After the knock the loader stays in its command loop until `G` or a reset.
Before reading each command it waits for any pending EEPROM write to finish
and sends the prompt `+` (0x2b) — the prompt is therefore also the completion
ack of the previous command. A session is: await `+`, send a command, read
its reply, repeat.
| Cmd | Arguments | Reply |
|---|---|---|
| `b` | — | the 12-byte info block |
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) |
| `W` | addr16, then one page of data | — (completion = next prompt) |
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
| `G` | — | `+`, then the application runs |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`W` streams exactly one SPM page (size from the info block) into the buffer,
then erases and programs; the address must be page-aligned. Pages inside the
loader's own 512 bytes are drained but never programmed — a broken host
cannot brick the chip. `w` is host-paced: send the next byte only after the
previous byte's `+`. `F` returns the bytes in the hardware's Z order; on a chip without an
extended fuse byte (the ATtiny13A) that slot carries no meaning. Fuse *writing* does not
exist: SPM reaches flash (and, on the mega, lock bits) only — fuse bytes are
external-programming territory by hardware.
The info block (`b`):
| Offset | Content |
|---|---|
| 02 | `'P'`, `'B'`, protocol version (1) |
| 35 | device signature |
| 6 | SPM page size in bytes |
| 78 | loader base — application flash ends here |
| 910 | EEPROM size |
| 11 | bit 0 set: host must patch the reset vector (no hardware boot section) |
Composites are the host's job: verify = read back and compare, erase =
write `0xff` (per page for flash, per byte for EEPROM), timeout = EEPROM
write to the last cell.
## Deployment
**ATmega328P**: program the loader at 0x7e00 with an external programmer;
fuses BOOTSZ = 11 (256 words) and BOOTRST programmed. Applications are
flashed unmodified — reset re-vectors to the loader in hardware, word 0
stays the application's own reset vector, and `G` jumps to 0.
**Tinies** (no boot section): program the loader at `flash - 512`; erased
flash below it walks up into the loader, so a virgin chip activates. When
flashing an application the host performs reset-vector surgery: the
application's own `rjmp` target is re-encoded as a trampoline `rjmp` in the
word just below the loader (`base - 2`, where `G` jumps), and word 0 is
rewritten to `rjmp` to the loader base. Every other vector stays the
application's. Page 0 is written last, so an interrupted flash leaves word 0
erased and the chip still falls through to the loader on the next reset.

326
pureboot/pureboot.cpp Normal file
View File

@@ -0,0 +1,326 @@
// pureboot — a serial bootloader on libavr, pure by constraint: one C++
// source with no inline assembly and no global register variables, built for
// every chip libavr targets, 512 bytes on each. The device speaks primitives
// — read/program flash, read/write EEPROM, fuse bytes, an info block, run —
// and everything composite (verify, erase, reset-vector surgery on the
// tinies, timeout configuration) lives in the host tool. Protocol reference:
// README.md next to this file.
//
// Entry: reset lands in avr::startup::entry below (BOOTRST on the mega; the
// patched reset vector — or erased flash walking up into the loader — on the
// tinies). A watchdog reset hands straight to the application. Otherwise the
// host has one activation window — EEPROM's last cell, in seconds — to knock
// ("pb"); an idle line boots the application. A session then stays in the
// command loop until 'G' hands over or the chip resets.
#include <libavr/libavr.hpp>
using namespace avr::literals;
namespace spm = avr::spm;
namespace ee = avr::eeprom;
namespace pureboot {
namespace {
// Purely polled — interrupts stay off, every guard folds to nothing.
constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t ack = '+';
// Per-chip personality, from the chip database: the clocks the dogfood
// boards run (16 MHz crystal on the mega, calibrated RC on the tinies) and
// the device signature (compile-time data — the tiny13A cannot even read its
// signature row from code).
consteval avr::hertz_t clock()
{
if (avr::hw::db.name == "ATtiny13A")
return 9.6_MHz;
if (avr::hw::db.name == "ATtiny85")
return 8_MHz;
return 16_MHz;
}
consteval std::array<std::uint8_t, 3> signature()
{
if (avr::hw::db.name == "ATtiny13A")
return {0x1e, 0x90, 0x07};
if (avr::hw::db.name == "ATtiny85")
return {0x1e, 0x93, 0x0b};
return {0x1e, 0x95, 0x0f};
}
using dev = avr::device<{.clock = clock()}>;
// Geometry: the loader owns the top 512 bytes of flash; the byte below it is
// the trampoline word (the application's relocated reset vector) on chips
// without a hardware boot section. The RWWSRE bit marks a separate boot
// section — on classic AVR the two capabilities coincide.
constexpr std::uint16_t boot_bytes = 512;
constexpr std::uint16_t base = static_cast<std::uint16_t>(spm::flash_bytes - boot_bytes);
constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::db.field_index("SPMCSR", "RWWSRE") >= 0;
// The activation timeout lives in EEPROM's last cell, in seconds; the host
// rewrites it with the ordinary EEPROM-write command. An unprogrammed cell —
// 0x00 or the erased 0xff — means the 4 s default: a stray value can never
// floor the window to nothing and lock the loader out, and erasing the whole
// EEPROM resets the timeout instead of maxing it to 255 s.
constexpr std::uint16_t timeout_cell = avr::hw::db.mem.eeprom_size - 1;
constexpr std::uint8_t default_seconds = 4;
// The 12-byte info block the host reads with the 'b' command; flash-resident
// (there is no crt to copy a .data image).
inline constexpr std::array<std::uint8_t, 12> info_data = {
'P',
'B',
1, // magic, protocol version
signature()[0],
signature()[1],
signature()[2],
static_cast<std::uint8_t>(page),
base & 0xff,
base >> 8, // app flash ends here; loader base
avr::hw::db.mem.eeprom_size & 0xff,
avr::hw::db.mem.eeprom_size >> 8,
boot_section ? 0 : 1, // bit 0: host must patch the reset vector (no hardware boot section)
};
using info = avr::flash_table<info_data>;
// 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.
template <avr::hertz_t C>
consteval std::int16_t rxc_field()
{
return avr::hw::db.field_index("UCSR0A", "RXC0");
}
template <avr::hertz_t C>
struct hardware_link {
using uart = avr::uart::usart0<C, {.baud = 115200_Bd, .max_baud_error = 2.5_pct}>;
static void init()
{
avr::init<uart>();
}
static bool pending()
{
return avr::hw::field_impl<rxc_field<C>()>::test();
}
static std::uint8_t rx()
{
return uart::read_blocking();
}
static void tx(std::uint8_t byte)
{
uart::write(byte);
}
};
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>;
static void init()
{
avr::init<rx_t, tx_t>();
}
static bool pending()
{
return !avr::io::input<avr::pb0>::read(); // a start bit has begun
}
static std::uint8_t rx()
{
return rx_t::template read_blocking<off>();
}
static void tx(std::uint8_t byte)
{
tx_t::template write<off>(byte);
}
};
using link = std::conditional_t<avr::hw::db.has_reg("UDR0"), hardware_link<dev::clock>, software_link<dev::clock>>;
// The application's entry: the linker pins pureboot_app to 0x0000 on the
// mega (reset re-vectors here through BOOTRST, so address 0 stays the
// application's own vector) and to the trampoline word at base - 2 on the
// tinies (--defsym in CMakeLists.txt).
extern "C" [[noreturn]] void pureboot_app();
[[noreturn]] void run_app()
{
pureboot_app();
}
// One activation tick is 65536 pending() polls — a pin (or flag) test plus a
// 16-bit countdown, about 8 cycles. Whole-second precision is all the
// timeout cell promises; the seconds count stays a loop bound (a runtime
// multiply would drag libgcc's __mulhi3 into the MUL-less tinies).
consteval std::uint16_t ticks_per_second()
{
return static_cast<std::uint16_t>(dev::clock.hz / (65536ull * 8u));
}
static_assert(ticks_per_second() >= 1);
bool pending_before(std::uint8_t seconds)
{
do {
std::uint16_t ticks = ticks_per_second();
do {
std::uint16_t spins = 0; // wraps first, so 65536 polls per tick
do {
if (link::pending())
return true;
} while (--spins);
} while (--ticks);
} while (--seconds);
return false;
}
// A knock byte under the activation deadline: an idle line means no host is
// there, and the application runs.
std::uint8_t rx_deadline(std::uint8_t seconds)
{
if (!pending_before(seconds))
run_app();
return link::rx();
}
std::uint16_t rx16()
{
std::uint8_t low = link::rx();
return static_cast<std::uint16_t>(low | (link::rx() << 8));
}
const std::uint8_t *flash_ptr(std::uint16_t address)
{
return reinterpret_cast<const std::uint8_t *>(address);
}
// The streamers take the count in the wire's 8-bit form: 0 means 256.
void send_flash(std::uint16_t address, std::uint8_t count)
{
do
link::tx(avr::flash_load(flash_ptr(address++)));
while (--count);
}
void send_eeprom(std::uint16_t address, std::uint8_t count)
{
do
link::tx(ee::read(address++));
while (--count);
}
// EEPROM write, host-paced: each ack goes out once the byte's write has
// begun, so the next byte arrives while it completes and the following
// write's own ready-wait sees an idle line. Nothing is ever missed, on
// either serial backend, without a buffer.
void store_eeprom(std::uint16_t address, std::uint8_t count)
{
do {
ee::write<off>(address++, link::rx());
link::tx(ack);
} while (--count);
}
// One flash page: stream the bytes into the SPM buffer as little-endian
// words, then erase and program. Addresses in the loader's own 512 bytes
// are drained but never programmed — a broken host cannot brick the chip.
// On the mega the RWW section is re-enabled so reads work immediately.
void program_flash(std::uint16_t address)
{
for (std::uint16_t i = 0; i < page; i += 2) {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(address + i, static_cast<std::uint16_t>(low | (high << 8)));
}
if (address < base) {
spm::erase_page<off>(address);
spm::wait();
spm::write_page<off>(address);
spm::wait();
if constexpr (boot_section)
spm::rww_enable<off>();
}
}
// The four fuse/lock bytes in the hardware's own Z order: low, lock,
// extended, high. Writing fuses is not a thing self-programming can do on
// AVR — SPM reaches flash (and boot lock bits) only.
void send_fuses()
{
for (std::uint8_t which = 0; which < 4; ++which)
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
}
[[noreturn]] void run()
{
// A watchdog reset belongs to the application (whose watchdog stays
// forced on until it clears WDRF) — no activation window in its way.
if (avr::hw::mcusr::wdrf.test())
run_app();
link::init();
std::uint8_t seconds = ee::read(timeout_cell);
if (seconds == 0 || seconds == 0xff)
seconds = default_seconds;
// 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.
while (rx_deadline(seconds) != 'p' || rx_deadline(seconds) != 'b') {
}
for (;;) {
// No prompt while an EEPROM write runs: a pending write blocks SPM
// and fuse reads (§26.2.1), and the ack tells the host all is done.
ee::wait();
link::tx(ack);
switch (link::rx()) {
case 'b': // info block
send_flash(reinterpret_cast<std::uint16_t>(info::storage.data()), info::size());
break;
case 'R': { // read flash: addr16, n8 (0 = 256)
std::uint16_t address = rx16();
send_flash(address, link::rx());
break;
}
case 'W': // program one flash page: addr16, page bytes
program_flash(rx16());
break;
case 'r': { // read EEPROM: addr16, n8
std::uint16_t address = rx16();
send_eeprom(address, link::rx());
break;
}
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
std::uint16_t address = rx16();
store_eeprom(address, link::rx());
break;
}
case 'F': // fuse and lock bytes
send_fuses();
break;
case 'G': // hand over to the application
link::tx(ack);
run_app();
default: // unknown bytes are ignored; the loop re-acks
break;
}
}
}
} // namespace
} // namespace pureboot
template struct avr::startup::entry<pureboot::run>;