7 Commits

Author SHA1 Message Date
BlackMark
ffdface1a8 docs: the watchdog-lockout and EEPROM-wrap gotchas
A sticky WDRF diverts every reset past the activation window (deliberate, so
an app can reboot instantly, at the cost of a possible lockout); an EEPROM
address past E2END wraps onto low EEPROM (the host bounds it, not the loader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:01:33 +02:00
BlackMark
17ee8bd2fe pureboot: the info block reads as a table, one wire byte per line
clang-format bin-packs braced lists to the column limit, collapsing the
'b' reply's byte layout into dense rows. A minimal clang-format-off span
keeps each wire byte on its own line, where the layout is legible against
the protocol. Whitespace only; image byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:19:09 +02:00
BlackMark
c37aeeede9 style: clang-format the W-fix line
Layout only, byte-identical output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:13:51 +02:00
BlackMark
dc4ec4b79e pureboot 4: the loader carries its fixes' identity
The unaligned-W and U2X-hand-over fixes change the loader's observable
on-wire behavior, and the --stay reconnect fix changes the host tool, so
both move: loader version 3 -> 4, tool VERSION 2 -> 3. The protocol and info
block are unchanged, so OLDEST_LOADER stays 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:09:38 +02:00
BlackMark
e807268dba pureboot: the info block is what proves a knock landed
A prompt byte alone does not: 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 the bare command that follows is discarded. Each
attempt is now the whole handshake, retried until the block comes back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:49:37 +02:00
BlackMark
495478771e pureboot: W addresses a page, not a word in it
The in-page bits of a W address are dropped so the fill always walks from
the page base; the wire contract is one page of data for any address
inside it, on both the byte- and the word-addressed path. +2 B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:43:41 +02:00
BlackMark
7fb77d04c3 test: the exhaustive clock x baud x backend size matrix
Every plausible oscillator against every rate it reaches against every
backend, on one chip per size-bearing class, under --full only. The baud
ladder becomes a reachability predicate the enumeration filters on, so an
unreachable point drops out instead of aborting the configure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:32:28 +02:00
8 changed files with 264 additions and 86 deletions

View File

@@ -233,11 +233,11 @@ if(PROJECT_IS_TOP_LEVEL)
# The size matrix: every configuration axis that could move the image # The size matrix: every configuration axis that could move the image
# size — the serial backend (different code), the USART instance # size — the serial backend (different code), the USART instance
# (different registers), the clock (different constants), and the baud # (different registers), the clock (different constants), and the baud
# through the two shapes its bit timing takes — each combination must # through the shapes its bit timing takes — each combination must still
# still fit the chip's slot budget. Pins are size-neutral (port and bit # fit the chip's slot budget. Pins are size-neutral (port and bit are
# are immediate operands) and the timeout is a constant, so neither adds # immediate operands) and the timeout is a constant, so neither adds an
# an axis. The stock build is one point of this matrix and already has # axis. The stock build is one point of this matrix and already has its
# its test. # test.
function(pureboot_size_variant name) function(pureboot_size_variant name)
pureboot_add_loader(${name} ${ARGN}) pureboot_add_loader(${name} ${ARGN})
add_test(NAME ${name}.size add_test(NAME ${name}.size
@@ -245,14 +245,67 @@ if(PROJECT_IS_TOP_LEVEL)
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
endfunction() endfunction()
# One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. Unreachable rates drop
# out here rather than aborting the configure.
function(pureboot_matrix_point hz baud link)
if(link STREQUAL "software")
pureboot_baud_feasible(${hz} ${baud} 1 _ok)
set(_args SERIAL software)
else()
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
set(_args USART ${link})
endif()
if(_ok)
pureboot_size_variant(pbm_${hz}_${baud}_${link} CLOCK ${hz} BAUD ${baud} ${_args})
endif()
endfunction()
# Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and # Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and
# the crystal the stock build assumes (the tiny13's ladder is its own RC # the crystal the stock build assumes (the tiny13's ladder is its own RC
# menu — it has no crystal option). # menu — it has no crystal option).
if(LIBAVR_MCU MATCHES "^attiny13") if(LIBAVR_MCU MATCHES "^attiny13")
set(_matrix_clocks 1200000 4800000 9600000) set(_matrix_clocks 1200000 4800000 9600000)
set(_full_clocks 128000 600000 1200000 4800000 9600000)
else() else()
set(_matrix_clocks 1000000 8000000 16000000) set(_matrix_clocks 1000000 8000000 16000000)
set(_full_clocks 128000 1000000 1843200 2000000 3686400 4000000 7372800 8000000
11059200 12000000 14745600 16000000 18432000 20000000)
endif() endif()
# The exhaustive cross product: every clock a deployment plausibly runs
# — the internal oscillators, the shipped CKDIV8 floor, the plain
# crystals and the UART crystals — against every rate, against every
# backend. Beyond the ladder the list carries the slow rates a
# sub-megahertz oscillator is left with, which no ladder rate reaches
# (16000 Bd is the only rate the 128 kHz oscillator holds exactly); at
# the fast clocks those same rates also select the software UART's
# 16-bit _delay_loop_2 bit spin (two words more setup at each of its five
# sites), the largest image the space produces and a shape the ladder
# default — always the *fastest* rate a clock reaches — never picks.
#
# Bounded to one chip per size-bearing class: flash addressing (the
# word-addressed 1284), hand-over shape (the patched vector on the tinies
# and m48s), page size, and USART inventory. Everything else in the image
# is chip-independent code, so a further chip buys builds and no
# coverage; every chip outside the set carries the compact matrix.
get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
list(APPEND _full_bauds 16000 4800 2400 1200)
set(_matrix_spot attiny13a attiny85 atmega48pa atmega8a atmega168pa
atmega328p atmega164a atmega644a atmega1284p)
if(DEFINED ENV{PUREBOOT_FULL_MATRIX} AND LIBAVR_MCU IN_LIST _matrix_spot)
foreach(_matrix_hz IN LISTS _full_clocks)
foreach(_matrix_baud IN LISTS _full_bauds)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software)
if(PUREBOOT_HAS_USART)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0)
endif()
if(PUREBOOT_HAS_USART1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1)
endif()
endforeach()
endforeach()
else()
foreach(_matrix_hz IN LISTS _matrix_clocks) foreach(_matrix_hz IN LISTS _matrix_clocks)
math(EXPR _matrix_khz "${_matrix_hz} / 1000") math(EXPR _matrix_khz "${_matrix_hz} / 1000")
if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz) if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz)
@@ -265,22 +318,13 @@ if(PROJECT_IS_TOP_LEVEL)
pureboot_size_variant(pureboot_usart1_${_matrix_khz}k CLOCK ${_matrix_hz} USART 1) pureboot_size_variant(pureboot_usart1_${_matrix_khz}k CLOCK ${_matrix_hz} USART 1)
endif() endif()
endforeach() endforeach()
list(GET _matrix_clocks -1 _matrix_top_hz)
pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software)
endif()
if(PUREBOOT_HAS_USART1) if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_usart1 USART 1) pureboot_size_variant(pureboot_usart1 USART 1)
endif() endif()
# The baud axis, whose one size-bearing shape the ladder never picks: a
# software UART spins out each bit with _delay_loop_1 while the count
# fits a byte and with the 16-bit _delay_loop_2 beyond it, two words more
# setup at every one of its five sites — the largest image the
# configuration space produces. The ladder default takes the *fastest*
# rate a clock reaches, which always lands in the byte, so the wide form
# needs the slowest ladder rate against the fastest clock to appear. The
# hardware USART has no such shape: its baud is a divisor constant, and
# the ladder's U2X solutions are already its larger form.
list(GET _matrix_clocks -1 _matrix_top_hz)
pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software)
# One configured deployment end to end — a real board's shape rather # One configured deployment end to end — a real board's shape rather
# than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses,
# the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder

View File

@@ -146,16 +146,19 @@ set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE) set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE) set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
# The fastest standard rate the clock reaches within 2.5 %, by the same # The rates a default may pick, fastest first.
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a default set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
# Whether <baud> is reachable from <clock> within 2.5 %, by the same
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a build
# never trips the compile-time error it is checked against. A software build # never trips the compile-time error it is checked against. A software build
# also needs the polled receiver's 100-cycles-a-bit floor: at low clocks the # also needs the polled receiver's 100-cycles-a-bit floor: at low clocks the
# U2X divisor reaches rates the bit-banged sampler cannot. # U2X divisor reaches rates the bit-banged sampler cannot.
function(pureboot_default_baud clock software outvar) function(pureboot_baud_feasible clock baud software outvar)
foreach(baud 115200 57600 38400 19200 9600) set(${outvar} 0 PARENT_SCOPE)
math(EXPR _cycles "${clock} / ${baud}") math(EXPR _cycles "${clock} / ${baud}")
if(software AND _cycles LESS 100) if(software AND _cycles LESS 100)
continue() return()
endif() endif()
foreach(divisor 8 16) foreach(divisor 8 16)
math(EXPR _step "${divisor} * ${baud}") math(EXPR _step "${divisor} * ${baud}")
@@ -170,12 +173,24 @@ function(pureboot_default_baud clock software outvar)
endif() endif()
math(EXPR _error_bp "${_delta} * 10000 / ${baud}") math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
if(_error_bp LESS_EQUAL 250) if(_error_bp LESS_EQUAL 250)
set(${outvar} 1 PARENT_SCOPE)
return()
endif()
endforeach()
endfunction()
# The fastest ladder rate the clock reaches.
function(pureboot_default_baud clock software outvar)
get_property(_ladder GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
foreach(baud ${_ladder})
pureboot_baud_feasible(${clock} ${baud} ${software} _ok)
if(_ok)
set(${outvar} ${baud} PARENT_SCOPE) set(${outvar} ${baud} PARENT_SCOPE)
return() return()
endif() endif()
endforeach() endforeach()
endforeach() message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 % "
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")
endfunction() endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>] # pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]

View File

@@ -98,8 +98,15 @@ speak to the build. This exact deployment runs the full protocol suite in CI
Reset enters the loader (BOOTRST on the boot-sectioned megas, the patched Reset enters the loader (BOOTRST on the boot-sectioned megas, the patched
reset vector elsewhere) — except a watchdog reset, which hands straight to the reset vector elsewhere) — except a watchdog reset, which hands straight to the
application, since the application owns its watchdog and must clear WDRF application with no activation window, since the application owns its watchdog.
itself. This is deliberate: it lets an application reboot itself instantly rather than
sit through the window. The application must clear WDRF itself (libavr's
`watchdog::disable()` does). **Gotcha:** WDRF is sticky (cleared only by
software, not by a later reset), so an application that watchdog-resets and
never clears it diverts *every* subsequent reset — external ones included —
past the window too, and the loader becomes reachable only through an external
programmer until the flag is cleared. A serial recovery path therefore assumes
the application clears WDRF on its own reset path.
The host then knocks `p` then `b`, each awaited byte under a fresh activation The host then knocks `p` then `b`, each awaited byte under a fresh activation
window; any other byte is discarded and awaited again, so line noise can delay window; any other byte is discarded and awaited again, so line noise can delay
@@ -123,19 +130,26 @@ On chips whose flash exceeds 64 KiB (the 1284s — info-block flag bit 1) the
addresses (the 644s' 64 KiB is exactly the 16-bit byte space). EEPROM addresses (the 644s' 64 KiB is exactly the 16-bit byte space). EEPROM
addresses and all counts are bytes. addresses and all counts are bytes.
The loader trusts the host to keep addresses in range: it does not bound them
against the info block. **Gotcha:** a `w` (or `r`) that runs past `E2END` wraps
— EEAR is only as wide as the array, so an address past the end truncates onto
low EEPROM and the write silently overwrites it. Keeping writes within the
advertised sizes is the host's job (the shipped tool does); the flash budget
is better spent on features than on re-checking a bound the host already holds.
| Cmd | Arguments | Reply | | Cmd | Arguments | Reply |
|---|---|---| |---|---|---|
| `b` | — | the 12-byte info block | | `b` | — | the 12-byte info block |
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) | | `R` | addr16, n8 | n flash bytes (n = 0 means 256) |
| `W` | addr16, then one page of data | — (completion = next prompt) | | `W` | addr16 (any address in the page), then one page of data | — (completion = next prompt) |
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) | | `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun | | `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse | | `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
| `J` | word address (16-bit) | `+`, then execution continues there | | `J` | word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) | | other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`W` streams exactly one page-aligned SPM page (size from the info block) into `W` streams exactly one SPM page (size from the info block) into the buffer,
the buffer, then erases and programs — except pages inside the 512-byte slot then erases and programs — except pages inside the 512-byte slot
the loader is *running* in, which are drained and left alone, so a broken host the loader is *running* in, which are drained and left alone, so a broken host
cannot brick the running copy and a staged copy may rewrite the resident. cannot brick the running copy and a staged copy may rewrite the resident.
@@ -309,6 +323,13 @@ Per chip preset, `ctest` runs:
the configuration space produces, and a shape the ladder default (always the the configuration space produces, and a shape the ladder default (always the
*fastest* rate a clock reaches) never picks. Pins are immediate operands and *fastest* rate a clock reaches) never picks. Pins are immediate operands and
the timeout is a constant: neither is an axis; the timeout is a constant: neither is an axis;
- `pbm_*.size` — under `--full`, the exhaustive cross product replacing that
compact matrix: every plausible oscillator (the internal ones, the CKDIV8
floor, the plain and the UART crystals) × every rate reachable from it ×
every backend, unreachable combinations dropping out rather than aborting
the configure. Bounded to one chip per size-bearing class — flash
addressing, hand-over shape, page size, USART inventory — since everything
else in the image is chip-independent code;
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, the - `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, the
info block within the image's first 256 bytes; info block within the image's first 256 bytes;
- `pureboot.planner` — the host tool's pure logic: programming orders and their - `pureboot.planner` — the host tool's pure logic: programming orders and their

View File

@@ -67,18 +67,29 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
// 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). // a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 3; constexpr std::uint8_t version = 4;
// The 'b' reply, byte for byte (layout: README.md). Flash-resident because // The 'b' reply, byte for byte (layout: README.md). Flash-resident because
// no crt copies a .data image — and flash_table's storage carries the word // no crt copies a .data image — and flash_table's storage carries the word
// alignment 'b' needs to halve the address on the large chips. // alignment 'b' needs to halve the address on the large chips.
// One wire byte per line: this is the reply's layout, not a list.
// clang-format off
inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{ inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{
'P', 'B', version, avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2], 'P',
'B',
version,
avr::hw::db.signature[0],
avr::hw::db.signature[1],
avr::hw::db.signature[2],
static_cast<std::uint8_t>(page), // 0 means 256 static_cast<std::uint8_t>(page), // 0 means 256
wire_base & 0xff, wire_base >> 8, avr::hw::db.mem.eeprom_size & 0xff, avr::hw::db.mem.eeprom_size >> 8, wire_base & 0xff,
wire_base >> 8,
avr::hw::db.mem.eeprom_size & 0xff,
avr::hw::db.mem.eeprom_size >> 8,
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), // patch-vector, word-addressed static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), // patch-vector, word-addressed
}> }>
info_data; info_data;
// clang-format on
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL, // The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL,
// defaulting to the chip's USART0 where it has one. The software receiver is // defaulting to the chip's USART0 where it has one. The software receiver is
@@ -309,18 +320,19 @@ void store_eeprom(std::uint16_t address, std::uint8_t count)
// that write clears the condition and the host's read-back rewrites the page. // that write clears the condition and the host's read-back rewrites the page.
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high) void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
{ {
// One induction either way: a byte-addressed wire address walks the page // The address names a page, so its in-page bits are dropped and the walk
// itself (aligned, so the offset bits wrap to zero), while a word one // starts at the page base — one induction either way: a byte-addressed
// becomes a byte cursor once. The slot index is the wire address's high // wire address walks the page itself (the offset bits wrap back to zero),
// byte — on byte-addressed chips the byte address's, with the low bit // while a word one becomes a byte cursor once. The slot index is the wire
// dropped, since a slot is two of those. // address's high byte — on byte-addressed chips the byte address's, with
// the low bit dropped, since a slot is two of those.
spm::flash_address_t address; spm::flash_address_t address;
std::uint8_t page_high; std::uint8_t page_high;
if constexpr (word_flash) { if constexpr (word_flash) {
// A page is aligned, so it never crosses 64 KiB: RAMPZ is a per-page // A page is aligned, so it never crosses 64 KiB: RAMPZ is a per-page
// constant and the 16-bit Z's low byte is the whole in-page offset. // constant and the 16-bit Z's low byte is the whole in-page offset.
const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15); const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15);
const std::uint16_t z0 = static_cast<std::uint16_t>(wire_address << 1); const std::uint16_t z0 = static_cast<std::uint16_t>(wire_address << 1) & ~static_cast<std::uint16_t>(page - 1);
std::uint16_t z = z0; std::uint16_t z = z0;
do { do {
std::uint8_t low = link::rx(); std::uint8_t low = link::rx();
@@ -331,7 +343,7 @@ void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0; address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0;
page_high = static_cast<std::uint8_t>(wire_address >> 8); page_high = static_cast<std::uint8_t>(wire_address >> 8);
} else { } else {
address = static_cast<spm::flash_address_t>(wire_address); address = static_cast<spm::flash_address_t>(wire_address & ~static_cast<std::uint16_t>(page - 1));
do { do {
std::uint8_t low = link::rx(); std::uint8_t low = link::rx();
std::uint8_t high = link::rx(); std::uint8_t high = link::rx();

View File

@@ -24,13 +24,13 @@ else:
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 2 # this tool's own version — free to drift from a loader's VERSION = 3 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire # The loader versions this tool speaks. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that # protocol, which carries no number of its own, so this window is where that
# map lives: every version so far speaks the same protocol, and one that # map lives: every version so far speaks the same protocol, and one that
# changes it becomes the new floor here. # changes it becomes the new floor here.
OLDEST_LOADER = 1 OLDEST_LOADER = 1
NEWEST_LOADER = 3 NEWEST_LOADER = 4
SLOT = 512 # the loader slot, on every chip SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
@@ -364,26 +364,37 @@ class Loader:
self.info = None self.info = None
def connect(self, wait): def connect(self, wait):
"""Knock until the window answers, then read the info block. Also """Knock until the info block comes back. The block is what proves the
converges into a live session: the knock bytes are ignored there and loader is listening — a prompt byte alone does not, since one left over
the drain absorbs whatever they produced.""" from a previous session can still be in the pipeline while the port
self.port.flush_input() opening resets the device into a fresh activation window, where a
command without its knock is discarded. Each attempt is therefore the
whole handshake, retried until it produces the block or the window
closes. Also converges into a live session: the knock bytes are ignored
there and the drain absorbs whatever they produced."""
deadline = time.monotonic() + wait deadline = time.monotonic() + wait
knocks = 0 knocks = 0
while True: while True:
self.port.flush_input()
self.port.write(b"pb") self.port.write(b"pb")
knocks += 1 knocks += 1
if PROMPT in self.port.read_available(0.4): if PROMPT in self.port.read_available(0.4):
break
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
while self.port.read_available(0.3): while self.port.read_available(0.3):
pass pass
self.port.write(b"b") self.port.write(b"b")
self.info = Info(self.port.read_exact(12, 2.0)) try:
block = self.port.read_exact(12, 2.0)
except Error:
block = b""
# A version the tool cannot speak is the loader's own answer,
# not a failed knock: Info reports it rather than retrying.
if block[0:2] == b"PB":
self.info = Info(block)
self._expect_prompt() self._expect_prompt()
verbose(f"loader answered knock {knocks}; info block read") verbose(f"loader answered knock {knocks}; info block read")
return self.info return self.info
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
def _expect_prompt(self, timeout=2.0): def _expect_prompt(self, timeout=2.0):
byte = self.port.read_exact(1, timeout) byte = self.port.read_exact(1, timeout)

View File

@@ -105,6 +105,17 @@ def main():
# was never told about is a loader it would refuse to speak to. # was never told about is a loader it would refuse to speak to.
if live.version != pb.NEWEST_LOADER: if live.version != pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}") fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}")
# A W addressed inside a page rather than at its base must still
# consume exactly one page and prompt. The loader's own slot is
# the target — it is drained and never programmed — and the
# payload is erased-state bytes, so the probe can disturb neither
# the image nor the page buffer it leaves behind.
wire = wire_base + 1
port.write(bytes((ord("W"), wire & 0xFF, wire >> 8)) + b"\xff" * page)
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("unaligned W did not return to the prompt")
loader.run_application() loader.run_application()
banner = port.read_exact(3, 5.0) banner = port.read_exact(3, 5.0)
if banner != b"APP": if banner != b"APP":

View File

@@ -280,6 +280,68 @@ def main():
if device.writes != pb.RETRIES + 1: if device.writes != pb.RETRIES + 1:
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}") fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
# The knock handshake against a device that is not listening yet — the
# state a port open leaves behind: it resets the chip into a fresh
# activation window while the previous session's prompt is still in
# flight, so the first knock is lost and a prompt arrives anyway.
class FakePort:
"""A loader in its activation window, plus `lost` leading writes the
reset swallows and one stale prompt still on the wire."""
def __init__(self, info_raw, lost=0, stale=b"", active=False):
self.info_raw = info_raw
self.lost = lost
self.inflight = bytearray(stale)
self.rx = bytearray()
self.active = active
self.last = None
def flush_input(self):
self.rx.clear()
def write(self, data):
if self.lost:
self.lost -= 1
return
for byte in bytes(data):
if not self.active:
self.active = self.last == ord("p") and byte == ord("b")
self.last = byte
if self.active:
self.rx += pb.PROMPT
elif byte == ord("b"):
self.rx += self.info_raw + pb.PROMPT
else:
self.rx += pb.PROMPT
def read_available(self, wait):
self.rx = self.inflight + self.rx # the stale prompt lands late
self.inflight.clear()
out, self.rx = bytes(self.rx), bytearray()
return out
def read_exact(self, count, timeout):
if len(self.rx) < count:
raise pb.Error(f"timeout: got {len(self.rx)} of {count} bytes")
out, self.rx = bytes(self.rx[:count]), self.rx[count:]
return out
raw = info_of(pb, 0x7E00, 128, False, 0x8000).raw
for what, port in (
("clean window", FakePort(raw)),
("stale prompt over a lost knock", FakePort(raw, lost=1, stale=pb.PROMPT)),
("live session", FakePort(raw, active=True)),
):
info = pb.Loader(port).connect(5)
if info.raw != raw:
fail(f"connect ({what}) returned {info.raw.hex()}")
# A device that never answers still says so, and a version the tool cannot
# speak is reported as such rather than retried into a timeout.
expect_error("dead device", lambda: pb.Loader(FakePort(raw, lost=99)).connect(0), "no answer")
old = bytes(raw[:2]) + bytes((pb.NEWEST_LOADER + 1,)) + bytes(raw[3:])
expect_error("unspeakable version", lambda: pb.Loader(FakePort(old)).connect(5), "needs a newer tool")
print("test_planner: all planner and policy checks pass") print("test_planner: all planner and policy checks pass")

View File

@@ -2,12 +2,14 @@
# The port's gate: every chip's generated workflow — build, size matrix, and # The port's gate: every chip's generated workflow — build, size matrix, and
# the simulator-driven protocol suites. --full adds the reflect-spot builds # the simulator-driven protocol suites. --full adds the reflect-spot builds
# (libavr's rule: reflect compiles are bounded to its spot set, never the # (libavr's rule: reflect compiles are bounded to its spot set, never the
# full matrix). LIBAVR_ROOT must point at the libavr checkout. # full matrix) and swaps the compact size matrix for the exhaustive
# clock × baud × backend cross product. LIBAVR_ROOT must point at the libavr
# checkout.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
full=0 full=0
[[ "$1" == "--full" ]] && { full=1; shift; } [[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85 CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85
atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a