pureboot v9: seal every command, and stop guarding what the seal covers
'W' handed the loader a whole page with no ack inside it and sp_spm handed any wire byte to SPMCSR, so a dropped byte re-aligned the stream and page data arrived where commands belong. That is how a page-address byte became BLBSET|SELFPRGEN on the tempmon board and programmed its lock bits. The first answer was to refuse that one command. It was the wrong shape twice over: it forbade a lock-bit write the owner may want, and it left every other command decided by bytes nobody checked. v9 checks them instead. One header for every command — opcode, selector, address, count, seal — folded and compared before the command is decoded, and *answered* before any payload moves: '+' accepts, 0xd4 (the ack inverted) refuses and nothing happened. An ack cannot do this job; it reports a command that has already run. It is smaller than v8 everywhere: 1284P 506→480, m8 498→480, 328P 484→468, t13A 474→460. The seal costs 14 bytes; bit opcodes in place of the letters pay for it twice over, since a letter costs a compare and a branch where a bit costs a skip. Both guards go — the lock-bit refusal because the seal covers it, the running-slot write guard because what it defended against was a wire fault naming an address and a wire fault can no longer name one. That one is a real trade: a host bug aimed at the running slot now lands. It buys a resident copy that can write its own slot, which is the only self-update route on a chip whose boot section *is* the slot. Two things the tests caught, both introduced here. Removing the invalid-opcode arm made every byte a command, so the knock stopped being harmless against a loader already in session and ate the five bytes behind it — identify moves to bit 5, which both 'p' and 'b' carry, so the knock is inert again and version discovery still works before the version is known. And the SPM value rides the count field because a data byte would arrive after the seal was checked. pbselfwrite and pbglitch are the new gates, both red-green: the same erase of the running page refused unsealed and performed sealed, and every header byte damaged after sealing refused where the identical damage before sealing is obeyed. Both judge by the simulator's flash, not the loader's opinion of it. pbreloc and pbrehome lose their write-guard probes, which is what those two gates replace. Defeating the seal in the loader turns seven tests red. 37 of 37 chips green with the exhaustive size matrix; README protocol section and every size row rewritten. pbhw gains an adversarial --seal-rounds sweep for the bench. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,12 @@
|
||||
// reset-vector surgery, self-update) lives in the host tool. Protocol,
|
||||
// deployment and configuration: README.md next to this file.
|
||||
//
|
||||
// The image is position-independent — PC-relative control flow, wire
|
||||
// addresses in, the write guard and the info block both anchored on the
|
||||
// runtime return address — so the identical binary runs from any slot. That
|
||||
// is what makes a copy one slot below able to rewrite the resident one, and
|
||||
// every change here has to keep it (test/check_pi.py).
|
||||
// The image is position-independent — PC-relative control flow and wire
|
||||
// addresses in, no absolute address formed anywhere — so the identical binary
|
||||
// runs from any slot. That is what makes a copy one slot below able to rewrite
|
||||
// the resident one, and every change here has to keep it (test/check_pi.py).
|
||||
// It does not need to know *which* slot it is in: nothing here refuses an
|
||||
// address, so there is no running-slot comparison to anchor.
|
||||
|
||||
#include <chrono>
|
||||
|
||||
@@ -26,6 +27,37 @@ constexpr auto off = avr::irq::guard_policy::unused;
|
||||
|
||||
constexpr std::uint8_t ack = '+';
|
||||
|
||||
// The refusal, which is the ack inverted: on a link whose whole problem is
|
||||
// flipped bits, the byte saying "nothing happened" should be as far as a byte
|
||||
// can be from the one saying "it did", and the complement is all eight bits.
|
||||
// It is also the only spelling that needs no justifying — every other value
|
||||
// would be a choice.
|
||||
constexpr std::uint8_t nak = static_cast<std::uint8_t>(~ack);
|
||||
|
||||
// What a command's fields must fold to. Any non-zero constant does: zero is
|
||||
// what a run of one repeated byte folds to, and a repeated byte is the shape of
|
||||
// both a line stuck at a level and a page of erased flash arriving where a
|
||||
// header belongs.
|
||||
constexpr std::uint8_t seal = 0x5a;
|
||||
|
||||
// The opcode, as bits rather than letters. Each is a one-instruction skip,
|
||||
// where a set of arbitrary values costs a compare and a branch apiece — and
|
||||
// with the seal deciding what is a command at all, there is nothing left for a
|
||||
// readable spelling to buy. A transfer is the absence of the other three, and
|
||||
// its direction is the low bit.
|
||||
//
|
||||
// Identify is bit 5 for one reason: 'p' and 'b' both carry it, and those are
|
||||
// the knock. Two things follow that no other assignment gives. A host cannot
|
||||
// know which generation it is talking to until something has answered, so the
|
||||
// command reporting the version has to mean the same thing before the version
|
||||
// is known — 'b' still asks it. And a knock aimed at a loader that is
|
||||
// *already* in session has to stay harmless: with no opcode reserved as
|
||||
// invalid, every byte now starts a command, so a knock that meant nothing to
|
||||
// earlier generations would otherwise consume the five header bytes behind it
|
||||
// and put the stream out of step. Answering both knock bytes with the identity
|
||||
// keeps the reconnect exactly as cheap as it was.
|
||||
enum : std::uint8_t { op_write = 1, op_fill = 4, op_jump = 8, op_identify = 0x20 };
|
||||
|
||||
// Deployment parameters come from the build (pureboot_add_loader()). The
|
||||
// signature is not one of them: the chip database is the only universal
|
||||
// source — a tiny13A cannot read its own signature row from code. An autobaud
|
||||
@@ -43,8 +75,8 @@ constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
|
||||
// The loader owns the top 512 bytes; a staging copy goes in the slot below.
|
||||
// Chips without a hardware boot section — the tinies and the m48s, whose SPM
|
||||
// runs from anywhere (Atmel-8271 §26) — keep the application's relocated
|
||||
// reset vector in the word under the slot.
|
||||
constexpr std::uint16_t slot_bytes = 512;
|
||||
// reset vector in the word under the slot. The size itself is the linker's and
|
||||
// the host's business: nothing in here needs to know where the slot ends.
|
||||
constexpr std::uint16_t page = spm::page_bytes;
|
||||
constexpr bool boot_section = avr::hw::curated::has_boot_section();
|
||||
|
||||
@@ -77,7 +109,7 @@ static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL
|
||||
|
||||
// The loader's one identity number. The protocol carries none of its own —
|
||||
// a version implies it, and the host tool holds that map (README.md).
|
||||
constexpr std::uint8_t version = 8;
|
||||
constexpr std::uint8_t version = 9;
|
||||
|
||||
// The image's identity stamp, for the host tool rather than for the wire: an
|
||||
// update image is a bare 512-byte slot, and without this nothing in it says
|
||||
@@ -105,12 +137,18 @@ constexpr std::uint8_t stamp_identity = 2;
|
||||
// The address spaces a transfer can name, in a selector byte's low nibble.
|
||||
// Flash is 0 so it is the cheapest to select.
|
||||
//
|
||||
// spm_ops is the one that is not memory: a write there hands its byte to
|
||||
// spm_ops is the one that is not memory: naming it hands the count field to
|
||||
// SPMCSR and fires the instruction at the transfer's address, which is how
|
||||
// page erase, page write and RWW re-enable reach the wire without the loader
|
||||
// carrying a command for each. The hardware's four-cycle store-to-SPM window
|
||||
// is why this is one fused primitive and not a poke of SPMCSR — no host can
|
||||
// hit that window across a serial link.
|
||||
//
|
||||
// It is the one space with no direction: the opcode's write bit is not
|
||||
// consulted, because a sealed command naming this space says what it means and
|
||||
// there is nothing for the other direction to denote. Testing the bit anyway
|
||||
// would cost six bytes to catch a host contradicting itself, which is the same
|
||||
// trade the running-slot guard lost.
|
||||
enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 };
|
||||
|
||||
// A selector's high nibble is the flash bank — the address bits above the
|
||||
@@ -128,22 +166,6 @@ enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_
|
||||
return static_cast<std::uint8_t>(selector >> 4);
|
||||
}
|
||||
|
||||
// The slot a flash address falls in, as one byte. A slot is half as many words
|
||||
// as bytes, so the word address's high byte is exactly this index — which is
|
||||
// what lets the write guard compare a single byte, and what the running copy's
|
||||
// own return address yields for free.
|
||||
constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes);
|
||||
constexpr std::uint8_t bank_shift = 16 - slot_shift;
|
||||
|
||||
[[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
|
||||
{
|
||||
const auto within = static_cast<std::uint8_t>(at >> slot_shift);
|
||||
if constexpr (banked_flash)
|
||||
return static_cast<std::uint8_t>((bank << bank_shift) | within);
|
||||
else
|
||||
return within;
|
||||
}
|
||||
|
||||
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL /
|
||||
// PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The
|
||||
// software receiver is the polled one: the vector table belongs to the
|
||||
@@ -474,37 +496,42 @@ void await_host()
|
||||
}
|
||||
|
||||
// One byte into a writable space. Flash is not one of them — it arrives a
|
||||
// page at a time through 'W' and is committed through sp_spm — and the fuses
|
||||
// are not writable at all: SPM reaches flash and boot lock bits only.
|
||||
[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value,
|
||||
std::uint8_t slot_high)
|
||||
// page at a time through 'W' and is committed by the sealed SPM command — and
|
||||
// the fuses are not writable at all: SPM reaches flash and boot lock bits only.
|
||||
[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint16_t at, std::uint8_t value)
|
||||
{
|
||||
if (space == sp_data) {
|
||||
*reinterpret_cast<volatile std::uint8_t *>(at) = value;
|
||||
return;
|
||||
}
|
||||
if (space == sp_spm) {
|
||||
// The running-slot write guard. An SPM command aimed at the slot this
|
||||
// code executes from is dropped, so a broken host cannot brick the
|
||||
// running loader — while a copy one slot lower may still rewrite the
|
||||
// resident one, which is what a self-update is. Guarding the commit
|
||||
// rather than the page fill covers erase and write both, and leaves a
|
||||
// refused page's words in the buffer: harmless, since the next page
|
||||
// write auto-erases it (§26.2.1).
|
||||
if (slot_of(bank, at) != slot_high)
|
||||
spm::command<off>(value, flash_address(bank, at));
|
||||
// Only a boot-sectioned mega runs on while its RWW section programs;
|
||||
// everywhere else the CPU halts through erase and write, so the wait
|
||||
// is already over by the time it returns.
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
return;
|
||||
}
|
||||
// Host-paced: the ack goes out once the write has begun, so the next byte
|
||||
// arrives while it completes and nothing is missed without a buffer.
|
||||
ee::write<off>(at, value);
|
||||
}
|
||||
|
||||
// The irreversible half of the protocol, and the whole of it: page erase, page
|
||||
// write and the lock bits are one SPM command each, and nothing else the loader
|
||||
// does outlasts being done again. Reached only from a sealed command (run()),
|
||||
// so both the byte handed to SPMCSR and the address it fires at are the ones
|
||||
// the host computed its seal over.
|
||||
//
|
||||
// Nothing here refuses an address. A loader that will not write its own slot
|
||||
// cannot plant anything in it either, and a resident copy able to rewrite its
|
||||
// own trailing page is what lets a 512-byte boot section — where no staging
|
||||
// copy can run SPM at all — carry an SPM primitive for an application-side
|
||||
// installer to drive. The protection that made the guard look necessary is the
|
||||
// seal: a wire fault can no longer name an address, only a host can, and a host
|
||||
// that names this one means it.
|
||||
[[gnu::always_inline]] inline void commit(std::uint8_t bank, std::uint16_t at, std::uint8_t value)
|
||||
{
|
||||
spm::command<off>(value, flash_address(bank, at));
|
||||
// Only a boot-sectioned mega runs on while its RWW section programs;
|
||||
// everywhere else the CPU halts through erase and write, so the wait
|
||||
// is already over by the time it returns.
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
}
|
||||
|
||||
// One page into the SPM buffer, and only that: the erase and the write that
|
||||
// commit it are host-issued sp_spm stores, which reach the same fused
|
||||
// store-and-SPM pair through the transfer path's own address and data.
|
||||
@@ -541,13 +568,6 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
run_app();
|
||||
|
||||
link::init();
|
||||
|
||||
// The slot this copy runs in, which the write guard follows: the return
|
||||
// address is a word address and a slot is half as many words as bytes, so
|
||||
// its high byte is the slot index outright. No absolute address is ever
|
||||
// formed, so the image stays position-independent.
|
||||
const auto slot_high = avr::startup::caller_page();
|
||||
|
||||
await_host();
|
||||
|
||||
for (;;) {
|
||||
@@ -556,54 +576,99 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
ee::wait();
|
||||
tx_ack();
|
||||
const std::uint8_t command = link::rx();
|
||||
switch (command) {
|
||||
case 'b': // identity: the version, then the three signature bytes
|
||||
// Straight out of the stamp, so the wire and the image can never
|
||||
// disagree about what this loader is. The indices are constant and
|
||||
// the array is constexpr, so these are immediates, not flash reads:
|
||||
// nothing here needs the stamp's runtime address.
|
||||
if (command & op_identify) {
|
||||
// Identity: the version, then the three signature bytes. Straight
|
||||
// out of the stamp, so the wire and the image can never disagree
|
||||
// about what this loader is. The indices are constant and the array
|
||||
// is constexpr, so these are immediates, not flash reads: nothing
|
||||
// here needs the stamp's runtime address. Unsealed, because it
|
||||
// takes no argument and changes nothing — and because a command
|
||||
// that cannot be got wrong is what a lost host resynchronises on.
|
||||
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
|
||||
link::tx(identity_stamp[at]);
|
||||
break;
|
||||
case 'J': // jump: sel8 (reserved), addr16 as a wire word address
|
||||
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
|
||||
case 'G': // read: sel8, addr16, n8 (0 = 256)
|
||||
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
|
||||
} else {
|
||||
// One decode, one cursor and one loop for every space, both
|
||||
// directions and the jump: a command per memory would carry a copy
|
||||
// of all three each. 'J' — the hand-over and staging transfer —
|
||||
// carries a selector it ignores so its address rides the same two
|
||||
// reads as everything else; 'W' joins the same decode rather than
|
||||
// keeping an address form of its own, so flash addressing is
|
||||
// uniform across every command that names it.
|
||||
// of all three each. The jump — the hand-over and staging transfer
|
||||
// — carries a selector it ignores so its address rides the same two
|
||||
// reads as everything else; the page fill joins the same decode
|
||||
// rather than keeping an address form of its own, so flash
|
||||
// addressing is uniform across every command that names it. Both
|
||||
// carry the count they do not use for the same reason: one header
|
||||
// shape is one decode, and one seal covers a fixed set of bytes.
|
||||
const std::uint8_t selector = link::rx();
|
||||
const std::uint8_t space = space_of(selector);
|
||||
const std::uint8_t bank = bank_of(selector);
|
||||
std::uint16_t at = rx16();
|
||||
if (command == 'J') {
|
||||
tx_ack();
|
||||
link::drain();
|
||||
jump(reinterpret_cast<void (*)()>(at));
|
||||
}
|
||||
if (command == 'W') {
|
||||
fill_page(bank, at);
|
||||
break;
|
||||
}
|
||||
std::uint8_t count = link::rx();
|
||||
do {
|
||||
// Read and write are one letter apart in case, so the direction
|
||||
// is a single bit and the loop picks it with a one-word skip.
|
||||
if (command & 0x20) {
|
||||
store(space, bank, at, link::rx(), slot_high);
|
||||
tx_ack();
|
||||
} else
|
||||
link::tx(load(space, bank, at));
|
||||
++at;
|
||||
} while (--count);
|
||||
break;
|
||||
}
|
||||
default: // unknown bytes are ignored; the loop re-acks
|
||||
break;
|
||||
const std::uint8_t sealed = link::rx();
|
||||
|
||||
// The seal: every field that decides what this command does folded
|
||||
// into one byte the host chose, tested before any of it happens.
|
||||
//
|
||||
// Checked here rather than acknowledged afterwards, which is the
|
||||
// whole point. An ack reports a command that has already run, and
|
||||
// for the one command that cannot be taken back a report is not a
|
||||
// defence. Once the running-slot guard is gone the address is as
|
||||
// fatal as the command byte — a wrong one reaches the loader's own
|
||||
// page — so the seal covers the act and the place together, and a
|
||||
// stream that lost or mangled either cannot produce it.
|
||||
//
|
||||
// Folded here, after the last read, and never accumulated across
|
||||
// the reads: every field is still live at this point because the
|
||||
// command needs it anyway, so the fold costs one xor each and no
|
||||
// register. An accumulator would have to survive four calls, and
|
||||
// paying for that in call-saved registers costs more than the whole
|
||||
// check costs in arithmetic — measured at fourteen bytes, on a
|
||||
// budget of ten.
|
||||
std::uint8_t fold = command;
|
||||
fold ^= selector;
|
||||
fold ^= static_cast<std::uint8_t>(at);
|
||||
fold ^= static_cast<std::uint8_t>(at >> 8);
|
||||
fold ^= count;
|
||||
fold ^= sealed;
|
||||
// The verdict, and it is not a courtesy. Every command whose
|
||||
// payload the host sends without waiting — a page fill, a write
|
||||
// burst — would otherwise be handed to a loader that has already
|
||||
// gone back to reading commands, so a *detected* error would
|
||||
// become the desync the seal exists to prevent: a 128-byte page
|
||||
// read as command headers is twenty-one more chances at the one in
|
||||
// two hundred and fifty-six. Answering the seal before the payload
|
||||
// is what keeps a refusal local to the command that earned it.
|
||||
//
|
||||
// An unknown opcode lands here too — every bit pattern is now some
|
||||
// command, so it is the seal, not a table of valid letters, that
|
||||
// rejects noise, and the host hears about it either way.
|
||||
if (fold != seal) {
|
||||
link::tx(nak);
|
||||
} else {
|
||||
tx_ack();
|
||||
if (command & op_jump) {
|
||||
link::drain();
|
||||
jump(reinterpret_cast<void (*)()>(at));
|
||||
} else if (command & op_fill) {
|
||||
fill_page(bank, at);
|
||||
} else if (space == sp_spm) {
|
||||
// An SPM command is the whole of what this loader can do
|
||||
// that doing again will not undo, and it is one byte — so
|
||||
// it rides the count field, inside the seal, rather than
|
||||
// arriving as data after the seal has been checked. Which
|
||||
// is also what makes a deliberate lock-bit write
|
||||
// expressible, where refusing it outright did not.
|
||||
commit(bank, at, count);
|
||||
} else {
|
||||
do {
|
||||
// Direction is one bit of the opcode, so the loop
|
||||
// picks it with a one-word skip.
|
||||
if (command & op_write) {
|
||||
store(space, at, link::rx());
|
||||
tx_ack();
|
||||
} else
|
||||
link::tx(load(space, bank, at));
|
||||
++at;
|
||||
} while (--count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user