Hardware testing found that a lone calibration pulse wedged the autobaud loader: run() budgeted only the start-edge wait in measure(), and the rx() that read the knock behind it was unbudgeted, so one stray low pulse held an unattended device in the loader and the application never ran. Bound the whole activation — an expired knock budget returns a byte that cannot be the knock, so control falls back into the budgeted measure() and an idle line boots the app there. That fix costs ~22 B, which neither version under review could absorb: the pure one goes 508 -> 530 on the 1284P and the register one 512 -> 534, both over a 512 B slot. Their margin was never spare capacity, it was the space the missing fix should have occupied. So the choice between them is moot; both are kept for the record and no longer built. pureboot_autobaud_uni.cpp replaces them at 464 B. It is pureboot 5: one read command and one write command over named spaces (G/g, sel8, addr16, n8) instead of four per-memory bodies, which collapses four transfer loops into one. The selector's high nibble carries flash's bank, so the shared cursor stays 16 bits and no command speaks word addresses. Three things fall out of the freed space: RAM read/write — the missing feature, and with it arbitrary I/O access, since AVR maps peripherals into the data space; host-issued SPM, so W's hardcoded erase/write/RWW tail becomes three writes to a space and any SPM operation is reachable; and W on the same selector-and-address decode as everything else. Strictly pure throughout: no inline asm, no global register variable, and no GPIOR either — the unit lives in a .noinit static, so the loader claims no chip resource and the chips without GPIOR stop being a special case. pureboot.py speaks both generations, keyed on the version, so the fixed-baud path is untouched; --peek/--poke reach the new data space. pbautobaud.py adds a RAM round-trip and a regression for the hang: a lone pulse must still let the app boot. All 37 chips plus the 12-preset reflect spot set build and size-test green, 444-466 B, worst case 46 B under budget. Sim suites 100%: 1284P 17/17, 328P 23/23. Only real-hardware acceptance remains (pureboot/autobaud.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
12 KiB
C++
372 lines
12 KiB
C++
// SUPERSEDED — kept for the record, not built. With the activation hang fixed
|
|
// (a lone calibration pulse used to wedge the loader, see autobaud.md) this
|
|
// version is 534 B on the 1284P against a 512 B slot, so the global register
|
|
// variable it broke purity for buys nothing. pureboot_autobaud_uni.cpp replaces
|
|
// it at 464 B, strictly pure and with more features.
|
|
//
|
|
// 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 REGISTER VERSION — keeps the running-slot write guard, at the cost
|
|
// of one global register variable (r4) holding the measured unit. That variable
|
|
// is pureboot's single, deliberate break from its no-global-register-variable
|
|
// rule, present only in this variant; everything else stays pure C++.
|
|
//
|
|
// Two source files exist for review (autobaud.md):
|
|
// pureboot_autobaud_pure.cpp, fully pure but dropping the write guard, and this
|
|
// one. They differ only in where the measured unit lives (a call-saved register
|
|
// here, GPIOR/RAM there) and whether the guard is present.
|
|
//
|
|
// The register buys ~32 B over a RAM home — an outlined rx/tx reads it with one
|
|
// move where a static costs an lds — and that is what lets the write guard stay
|
|
// while the image still fits 512 B (the 1284 at 512, exactly). The unit is
|
|
// written once through a noinline setter so the store lands immediately before a
|
|
// ret: GCC otherwise deletes a global-register store whose only readers are
|
|
// callees (autobaud.md, upstream bug 6).
|
|
//
|
|
// Simplifications shared with the pure version, each licensed: a slimmed info
|
|
// block (version + signature; the host derives geometry from its chip database)
|
|
// and a single-byte activation knock (the calibration pulse already proves a
|
|
// host). Position independence is kept: control flow is PC-relative and the
|
|
// write guard anchors on the runtime return address, as the fixed-baud loader.
|
|
|
|
#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;
|
|
|
|
// The measured per-bit delay (in _delay_loop_2 four-cycle iterations) in a
|
|
// call-saved register that the serial callees read directly, so no wire path
|
|
// threads it and rx/tx reach it with a move, not a load. Written only through
|
|
// set_unit() below.
|
|
register std::uint16_t g_unit asm("r4");
|
|
|
|
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; only the software-UART pins are a parameter.
|
|
#if !defined(PUREBOOT_RX)
|
|
#define PUREBOOT_RX pb0
|
|
#endif
|
|
#if !defined(PUREBOOT_TX)
|
|
#define PUREBOOT_TX pb1
|
|
#endif
|
|
|
|
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");
|
|
}
|
|
|
|
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();
|
|
constexpr bool word_flash = spm::flash_bytes > 65536;
|
|
|
|
constexpr std::uint8_t version = 4;
|
|
|
|
#if !defined(PUREBOOT_AUTOBAUD_POLLS)
|
|
#define PUREBOOT_AUTOBAUD_POLLS 4000000
|
|
#endif
|
|
constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
|
|
|
|
// The one store into g_unit, isolated so it lands right before the ret: a
|
|
// global-register store whose only later readers are callees is dropped
|
|
// otherwise (autobaud.md, upstream bug 6).
|
|
[[gnu::noinline]] void set_unit(std::uint16_t v)
|
|
{
|
|
g_unit = v;
|
|
}
|
|
|
|
// The autobaud software link: bit-banged with cycle-counted delays, but the
|
|
// per-bit delay is g_unit, measured from the host's calibration pulse.
|
|
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 g_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 count >> 2 is the bit
|
|
// period in _delay_loop_2's four-cycle iterations. 0 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);
|
|
set_unit(u);
|
|
return u;
|
|
}
|
|
|
|
// The eight data bits, entered just past the falling edge of the start bit.
|
|
// Split out of rx() so the activation path can wait for that edge under a
|
|
// budget while the command loop waits for it indefinitely.
|
|
static std::uint8_t sample()
|
|
{
|
|
_delay_loop_2(static_cast<std::uint16_t>(g_unit + (g_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(g_unit);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
static std::uint8_t rx()
|
|
{
|
|
while (in_t::read()) // await the start edge
|
|
;
|
|
return sample();
|
|
}
|
|
|
|
// A byte under the poll budget, for the activation knock. An expired budget
|
|
// returns 0, which is not the knock, so the caller falls back into the
|
|
// budgeted measure() — and a line that stays idle boots the application
|
|
// there. Without this the knock's edge wait was unbounded, so a single
|
|
// spurious calibration pulse (EMI, or a host that opens the port and never
|
|
// knocks) wedged the loader and the application never ran.
|
|
static std::uint8_t rx_bounded(__uint24 budget)
|
|
{
|
|
while (in_t::read())
|
|
if (!--budget)
|
|
return 0;
|
|
return sample();
|
|
}
|
|
|
|
static void tx(std::uint8_t value)
|
|
{
|
|
out_t::clear(); // start bit
|
|
_delay_loop_2(g_unit);
|
|
for (std::uint8_t bit = 0; bit < 8; ++bit) {
|
|
out_t::write(value & 1);
|
|
value >>= 1;
|
|
_delay_loop_2(g_unit);
|
|
}
|
|
out_t::set(); // stop bit
|
|
_delay_loop_2(g_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);
|
|
}
|
|
|
|
[[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 — except the slot this
|
|
// code is running in (`slot_high`, from run()), which is drained and left
|
|
// alone. A broken host therefore cannot brick the running loader, and a copy one
|
|
// slot lower may rewrite the resident one.
|
|
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
|
|
{
|
|
spm::flash_address_t address;
|
|
std::uint8_t page_high;
|
|
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;
|
|
page_high = static_cast<std::uint8_t>(wire_address >> 8);
|
|
} 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
|
|
page_high = static_cast<std::uint8_t>(address >> 8) & 0xfe;
|
|
}
|
|
if (page_high != slot_high) {
|
|
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();
|
|
|
|
// The high byte of the slot this copy runs at, which the write guard
|
|
// follows: the return address is a word address, so its high byte is the
|
|
// 256-word slot index, doubled back into byte terms on a byte-addressed
|
|
// chip. Taken as byteswap's low byte — the builtin already swaps the two
|
|
// stacked bytes, and the double swap folds away.
|
|
const std::uint16_t ra_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
|
|
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 : static_cast<std::uint8_t>(ra_high << 1);
|
|
|
|
// Measure the calibration pulse into g_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_bounded(autobaud_budget) == '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 (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(), slot_high);
|
|
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>;
|