fix: the four tiers stop describing features they do not have, and three gates start failing

The reading pass over this repo found the tiers disagreeing with themselves,
and every fix here was measured.

**The turn-around guard is real code.** `tsb_asm` and `tsb_tricks` wrote
`for (std::uint8_t guard = 46; guard; --guard) ;` between taking the one-wire
line and the first UDR0 store, under a comment naming it a turn-around guard.
It has no side effect, so GCC deleted it - `sts UCSR0B` went straight to
`sts UDR0` - while the hand-written oracle spends six bytes on that wait and
libavr's own half-duplex spends them through `delay::cycles`. Two of four
tiers described a feature they did not have, which made the size gradient a
comparison between different loaders. `avr::delay::cycles<one bit time>()`
bottoms out in asm and cannot be deleted.

**The entry belongs to the library, and hand-rolling it was expensive.** Three
tiers wrote their own naked `.vectors` stub with `asm volatile("clr
__zero_reg__")` - which design.md fences to libavr and never a port, and which
`tsb_tricks` denied having in its own title line. `avr::startup::entry` also
keeps the body `noinline` for a stated reason: avr-ld must not shrink a
`.vectors` section, so a loader inlined into one forfeits call relaxation
everywhere. `tsb_pure` came out **836 -> 734** bytes for that alone.
`stack::hardware` - the reset value this part guarantees, with the write kept
where a part does not - saved another four, which is what let `tsb_asm` afford
the guard it had been four bytes short of. It fills its 512-byte section
exactly now, with the whole feature set.

**`tsb_pure` had no receive timeout.** Its `rx()` was `read_blocking()`, so a
silent host wedged the password gate and the command loop forever - the one
fix the oracle's own header lists by name, and one the other three tiers
implement. It is bounded now, and 0-on-silence falls through every compare as
theirs does.

Three gates could pass without proving anything. `sizes.py check-readme`
reported a match when every row's lookup missed; `check_size.cmake` used
`CMAKE_MATCH_1` without checking the match succeeded, which is the guard its
sibling `check_unit.cmake` has and it is the size gate; `check_pi.py` raised
IndexError instead of reporting a position-independence break that changed the
image's length. And `check.sh` spelled the 37-chip list a second time beside
make_presets.py, where a chip added to one and missed in the other is a
silently unbuilt chip - it reads the presets now, and produces the same 37 and
12.

tsbtest.py gains the scenario nothing covered: a wrong password byte must
neither activate the loader nor reach the emergency erase behind it. Red-green
on a tier with the refusal removed.

Smaller, all measured or checked: the signature is `hw::db.signature` in every
tier as the page size and EEPROM end beside it already were; `act_min` derives
from the clock; pureboot.py's `rjmp` helpers refuse a part past rjmp's
4096-word reach rather than silently folding an offset (unreachable today, the
ATtiny85 sits exactly on it); the host tool calls space 2 `data` as the wire
and the loader do; `.clangd` strips the fifth GCC-only flag the build passes;
pbrig's bitclock guard reads its own ladder; pbreloc's unexplained retry is
gone, the write being reliable on five runs without it; and the four tier
sizes live in oracle/README.md's table instead of four file headers and a
CMake comment.

`--poke` before `--peek` turned out to be right - pbtest.py round-trips a poke
through the peek behind it - so the parser order and README say so now.

Every chip green, the README size table matching every image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 16:41:50 +02:00
parent be78f38f3f
commit 735ffab7dc
17 changed files with 203 additions and 121 deletions

View File

@@ -25,6 +25,7 @@ CompileFlags:
- -fno-split-wide-types - -fno-split-wide-types
- -fno-tree-ter - -fno-tree-ter
- -fno-ivopts - -fno-ivopts
- -fno-move-loop-invariants
# The build promotes warnings for the compiler that has to be right about # The build promotes warnings for the compiler that has to be right about
# them; in the editor the flag paints a second frontend's opinions in the # them; in the editor the flag paints a second frontend's opinions in the
# colour reserved for things that do not compile. # colour reserved for things that do not compile.

View File

@@ -84,26 +84,24 @@ endfunction()
# watchdog bail, one-wire half-duplex, config-page activation timeout, password # watchdog bail, one-wire half-duplex, config-page activation timeout, password
# gate, emergency erase, config/flash/EEPROM read-write. They differ only in how, # gate, emergency erase, config/flash/EEPROM read-write. They differ only in how,
# and the size gradient is the cost of that "how". # and the size gradient is the cost of that "how".
# tsb_asm - the tricks tier's C++ with exactly two routines in asm (the # tsb_asm - the tricks tier's C++ with exactly two routines in asm: the
# bounded rx and the page-store loop - the two whose remaining # bounded rx and the page-store loop, the two whose remaining
# cost is the C ABI itself): 510 B in the 512 B section the # cost is the C ABI itself. Everything else, bring-up to
# hand-written 500 B oracle occupies. Everything else, from # dispatch, is C++ on libavr.
# bring-up to dispatch, is C++ on libavr.
# tsb_tricks - no asm at all: the whole-loader register allocation lives in # tsb_tricks - no asm at all: the whole-loader register allocation lives in
# global register variables (Y walks the page pointer), every # global register variables (Y walks the page pointer), every
# helper is a tiny noinline primitive placed by the # helper is a tiny noinline primitive placed by the
# global-register store rules, pages stream straight to # global-register store rules, pages stream straight to
# SPM/EEPROM, and the bring-up is the two reset-non-default # SPM/EEPROM.
# registers only. 526 B in the 1 KB section (BOOTSZ=10) - 14
# over the oracle's section, from 168 over at this tier's first
# floor.
# tsb_pure - pure idiomatic libavr, one function per command, TU-local # tsb_pure - pure idiomatic libavr, one function per command, TU-local
# (internal linkage), streaming (no SRAM page buffer): 836 B in # (internal linkage), streaming (no SRAM page buffer).
# the 1 KB section.
# tsb_policy - the policy floor: pureboot's rules (no asm, no register # tsb_policy - the policy floor: pureboot's rules (no asm, no register
# variables) with every pureboot lesson applied. 638 B in the # variables) with every pureboot lesson applied, and the
# 1 KB section - the measured evidence that the 512 B fit is a # measured evidence that the 512 B fit is a property of the
# property of the mechanisms philosophy #5 bans. # mechanisms philosophy #5 bans.
#
# What each measures is oracle/README.md's table, which is the one place the
# four numbers and the hand-written loader's own are compared.
# #
# add_tsb_variant(<name> <boot-section-bytes>) # add_tsb_variant(<name> <boot-section-bytes>)
function(add_tsb_variant name bytes) function(add_tsb_variant name bytes)

View File

@@ -38,11 +38,23 @@ avra -I /usr/share/avra tsb-fixedbaud.asm # after uncommenting .include "m328P
``` ```
**500 bytes with every feature** — the proof that ≤512 B and full feature parity **500 bytes with every feature** — the proof that ≤512 B and full feature parity
are simultaneously reachable. The port's `tsb_asm` tier meets the same bar at are simultaneously reachable. The port's four tiers reach it from the other
510 B in the same 512 B section, written in C++ on libavr except the two side, and the gradient between them is the cost of the mechanisms each is
routines whose remaining cost is the calling convention itself (the bounded rx allowed:
and the page-store loop); `tsb_tricks` needs no assembly at all at 526 B, and
`tsb_pure` stays fully idiomatic at 836 B, both in the 1 KB section. | tier | bytes | section | what it is allowed |
|---|---|---|---|
| oracle | 500 | 512 B | hand-written assembly, the reference |
| `tsb_asm` | 512 | 512 B | C++ on libavr, two routines in asm |
| `tsb_tricks` | 528 | 1 KB | no asm; global register variables |
| `tsb_policy` | 630 | 1 KB | pureboot's rules: no asm, no register variables |
| `tsb_pure` | 776 | 1 KB | idiomatic libavr throughout |
The two routines `tsb_asm` keeps are the ones whose remaining cost is the
calling convention itself: the bounded rx and the page-store loop. It fills
its section exactly, with the same one-bit-time turn-around guard the oracle
spends six bytes on - every tier implements the whole feature set, which is
what makes the column a gradient rather than four different loaders.
The oracle targets 20 MHz / 33333 baud; the port targets 16 MHz / 115200 baud The oracle targets 20 MHz / 33333 baud; the port targets 16 MHz / 115200 baud
(what the simavr protocol test drives). Baud and geometry differ, code size and (what the simavr protocol test drives). Baud and geometry differ, code size and

View File

@@ -604,7 +604,8 @@ to reset gets its reset pulse and opens the activation window by itself.
Operations run in a fixed order within one session: info, fuses, loader Operations run in a fixed order within one session: info, fuses, loader
update, flash (erase / program / read / verify), EEPROM (the same), then update, flash (erase / program / read / verify), EEPROM (the same), then
`--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive `--poke` and `--peek` in that order, so one invocation writes and reads the
write back — then the loader hands over to the application. `--stay` keeps the session alive
instead, and a later invocation reconnects into it. `--flash` and `--eeprom` instead, and a later invocation reconnects into it. `--flash` and `--eeprom`
verify by read-back unless `--no-verify`, and a flash page that reads back verify by read-back unless `--no-verify`, and a flash page that reads back
wrong is rewritten up to three times before the run stops (see the fill above). wrong is rewritten up to three times before the run stops (see the fill above).
@@ -633,7 +634,7 @@ found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it. operation combines with it.
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) — `--poke ADDR:HEX` and `--peek ADDR[:N]` reach the data space (pureboot 5) —
SRAM, and through the same address space the register file and every I/O SRAM, and through the same address space the register file and every I/O
register. Reading an I/O register can have side effects (reading UDR clears its register. Reading an I/O register can have side effects (reading UDR clears its
flags), which is the caller's business to know. flags), which is the caller's business to know.

View File

@@ -55,7 +55,7 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# 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. # host read its own bytes back, which is what --one-wire consumes.
UNIFIED_LOADER = 5 UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 SP_FLASH, SP_EEPROM, SP_DATA, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# pureboot 9 replaces the command letters with bits and seals every command. # 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, # The header is one shape for all of them - opcode, selector, address, count,
@@ -898,13 +898,13 @@ class Loader:
return return
self._write_space(SP_SPM, address, bytes((operation,))) self._write_space(SP_SPM, address, bytes((operation,)))
def read_ram(self, address, count): def read_data(self, address, count):
"""Data space: SRAM, and with it the register file and every I/O """Data space: SRAM, and with it the register file and every I/O
register, which share the address space on AVR. New in pureboot 5.""" register, which share the address space on AVR. New in pureboot 5."""
return self._read_space(SP_RAM, address, count) return self._read_space(SP_DATA, address, count)
def write_ram(self, address, data): def write_data(self, address, data):
self._write_space(SP_RAM, address, data) self._write_space(SP_DATA, address, data)
def read_flash(self, address, count): def read_flash(self, address, count):
if self.unified: if self.unified:
@@ -1077,11 +1077,30 @@ def load_image(path):
# --------------------------------------------------------------- surgery --- # --------------------------------------------------------------- surgery ---
# rjmp's displacement is 12 bits, so a part whose flash is wider than 4096
# words cannot be walked this way: an offset the hardware wraps modulo the
# flash size and one it wraps modulo 4096 are then different addresses, and
# nothing in the opcode says which was meant. Every chip that needs the
# reset-vector surgery is at or under that today - the ATtiny85 sits exactly on
# it - and this is what fails loudly if one is ever added that is not.
RJMP_REACH_WORDS = 1 << 12
def rjmp_wraps_cleanly(flash_words):
return flash_words <= RJMP_REACH_WORDS
def rjmp_target(word_address, opcode, flash_words): def rjmp_target(word_address, opcode, flash_words):
if not rjmp_wraps_cleanly(flash_words):
raise Error(f"{flash_words} words of flash is past rjmp's {RJMP_REACH_WORDS}-word reach - "
f"a relocated reset vector cannot be read back from the opcode alone")
return (word_address + 1 + (opcode & 0x0FFF)) % flash_words return (word_address + 1 + (opcode & 0x0FFF)) % flash_words
def rjmp_to(word_address, destination, flash_words): def rjmp_to(word_address, destination, flash_words):
if not rjmp_wraps_cleanly(flash_words):
raise Error(f"{flash_words} words of flash is past rjmp's {RJMP_REACH_WORDS}-word reach - "
f"a relocated reset vector cannot be spelled as one rjmp")
return 0xC000 | ((destination - word_address - 1) % flash_words % 0x1000) return 0xC000 | ((destination - word_address - 1) % flash_words % 0x1000)
@@ -1612,7 +1631,7 @@ def _peek_spec(spec):
def op_peek(loader, spec): def op_peek(loader, spec):
_require_unified(loader, "--peek") _require_unified(loader, "--peek")
address, count = _peek_spec(spec) address, count = _peek_spec(spec)
data = loader.read_ram(address, count) data = loader.read_data(address, count)
for offset in range(0, len(data), 16): for offset in range(0, len(data), 16):
row = data[offset : offset + 16] row = data[offset : offset + 16]
text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row) text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
@@ -1625,7 +1644,7 @@ def op_poke(loader, spec):
if not payload: if not payload:
raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef") raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef")
data = bytes.fromhex(payload.replace(" ", "")) data = bytes.fromhex(payload.replace(" ", ""))
loader.write_ram(int(address, 0), data) loader.write_data(int(address, 0), data)
print(f"poke: {len(data)} B at {int(address, 0):#06x}") print(f"poke: {len(data)} B at {int(address, 0):#06x}")
@@ -1757,10 +1776,10 @@ def main():
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)") parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM") 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("--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 - " parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space - "
"pureboot 5 and later") "pureboot 5 and later")
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("--force", action="store_true", help="override refusable safety checks") 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") parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
parser.add_argument("-v", "--verbose", action="store_true", parser.add_argument("-v", "--verbose", action="store_true",
@@ -1804,7 +1823,7 @@ def main():
# or a fixed-baud build against (README.md: deployment). The # or a fixed-baud build against (README.md: deployment). The
# autobaud identity path refuses unknown signatures, so the # autobaud identity path refuses unknown signatures, so the
# home is always known here; the guard states that dependency. # home is always known here; the guard states that dependency.
unit = int.from_bytes(loader.read_ram(info.unit_home, 2), "little") unit = int.from_bytes(loader.read_data(info.unit_home, 2), "little")
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud clock = cycles * args.baud
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else "" offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""

View File

@@ -64,6 +64,9 @@ def main():
check=True, capture_output=True) check=True, capture_output=True)
subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True) subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True)
images.append(open(binary, "rb").read()) images.append(open(binary, "rb").read())
if len(images[0]) != len(images[1]):
fail(f"the image is {len(images[0])} B linked at {text_start:#x} and "
f"{len(images[1])} B at {elsewhere:#x} - relaxation followed the address")
if images[0] != images[1]: if images[0] != images[1]:
differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b] differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b]
fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: " fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: "

View File

@@ -4,6 +4,9 @@ if(NOT _res EQUAL 0)
endif() endif()
# avr-size line 2 is "<text> <data> <bss> <dec> <hex> <file>". # avr-size line 2 is "<text> <data> <bss> <dec> <hex> <file>".
string(REGEX MATCH "\n[ \t]*([0-9]+)" _m "${_out}") string(REGEX MATCH "\n[ \t]*([0-9]+)" _m "${_out}")
if(NOT _m)
message(FATAL_ERROR "could not read a .text size out of ${SIZE_TOOL}'s output for ${ELF}:\n${_out}")
endif()
set(_text ${CMAKE_MATCH_1}) set(_text ${CMAKE_MATCH_1})
if(_text GREATER LIMIT) if(_text GREATER LIMIT)
message(FATAL_ERROR ".text is ${_text} bytes, over the ${LIMIT}-byte boot section") message(FATAL_ERROR ".text is ${_text} bytes, over the ${LIMIT}-byte boot section")

View File

@@ -136,13 +136,13 @@ def main():
# the stack at the top. Reading it back over the same # the stack at the top. Reading it back over the same
# locked link proves both directions of the new space. # locked link proves both directions of the new space.
probe = bytes(range(0x30, 0x40)) probe = bytes(range(0x30, 0x40))
loader.write_ram(0x0200, probe) loader.write_data(0x0200, probe)
if loader.read_ram(0x0200, len(probe)) != probe: if loader.read_data(0x0200, len(probe)) != probe:
fail(f"{label}: RAM round-trip mismatch") fail(f"{label}: RAM round-trip mismatch")
# The register file and the I/O space share the data # The register file and the I/O space share the data
# address space on AVR, so the same command reaches a # address space on AVR, so the same command reaches a
# peripheral register. SPMCSR reads back as idle here. # peripheral register. SPMCSR reads back as idle here.
verbose_ram = loader.read_ram(0x0200, 4) verbose_ram = loader.read_data(0x0200, 4)
print(f" {label}: RAM read/write ok ({verbose_ram.hex()})") print(f" {label}: RAM read/write ok ({verbose_ram.hex()})")
loader.run_application() loader.run_application()
banner = port.read_exact(3, 5.0) banner = port.read_exact(3, 5.0)

View File

@@ -72,9 +72,7 @@ def main():
marker = bytes((i * 3) & 0xFF for i in range(page)) marker = bytes((i * 3) & 0xFF for i in range(page))
loader.write_page(base, marker) loader.write_page(base, marker)
if loader.read_flash(base, page) != marker: if loader.read_flash(base, page) != marker:
loader.write_page(base, marker) fail("the staged copy could not write the resident slot")
if loader.read_flash(base, page) != marker:
fail("the staged copy could not write the resident slot, even on retry")
# Restore the resident image through the staged copy, then 'J' back # Restore the resident image through the staged copy, then 'J' back
# into it and prove it lives. # into it and prove it lives.

View File

@@ -167,6 +167,20 @@ class Host:
self._expect(CONFIRM, "emergency mainloop ready") self._expect(CONFIRM, "emergency mainloop ready")
# A wrong password byte hangs the loader, still draining the line. Two
# things must not happen: it must not activate, and it must not fall
# through to the emergency erase - a byte the gate has already refused
# reaching the erase would let a guess wipe the part.
def refuse_password(self, byte):
self.s.reset_input_buffer()
self.s.write(bytes([KNOCK, KNOCK, KNOCK, byte]))
return self.s.read(1)
def say(self, byte):
self.s.write(bytes([byte]))
return self.s.read(1)
def check(cond, msg): def check(cond, msg):
if not cond: if not cond:
raise AssertionError(msg) raise AssertionError(msg)
@@ -219,6 +233,15 @@ def scenario_emergency(host):
check(host.read_eeprom(1) == b"\xff" * PAGE, "EEPROM wiped") check(host.read_eeprom(1) == b"\xff" * PAGE, "EEPROM wiped")
def scenario_wrong_password(host):
"""A wrong password byte neither activates the loader nor opens the
emergency erase behind it - the oracle carries a dedicated fix for the
second, and nothing here exercised either half."""
check(host.refuse_password(PW_BYTES[0] ^ 1) == b"", "a wrong password byte draws no reply")
check(host.say(0x00) == b"", "a 0 byte after it does not request the erase")
check(host.say(CONFIRM) == b"", "and neither does a confirm")
def main(): def main():
binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3] binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3]
failures = [] failures = []
@@ -229,6 +252,7 @@ def main():
("round-trip", None, scenario_roundtrip), ("round-trip", None, scenario_roundtrip),
("password activation", PW_CONFIG, scenario_password), ("password activation", PW_CONFIG, scenario_password),
("emergency erase", PW_CONFIG, scenario_emergency), ("emergency erase", PW_CONFIG, scenario_emergency),
("wrong password", PW_CONFIG, scenario_wrong_password),
] ]
for name, config, fn in groups: for name, config, fn in groups:
print(f"--- {name} ---") print(f"--- {name} ---")

View File

@@ -11,18 +11,27 @@ cd "$(dirname "$0")/.."
full=0 full=0
[[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; } [[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85 # The chip lists come from the presets rather than being spelled a second time
atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a # here: a chip added to make_presets.py and missed in a copy of its list would
atmega48 atmega48a atmega48p atmega48pa # be a gate that silently never builds it, which is the one failure mode a gate
atmega88 atmega88a atmega88p atmega88pa # cannot report. tools/make_presets.py is the single source, CMakePresets.json
atmega168 atmega168a atmega168p atmega168pa # is its output, and this reads that.
atmega328 atmega328p readarray -t WORKFLOWS < <(python3 -c '
atmega164a atmega164p atmega164pa import json, sys
atmega324a atmega324p atmega324pa presets = json.load(open("CMakePresets.json"))["workflowPresets"]
atmega644 atmega644a atmega644p atmega644pa print("\n".join(p["name"] for p in presets))')
atmega1284 atmega1284p) if ((${#WORKFLOWS[@]} == 0)); then
REFLECT_SPOT=(attiny13a attiny85 atmega8 atmega16a atmega32a atmega48pa echo "no workflow presets in CMakePresets.json - run tools/make_presets.py" >&2
atmega88 atmega168pa atmega328p atmega164a atmega644p atmega1284) exit 1
fi
CHIPS=()
REFLECT_SPOT=()
for workflow in "${WORKFLOWS[@]}"; do
case $workflow in
*-generated) CHIPS+=("${workflow%-generated}") ;;
*-reflect) REFLECT_SPOT+=("${workflow%-reflect}") ;;
esac
done
# Every preset runs even after one goes red, and the gate fails at the end # Every preset runs even after one goes red, and the gate fails at the end
# naming all of them: stopping at the first failure turns a red - a stale size # naming all of them: stopping at the first failure turns a red - a stale size

View File

@@ -68,15 +68,10 @@ def bitclock_for(hz: int) -> str:
against an oscillator that is already known to be off its nominal. against an oscillator that is already known to be off its nominal.
""" """
ceiling = hz // 8 ceiling = hz // 8
for candidate in (1000, 4000, 8000, 32000, 125000, 400000): rungs = (1000, 4000, 8000, 32000, 125000, 400000)
if candidate <= ceiling: if ceiling < rungs[0]:
best = candidate
else:
break
else:
best = 400000
if ceiling < 1000:
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely") raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
best = max(rung for rung in rungs if rung <= ceiling)
return f"{best // 1000}kHz" return f"{best // 1000}kHz"

View File

@@ -170,6 +170,12 @@ def cmd_check_readme(args) -> int:
if bad: if bad:
print(f"\n{bad} row(s) stale - update pureboot/README.md") print(f"\n{bad} row(s) stale - update pureboot/README.md")
return 1 return 1
# A row whose target was not built is only skipped, so a chip-name change
# or a build tree that holds nothing would otherwise skip every row and
# report a match over an empty comparison.
if skipped == 2 * len(rows):
sys.exit(f"none of the {len(rows)} README rows matched a built image - "
f"refusing to report a match over nothing")
print(f"README size table matches every built image ({len(rows)} rows" print(f"README size table matches every built image ({len(rows)} rows"
+ (f", {skipped} not built" if skipped else "") + ")") + (f", {skipped} not built" if skipped else "") + ")")
return 0 return 0

View File

@@ -3,8 +3,9 @@
// //
// The complete TinySafeBoot feature set - watchdog-reset bail, one-wire // The complete TinySafeBoot feature set - watchdog-reset bail, one-wire
// half-duplex UART, a config-page activation timeout, the password gate, // half-duplex UART, a config-page activation timeout, the password gate,
// emergency erase, and config/flash/EEPROM read-write - at 510 bytes in the // emergency erase, and config/flash/EEPROM read-write - inside the 512-byte
// 512-byte BOOTSZ=11 section the hand-written oracle occupies (500 B). The // BOOTSZ=11 section the hand-written oracle occupies (oracle/README.md holds
// what each tier measures, in one table rather than four). The
// body is the tricks tier's C++ (same register protocol, same structure - see // body is the tricks tier's C++ (same register protocol, same structure - see
// tsb_tricks.cpp, including the global-register miscompile rules) with exactly // tsb_tricks.cpp, including the global-register miscompile rules) with exactly
// two routines kept in assembly, the two whose remaining cost *is* the calling // two routines kept in assembly, the two whose remaining cost *is* the calling
@@ -63,8 +64,9 @@ constexpr std::uint16_t boot_bytes = 512;
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page; constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). // Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so
constexpr std::uint8_t act_min = 16; // it follows the clock rather than restating it (rule 41).
constexpr auto act_min = static_cast<std::uint8_t>((16_MHz).hz / 1'000'000);
// Post-activation window: the host gets seconds, not milliseconds, mid-session. // Post-activation window: the host gets seconds, not milliseconds, mid-session.
constexpr std::uint8_t comm_window = 200; constexpr std::uint8_t comm_window = 200;
@@ -73,13 +75,18 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd, 8, avr::uart::parity::none); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd, 8, avr::uart::parity::none);
// One bit time on the wire: the turn-around a shared-line peer needs to stop
// driving before this one starts. Derived from the solved rate, so it follows
// the link rather than a count measured against one.
constexpr auto guard_cycles = static_cast<std::uint32_t>((16_MHz).hz / baud.actual);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off
[[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({ [[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({
'T', 'S', 'B', 'T', 'S', 'B',
build_date & 0xFF, build_date >> 8, build_date & 0xFF, build_date >> 8,
0xF3, // status: native-UART fixed-baud lineage 0xF3, // status: native-UART fixed-baud lineage
0x1E, 0x95, 0x0F, // ATmega328P signature avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
page / 2, // page size in words page / 2, // page size in words
(app_end / 2) & 0xFF, (app_end / 2) >> 8, (app_end / 2) & 0xFF, (app_end / 2) >> 8,
eeprom_end & 0xFF, eeprom_end >> 8, eeprom_end & 0xFF, eeprom_end >> 8,
@@ -141,8 +148,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
if (g_receiving) { if (g_receiving) {
g_receiving = 0; g_receiving = 0;
hw::ucsr0b::write(hw::ucsr0b::txen0(1)); hw::ucsr0b::write(hw::ucsr0b::txen0(1));
for (std::uint8_t guard = 46; guard; --guard) { avr::delay::cycles<guard_cycles>();
}
} }
hw::udr0::write(byte); hw::udr0::write(byte);
std::uint8_t status; std::uint8_t status;
@@ -390,14 +396,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
} // namespace } // namespace
} // namespace tsb } // namespace tsb
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is // Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
// laid first, so this is the first instruction executed. No crt ran, so set // is laid first and does the one line of crt a crt-less image needs.
// the stack pointer before anything is called. template struct avr::startup::entry<tsb::run, avr::startup::stack::hardware>;
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
{
SP = RAMEND;
// The one line of crt this loader needs: compiled code assumes
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
asm volatile("clr __zero_reg__");
tsb::run();
}

View File

@@ -8,9 +8,10 @@
// library's half-duplex serial and startup entry, lean bring-up from reset // library's half-duplex serial and startup entry, lean bring-up from reset
// state, one merged send loop over both memories, oracle-shaped loop bounds, // state, one merged send loop over both memories, oracle-shaped loop bounds,
// locals threaded through noinline primitives, pureboot's codegen flags - // locals threaded through noinline primitives, pureboot's codegen flags -
// and the result is 638 bytes: 198 below the idiomatic tier, and 126 above // and the result sits below the idiomatic tier and above the 512 B boot
// the 512 B boot section the tricks/asm tiers reach with the banned // section the tricks/asm tiers reach with the banned mechanisms
// mechanisms (526/510). This tier exists to keep that number an artifact // (oracle/README.md holds all four). This tier exists to keep that gap an
// artifact
// rather than a claim: the gap to 512 is the rent of policy-clean C++ - // rather than a claim: the gap to 512 is the rent of policy-clean C++ -
// helpers that hold a cursor across rx()/tx() pay push/pop and argument // helpers that hold a cursor across rx()/tx() pay push/pop and argument
// threading where a global-register protocol pays nothing, and both // threading where a global-register protocol pays nothing, and both
@@ -68,8 +69,9 @@ constexpr std::uint16_t boot_bytes = 1024;
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page; constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). // Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so
constexpr std::uint8_t act_min = 16; // it follows the clock rather than restating it (rule 41).
constexpr auto act_min = static_cast<std::uint8_t>(dev::clock.hz / 1'000'000);
// Post-activation window: the host gets seconds, not milliseconds, mid-session. // Post-activation window: the host gets seconds, not milliseconds, mid-session.
constexpr std::uint8_t comm_window = 200; constexpr std::uint8_t comm_window = 200;
@@ -324,4 +326,4 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
// Reset lands at the boot section base (BOOTRST): the entry stub in .vectors // Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
// is laid first and does the one line of crt a crt-less image needs. // is laid first and does the one line of crt a crt-less image needs.
template struct avr::startup::entry<tsb::run>; template struct avr::startup::entry<tsb::run, avr::startup::stack::hardware>;

View File

@@ -63,26 +63,48 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// The 16-byte device-info block the host reads on activation. A flash_table // The 16-byte device-info block the host reads on activation. A flash_table
// keeps it in progmem with no .data image (there is no crt to copy one). // keeps it in progmem with no .data image (there is no crt to copy one).
// clang-format off // clang-format off
inline constexpr std::array<std::uint8_t, 16> info_data = { inline constexpr auto info_data = std::to_array<std::uint8_t>({
'T', 'S', 'B', 'T', 'S', 'B',
build_date & 0xFF, build_date >> 8, build_date & 0xFF, build_date >> 8,
0xF3, // status byte (native-UART fixed-baud lineage) 0xF3, // status byte (native-UART fixed-baud lineage)
0x1E, 0x95, 0x0F, // ATmega328P signature avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
page / 2, // page size in words page / 2, // page size in words
(app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words (app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words
eeprom_end & 0xFF, eeprom_end >> 8, eeprom_end & 0xFF, eeprom_end >> 8,
0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15) 0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15)
}; });
// clang-format on // clang-format on
using info = avr::flash_table<info_data>; using info = avr::flash_table<info_data>;
// Blocking byte read/write over the one-wire line: read() releases the line to // The lockout-proof floor for the receive window: the oracle's F_CPU/1MHz, so
// the receiver, write() takes it and holds it until the frame is out. // it follows the clock rather than restating it.
constexpr auto act_min = static_cast<std::uint8_t>(dev::clock.hz / 1'000'000);
// The receive window, pre-floored where it is set. In .noinit: there is no crt
// to clear a .bss image, and run() stores it before the first receive.
[[gnu::section(".noinit")]] std::uint8_t window;
// Bounded byte read over the one-wire line - read() releases the line to the
// receiver - answering 0 on silence. That 0 falls through every compare below:
// not a knock, not a confirm, not a command, so a silent host unwinds the
// loader to the application from anywhere and a mid-session cable pull cannot
// wedge it. The oracle lists that timeout among its own fixes, and a blocking
// read is how a tier loses it.
std::uint8_t rx() std::uint8_t rx()
{ {
return serial.read_blocking(); std::uint16_t outer = static_cast<std::uint16_t>(window) << 8;
do {
std::uint8_t fine = 0;
do {
if (auto byte = serial.read()) {
return *byte;
}
} while (--fine);
} while (--outer);
return 0;
} }
// write() takes the line and holds it until the frame is out.
void tx(std::uint8_t byte) void tx(std::uint8_t byte)
{ {
serial.write(byte); serial.write(byte);
@@ -270,9 +292,11 @@ gate password_gate()
avr::init<serial_t>(); avr::init<serial_t>();
// Activation: the host knocks three '@' inside a window whose length is the // Activation: the host knocks three '@' inside a window whose length is the
// config page's timeout byte (floored so a corrupt page can never lock the // config page's timeout byte, floored so a corrupt page can never lock the
// loader out). An idle port times out and boots the application. // loader out. An idle port times out and boots the application; the same
__uint24 idle = static_cast<__uint24>(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16; // window then bounds every receive of the session.
window = avr::flash_load(flash_ptr(app_end + 2)) | act_min;
__uint24 idle = static_cast<__uint24>(window) << 16;
std::uint8_t knocks = 0; std::uint8_t knocks = 0;
while (knocks < 3) { while (knocks < 3) {
if (auto byte = serial.read()) { if (auto byte = serial.read()) {
@@ -324,14 +348,6 @@ gate password_gate()
} // namespace } // namespace
} // namespace tsb } // namespace tsb
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is // Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
// laid first, so this is the first instruction executed. No crt ran, so set the // is laid first and does the one line of crt a crt-less image needs.
// stack pointer before anything is called. template struct avr::startup::entry<tsb::run, avr::startup::stack::hardware>;
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
{
SP = RAMEND;
// The one line of crt this loader needs: compiled code assumes
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
asm volatile("clr __zero_reg__");
tsb::run();
}

View File

@@ -2,9 +2,9 @@
// //
// The full TinySafeBoot feature set - watchdog bail, one-wire half-duplex, // The full TinySafeBoot feature set - watchdog bail, one-wire half-duplex,
// config-page activation timeout, password gate, emergency erase, and // config-page activation timeout, password gate, emergency erase, and
// config/flash/EEPROM read-write - in pure C++, 526 bytes: 14 over the 512-byte // config/flash/EEPROM read-write - in pure C++, a little over the 512-byte boot
// boot section the hand-written oracle fits, from 168 over at this tier's first // section the hand-written oracle fits (oracle/README.md holds what each tier
// floor. The structure mirrors the oracle's: a handful of tiny noinline // measures). The structure mirrors the oracle's: a handful of tiny noinline
// primitives sharing one whole-loader register allocation, expressed as global // primitives sharing one whole-loader register allocation, expressed as global
// register variables so no helper ever saves, spills, or reloads any of it. // register variables so no helper ever saves, spills, or reloads any of it.
// //
@@ -63,8 +63,9 @@ constexpr std::uint16_t boot_bytes = 1024;
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page; constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1; constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). // Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so
constexpr std::uint8_t act_min = 16; // it follows the clock rather than restating it (rule 41).
constexpr auto act_min = static_cast<std::uint8_t>((16_MHz).hz / 1'000'000);
// Post-activation window: the host gets seconds, not milliseconds, mid-session. // Post-activation window: the host gets seconds, not milliseconds, mid-session.
constexpr std::uint8_t comm_window = 200; constexpr std::uint8_t comm_window = 200;
@@ -73,13 +74,18 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd, 8, avr::uart::parity::none); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd, 8, avr::uart::parity::none);
// One bit time on the wire: the turn-around a shared-line peer needs to stop
// driving before this one starts. Derived from the solved rate, so it follows
// the link rather than a count measured against one.
constexpr auto guard_cycles = static_cast<std::uint32_t>((16_MHz).hz / baud.actual);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off
[[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({ [[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({
'T', 'S', 'B', 'T', 'S', 'B',
build_date & 0xFF, build_date >> 8, build_date & 0xFF, build_date >> 8,
0xF3, // status: native-UART fixed-baud lineage 0xF3, // status: native-UART fixed-baud lineage
0x1E, 0x95, 0x0F, // ATmega328P signature avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
page / 2, // page size in words page / 2, // page size in words
(app_end / 2) & 0xFF, (app_end / 2) >> 8, (app_end / 2) & 0xFF, (app_end / 2) >> 8,
eeprom_end & 0xFF, eeprom_end >> 8, eeprom_end & 0xFF, eeprom_end >> 8,
@@ -134,8 +140,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
if (g_receiving) { if (g_receiving) {
g_receiving = 0; g_receiving = 0;
hw::ucsr0b::write(hw::ucsr0b::txen0(1)); hw::ucsr0b::write(hw::ucsr0b::txen0(1));
for (std::uint8_t guard = 46; guard; --guard) { avr::delay::cycles<guard_cycles>();
}
} }
hw::udr0::write(byte); hw::udr0::write(byte);
std::uint8_t status; std::uint8_t status;
@@ -369,14 +374,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
} // namespace } // namespace
} // namespace tsb } // namespace tsb
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is // Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
// laid first, so this is the first instruction executed. No crt ran, so set // is laid first and does the one line of crt a crt-less image needs.
// the stack pointer before anything is called. template struct avr::startup::entry<tsb::run, avr::startup::stack::hardware>;
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
{
SP = RAMEND;
// The one line of crt this loader needs: compiled code assumes
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
asm volatile("clr __zero_reg__");
tsb::run();
}