Files
bootloader/tsb/tsb_asm.cpp
BlackMark 74d8b92885 tsb: third size pass — restructure to the oracle's shape
The second pass concluded the 168 B tricks->asm gap was per-call ABI
cost. Most of it was structure. Rebuilt around the oracle's own shape —
argless noinline primitives over a whole-loader call-saved register
protocol (g_addr in Y, count r16, window r7, direction latch r6), a
top-down erase_below whose loop tests against zero and hands callers
g_addr = 0 for free, bounded rx everywhere (a silent host unwinds to
the app from any state, as the oracle does), and a named tsb_app entry
that --pmem-wrap-around=32k relaxes to the wrapped rjmp:

  tsb_asm    510 B in the 512 B section (oracle: 500), C++ except rx
             and the page-store loop — the two routines whose remaining
             cost is the calling convention itself (~30 asm lines, was
             ~280)
  tsb_tricks 526 B, no assembly at all (was 666)
  tsb_pure   836 B, still one readable function per command (was 842)

Every g_* update placement works around a GCC 16.1 wrong-code bug
(stores into global register variables deleted when only callees read
them — repro and rules in libavr dev/lessons.md). Also fixes two
latent hardware bugs all earlier tiers carried, masked by simavr's
zeroed register file: the crt-less entries never established
__zero_reg__ = 0, and the direction latch was read before written —
power-on registers are undefined.

All tiers full oracle feature parity, protocol tests green in both
libavr modes, .text byte-identical across modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:00:27 +02:00

387 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TinySafeBoot on libavr — tier 3: full feature parity in the 512-byte boot
// section, in C++ except where the C ABI itself is the cost.
//
// The complete TinySafeBoot feature set — watchdog-reset bail, one-wire
// half-duplex UART, a config-page activation timeout, the password gate,
// emergency erase, and config/flash/EEPROM read-write — at 510 bytes in the
// 512-byte BOOTSZ=11 section the hand-written oracle occupies (500 B). This tier used to be one
// monolithic inline-asm routine; it is now the tricks tier's C++ (same
// register protocol, same structure — see tsb_tricks.cpp, including the
// global-register miscompile rules) with exactly two routines kept in
// assembly, the two whose remaining cost *is* the calling convention:
//
// rx the bounded receive: C++ must re-floor the timeout window on every
// call (the global-register-store miscompile) and split it across
// call-saved registers; the asm keeps the oracle's X-register nested
// countdown.
// store the page-store loop: C++ cannot hold the receive byte pair and the
// walked Z pointer across the rx calls without call-saved staging
// (push/pop + a Y→Z copy per word); the asm calls rx knowing exactly
// which registers it touches and walks Z live across the whole page.
//
// Everything else — bring-up, activation, password gate, emergency erase,
// dispatch, every SPM/EEPROM/flash primitive, every geometry/baud/info
// constant — is C++ on libavr, and the two asm routines splice into the same
// global-register protocol the C++ uses (g_addr in Y, g_cnt in r16, g_window
// in r7, g_receiving in r6), so calls cross the boundary with no marshalling.
//
// The wire protocol is strict request/response, which is what makes the shared
// line safe: the device drives it only between a received command and its
// reply, and releases it (RXEN0 only) whenever it waits.
#include <libavr/libavr.hpp>
#include <avr/io.h> // SP / RAMEND for the crt-free boot entry, SFR addresses for the asm routines
using namespace avr::literals;
namespace spm = avr::spm;
namespace ee = avr::eeprom;
namespace hw = avr::hw;
namespace tsb {
namespace {
// The loader is purely polled — it never enables interrupts — so every SPM and
// EEPROM lock folds to nothing under this posture.
constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t confirm = '!';
constexpr std::uint8_t request = '?';
constexpr std::uint8_t knock = '@';
// Boot geometry for the 512 B boot section (BOOTSZ=11); the page size and the
// flash/EEPROM extents are the chip database's to know. app_end is the config
// page (TSB's LASTPAGE), one page below the boot section.
constexpr std::uint16_t page = spm::page_bytes;
constexpr std::uint16_t boot_bytes = 512;
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz).
constexpr std::uint8_t act_min = 16;
// Post-activation window: the host gets seconds, not milliseconds, mid-session.
constexpr std::uint8_t comm_window = 200;
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd);
// The 16-byte device-info block, streamed out on activation.
// clang-format off
[[gnu::progmem]] constexpr std::uint8_t info[16] = {
'T', 'S', 'B',
build_date & 0xFF, build_date >> 8,
0xF3, // status: native-UART fixed-baud lineage
0x1E, 0x95, 0x0F, // ATmega328P signature
page / 2, // page size in words
(app_end / 2) & 0xFF, (app_end / 2) >> 8,
eeprom_end & 0xFF, eeprom_end >> 8,
0xAA, 0xAA,
};
// clang-format on
register std::uint16_t g_addr asm("r28");
register std::uint8_t g_cnt asm("r16");
register std::uint8_t g_window asm("r7");
register std::uint8_t g_receiving asm("r6");
const std::uint8_t *flash_ptr(std::uint16_t addr)
{
return reinterpret_cast<const std::uint8_t *>(addr);
}
// Bounded byte receive (asm 1 of 2): release the one-wire line on a direction
// change, poll RXC0 under the oracle's nested X-register countdown seeded from
// g_window (floored against lockout), byte or 0-on-silence in r24. Z survives
// — the property the store's word loop rides on.
[[gnu::noinline, gnu::noclone]] std::uint8_t rx()
{
std::uint8_t byte;
asm volatile(" tst %[dir] \n\t" // already receiving? keep the line released
" brne 1f \n\t"
" ldi %[b], 0x10 \n\t" // RXEN0 alone: release the line and listen
" sts %[ucsr0b], %[b] \n\t"
" ser %[b] \n\t"
" mov %[dir], %[b] \n\t"
"1: mov r27, %[to] \n\t" // outer countdown high byte = window
" ori r27, %[actmin] \n\t" // lockout-proof floor
" clr r26 \n\t"
"2: ser %[b] \n\t"
"3: lds %[b], %[ucsr0a] \n\t"
" sbrc %[b], 7 \n\t" // RXC0
" rjmp 4f \n\t"
" dec %[b] \n\t"
" brne 3b \n\t"
" sbiw r26, 1 \n\t"
" brcc 2b \n\t"
" clr %[b] \n\t" // silence → 0, which no compare accepts
" rjmp 5f \n\t"
"4: lds %[b], %[udr0] \n\t"
"5: \n\t"
: [b] "=&d"(byte), [dir] "+r"(g_receiving)
: [to] "r"(g_window), [actmin] "M"(act_min), [ucsr0a] "n"(_SFR_MEM_ADDR(UCSR0A)),
[ucsr0b] "n"(_SFR_MEM_ADDR(UCSR0B)), [udr0] "n"(_SFR_MEM_ADDR(UDR0))
: "r26", "r27", "cc");
return byte;
}
// One-wire transmit: take the line (TXEN0 alone) on a direction change with a
// turn-around guard, put the byte out, hold the line until the whole frame is
// out (TXC0, not UDRE0), W1C TXC0 by storing the sampled status back (keeps
// U2X0). Plain C++ — it compiles *smaller* than the oracle's routine.
[[gnu::noinline, gnu::noclone]] void tx(std::uint8_t byte)
{
if (g_receiving) {
g_receiving = 0;
hw::ucsr0b::write(hw::ucsr0b::txen0(1));
for (std::uint8_t guard = 46; guard; --guard)
;
}
hw::udr0::write(byte);
std::uint8_t status;
do {
status = hw::ucsr0a::read();
} while (!(status & hw::ucsr0a::txc0(1).value));
hw::ucsr0a::write(status);
}
// '?', then hand back the host's reply for the callers' one-byte compare.
[[gnu::noinline, gnu::noclone]] std::uint8_t rcnf()
{
tx(request);
return rx();
}
// One flash byte ← [g_addr++] (the advance right before ret — the
// global-register rule, see tsb_tricks.cpp).
[[gnu::noinline, gnu::noclone]] std::uint8_t sflash()
{
std::uint8_t byte = avr::flash_load(flash_ptr(g_addr));
++g_addr;
return byte;
}
// One EEPROM byte ← [g_addr++].
[[gnu::noinline, gnu::noclone]] std::uint8_t eerd()
{
std::uint8_t byte = ee::read(g_addr);
++g_addr;
return byte;
}
// One EEPROM byte → [g_addr++].
[[gnu::noinline, gnu::noclone]] void eewr(std::uint8_t byte)
{
ee::write<off>(g_addr, byte);
++g_addr;
}
// Stream g_cnt flash bytes from g_addr to the host.
[[gnu::noinline, gnu::noclone]] void sendf()
{
do {
tx(sflash());
} while (--g_cnt);
}
// Wait out a running SPM op, then re-open the RWW section — after every page
// op and before handing over, as the oracle does.
[[gnu::noinline, gnu::noclone]] void settle()
{
spm::wait();
spm::rww_enable<off>();
}
extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0
[[noreturn]] void appjump()
{
settle();
tsb_app();
}
// Step g_addr one page down and erase that page (the decrement lives here —
// the global-register rule).
[[gnu::noinline, gnu::noclone]] void erase_below()
{
g_addr -= page;
spm::erase_page<off>(g_addr);
settle();
}
// Erase the whole application, top-down like the oracle: the loop bound is a
// compare with zero, and g_addr = 0 is handed back for free.
[[gnu::noinline, gnu::noclone]] void erase_application()
{
g_addr = app_end;
do {
erase_below();
} while (g_addr != 0);
}
// Stream one host page into the erased flash page at g_addr (asm 2 of 2): the
// word pair stages in r0:r1 straight from rx (whose register set is known —
// the cross-call liveness C++ cannot express), Z walks the page and PGWRT
// programs it. g_addr is left at the next page base.
[[gnu::noinline, gnu::noclone]] void store_flash()
{
asm volatile(" movw r30, r28 \n\t" // Z = page base; rx leaves Z live
" ldi r20, %[words] \n\t"
"1: rcall %x[rx] \n\t"
" mov r0, r24 \n\t" // word low byte
" rcall %x[rx] \n\t"
" mov r1, r24 \n\t" // word high byte
" ldi r24, 0x01 \n\t" // SPMEN: buffer the word at Z
" out %[spmcsr], r24 \n\t"
" spm \n\t"
" clr r1 \n\t"
" adiw r30, 2 \n\t"
" dec r20 \n\t"
" brne 1b \n\t"
" movw %[base], r30 \n\t" // g_addr = the next page base
" subi r30, %[pagelo] \n\t" // Z back to this page's base
" sbci r31, %[pagehi] \n\t"
" ldi r24, 0x05 \n\t" // PGWRT | SPMEN: program the page
" out %[spmcsr], r24 \n\t"
" spm \n\t"
: [base] "+r"(g_addr)
: [rx] "i"(&rx), [spmcsr] "I"(_SFR_IO_ADDR(SPMCSR)), [words] "M"(page / 2), [pagelo] "M"(page & 0xff),
[pagehi] "M"(page >> 8)
: "r0", "r1", "r20", "r24", "r26", "r27", "r30", "r31", "cc", "memory");
settle();
}
[[noreturn, gnu::noinline]] void run()
{
// A watchdog reset hands straight back to the application, as the
// reference loader does, rather than re-entering the bootloader.
if (hw::mcusr::wdrf.test())
appjump();
// Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
// byte and U2X0 need a store. The library still does the datasheet work.
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
// General-purpose registers are undefined at power-on (no crt zeroes them);
// the direction latch must start "not receiving" so the first rx() enables
// the receiver. The reference loader clears its shadow register for the
// same reason.
g_receiving = 0;
// Activation: 3×'@', each inside the config page's timeout window (rx
// floors it so a corrupt page cannot lock the loader out); anything else —
// including silence — hands over.
g_window = avr::flash_load(flash_ptr(app_end + 2));
for (std::uint8_t k = 3; k; --k)
if (rx() != knock)
appjump();
g_window = comm_window;
// Password gate (config page from app_end+3, 0xff-terminated; a blank
// page is no password). A wrong byte blanks the comparison and drains the
// line forever, so a wrong password can never fall through; a 0 requests
// emergency erase behind two confirms. On pass the info block goes out;
// the emergency path skips it and drops into the command loop.
g_addr = app_end + 3;
std::uint8_t mask = 0xff;
for (;;) {
std::uint8_t expected = avr::flash_load(flash_ptr(g_addr)) & mask;
++g_addr;
if (expected == 0xff) {
g_addr = reinterpret_cast<std::uint16_t>(&info[0]);
g_cnt = sizeof(info);
sendf();
break;
}
std::uint8_t got = rx();
if (got == 0) {
if (mask == 0)
continue;
if (rcnf() != confirm || rcnf() != confirm)
appjump();
erase_application(); // leaves g_addr = 0 for the EEPROM walk
do {
eewr(0xff);
} while (g_addr <= eeprom_end);
g_addr = app_end + page;
erase_below();
break;
}
if (got != expected)
mask = 0;
}
for (;;) {
tx(confirm); // Mainloop ready
g_addr = 0;
switch (rx()) {
case 'f': // read application flash, one page per host '!'
for (;;) {
if (rx() != confirm)
break;
g_cnt = page;
sendf();
if (g_addr >= app_end)
break;
}
break;
case 'F': // erase the application, then take pages behind '?'
erase_application(); // leaves g_addr = 0, the write start
while (rcnf() == confirm)
store_flash();
break;
case 'e': // read EEPROM, one page per host '!', until the host stops
for (;;) {
if (rx() != confirm)
break;
g_cnt = page;
do {
tx(eerd());
} while (--g_cnt);
}
break;
case 'E': // take EEPROM pages behind '?'
while (rcnf() == confirm) {
g_cnt = page;
do {
eewr(rx());
} while (--g_cnt);
}
break;
case 'c': // read the config page
read_config:
g_addr = app_end;
g_cnt = page;
sendf();
break;
case 'C': // replace the config page, then echo it back to verify
if (rcnf() != confirm)
break;
g_addr = app_end + page;
erase_below(); // leaves g_addr = app_end, the store target
store_flash();
goto read_config;
default: // 'q' or any other byte runs the application
appjump();
}
}
}
} // namespace
} // namespace tsb
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is
// laid first, so this is the first instruction executed. No crt ran, so set
// the stack pointer before anything is called.
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
{
SP = RAMEND;
// The one line of crt this loader needs: compiled code assumes
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
asm volatile("clr __zero_reg__");
tsb::run();
}