diff --git a/.clangd b/.clangd index af31006..7627880 100644 --- a/.clangd +++ b/.clangd @@ -25,6 +25,7 @@ CompileFlags: - -fno-split-wide-types - -fno-tree-ter - -fno-ivopts + - -fno-move-loop-invariants # 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 # colour reserved for things that do not compile. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0835b9e..5007318 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,26 +84,24 @@ endfunction() # watchdog bail, one-wire half-duplex, config-page activation timeout, password # gate, emergency erase, config/flash/EEPROM read-write. They differ only in 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 -# 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 -# hand-written 500 B oracle occupies. Everything else, from -# bring-up to dispatch, is C++ on libavr. +# 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 +# cost is the C ABI itself. Everything else, bring-up to +# dispatch, is C++ on libavr. # tsb_tricks - no asm at all: the whole-loader register allocation lives in # global register variables (Y walks the page pointer), every # helper is a tiny noinline primitive placed by the # global-register store rules, pages stream straight to -# SPM/EEPROM, and the bring-up is the two reset-non-default -# 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 -# (internal linkage), streaming (no SRAM page buffer): 836 B in -# the 1 KB section. +# SPM/EEPROM. +# tsb_pure - pure idiomatic libavr, one function per command, TU-local +# (internal linkage), streaming (no SRAM page buffer). # tsb_policy - the policy floor: pureboot's rules (no asm, no register -# variables) with every pureboot lesson applied. 638 B in the -# 1 KB section - the measured evidence that the 512 B fit is a -# property of the mechanisms philosophy #5 bans. +# variables) with every pureboot lesson applied, and the +# measured evidence that the 512 B fit is a property of the +# 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( ) function(add_tsb_variant name bytes) diff --git a/oracle/README.md b/oracle/README.md index 7943e61..27459f6 100644 --- a/oracle/README.md +++ b/oracle/README.md @@ -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 -are simultaneously reachable. The port's `tsb_asm` tier meets the same bar at -510 B in the same 512 B section, written in C++ on libavr except the two -routines whose remaining cost is the calling convention itself (the bounded rx -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. +are simultaneously reachable. The port's four tiers reach it from the other +side, and the gradient between them is the cost of the mechanisms each is +allowed: + +| 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 (what the simavr protocol test drives). Baud and geometry differ, code size and diff --git a/pureboot/README.md b/pureboot/README.md index dc725f3..73ebe17 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -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 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` 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). @@ -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 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 register. Reading an I/O register can have side effects (reading UDR clears its flags), which is the caller's business to know. diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index e245cb1..012d071 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -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 # 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 +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. # The header is one shape for all of them - opcode, selector, address, count, @@ -898,13 +898,13 @@ class Loader: return 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 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): - self._write_space(SP_RAM, address, data) + def write_data(self, address, data): + self._write_space(SP_DATA, address, data) def read_flash(self, address, count): if self.unified: @@ -1077,11 +1077,30 @@ def load_image(path): # --------------------------------------------------------------- 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): + 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 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) @@ -1612,7 +1631,7 @@ def _peek_spec(spec): def op_peek(loader, spec): _require_unified(loader, "--peek") 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): row = data[offset : offset + 16] 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: raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef") 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}") @@ -1757,10 +1776,10 @@ def main(): 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("--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 - " "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("--stay", action="store_true", help="leave the loader in its session") parser.add_argument("-v", "--verbose", action="store_true", @@ -1804,7 +1823,7 @@ def main(): # 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. - 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 clock = cycles * args.baud offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else "" diff --git a/test/check_pi.py b/test/check_pi.py index 5b9d956..0b15bb4 100644 --- a/test/check_pi.py +++ b/test/check_pi.py @@ -64,6 +64,9 @@ def main(): check=True, capture_output=True) subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True) 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]: 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}: " diff --git a/test/check_size.cmake b/test/check_size.cmake index 8286ea9..a0925cb 100644 --- a/test/check_size.cmake +++ b/test/check_size.cmake @@ -4,6 +4,9 @@ if(NOT _res EQUAL 0) endif() # avr-size line 2 is " ". 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}) if(_text GREATER LIMIT) message(FATAL_ERROR ".text is ${_text} bytes, over the ${LIMIT}-byte boot section") diff --git a/test/pbautobaud.py b/test/pbautobaud.py index 413101e..9cbf885 100644 --- a/test/pbautobaud.py +++ b/test/pbautobaud.py @@ -136,13 +136,13 @@ def main(): # the stack at the top. Reading it back over the same # locked link proves both directions of the new space. probe = bytes(range(0x30, 0x40)) - loader.write_ram(0x0200, probe) - if loader.read_ram(0x0200, len(probe)) != probe: + loader.write_data(0x0200, probe) + if loader.read_data(0x0200, len(probe)) != probe: fail(f"{label}: RAM round-trip mismatch") # The register file and the I/O space share the data # address space on AVR, so the same command reaches a # 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()})") loader.run_application() banner = port.read_exact(3, 5.0) diff --git a/test/pbreloc.py b/test/pbreloc.py index e406154..8ed2770 100644 --- a/test/pbreloc.py +++ b/test/pbreloc.py @@ -72,9 +72,7 @@ def main(): marker = bytes((i * 3) & 0xFF for i in range(page)) loader.write_page(base, marker) if loader.read_flash(base, page) != marker: - loader.write_page(base, marker) - if loader.read_flash(base, page) != marker: - fail("the staged copy could not write the resident slot, even on retry") + fail("the staged copy could not write the resident slot") # Restore the resident image through the staged copy, then 'J' back # into it and prove it lives. diff --git a/test/tsbtest.py b/test/tsbtest.py index 114f62f..46e15d2 100644 --- a/test/tsbtest.py +++ b/test/tsbtest.py @@ -167,6 +167,20 @@ class Host: 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): if not cond: raise AssertionError(msg) @@ -219,6 +233,15 @@ def scenario_emergency(host): 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(): binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3] failures = [] @@ -229,6 +252,7 @@ def main(): ("round-trip", None, scenario_roundtrip), ("password activation", PW_CONFIG, scenario_password), ("emergency erase", PW_CONFIG, scenario_emergency), + ("wrong password", PW_CONFIG, scenario_wrong_password), ] for name, config, fn in groups: print(f"--- {name} ---") diff --git a/tools/check.sh b/tools/check.sh index 3ea86aa..e3a6ae4 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -11,18 +11,27 @@ cd "$(dirname "$0")/.." full=0 [[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; } -CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85 - atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a - atmega48 atmega48a atmega48p atmega48pa - atmega88 atmega88a atmega88p atmega88pa - atmega168 atmega168a atmega168p atmega168pa - atmega328 atmega328p - atmega164a atmega164p atmega164pa - atmega324a atmega324p atmega324pa - atmega644 atmega644a atmega644p atmega644pa - atmega1284 atmega1284p) -REFLECT_SPOT=(attiny13a attiny85 atmega8 atmega16a atmega32a atmega48pa - atmega88 atmega168pa atmega328p atmega164a atmega644p atmega1284) +# The chip lists come from the presets rather than being spelled a second time +# here: a chip added to make_presets.py and missed in a copy of its list would +# be a gate that silently never builds it, which is the one failure mode a gate +# cannot report. tools/make_presets.py is the single source, CMakePresets.json +# is its output, and this reads that. +readarray -t WORKFLOWS < <(python3 -c ' +import json, sys +presets = json.load(open("CMakePresets.json"))["workflowPresets"] +print("\n".join(p["name"] for p in presets))') +if ((${#WORKFLOWS[@]} == 0)); then + echo "no workflow presets in CMakePresets.json - run tools/make_presets.py" >&2 + 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 # naming all of them: stopping at the first failure turns a red - a stale size diff --git a/tools/pbrig.py b/tools/pbrig.py index 69cb225..05e8a5c 100755 --- a/tools/pbrig.py +++ b/tools/pbrig.py @@ -68,15 +68,10 @@ def bitclock_for(hz: int) -> str: against an oscillator that is already known to be off its nominal. """ ceiling = hz // 8 - for candidate in (1000, 4000, 8000, 32000, 125000, 400000): - if candidate <= ceiling: - best = candidate - else: - break - else: - best = 400000 - if ceiling < 1000: + rungs = (1000, 4000, 8000, 32000, 125000, 400000) + if ceiling < rungs[0]: 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" diff --git a/tools/sizes.py b/tools/sizes.py index 1740241..176eb83 100755 --- a/tools/sizes.py +++ b/tools/sizes.py @@ -170,6 +170,12 @@ def cmd_check_readme(args) -> int: if bad: print(f"\n{bad} row(s) stale - update pureboot/README.md") 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" + (f", {skipped} not built" if skipped else "") + ")") return 0 diff --git a/tsb/tsb_asm.cpp b/tsb/tsb_asm.cpp index 05ea81a..eef2dda 100644 --- a/tsb/tsb_asm.cpp +++ b/tsb/tsb_asm.cpp @@ -3,8 +3,9 @@ // // The complete TinySafeBoot feature set - watchdog-reset bail, one-wire // half-duplex UART, a config-page activation timeout, the password gate, -// emergency erase, and config/flash/EEPROM read-write - at 510 bytes in the -// 512-byte BOOTSZ=11 section the hand-written oracle occupies (500 B). The +// emergency erase, and config/flash/EEPROM read-write - inside the 512-byte +// 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 // tsb_tricks.cpp, including the global-register miscompile rules) with exactly // 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 eeprom_end = avr::hw::db.mem.eeprom_size - 1; -// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). -constexpr std::uint8_t act_min = 16; +// Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so +// it follows the clock rather than restating it (rule 41). +constexpr auto act_min = static_cast((16_MHz).hz / 1'000'000); // Post-activation window: the host gets seconds, not milliseconds, mid-session. 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. 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((16_MHz).hz / baud.actual); + // The 16-byte device-info block, streamed out on activation. // clang-format off [[gnu::progmem]] constexpr auto info = std::to_array({ 'T', 'S', 'B', build_date & 0xFF, build_date >> 8, 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 (app_end / 2) & 0xFF, (app_end / 2) >> 8, eeprom_end & 0xFF, eeprom_end >> 8, @@ -141,8 +148,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) if (g_receiving) { g_receiving = 0; hw::ucsr0b::write(hw::ucsr0b::txen0(1)); - for (std::uint8_t guard = 46; guard; --guard) { - } + avr::delay::cycles(); } hw::udr0::write(byte); std::uint8_t status; @@ -390,14 +396,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def } // namespace } // namespace tsb -// Reset lands here: BOOTRST vectors to the boot section base and .vectors is -// laid first, so this is the first instruction executed. No crt ran, so set -// the stack pointer before anything is called. -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(); -} +// 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. +template struct avr::startup::entry; diff --git a/tsb/tsb_policy.cpp b/tsb/tsb_policy.cpp index 64d8d67..81030ff 100644 --- a/tsb/tsb_policy.cpp +++ b/tsb/tsb_policy.cpp @@ -8,9 +8,10 @@ // 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, // locals threaded through noinline primitives, pureboot's codegen flags - -// and the result is 638 bytes: 198 below the idiomatic tier, and 126 above -// the 512 B boot section the tricks/asm tiers reach with the banned -// mechanisms (526/510). This tier exists to keep that number an artifact +// and the result sits below the idiomatic tier and above the 512 B boot +// section the tricks/asm tiers reach with the banned mechanisms +// (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++ - // helpers that hold a cursor across rx()/tx() pay push/pop and argument // 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 eeprom_end = avr::hw::db.mem.eeprom_size - 1; -// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). -constexpr std::uint8_t act_min = 16; +// Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so +// it follows the clock rather than restating it (rule 41). +constexpr auto act_min = static_cast(dev::clock.hz / 1'000'000); // Post-activation window: the host gets seconds, not milliseconds, mid-session. 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 // is laid first and does the one line of crt a crt-less image needs. -template struct avr::startup::entry; +template struct avr::startup::entry; diff --git a/tsb/tsb_pure.cpp b/tsb/tsb_pure.cpp index 6245488..bbfab3e 100644 --- a/tsb/tsb_pure.cpp +++ b/tsb/tsb_pure.cpp @@ -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 // keeps it in progmem with no .data image (there is no crt to copy one). // clang-format off -inline constexpr std::array info_data = { +inline constexpr auto info_data = std::to_array({ 'T', 'S', 'B', build_date & 0xFF, build_date >> 8, 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 (app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words eeprom_end & 0xFF, eeprom_end >> 8, 0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15) -}; +}); // clang-format on using info = avr::flash_table; -// Blocking byte read/write over the one-wire line: read() releases the line to -// the receiver, write() takes it and holds it until the frame is out. +// The lockout-proof floor for the receive window: the oracle's F_CPU/1MHz, so +// it follows the clock rather than restating it. +constexpr auto act_min = static_cast(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() { - return serial.read_blocking(); + std::uint16_t outer = static_cast(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) { serial.write(byte); @@ -270,9 +292,11 @@ gate password_gate() avr::init(); // 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 - // loader out). An idle port times out and boots the application. - __uint24 idle = static_cast<__uint24>(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16; + // 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; the same + // 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; while (knocks < 3) { if (auto byte = serial.read()) { @@ -324,14 +348,6 @@ gate password_gate() } // namespace } // namespace tsb -// Reset lands here: BOOTRST vectors to the boot section base and .vectors is -// laid first, so this is the first instruction executed. No crt ran, so set the -// stack pointer before anything is called. -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(); -} +// 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. +template struct avr::startup::entry; diff --git a/tsb/tsb_tricks.cpp b/tsb/tsb_tricks.cpp index bd90d7c..09ed7c2 100644 --- a/tsb/tsb_tricks.cpp +++ b/tsb/tsb_tricks.cpp @@ -2,9 +2,9 @@ // // The full TinySafeBoot feature set - watchdog bail, one-wire half-duplex, // config-page activation timeout, password gate, emergency erase, and -// config/flash/EEPROM read-write - in pure C++, 526 bytes: 14 over the 512-byte -// boot section the hand-written oracle fits, from 168 over at this tier's first -// floor. The structure mirrors the oracle's: a handful of tiny noinline +// config/flash/EEPROM read-write - in pure C++, a little over the 512-byte boot +// section the hand-written oracle fits (oracle/README.md holds what each tier +// measures). The structure mirrors the oracle's: a handful of tiny noinline // primitives sharing one whole-loader register allocation, expressed as global // 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 eeprom_end = avr::hw::db.mem.eeprom_size - 1; -// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz). -constexpr std::uint8_t act_min = 16; +// Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so +// it follows the clock rather than restating it (rule 41). +constexpr auto act_min = static_cast((16_MHz).hz / 1'000'000); // Post-activation window: the host gets seconds, not milliseconds, mid-session. 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. 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((16_MHz).hz / baud.actual); + // The 16-byte device-info block, streamed out on activation. // clang-format off [[gnu::progmem]] constexpr auto info = std::to_array({ 'T', 'S', 'B', build_date & 0xFF, build_date >> 8, 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 (app_end / 2) & 0xFF, (app_end / 2) >> 8, eeprom_end & 0xFF, eeprom_end >> 8, @@ -134,8 +140,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr) if (g_receiving) { g_receiving = 0; hw::ucsr0b::write(hw::ucsr0b::txen0(1)); - for (std::uint8_t guard = 46; guard; --guard) { - } + avr::delay::cycles(); } hw::udr0::write(byte); std::uint8_t status; @@ -369,14 +374,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def } // namespace } // namespace tsb -// Reset lands here: BOOTRST vectors to the boot section base and .vectors is -// laid first, so this is the first instruction executed. No crt ran, so set -// the stack pointer before anything is called. -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(); -} +// 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. +template struct avr::startup::entry;