build: the libavr pin advances past phase 6, at byte parity everywhere

The pin crosses libavr's phase 6 - the renamed system surface, the named
serial configs, the receiver-tolerance table, the paged SPM receipts -
and every loader image comes out size-identical: the full matrix on six
representative chips (the exhaustive cross product on three of them),
the stock and autobaud columns untouched, the four tsb tiers back on
their recorded floors at 510/526/638/836.

Byte parity was not free, and the two libavr defects it surfaced were
fixed there rather than absorbed here. The EEPROM write procedure's
step 2 - the SPMEN spin - had landed unconditionally and cost every
build six bytes for a wait a polled loader can never take; it is scoped
now, and the loaders state the datasheet's own omission clause
(spm_interlock::omitted, DS40002061B 8.6.3). The blocking page
erase/write grew an internal wait the tiers' settle() already provides,
so the tiers issue the command form and pureboot keeps its host-driven
sp_spm path.

What the port states rather than inherits: the stock 115200 at 16 MHz
sits +2.1 % past the receiver-tolerance table libavr now holds rates
to, so the hardware links say .allow_baud_error = true - the same
2.5 % envelope pureboot_baud_feasible() has always enforced, proven on
silicon across the fleet. rx_ready() reads readable() now.

Alongside the pin: rule 33's ASCII sweep over every source (docs keep
their typography), rule 34's InsertBraces in .clang-format with the
tree reformatted, std::array over the simavr runners' raw buffers, and
the stale Studio size in ide/README.md replaced by the claim its
check-flags gate actually holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:43:44 +02:00
parent 0cb83ff36f
commit e4390d2ba8
38 changed files with 791 additions and 625 deletions

View File

@@ -1,5 +1,5 @@
# pureboot as a consumable CMake unit: the per-chip geometry, the default baud
# ladder, and pureboot_add_loader() the one way a loader target is created.
# ladder, and pureboot_add_loader() - the one way a loader target is created.
# A downstream project brings its usual libavr setup (the `libavr` target and
# the LIBAVR_MCU toolchain preset), adds this directory, and states its
# deployment; every argument is optional (README.md):
@@ -9,7 +9,7 @@
# Per-family geometry, deployment defaults, and the linker wrap the PC modulo
# needs. The slot is 512 bytes on every chip. The USART flags mirror the
# hardware inventory the loader's own static asserts check the plain 644 is
# hardware inventory the loader's own static asserts check - the plain 644 is
# the x4 family's one single-USART die (Atmel-2593).
set(_pb_has_usart 1)
set(_pb_has_usart1 0)
@@ -107,7 +107,7 @@ set(_pb_slot 512)
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
# Patched-vector chips hand over through the trampoline word below the slot,
# which is also the slot's own last word their budget is slot 2.
# which is also the slot's own last word - their budget is slot - 2.
if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
set(_pb_app 0)
set(_pb_limit ${_pb_slot})
@@ -118,7 +118,7 @@ endif()
# The pins each USART owns. A bit-banged link deployed on them has to release
# that USART before it can drive the line, and those instructions are the one
# way the choice of pins moves the image so a size matrix needs them as an
# way the choice of pins moves the image - so a size matrix needs them as an
# axis even though pins are otherwise immediate operands. Uniform across every
# mega libavr covers: USART0 (the classics' un-numbered USART included) on
# PD0/PD1, USART1 on PD2/PD3.
@@ -233,7 +233,7 @@ function(pureboot_default_baud clock software outvar)
endif()
endforeach()
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 % "
" pass BAUD <rate> to deploy a non-standard one")
" - pass BAUD <rate> to deploy a non-standard one")
endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
@@ -245,12 +245,12 @@ endfunction()
# <name>.bin for --update-loader). The resolved deployment is stamped on the
# target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
# usart0, usart1, or sw:<RX>,<TX> with a trailing @<n> where those pins are a
# USART's own) what a test harness speaks to it with.
# USART's own) - what a test harness speaks to it with.
#
# HALF_DUPLEX is the one-wire deployment, per backend: on the hardware USART
# it enables the library's .half_duplex turn-around (RXD and TXD tied
# together off-chip); on a software or autobaud link it puts both directions
# on the RX pin the same thing RX == TX spells directly.
# on the RX pin - the same thing RX == TX spells directly.
#
# SERIAL autobaud measures the host's bit timing at run time, so the image
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
@@ -260,7 +260,7 @@ endfunction()
#
# OSCCAL bakes a measured oscillator trim into the loader (README.md: the
# RC-oscillator deployment answer): the byte is written at the top of run(),
# so every reset path the watchdog hand-over included runs on the
# so every reset path - the watchdog hand-over included - runs on the
# corrected clock. Orthogonal to the backend: an autobaud build may carry it
# purely for the application's benefit, its own link being clock-free. No
# value, no code.
@@ -310,7 +310,7 @@ function(pureboot_add_loader name)
if(PB_SERIAL STREQUAL "auto")
if(_usart AND (PB_RX OR PB_TX))
message(WARNING "pureboot_add_loader(${name}): RX/TX apply to the software UART, "
"which auto does not pick on ${LIBAVR_MCU} SERIAL software to force it")
"which auto does not pick on ${LIBAVR_MCU} - SERIAL software to force it")
endif()
if(_usart)
set(_link usart0)
@@ -347,7 +347,7 @@ function(pureboot_add_loader name)
else()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
endif()
# sw:<RX>,<TX> as port letter and bit, upcased with @<n> where
# sw:<RX>,<TX> as port letter and bit, upcased - with @<n> where
# the TX pin is a USART's own TXD, since a harness driving that
# link has to know the USART owns the pin until the loader
# releases it.
@@ -365,8 +365,8 @@ function(pureboot_add_loader name)
set(_link "${_link}@1")
elseif(PB_TX STREQUAL PB_RX AND _usart AND PB_RX STREQUAL _rx0)
# One-wire on a USART's RXD: RXEN forces that pin's direction
# (§20.7.3), so the driven shared pin is held exactly like a
# TXD the harness models the hold either way.
# (section 20.7.3), so the driven shared pin is held exactly like a
# TXD - the harness models the hold either way.
set(_link "${_link}@0")
elseif(PB_TX STREQUAL PB_RX AND _usart1 AND PB_RX STREQUAL _rx1)
set(_link "${_link}@1")
@@ -403,12 +403,12 @@ function(pureboot_add_loader name)
# byte-stream loops' counters into end-pointer forms that cost registers
# (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the
# table with the default allocator (-fira-algorithm=priority), and keeps
# expression temporaries in registers (-fno-tree-ter) but every loop body
# expression temporaries in registers (-fno-tree-ter) - but every loop body
# here contains a call, so a register held across it costs more than the
# load-immediate it saves. The set is fitted to the loader's body and has to
# be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes
# now that it carries one, and -fno-ivopts is fitted per backend an
# now that it carries one, and -fno-ivopts is fitted per backend - an
# autobaud body needs ivopts to keep the calibration countdown a single
# induction variable (without it the counter is duplicated and the
# measurement loop runs 9 cycles instead of its contracted 7), while the

View File

@@ -1,11 +1,11 @@
// pureboot a serial bootloader on libavr: one C++ source, no inline
// pureboot - a serial bootloader on libavr: one C++ source, no inline
// assembly, no global register variables, a 512-byte slot on every chip
// libavr targets. The device speaks primitives; every composite (verify, erase,
// 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 and wire
// addresses in, no absolute address formed anywhere so the identical binary
// 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
@@ -25,12 +25,20 @@ namespace {
// Purely polled: every interrupt guard folds to nothing.
constexpr auto off = avr::irq::guard_policy::unused;
// The EEPROM procedure's step 2 - wait until SPMEN clears - guards a flash
// operation still in flight, and this loader never has one when it touches
// the EEPROM: commit() waits every boot-sectioned page operation out before
// the ack, and everywhere else the CPU halts through the operation itself.
// The posture states the omission the datasheet grants for exactly that
// (DS40002061B section 8.6.3).
constexpr auto no_spm = ee::spm_interlock::omitted;
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
// 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);
@@ -41,7 +49,7 @@ constexpr std::uint8_t nak = static_cast<std::uint8_t>(~ack);
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
// 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.
@@ -50,7 +58,7 @@ constexpr std::uint8_t seal = 0x5a;
// 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
// 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
@@ -60,11 +68,11 @@ 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
// source - a tiny13A cannot read its own signature row from code. An autobaud
// build carries no clock and no baud at all; it measures both.
#if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD))
#error \
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)"
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud - create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)"
#endif
#if !defined(PUREBOOT_AUTOBAUD)
@@ -73,8 +81,8 @@ constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
#endif
// 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
// Chips without a hardware boot section - the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 section 26) - keep the application's relocated
// 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;
@@ -101,23 +109,23 @@ constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// A build may bake a measured oscillator trim (README.md: the RC-oscillator
// deployment answer); the byte is applied at the top of run(). Orthogonal to
// the serial backend an autobaud build may carry it for the application's
// the serial backend - an autobaud build may carry it for the application's
// benefit alone.
#if defined(PUREBOOT_OSCCAL)
static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
#endif
// The loader's one identity number. The protocol carries none of its own
// 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 = 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
// which chip it was built for. The tool refuses to install an image whose
// stamp does not match the device flashing a foreign loader bricks the
// stamp does not match the device - flashing a foreign loader bricks the
// target, and the loader itself cannot check what has already replaced it.
//
// Never read from flash by the loader 'b' answers out of this array, but at
// Never read from flash by the loader - 'b' answers out of this array, but at
// constant indices, so those fold to immediates and no runtime address of it
// is ever formed. `used` keeps the compiler from dropping the copy the host
// needs and `retain` keeps --gc-sections from collecting it.
@@ -141,7 +149,7 @@ constexpr std::uint8_t stamp_identity = 2;
// 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
// 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
@@ -151,7 +159,7 @@ constexpr std::uint8_t stamp_identity = 2;
// 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
// A selector's high nibble is the flash bank - the address bits above the
// 16-bit wire address, RAMPZ on the chips that have one. Keeping it here
// rather than widening the wire address is what lets one 16-bit cursor serve
// every space: a 24-bit cursor would pay its extra byte on EEPROM and data
@@ -195,9 +203,9 @@ constexpr int usart_unit = 0;
#endif
// One-wire on the hardware USART (PUREBOOT_HALF_DUPLEX): RXD and TXD tied
// together off-chip, exactly one direction enabled at a time the library's
// together off-chip, exactly one direction enabled at a time - the library's
// .half_duplex turn-around. The activation window is unchanged; only its
// poll grows the release-line test rx_ready() carries in this mode.
// poll grows the release-line test readable() carries in this mode.
constexpr bool hw_half_duplex =
#if defined(PUREBOOT_HALF_DUPLEX)
true;
@@ -207,19 +215,24 @@ constexpr bool hw_half_duplex =
template <avr::hertz_t C, avr::baud_t B>
struct hardware_link {
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct, .half_duplex = hw_half_duplex}>;
// The deployment envelope is the build's: pureboot_baud_feasible() holds
// every configured rate within 2.5 %, which the bench has proven across
// the fleet, and the datasheet's stricter per-frame tolerance table would
// refuse the stock 115200 at 16 MHz (+2.1 %) that every deployed board
// runs. .allow_baud_error states that this is meant.
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .allow_baud_error = true, .half_duplex = hw_half_duplex}>;
// The compiled idle poll around the window's narrow (uint24_t) countdown:
// the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
// status register's home a 2-cycle bit-skip where UCSRnA sits in
// status register's home - a 2-cycle bit-skip where UCSRnA sits in
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
// I/O. Half-duplex polls through rx_ready()'s release-line test, which
// I/O. Half-duplex polls through readable()'s release-line test, which
// -Os outlines: the rcall (3), the UCSR#B read and not-taken skip with
// the jump over the write (I/O 3, extended 5), the ret (4) and the
// the jump over the write (I/O 3, extended 5), the ret (4) - and the
// call in the loop body pushes the countdown into call-saved registers,
// where the uint24_t step is ldi+sub+sbc+sbc (4) instead of sbiw+sbci
// (3). Measured off the built loops: 18 a poll in bit-addressable I/O,
// 22 in extended. A uint32_t countdown pays one more sbci
// 22 in extended. A uint32_t countdown pays one more sbci -
// window_polls() adds it where the count forces the wide type. Held per
// chip by the pureboot.window gates. The lookup rides the baud parameter
// so it stays dependent: the trait is an incomplete type on the
@@ -228,8 +241,9 @@ struct hardware_link {
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
static consteval std::uint8_t poll_cost()
{
if (hw_half_duplex)
if (hw_half_duplex) {
return U::ucsra::addr < 0x40 ? 18 : 22;
}
return U::ucsra::addr < 0x40 ? 7 : 9;
}
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
@@ -241,7 +255,7 @@ struct hardware_link {
static bool pending()
{
return uart::rx_ready();
return uart::readable();
}
static std::uint8_t rx()
@@ -256,7 +270,7 @@ struct hardware_link {
static void drain()
{
// A drain here always follows this link's own write the frame is
// A drain here always follows this link's own write - the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
@@ -273,7 +287,7 @@ struct software_link {
// The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
// countdown pays one more sbci window_polls() adds it where the count
// countdown pays one more sbci - window_polls() adds it where the count
// forces the wide type. Held by the pureboot.window gate.
static constexpr std::uint8_t poll_cycles = 7;
@@ -305,12 +319,12 @@ struct software_link {
// The clock-free link: the bit period is measured from the host's calibration
// pulse instead of derived from a clock, so one image serves every F_CPU and
// every rate. Activation differs in kind from the other two there is no
// clock to time a window against so this backend brings its own, below.
// every rate. Activation differs in kind from the other two - there is no
// clock to time a window against - so this backend brings its own, below.
struct autobaud_link {
// The unit in GPIOR2:GPIOR1 where the chip has them: the loader owns the
// whole chip while it runs, and the pair costs one word per access where
// the RAM word costs two six words across the image.
// the RAM word costs two - six words across the image.
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX, avr::uart::unit_home::gpior>;
static void init()
@@ -330,7 +344,7 @@ struct autobaud_link {
static void drain()
{
// A drain here always follows this link's own write the frame is
// A drain here always follows this link's own write - the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
@@ -350,7 +364,7 @@ using link = std::conditional_t<avr::uart::has_usart<usart_unit>(), hardware_lin
#endif
// The application's entry, pinned by the linker (--defsym): word 0 on a
// boot-sectioned mega, the trampoline at base 2 elsewhere. Reaching it must
// boot-sectioned mega, the trampoline at base - 2 elsewhere. Reaching it must
// not depend on where this copy runs, so the jump goes through a pointer, and
// [[gnu::noipa]] keeps the constant from folding back into a relative call.
extern "C" [[noreturn]] void pureboot_app();
@@ -368,36 +382,38 @@ extern "C" [[noreturn]] void pureboot_app();
// Activation: a bounded wait for the host, then the knock. Both forms boot the
// application when the window closes on an idle line, and both bound *every*
// wait a knock awaited without a deadline would let one stray edge hold an
// wait - a knock awaited without a deadline would let one stray edge hold an
// unattended device in the loader forever.
#if defined(PUREBOOT_AUTOBAUD)
// The window is a fixed poll budget: with no clock, whole seconds cannot be
// timed. A uint24_t holds it a fourth byte would cost two words at every
// timed. A uint24_t holds it - a fourth byte would cost two words at every
// countdown step for range never used.
void await_host()
{
for (;;) {
if (!link::uart::calibrate(autobaud_budget))
if (!link::uart::calibrate(autobaud_budget)) {
run_app();
}
// The calibration pulse has already proven a host is there, so one
// byte activates. A knock that never arrives falls back to calibrate(),
// whose own budget then boots the application.
if (link::uart::template read<off>(autobaud_budget) == 'p')
if (link::uart::template read<off>(autobaud_budget) == 'p') {
return;
}
}
}
#else
// The window as one countdown, divided by the backend's counted poll-loop
// cycles. Whole seconds is all it promises. The per-poll cost depends on the
// countdown's own width (a uint32_t decrement chain is one sbci longer), and
// the width depends on the poll count solved narrow-first: a count that
// the width depends on the poll count - solved narrow-first: a count that
// fits 24 bits at the narrow cost keeps the narrow loop, anything else takes
// the wide loop at its own cost. A count fitting 24 bits only at the wide
// cost stays wide, so the choice cannot oscillate on the boundary.
consteval std::uint32_t polls_at(std::uint32_t per_poll)
{
// Whole-window cycles first, then the per-poll division: one truncation
// instead of one per second. Same instructions either way only the
// instead of one per second. Same instructions either way - only the
// countdown's immediate moves.
return static_cast<std::uint32_t>(dev::cycles_for<std::chrono::seconds{timeout_seconds}>() / per_poll);
}
@@ -421,8 +437,9 @@ bool pending_before_deadline()
{
window_t polls = window_polls();
do {
if (link::pending())
if (link::pending()) {
return true;
}
} while (--polls);
return false;
}
@@ -431,8 +448,9 @@ bool pending_before_deadline()
// application runs.
std::uint8_t rx_deadline()
{
if (!pending_before_deadline())
if (!pending_before_deadline()) {
run_app();
}
return link::rx();
}
@@ -452,7 +470,7 @@ void await_host()
return static_cast<std::uint16_t>(low | (link::rx() << 8));
}
// The wire's byte pair as the word it is AVR is little-endian too, so the
// The wire's byte pair as the word it is - AVR is little-endian too, so the
// cast is the identity a shift-and-or spelling makes the compiler rediscover.
// Callers read into named variables first: the wire order is a sequence of
// reads, not an argument order.
@@ -471,10 +489,11 @@ void await_host()
// A wire address and its selector's bank as the flash address they name.
[[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
{
if constexpr (banked_flash)
if constexpr (banked_flash) {
return (static_cast<spm::flash_address_t>(bank) << 16) | at;
else
} else {
return at;
}
}
// One byte out of any space. Every accessor shares the transfer's cursor, its
@@ -483,20 +502,24 @@ void await_host()
[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank,
std::uint16_t at)
{
if (space == sp_eeprom)
return ee::read(at);
if (space == sp_data)
if (space == sp_eeprom) {
return ee::read<no_spm>(at);
}
if (space == sp_data) {
return *reinterpret_cast<volatile std::uint8_t *>(at);
if (space == sp_fuse)
}
if (space == sp_fuse) {
return spm::read_fuse<off>(static_cast<spm::fuse>(at));
if constexpr (banked_flash)
}
if constexpr (banked_flash) {
return avr::flash_load_far<std::uint8_t>(flash_address(bank, at));
else
} else {
return avr::flash_load(reinterpret_cast<const std::uint8_t *>(at));
}
}
// One byte into a writable space. Flash is not one of them it arrives a
// page at a time through 'W' and is committed by the sealed SPM command and
// One byte into a writable space. Flash is not one of them - it arrives a
// 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)
{
@@ -506,7 +529,7 @@ void await_host()
}
// 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);
ee::write<off, no_spm>(at, value);
}
// The irreversible half of the protocol, and the whole of it: page erase, page
@@ -517,8 +540,8 @@ void await_host()
//
// 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
// 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.
@@ -528,17 +551,18 @@ void await_host()
// 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)
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.
//
// Nothing discards the buffer first: it is write-once per word (§26.2.1), so
// Nothing discards the buffer first: it is write-once per word (section 26.2.1), so
// filling over a refused page or an application's leavings programs stale
// words but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so
// words - but a page write auto-erases it (section 26.2.1; section 19.2 on the tinies), so
// that write clears the condition and the host's read-back rewrites the page.
void fill_page(std::uint8_t bank, std::uint16_t at)
{
@@ -546,10 +570,14 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
// starts at the page base; the low byte of the cursor is the whole in-page
// offset, since a page is aligned and never crosses a bank.
std::uint16_t z = at & ~static_cast<std::uint16_t>(page - 1);
// The receipt is the erase-first contract's token and costs nothing here:
// the erase is the host's own sealed SPM command, before or after the fill
// as it chooses (the page write clears a stale buffer either way, above).
const auto open = spm::page::begin<spm::from::boot_section, off>(flash_address(bank, z));
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(flash_address(bank, z), word_of({low, high}));
spm::fill<off>(open, flash_address(bank, z), word_of({low, high}));
z += 2;
} while (static_cast<std::uint8_t>(z) & (page - 1));
}
@@ -557,23 +585,24 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
[[noreturn]] void run()
{
#if defined(PUREBOOT_OSCCAL)
// The build's oscillator trim, ahead of everything the WDRF bail
// included so every path out of reset, the watchdog hand-over to the
// The build's oscillator trim, ahead of everything - the WDRF bail
// included - so every path out of reset, the watchdog hand-over to the
// application first among them, runs on the corrected clock.
avr::clock::calibrate(PUREBOOT_OSCCAL);
#endif
// A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF no activation window in its way.
if (avr::power::peek_reset_cause().watchdog)
// on until it clears WDRF - no activation window in its way.
if (avr::power::peek_reset_cause().watchdog) {
run_app();
}
link::init();
await_host();
for (;;) {
// No prompt while an EEPROM write runs: it blocks SPM and fuse reads
// (§26.2.1), and the prompt is the previous command's completion ack.
ee::wait();
// (section 26.2.1), and the prompt is the previous command's completion ack.
ee::wait<no_spm>();
tx_ack();
const std::uint8_t command = link::rx();
if (command & op_identify) {
@@ -582,15 +611,16 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
// 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
// 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)
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at) {
link::tx(identity_stamp[at]);
}
} 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. The jump the hand-over and staging transfer
// carries a selector it ignores so its address rides the same two
// 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
@@ -610,8 +640,8 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
// 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
// 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
@@ -619,7 +649,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
// 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
// check costs in arithmetic - measured at fourteen bytes, on a
// budget of ten.
std::uint8_t fold = command;
fold ^= selector;
@@ -628,15 +658,15 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
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
// 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
// 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) {
@@ -650,7 +680,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
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
// 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
@@ -663,8 +693,9 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
if (command & op_write) {
store(space, at, link::rx());
tx_ack();
} else
} else {
link::tx(load(space, bank, at));
}
++at;
} while (--count);
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""pureboot host tool the smart half of the protocol (README.md).
"""pureboot host tool - the smart half of the protocol (README.md).
The device exposes primitives; everything composite is here: HEX/raw images,
programming with repairing read-back verification, the reset-vector surgery
@@ -29,10 +29,10 @@ PROMPT = b"+"
# The refusal, from pureboot 9: the prompt inverted, so no single flipped bit
# turns "nothing happened" into "it did".
NAK = bytes((~PROMPT[0] & 0xFF,))
VERSION = 10 # this tool's own version free to drift from a loader's
VERSION = 10 # this tool's own version - free to drift from a loader's
# The loader versions this tool can drive. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that
# map lives: the tool keeps a decoder for every generation in it (14 speak
# map lives: the tool keeps a decoder for every generation in it (1-4 speak
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
# builds and changes nothing on the wire; 8 the one-wire deployments, whose
# only host-side trace is the --one-wire echo discard; 9 seals every command
@@ -46,20 +46,20 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
# the spaces below. The loader carries one transfer loop instead of four bodies
# which is what buys the data space and the host-issued SPM operations.
# - which is what buys the data space and the host-issued SPM operations.
# 6 marks the builds that may carry a baked OSCCAL trim, nothing on the wire;
# 7 gives 'J' a selector byte (older loaders take the bare address jump()
# 7 gives 'J' a selector byte (older loaders take the bare address - jump()
# sends each form to the version that speaks it) and re-homes the autobaud
# unit into the GPIOR pair where the chip has one; 8 marks the builds whose
# deployment may be one-wire (hardware half-duplex, or a software link folded
# onto one pin) nothing on the wire either, but a shared line makes the
# onto one pin) - nothing on the wire either, but a shared line makes the
# host read its own bytes back, which is what --one-wire consumes.
UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# pureboot 9 replaces the command letters with bits and seals every command.
# The header is one shape for all of them opcode, selector, address, count,
# seal and the loader answers the seal *before* it acts: PROMPT accepts, NAK
# The header is one shape for all of them - opcode, selector, address, count,
# seal - and the loader answers the seal *before* it acts: PROMPT accepts, NAK
# refuses and nothing happened. That verdict is what lets a refusal stay local
# to its own command: the payload of a fill or a write burst only goes out
# after the header has been accepted, so a rejected header never leaves the
@@ -69,12 +69,12 @@ SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# the low bit. Identify is bit 5 because both knock bytes carry it: 'b' has to
# still ask the version (the host cannot know which generation it is talking to
# until something answers), and 'p' has to stay harmless against a loader
# already in session pureboot 9 reserves no invalid opcode, so a knock that
# already in session - pureboot 9 reserves no invalid opcode, so a knock that
# meant nothing before would otherwise eat the five bytes behind it.
SEALED_LOADER = 9
OP_WRITE, OP_FILL, OP_JUMP, OP_IDENTIFY = 1, 4, 8, 0x20
# What a sealed header's fields must fold to. Non-zero, so a run of one
# repeated byte a stuck line, a page of erased flash read as a header
# repeated byte - a stuck line, a page of erased flash read as a header -
# cannot satisfy it.
SEAL = 0x5A
# An SPM command carries its SPMCSR byte in the count field, where the seal
@@ -82,16 +82,16 @@ SEAL = 0x5A
# the seal had already been checked, which is the hole the seal exists to close.
# An autobaud loader keeps its measured bit period readable, encoded as
# delay-loop counts: (bit cycles UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# floored the spin granule and per-bit overhead of libavr's software UART.
# delay-loop counts: (bit cycles - UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# floored - the spin granule and per-bit overhead of libavr's software UART.
# v5/v6 keep it at ram_start; v7 moves it into GPIOR2:GPIOR1 on the chips
# that have the pair (their data addresses are in the geometry) and keeps
# ram_start only where they do not exist. --info undoes the encoding to
# report the true clock, which therefore sits within one granule below it.
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
# A selector's high nibble is the flash bank the address bits above the 16-bit
# wire address so a transfer names a byte address within one 64 KiB bank and
# A selector's high nibble is the flash bank - the address bits above the 16-bit
# wire address - so a transfer names a byte address within one 64 KiB bank and
# no command has to speak word addresses. No single transfer may cross a bank
# boundary; the host chunks to keep that true.
def selector(space, address):
@@ -104,7 +104,7 @@ def selector(space, address):
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero
# data bits one low pulse of seven bit-times, which the loader times into its
# data bits - one low pulse of seven bit-times, which the loader times into its
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
CALIBRATE = 0xC0
@@ -119,7 +119,7 @@ CHIP_GEOMETRY = {
# ram_start is where SRAM begins in data space: the classic megas and the
# tinies keep it right after the plain I/O registers (0x60), the x8/x4
# generations past their extended I/O file (0x100). gpior1 is GPIOR1's
# data address 0x32 on the t25/45/85, 0x4A from the x8 generation on,
# data address - 0x32 on the t25/45/85, 0x4A from the x8 generation on,
# None where the chip has no pair (t13, classic megas). A v7 autobaud
# loader's measured bit period lives in GPIOR2:GPIOR1 where they exist
# and at exactly ram_start elsewhere (its only RAM object; the loader's
@@ -164,7 +164,7 @@ class Error(Exception):
class Progress:
"""A transient bar on stderr, drawn only for a tty and erased when done
"""A transient bar on stderr, drawn only for a tty and erased when done -
logs and pipes see only the summary line each operation prints. No label
or a zero total disables it, so callers can pass one unconditionally."""
@@ -203,7 +203,7 @@ class Progress:
class PosixPort:
"""A raw serial port with deadline-based reads, over termios. A rate with
no B-constant the off-nominal probes `--scan` walks goes through
no B-constant - the off-nominal probes `--scan` walks - goes through
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
by name."""
@@ -252,7 +252,7 @@ class PosixPort:
raise
def set_baud(self, baud):
"""Retune the port without closing it the fd stays open, so no DTR
"""Retune the port without closing it - the fd stays open, so no DTR
pulse and no reset. That matters: the only caller is mid-session with a
loader copy that a reset would throw away."""
self._apply_baud(termios.tcgetattr(self.fd), baud)
@@ -285,7 +285,7 @@ class PosixPort:
if os.name == "nt":
# The same port, over the Win32 serial API kernel32 through ctypes, so
# The same port, over the Win32 serial API - kernel32 through ctypes, so
# the tool stays standard-library only. Timeouts live in the driver
# (COMMTIMEOUTS) rather than in a readiness call: Windows has no select()
# for a COM handle, so each read asks the driver for its own deadline.
@@ -350,7 +350,7 @@ if os.name == "nt":
def __init__(self, path, baud):
# Win32 takes the rate as a plain integer, so unlike termios any
# rate the hardware can divide down to is available but a driver
# rate the hardware can divide down to is available - but a driver
# may also accept one it cannot produce (an FT232R takes a baud of
# 3, reports it back, and goes on using the previous divisor).
# Only obvious nonsense is refusable; the rest is the driver's word.
@@ -375,14 +375,14 @@ if os.name == "nt":
# fBinary, and DTR/RTS asserted (fDtrControl and fRtsControl,
# two bits each, = _ENABLE); every other flag clear, so no
# parity and no flow control. Raising both matches what opening
# a POSIX tty does including the reset pulse on the boards
# a POSIX tty does - including the reset pulse on the boards
# that wire DTR to it.
dcb.fBits = 0x1 | (1 << 4) | (1 << 12)
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
_fail(f"cannot configure {path} for {baud} baud 8N1")
# Arm them once here too: reads re-arm per call, but the write
# timeout would otherwise stay at the driver's default which
# may be "wait forever" until the first read.
# timeout would otherwise stay at the driver's default - which
# may be "wait forever" - until the first read.
self._deadline(_GAP_MS, 1000)
self.baud = baud
except Error:
@@ -393,7 +393,7 @@ if os.name == "nt":
raise
def set_baud(self, baud):
"""Retune the port on its live handle SetCommState only, so the
"""Retune the port on its live handle - SetCommState only, so the
handle is never reopened and DTR never drops. That matters: the only
caller is mid-session with a loader copy a reset would throw away."""
if baud < 50:
@@ -444,7 +444,7 @@ if os.name == "nt":
def read_available(self, wait):
"""Everything that arrives within `wait` seconds of quiet start."""
# A zero total means *no* timeout to the driver, so never round
# down to it the same trap on the deadline below.
# down to it - the same trap on the deadline below.
self._deadline(_GAP_MS, max(1, round(wait * 1000)))
return self._read(4096)
@@ -467,15 +467,15 @@ Port = WindowsPort if os.name == "nt" else PosixPort
class OneWirePort:
"""The host side of a shared line (--one-wire): an FTDI-style adapter on
a one-wire link reads back every byte it transmits its RX is tied to
a one-wire link reads back every byte it transmits - its RX is tied to
its own TX through the line. Consume that echo at each write and verify
it, which doubles as a wiring check: an echo that never comes is an RX
not on the line, and is reported as itself instead of decoding as a
device reply.
The device's reply may interleave with the echo of a multi-byte write
The device's reply may interleave with the echo of a multi-byte write -
a loader already in session re-prompts after the knock's first byte
while the second is still queued behind that reply so the echo is
while the second is still queued behind that reply - so the echo is
matched byte for byte and anything else arriving in between is device
traffic, held for the next read."""
@@ -491,11 +491,11 @@ class OneWirePort:
"""Put `data` on the line and consume its echo.
`blind` marks the protocol's one multi-byte write with no ack between
its bytes the knock. Aimed at a loader already in session, its first
its bytes - the knock. Aimed at a loader already in session, its first
byte draws a prompt while the second is still going out, and on real
wiring the device's push-pull ack **wins the line** against the host's
1 k series resistor: that second byte is *destroyed, not delayed*, and
its echo never comes. Measured on an ATtiny13A at 57600 the loader
its echo never comes. Measured on an ATtiny13A at 57600 - the loader
answers a single byte perfectly and loses the knock's second every
time. So on a blind write a missing echo is a property of the wiring
rather than a fault in it, and the caller's retry is what deals with
@@ -504,7 +504,7 @@ class OneWirePort:
"""
data = bytes(data)
self._port.write(data)
# The echo arrives at line rate 10 bits a byte plus adapter
# The echo arrives at line rate - 10 bits a byte - plus adapter
# latency; a generous floor keeps slow rates and USB scheduling out
# of the error path.
deadline = time.monotonic() + 10 * len(data) / self._port.baud + 0.5
@@ -513,7 +513,7 @@ class OneWirePort:
# Speculative, so it cannot be read_exact, whose contract is to
# raise: doing that made the diagnosis below unreachable on every
# quiet line and surfaced a bare "timeout: got 0 of 1 bytes" in
# its place the one message this class exists to replace.
# its place - the one message this class exists to replace.
for byte in self._port.read_available(0.02):
if remaining and byte == remaining[0]:
remaining = remaining[1:]
@@ -523,17 +523,17 @@ class OneWirePort:
return
if not blind:
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
f"{len(data)} byte(s) is the adapter's RX tied to the line?")
f"{len(data)} byte(s) - is the adapter's RX tied to the line?")
self.lost_echoes += len(remaining)
# Which loss this is matters, and the count says it. *Some* bytes lost is
# the device's ack winning the line against the host's series resistor
# the device's ack winning the line against the host's series resistor -
# ordinary, and what the retry absorbs. *Every* byte lost is nothing
# coming back at all, which is a line that is not free: an application
# holding the shared pin low (this rig's LED demo ends that way), a
# wedge, or an RX that is not on the line. Same retry either way, but
# blaming an ack that never happened sends the reader to the wrong place.
if len(remaining) == len(data):
verbose(f"one-wire: none of {len(data)} byte(s) echoed the line is not "
verbose(f"one-wire: none of {len(data)} byte(s) echoed - the line is not "
f"coming back. Held low by something? (a pin driven low, a wedge, "
f"or an RX not on the line)")
else:
@@ -567,7 +567,7 @@ class Info:
@classmethod
def from_identity(cls, raw):
"""pureboot 5's reply: the version and the chip signature. The rest of
the geometry is looked up from the signature the loader derived the
the geometry is looked up from the signature - the loader derived the
same facts from its chip database at build time, so nothing is guessed,
it is simply not sent. Reconstructs a block in the older layout, so
every derived attribute below is shared with the loaders that do send
@@ -584,7 +584,7 @@ class Info:
geometry = CHIP_GEOMETRY.get(signature)
if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} this tool has no geometry for it")
raise Error(f"unknown signature {sig} - this tool has no geometry for it")
flash, page, eeprom, patch, _, _ = geometry
base = flash - SLOT
word_flash = flash > 0x10000
@@ -601,7 +601,7 @@ class Info:
if not OLDEST_LOADER <= self.version <= NEWEST_LOADER:
raise Error(
f"pureboot {self.version}: this tool (version {VERSION}) speaks pureboot "
f"{OLDEST_LOADER}..{NEWEST_LOADER} a newer loader needs a newer tool"
f"{OLDEST_LOADER}..{NEWEST_LOADER} - a newer loader needs a newer tool"
)
self.raw = bytes(raw)
self.signature = raw[3:6]
@@ -621,8 +621,8 @@ class Info:
# The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware.
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
# Where SRAM begins, from the signature None only for a chip this
# tool has no geometry row for, which the wire-block path (v14)
# Where SRAM begins, from the signature - None only for a chip this
# tool has no geometry row for, which the wire-block path (v1-4)
# permits where from_identity refuses.
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
self.ram = geometry[4] if geometry else None
@@ -642,7 +642,7 @@ class Info:
)
def lines(self):
"""One fact per line what --info prints."""
"""One fact per line - what --info prints."""
if self.patch_vector:
hand_over = f"host-patched reset vector, trampoline at {self.base - 2:#06x}"
else:
@@ -662,7 +662,7 @@ class Info:
class Loader:
"""A session. Between commands the loader has prompted and awaits a
command byte; every method restores that, except jump() after which the
command byte; every method restores that, except jump() - after which the
target must be knocked afresh."""
def __init__(self, port):
@@ -673,7 +673,7 @@ class Loader:
self.autobaud = False
# The pre-knock drain runs once per port: the bytes it exists for are
# leftovers from before this process opened the port. Re-knocks later
# in the same session must not pay it a fresh activation window is
# in the same session must not pay it - a fresh activation window is
# already burning while they wait.
self._line_drained = False
# The link this session is speaking. It moves when the host follows a
@@ -689,7 +689,7 @@ class Loader:
The timeout is short on purpose: a real answer follows the prompt
within a frame time or two, so half a second is dozens of times the
worst case while a *false* prompt match (a stale byte, reset
worst case - while a *false* prompt match (a stale byte, reset
garbage) makes this read collect noise, and every second spent on it
comes out of the activation window the retry needs."""
head = self.port.read_exact(4, 0.5)
@@ -701,7 +701,7 @@ class Loader:
"""Ask a live session who it is.
The identity command takes no argument and changes nothing, which makes
it the one question whose answer is known in advance so it doubles as
it the one question whose answer is known in advance - so it doubles as
the host's check that the stream is still in step, and as the byte a
lost host resynchronises on."""
self.port.write(b"b")
@@ -711,7 +711,7 @@ class Loader:
def _handshake(self, wait, knock, what):
"""One activation, retried until the loader answers or the window
closes. The identity reply is what proves the loader is listening a
closes. The identity reply is what proves the loader is listening - a
prompt byte alone does not, since one left over from a previous session
can still be in the pipeline while the port opening resets the device
into a fresh window, where a command without its knock is discarded.
@@ -722,7 +722,7 @@ class Loader:
Before the port's first knock ever, the line is drained until quiet: a
prompt from a previous session (`--stay`) can still be in the USB
pipeline when the port opens, where a flush cannot clear what has not
arrived yet and on a board that resets when its port opens, trusting
arrived yet - and on a board that resets when its port opens, trusting
that stale byte would spend the fresh activation window reading noise
from a device that never heard the knock. Once only, and bounded:
later re-knocks in this session face no foreign leftovers, and their
@@ -738,7 +738,7 @@ class Loader:
refusal = None
# The knock is the only write in the protocol with no ack between its
# bytes, so on a shared line it is the only one whose echo may
# legitimately not come back the device's ack collides with it and
# legitimately not come back - the device's ack collides with it and
# wins (OneWirePort.write). Losing a byte here is what the retry below
# is for; raising instead aborted the loop before it ever ran, which on
# real wiring made every reconnect into a live session fail.
@@ -750,8 +750,8 @@ class Loader:
if PROMPT in self.port.read_available(0.4):
# Settle: absorb a real loader's trailing bytes before asking
# for the identity. Bounded by the deadline so a target that
# never falls quiet a board stuck in a reset loop, whose
# garbage carries a stray prompt cannot spin here forever.
# never falls quiet - a board stuck in a reset loop, whose
# garbage carries a stray prompt - cannot spin here forever.
while self.port.read_available(0.3):
if time.monotonic() > deadline:
break
@@ -775,8 +775,8 @@ class Loader:
return self.info
if time.monotonic() > deadline:
if refusal is not None:
raise Error(f"no usable answer the last identity reply failed: {refusal}")
raise Error("no answer reset the device within its activation window")
raise Error(f"no usable answer - the last identity reply failed: {refusal}")
raise Error("no answer - reset the device within its activation window")
def connect(self, wait):
"""Knock 'p' then 'b' and read the identity."""
@@ -784,8 +784,8 @@ class Loader:
def connect_autobaud(self, wait):
"""The autobaud handshake. In place of the p+b knock the host sends the
calibration pulse one seven-bit-time low pulse at the host's chosen
baud, which the loader times into its per-bit unit then a single 'p'
calibration pulse - one seven-bit-time low pulse at the host's chosen
baud, which the loader times into its per-bit unit - then a single 'p'
the loader decodes at the rate it just measured. A lost pulse, or a
knock landing while the loader is mid-frame, simply fails to answer and
leaves the measurement loop waiting for the next pulse, so the retry in
@@ -808,7 +808,7 @@ class Loader:
"""Send a sealed header and take the loader's verdict on it.
Returning normally means the loader has accepted the command and not
yet done it which is the whole point of the verdict, and why the
yet done it - which is the whole point of the verdict, and why the
payload of a fill or a write burst is sent only after this returns."""
head = bytes((op, selector(space, address), address & 0xFF, (address >> 8) & 0xFF, count & 0xFF))
seal = SEAL
@@ -819,7 +819,7 @@ class Loader:
if answer == NAK:
raise Error(
f"the loader refused the command (opcode {op:#04x}, {address:#06x}): the "
f"seal did not match, so nothing was done the link mangled the header"
f"seal did not match, so nothing was done - the link mangled the header"
)
if answer != PROMPT:
raise Error(f"expected a verdict on the seal, got {answer.hex()}")
@@ -864,7 +864,7 @@ class Loader:
return data
def _write_space(self, space, address, data, progress=None):
"""A run into any space. Each byte is acked as its write begins an
"""A run into any space. Each byte is acked as its write begins - an
EEPROM cell and an SPM operation both need that pacing, and the ack is
what the loader sends in place of a completion status."""
offset = 0
@@ -885,7 +885,7 @@ class Loader:
offset += len(chunk)
def spm(self, operation, address):
"""One SPM operation at a flash address the erase, write and RWW
"""One SPM operation at a flash address - the erase, write and RWW
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here.
From pureboot 9 the operation rides the header's count field instead of
@@ -935,13 +935,13 @@ class Loader:
if self.unified:
# The fill loads the page buffer and stops there; the erase and the
# write are host-issued SPM operations. Only a chip with a boot
# section has RWW to re-enable on the others bit 4 of SPMCSR
# section has RWW to re-enable - on the others bit 4 of SPMCSR
# means something else entirely, so it must not be sent.
if self.sealed:
# The page goes out only once the header is accepted. It is the
# protocol's one unacked burst, so a header refused after the
# host had already started sending it would leave the page
# being read as commands which is exactly what the verdict
# being read as commands - which is exactly what the verdict
# is placed here to prevent.
self._header(OP_FILL, SP_FLASH, address, len(data) & 0xFF)
self.port.write(data)
@@ -985,7 +985,7 @@ class Loader:
"""The device acks, then execution continues at the word address.
From v7 the jump rides the unified decode, so it carries a selector
byte the loader ignores; older loaders take the bare address. From v9
the ack is the verdict on its seal a jump to a mangled address is a
the ack is the verdict on its seal - a jump to a mangled address is a
jump into arbitrary code, so it is sealed like everything else."""
if self.sealed:
self._header(OP_JUMP, 0, word_address, 0)
@@ -997,7 +997,7 @@ class Loader:
self._expect_prompt()
def enter_copy(self, byte_address, wait, link=None):
"""Jump into the loader copy at `byte_address` and knock it a slot
"""Jump into the loader copy at `byte_address` and knock it - a slot
base is that copy's entry stub, so it can only land there.
`link` is that copy's own `(baud, autobaud)`, for when it is not this
@@ -1005,7 +1005,7 @@ class Loader:
backend it was built for; the host has to be told which, because 512
bytes of position-independent code carry no header to read it from.
Retuning goes through the open port, so no DTR pulse resets the copy that
is now running and the session keeps the new link afterwards, since
is now running - and the session keeps the new link afterwards, since
every later jump lands in the same image.
"""
baud, autobaud = link if link is not None else (self.baud, self.autobaud)
@@ -1028,7 +1028,7 @@ class Loader:
raise Error(
f"the copy at {byte_address:#06x} did not answer on this session's "
f"link ({baud} Bd, {'autobaud' if autobaud else 'fixed baud'}). An "
f"image built for another baud or backend speaks that one instead "
f"image built for another baud or backend speaks that one instead - "
f"say which with --staged-baud / --staged-autobaud"
) from unheard
@@ -1099,13 +1099,13 @@ def plan_flash(image, info):
word0 = final[0] | (final[1] << 8)
if word0 & 0xF000 != 0xC000:
raise Error(
"the image's reset vector is not an rjmp pureboot's vector "
"the image's reset vector is not an rjmp - pureboot's vector "
"surgery cannot re-home it (crt-less entry at address 0?)"
)
entry = rjmp_target(0, word0, flash_words)
if entry >= info.base // 2:
raise Error(
"the image's reset vector already targets the loader this "
"the image's reset vector already targets the loader - this "
"is a read-back of a patched image; flash the original"
)
trampoline_word = (info.base - 2) // 2
@@ -1129,7 +1129,7 @@ def covered(pages, info, skip_blank):
A patched vector puts page 0 first and the trampoline page second, so from
the first write on a reset lands in the loader and its fall-through on the
application entry every interruption point recoverable. A hardware boot
application entry - every interruption point recoverable. A hardware boot
section re-vectors reset regardless; page 0 goes last there, which
maximizes what an interrupted image retains."""
trampoline_page = info.base - info.page if info.patch_vector else None
@@ -1150,7 +1150,7 @@ def covered(pages, info, skip_blank):
# which byte of the 'F' reply (low, lock, extended, high) carries BOOTSZ/
# BOOTRST, and the BOOTSZ->words ladder. A die revision shares its base
# signature, so one row covers it. The m48s have no boot section and no
# row their info block says patch-vector and this table is never
# row - their info block says patch-vector and this table is never
# consulted. Sources: Atmel-2486/2466/2503 (HIGH fuse), Atmel-2545/8271/
# DS40002065 (x8: EXTENDED, except the m328s' HIGH), Atmel-8272/8011/2593/
# 42719 (x4: HIGH).
@@ -1186,7 +1186,7 @@ def mega_boot(info, fuse_bytes):
to its start. Returns (bootrst_programmed, boot_section_start_byte)."""
entry = BOOT_FUSE.get(bytes(info.signature[1:3]))
if entry is None:
raise Error(f"unknown mega signature {info.signature.hex()} no boot fuse map")
raise Error(f"unknown mega signature {info.signature.hex()} - no boot fuse map")
which, ladder = entry
fuse = fuse_bytes[which]
words = ladder[(fuse >> 1) & 0x03]
@@ -1200,7 +1200,7 @@ def image_info(image):
"""What a pureboot binary says about itself, or None.
An update image is a bare slot: nothing about it names the chip it was
built for, and installing a foreign one bricks the target so every
built for, and installing a foreign one bricks the target - so every
loader carries a stamp for this. Through pureboot 4 the stamp is the
12-byte info block the device also serves; pureboot 5 serves its identity
from immediates and carries a 6-byte stamp (magic, version, signature)
@@ -1234,8 +1234,8 @@ def loader_image(path):
def staging_content(image, info):
"""The staging slot's content: the image, padding, and where the
hand-over jumps through the word below the resident that word, which for
"""The staging slot's content: the image, padding, and - where the
hand-over jumps through the word below the resident - that word, which for
a staging copy is its own last one. Composed as an rjmp to the resident,
so an abandoned staging copy still falls through into a loader."""
budget = SLOT - 2 if info.patch_vector else SLOT
@@ -1253,7 +1253,7 @@ def update_preflight(image, info, fuse_bytes):
embedded = image_info(image)
if embedded is None:
raise Error(
"no pureboot info block in the update image not a pureboot binary, "
"no pureboot info block in the update image - not a pureboot binary, "
f"or a version this tool ({VERSION}) does not know"
)
if embedded.raw[3:] != info.raw[3:]:
@@ -1264,13 +1264,13 @@ def update_preflight(image, info, fuse_bytes):
warnings = []
if not info.patch_vector:
if fuse_bytes is None:
raise Error("a loader update on this chip needs its fuses unreadable? pass --assume-fuses")
raise Error("a loader update on this chip needs its fuses - unreadable? pass --assume-fuses")
bootrst, bls_start = mega_boot(info, fuse_bytes)
if info.stage < bls_start:
raise Error(
f"cannot self-update: the staging slot {info.stage:#06x} lies below the "
f"boot section ({bls_start:#06x}) where SPM is disabled "
f" a boot section of at least two slots ({2 * SLOT} B, BOOTSZ) is "
f" - a boot section of at least two slots ({2 * SLOT} B, BOOTSZ) is "
f"required, and only an external programmer can change fuses"
)
if not bootrst:
@@ -1294,7 +1294,7 @@ def update_preflight(image, info, fuse_bytes):
class UpdateState:
"""The host-side memory of an update in flight: what the staging slot
held (and page 0, where the update repoints it). Losing this file after
the staging slot was overwritten loses those saved bytes the update
the staging slot was overwritten loses those saved bytes - the update
still completes, but the staging region can then only be restored by
reflashing the application."""
@@ -1307,7 +1307,7 @@ class UpdateState:
if os.path.exists(self.path):
self.data = json.load(open(self.path))
if bytes.fromhex(self.data["signature"]) != info.signature or self.data["base"] != info.base:
raise Error(f"{self.path} belongs to a different device remove it to start over")
raise Error(f"{self.path} belongs to a different device - remove it to start over")
return
self.data = {
"signature": info.signature.hex(),
@@ -1368,7 +1368,7 @@ def write_differing(loader, base, content, order=None, label=None):
def patch_word0(loader, page0, target_base):
"""Re-aim word 0 at `target_base` the resume insurance around
"""Re-aim word 0 at `target_base` - the resume insurance around
rewriting a loader slot the reset path goes through."""
info = loader.info
patched = bytearray(page0)
@@ -1384,7 +1384,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
so a re-run resumes; the state file carries what the staging slot held.
`staged_link` is the new image's own `(baud, autobaud)` where it differs from
this session's the copies the host enters *are* that image, so they answer
this session's - the copies the host enters *are* that image, so they answer
on its link and not the resident's. Note what this does to the idempotence
above: once the staging copy is installed, the resumable state is only
reachable on the new link, so a re-run has to name it too."""
@@ -1408,7 +1408,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
# A loader already sitting whole in the staging slot IS the staging copy:
# rewriting it in place would be a copy overwriting itself as it runs. Any pureboot
# with the device's info block serves, since a staged copy only streams
# pages. "Whole" needs both checks the block where every image carries
# pages. "Whole" needs both checks - the block where every image carries
# it and matching byte for byte, and the slot unchanged since this update
# began, so a half-written install takes the path below instead.
current = loader.read_flash(info.stage, SLOT)
@@ -1417,7 +1417,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
# before that.
staged_loader = image_info(current)
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
print("staging slot already holds a loader left in place")
print("staging slot already holds a loader - left in place")
else:
# Where the staging slot starts at address 0 (the 1 KB tiny13s) its
# first page carries the reset vector, so it goes last: until then a
@@ -1471,7 +1471,7 @@ def check_walk_region(pages, info, fuse_bytes, force):
raise Error(
f"the image writes {overlap[0]:#06x}.. inside the reset walk region "
f"[{bls_start:#06x}, {info.base:#06x}) (BOOTRST programmed): reset could no "
f"longer reach the loader --force to flash it anyway"
f"longer reach the loader - --force to flash it anyway"
)
@@ -1481,7 +1481,7 @@ def check_walk_region(pages, info, fuse_bytes, force):
def op_erase_flash(loader):
"""0xff over the application area, descending where the reset vector is
patched: page 0 goes last, so an interrupted erase still resets into the
loader and once it is gone, the erased walk reaches it anyway."""
loader - and once it is gone, the erased walk reaches it anyway."""
blank = bytes([0xFF] * loader.info.page)
addresses = range(0, loader.info.base, loader.info.page)
with Progress("erase", len(addresses)) as bar:
@@ -1537,7 +1537,7 @@ def verify_pages(loader, pages, repair=False):
raise Error(detail)
if retry == RETRIES:
raise Error(f"{detail} (still wrong after {RETRIES} retries)")
verbose(f"{detail} rewriting page {address:#06x} (retry {retry + 1})")
verbose(f"{detail} - rewriting page {address:#06x} (retry {retry + 1})")
loader.write_page(address, pages[address])
repaired += 1
bar.step()
@@ -1604,7 +1604,7 @@ def _require_unified(loader, what):
def _peek_spec(spec):
"""ADDR[:N] addresses and counts in any Python integer base."""
"""ADDR[:N] - addresses and counts in any Python integer base."""
address, _, count = spec.partition(":")
return int(address, 0), int(count, 0) if count else 1
@@ -1638,13 +1638,13 @@ def op_fuses(loader):
print(f" lock 0x{lock:02x}")
fuse_bytes = bytes((low, lock, extended, high))
# On a boot-sectioned mega the BOOTSZ/BOOTRST decode is the fuse fact the
# loader's whole deployment hangs on say it in words.
# loader's whole deployment hangs on - say it in words.
if not loader.info.patch_vector:
try:
bootrst, bls_start = mega_boot(loader.info, fuse_bytes)
reset = "reset enters it" if bootrst else "reset boots the application"
print(f" boot section at {bls_start:#06x} ({loader.info.flash_size - bls_start} B), "
f"BOOTRST {'programmed' if bootrst else 'unprogrammed'} {reset}")
f"BOOTRST {'programmed' if bootrst else 'unprogrammed'} - {reset}")
except Error:
pass # unknown signature: the raw bytes above still stand
return fuse_bytes
@@ -1652,7 +1652,7 @@ def op_fuses(loader):
def scan_ratios():
"""The probe walk, in percent of the built rate: the built rate itself
first, then ±10 % in 2 % steps nearest-first a drifted oscillator near
first, then +/-10 % in 2 % steps nearest-first - a drifted oscillator near
its trim is the common case, and each probe costs a reset."""
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
@@ -1664,9 +1664,9 @@ def scan_rate(baud, pct):
def scan_report(baud, pct, version, clock=None):
"""The findings, one per line: the found rate is the session workaround,
its ratio to the built rate is the oscillator's offset, and the fixes are
the OSCCAL bake (1 %/step, opposing the drift) or the autobaud build."""
the OSCCAL bake (~1 %/step, opposing the drift) or the autobaud build."""
rate = scan_rate(baud, pct)
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) pureboot {version}",
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) - pureboot {version}",
f" session --baud {rate}"]
if clock:
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
@@ -1675,12 +1675,12 @@ def scan_report(baud, pct, version, clock=None):
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
"or the autobaud build")
else:
lines.append(" fix none the built rate answers; check the earlier wiring instead")
lines.append(" fix none - the built rate answers; check the earlier wiring instead")
return lines
def op_scan(port_path, baud, wait, clock=None, one_wire=False):
"""A fixed-baud loader whose oscillator drifted still answers at the
"""A fixed-baud loader whose oscillator drifted still answers - at the
drifted ratio, since its rate scales with its clock. One probe per
activation window, and with an application resident the window opens
exactly once per reset, so each probe announces itself and expects a
@@ -1688,11 +1688,11 @@ def op_scan(port_path, baud, wait, clock=None, one_wire=False):
everything else; undiscarded they would answer every rate."""
for pct in scan_ratios():
rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) reset the target", flush=True)
print(f"scan: {rate} Bd ({pct:+d} %) - reset the target", flush=True)
try:
port = Port(port_path, rate)
except Error as unmakeable:
print(f"scan: {rate} Bd skipped {unmakeable}")
print(f"scan: {rate} Bd skipped - {unmakeable}")
continue
if one_wire:
port = OneWirePort(port)
@@ -1705,7 +1705,7 @@ def op_scan(port_path, baud, wait, clock=None, one_wire=False):
for line in scan_report(baud, pct, info.version, clock):
print(line)
return
raise Error("no answer within ±10 % of the built rate check the wiring, or deploy the "
raise Error("no answer within +/-10 % of the built rate - check the wiring, or deploy the "
"autobaud build, which has no rate to miss (README.md)")
@@ -1728,10 +1728,10 @@ def main():
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--scan", action="store_true",
help="walk ±10%% around --baud for a fixed-baud loader gone silent one "
help="walk +/-10%% around --baud for a fixed-baud loader gone silent - one "
"reset per probe, standalone (README.md: deployment)")
parser.add_argument("--clock", type=int, metavar="HZ",
help="the clock the loader was built for lets --scan and an autobaud "
help="the clock the loader was built for - lets --scan and an autobaud "
"--info state drift in absolute terms")
parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
@@ -1747,7 +1747,7 @@ def main():
help="whether that image is an autobaud build, where it differs "
"from --autobaud")
parser.add_argument("--assume-fuses", metavar="HEX8", help="fuse bytes low,lock,ext,high as 8 hex digits "
"(overrides reading them e.g. under a simulator that cannot)")
"(overrides reading them - e.g. under a simulator that cannot)")
parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash")
parser.add_argument("--flash", metavar="FILE", help="program an application (bin or ihex)")
parser.add_argument("--no-verify", action="store_true", help="skip read-back after writes")
@@ -1758,8 +1758,8 @@ def main():
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, "
"I/O) pureboot 5 and later")
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space "
"I/O) - pureboot 5 and later")
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space - "
"pureboot 5 and later")
parser.add_argument("--force", action="store_true", help="override refusable safety checks")
parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
@@ -1800,7 +1800,7 @@ def main():
if args.autobaud and info.unit_home is not None:
# The measured bit period, from wherever this version keeps it
# (unit_home); decoded and times the rate this session drives,
# that is the true clock the number to hold an OSCCAL bake
# that is the true clock - the number to hold an OSCCAL bake
# or a fixed-baud build against (README.md: deployment). The
# autobaud identity path refuses unknown signatures, so the
# home is always known here; the guard states that dependency.
@@ -1808,7 +1808,7 @@ def main():
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
print(f" measured {clock} Hz ({cycles} cycles/bit x {args.baud} Bd{offset})")
fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader)