pureboot: autobaud variant, two versions for review

Measure the host's bit timing at runtime from a 0xC0 calibration pulse, so one
clock-agnostic image per chip runs at any F_CPU — the RC-oscillator deployments
no longer need a per-clock build.

Two source files, differing only in the write-guard/purity tradeoff:
pureboot_autobaud_pure.cpp (the measured unit in the GPIOR I/O scratch
registers, running-slot write guard dropped, 508 B on the 1284) stays strictly
pure; pureboot_autobaud_reg.cpp (unit in one global register variable, guard
kept, 512 B) keeps every feature at the cost of that single GRV. Both fit
512/510 on all 37 chips and share two licensed simplifications: a slimmed info
block (version + signature; the host derives geometry from the chip database)
and a single-byte activation knock.

pureboot/autobaud.md records the decision, the hand-assembly floor (506 B) that
set the target, and the compiler-knob path to it. Size-tested on every chip via
pureboot_add_autobaud(); the fixed-baud loader is untouched. Sim validation, the
host calibration handshake, and real-hardware acceptance remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:15:38 +02:00
parent c78bf2841a
commit eecf9673b9
5 changed files with 952 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
// pureboot autobaud — the software-serial variant that measures the host's bit
// timing at runtime, so one binary runs at any F_CPU: the image carries no
// clock. THE PURE VERSION — no inline assembly, no global register variables,
// exactly the constraints the fixed-baud loader keeps.
//
// Two source files exist for review (autobaud.md):
// this one, pure, and pureboot_autobaud_reg.cpp, which keeps the running-slot
// write guard at the cost of one global register variable. They differ only in
// where the measured unit lives and whether the guard is present.
//
// What this version trades to fit 512 B in pure C++ (the 1284 at 508), each
// licensed by "the host guarantees safety" (README.md) and the owner's approval
// to simplify the info block:
// - the measured per-bit unit lives in the two general-purpose I/O scratch
// registers (GPIOR) where the chip has them, in a static otherwise —
// reached through libavr's named register surface, so no asm and no global
// register variable; the loader stays pure;
// - the info block is slimmed to the version and the signature — the chip's
// identity — from which the host derives page size, loader base, EEPROM
// size and the addressing flags via its own chip database;
// - no running-slot write guard: the host never programs the loader's own
// slot, and a broken host bricking the target is the host's bug;
// - a single-byte activation knock: the calibration pulse already proves a
// host is present.
//
// Position independence is kept and is in fact total here: control flow is
// PC-relative, the wire carries addresses, and with the slimmed info block and
// no write guard nothing anchors on the runtime address at all.
#include <libavr/libavr.hpp>
#include <util/delay_basic.h>
using namespace avr::literals;
namespace spm = avr::spm;
namespace ee = avr::eeprom;
namespace hw = avr::hw;
namespace pureboot {
namespace {
// Purely polled: every interrupt guard folds to nothing.
constexpr auto off = avr::irq::guard_policy::unused;
constexpr std::uint8_t ack = '+';
// Autobaud carries no clock, so PUREBOOT_CLOCK_HZ / PUREBOOT_BAUD are not
// consulted; only the software-UART pins are a deployment parameter.
#if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0
#endif
#if !defined(PUREBOOT_TX)
#define PUREBOOT_TX pb1
#endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
{
auto reg = std::string_view{hw::db.regs[static_cast<std::size_t>(avr::power::detail::reset_reg())].name};
return hw::db.field_index(reg, "WDRF");
}
// The loader owns the top 512 bytes; a staging copy goes in the slot below.
constexpr std::uint16_t slot_bytes = 512;
constexpr std::uint32_t base = spm::flash_bytes - slot_bytes;
constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = hw::curated::has_boot_section();
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash
// addresses there are word addresses ('J' always was one).
constexpr bool word_flash = spm::flash_bytes > 65536;
// The loader's one identity number (README.md); the slimmed info block carries
// it and the signature, and the host maps a version to its protocol.
constexpr std::uint8_t version = 4;
// The activation window as a fixed poll budget: with no clock, whole seconds
// cannot be timed. A __uint24 (AVR's three-byte type) holds it — a fourth byte
// would cost two words at each countdown step for range never used.
#if !defined(PUREBOOT_AUTOBAUD_POLLS)
#define PUREBOOT_AUTOBAUD_POLLS 4000000
#endif
constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// The measured per-bit delay (in _delay_loop_2 four-cycle iterations). It lives
// in the two adjacent general-purpose I/O scratch registers (GPIOR1:GPIOR2)
// where the chip has them — in/out reach them in one word where a static's
// lds/sts take two, and there is no .bss to clear — and in a plain static
// otherwise (the t13, m8 and m16/32 have no GPIOR). Both are pure: the named
// register surface, no inline asm, no global register variable.
constexpr bool have_gpior = hw::db.reg_index("GPIOR1") >= 0 && hw::db.reg_index("GPIOR2") >= 0;
std::uint16_t unit_backing;
template <bool Gpior = have_gpior>
[[gnu::always_inline]] inline std::uint16_t get_unit()
{
if constexpr (Gpior)
return static_cast<std::uint16_t>(hw::reg_impl<hw::db.reg_index("GPIOR1")>::read() |
(hw::reg_impl<hw::db.reg_index("GPIOR2")>::read() << 8));
else
return unit_backing;
}
template <bool Gpior = have_gpior>
[[gnu::always_inline]] inline void put_unit(std::uint16_t u)
{
if constexpr (Gpior) {
hw::reg_impl<hw::db.reg_index("GPIOR1")>::write(static_cast<std::uint8_t>(u));
hw::reg_impl<hw::db.reg_index("GPIOR2")>::write(static_cast<std::uint8_t>(u >> 8));
} else
unit_backing = u;
}
// The autobaud software link: bit-banged with cycle-counted delays like the
// fixed-baud software backend, but the per-bit delay is the measured unit, not
// a consteval constant. rx/tx load the unit once into a local, so each bit
// spins from a register with no per-bit reload.
struct link {
using in_t = avr::io::input<avr::PUREBOOT_RX, avr::io::pull::up>;
using out_t = avr::io::output<avr::PUREBOOT_TX>;
static void init()
{
avr::init<in_t, out_t>();
out_t::set(); // idle high
}
// Time the calibration pulse into the unit. The host sends 0xC0 — a start
// bit plus six zero data bits are one low pulse of seven bit-times — and the
// counted poll loop is seven cycles an iteration, so the count is the pulse
// length in cycles ÷ 7 × 7 = one bit period in cycles, and count >> 2 is that
// period in _delay_loop_2's four-cycle iterations. Waits for the start edge
// under the poll budget; 0 (returned, and stored) means the budget expired.
static std::uint16_t measure(__uint24 budget)
{
while (in_t::read())
if (!--budget)
return 0;
std::uint16_t count = 0;
while (!in_t::read())
++count;
const std::uint16_t u = static_cast<std::uint16_t>(count >> 2);
put_unit(u);
return u;
}
static std::uint8_t rx()
{
const std::uint16_t unit = get_unit();
while (in_t::read()) // await the start edge
;
_delay_loop_2(static_cast<std::uint16_t>(unit + (unit >> 1))); // 1.5 bits to the LSB centre
std::uint8_t value = 0;
for (std::uint8_t bit = 0; bit < 8; ++bit) {
value >>= 1;
if (in_t::read())
value |= 0x80;
_delay_loop_2(unit);
}
return value;
}
static void tx(std::uint8_t value)
{
const std::uint16_t unit = get_unit();
out_t::clear(); // start bit
_delay_loop_2(unit);
for (std::uint8_t bit = 0; bit < 8; ++bit) {
out_t::write(value & 1);
value >>= 1;
_delay_loop_2(unit);
}
out_t::set(); // stop bit
_delay_loop_2(unit);
}
static void drain()
{
// The software transmitter returns only after the stop bit.
}
};
extern "C" [[noreturn]] void pureboot_app();
[[gnu::noipa, noreturn]] void jump(void (*target)())
{
target();
__builtin_unreachable();
}
[[gnu::noinline, noreturn]] void run_app()
{
jump(pureboot_app);
}
// Inlined: read across a call, the first byte strands in a call-saved register
// the caller has to push and pop.
[[gnu::always_inline]] inline std::uint16_t rx16()
{
std::uint16_t low = link::rx();
return static_cast<std::uint16_t>(low | (link::rx() << 8));
}
[[gnu::always_inline]] inline std::uint16_t word_of(std::array<std::uint8_t, 2> pair)
{
return std::bit_cast<std::uint16_t>(pair);
}
[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count)
{
do
link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++)));
while (--count);
}
[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count)
{
std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15);
std::uint16_t z = static_cast<std::uint16_t>(address << 1);
do {
link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z));
if (++z == 0)
++rampz;
} while (--count);
}
[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count)
{
if constexpr (word_flash)
send_flash_far(address, count);
else
send_flash_near(address, count);
}
[[gnu::noinline]] void tx_ack()
{
link::tx(ack);
}
void send_eeprom(std::uint16_t address, std::uint8_t count)
{
do
link::tx(ee::read(address++));
while (--count);
}
void store_eeprom(std::uint16_t address, std::uint8_t count)
{
do {
ee::write<off>(address++, link::rx());
tx_ack();
} while (--count);
}
// One page into the SPM buffer, then erase and program. No running-slot write
// guard: the host guarantees it never targets the loader's own slot (the pure
// version's one dropped safety net, licensed — README.md).
void program_flash(std::uint16_t wire_address)
{
spm::flash_address_t address;
if constexpr (word_flash) {
const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15);
const std::uint16_t z0 = static_cast<std::uint16_t>(wire_address << 1) & ~static_cast<std::uint16_t>(page - 1);
std::uint16_t z = z0;
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>((static_cast<spm::flash_address_t>(rampz) << 16) | z, word_of({low, high}));
z += 2;
} while (static_cast<std::uint8_t>(z));
address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0;
} else {
address = static_cast<spm::flash_address_t>(wire_address & ~static_cast<std::uint16_t>(page - 1));
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(address, word_of({low, high}));
address += 2;
} while (static_cast<std::uint8_t>(address) & (page - 1));
address -= 2; // back inside the page — erase and write ignore the word bits
}
spm::erase_page<off>(address);
if constexpr (boot_section)
spm::wait();
spm::write_page<off>(address);
if constexpr (boot_section)
spm::wait();
if constexpr (boot_section)
spm::rww_enable<off>();
}
void send_fuses()
{
std::uint8_t which = 0;
do
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
while (++which != 4);
}
[[noreturn]] void run()
{
if (hw::field_impl<wdrf_field()>::test())
run_app();
link::init();
// Measure the calibration pulse into the unit, then take one 'p' knock. An
// expired budget (no host) boots the application; a pulse that decodes to
// anything but 'p' re-measures.
for (;;) {
if (link::measure(autobaud_budget) == 0)
run_app();
if (link::rx() == 'p')
break;
}
for (;;) {
ee::wait();
tx_ack();
const std::uint8_t command = link::rx();
switch (command) {
case 'J': { // jump to a wire word address: hand-over and staging transfer
auto target = reinterpret_cast<void (*)()>(rx16());
tx_ack();
link::drain();
jump(target);
}
case 'b': // chip identity: version then the three signature bytes
link::tx(version);
link::tx(hw::db.signature[0]);
link::tx(hw::db.signature[1]);
link::tx(hw::db.signature[2]);
break;
case 'R': // read flash: addr16, n8 (0 = 256)
case 'r': // read EEPROM: addr16, n8
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
// One address-and-count path for the three, so the flash streamer
// keeps a single call site and inlines into this never-returning
// loop — its cursor then lives in the loop's own call-saved
// registers instead of being saved and restored around a call
// (the call-site-count lesson, autobaud.md).
const std::uint16_t address = rx16();
const std::uint8_t count = link::rx();
if (command == 'r')
send_eeprom(address, count);
else if (command == 'w')
store_eeprom(address, count);
else
send_flash(address, count);
break;
}
case 'W': // program one flash page: addr16, page bytes
program_flash(rx16());
break;
case 'F': // fuse and lock bytes
send_fuses();
break;
default: // unknown bytes are ignored; the loop re-acks
break;
}
}
}
} // namespace
} // namespace pureboot
template struct avr::startup::entry<pureboot::run>;