From 7716e1e291eddb71cb8e646d731d811d9d7df58b Mon Sep 17 00:00:00 2001 From: BlackMark Date: Tue, 28 Jul 2026 10:12:17 +0200 Subject: [PATCH] =?UTF-8?q?audit:=20the=20port's=20pass=20=E2=80=94=20the?= =?UTF-8?q?=20scan=20that=20could=20not=20walk,=20and=20the=20drift=20a=20?= =?UTF-8?q?generator=20ends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --scan's walk was unwalkable on POSIX: probe rates have no termios B-constant, so the first off-nominal probe raised out of the loop. The port speaks termios2 BOTHER now (red-proven on a pty at 9984 Bd), the probe's open lives inside the walk's error handling, an fd no longer leaks on an unmakeable rate, and the swallowed unknown-signature reply is named at timeout instead of reported as silence. CMakePresets.json's generator emits the submodule toolchain path it had drifted from — a hand edit on a generated file, exactly the class rule 10 exists for — and presets.generated gates the pair from here on (the marker CMake rejects at the presets root stayed out; the check is the guard). The over-slot image guard the tsb runner gained reaches the pureboot runner too; the GPIO bridge's delivery comment states the hardware truth (RXC at the stop bit's sampling point); the hardware suite gains the scan check — the one place the rate physics is real; and the libavr pin advances over both audit rounds. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 23 +++++++----- libavr | 2 +- pureboot/README.md | 49 +++++++++++++++++-------- pureboot/pureboot.py | 81 +++++++++++++++++++++++++++++++----------- test/pureboot_device.c | 17 ++++++--- test/test_scan.py | 18 ++++++++++ tools/make_presets.py | 23 +++++++++--- tools/pbhw.py | 33 +++++++++++++++++ 8 files changed, 193 insertions(+), 53 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e42fa9..ec55f29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,16 +66,18 @@ function(add_image_outputs name) $ $.bin) endfunction() -# The TinySafeBoot protocol reimplemented on libavr in three variants that trade +# The TinySafeBoot protocol reimplemented on libavr in variants that trade # clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects # its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled -# loader has no use for the crt or the vector table. The naked entry sits in -# .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section -# size; the linker section-start and the source's boot_bytes agree. tsb_app is +# loader has no use for the crt or the vector table. The entry sits in +# .vectors, laid first, and runs — avr::startup::entry on the policy tier, +# the experiment tiers' own naked stubs elsewhere, each documented in its +# source. The boot base is FLASHEND+1 minus the section size; the linker +# section-start and the source's boot_bytes agree. tsb_app is # the application's reset vector, pinned to 0 here so the loaders jump to a # named function; --pmem-wrap-around lets relaxation turn that absolute jump # into the wrapped rjmp AVR's modulo-flash PC actually executes. -# All three implement the full oracle feature set (see oracle/README.md): +# All four implement the full oracle feature set (see oracle/README.md): # 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" — see dev/lessons.md. @@ -170,6 +172,11 @@ if(PROJECT_IS_TOP_LEVEL) add_test(NAME pureboot.scan COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) + # CMakePresets.json is generated; hand edits drift the moment the + # generator reruns, so the gate holds the pair together. + add_test(NAME presets.generated + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/make_presets.py + --check) add_test(NAME pureboot.handshake COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) add_test(NAME pureboot.updatelink @@ -509,9 +516,9 @@ if(PROJECT_IS_TOP_LEVEL) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) endif() - # The autobaud variants driven end to end over the software-UART bridge (both - # under review — pureboot/autobaud.md): the host sends the 0xC0 calibration - # pulse, the loader times it, locks, and programs. Run on the near-flash 328P + # The autobaud loader driven end to end over the software-UART bridge: + # the host sends the 0xC0 calibration pulse, the loader times it, locks, + # and programs. Run on the near-flash 328P # and the word-addressed 1284P — the two flash-addressing classes — and each # at two clocks with the one binary, which is the clock-agnostic property # autobaud exists for (test/pbautobaud.py). The fixture application banners diff --git a/libavr b/libavr index 43b1f34..d952c7e 160000 --- a/libavr +++ b/libavr @@ -1 +1 @@ -Subproject commit 43b1f34ed102f11da82491370ea688b1c912e09e +Subproject commit d952c7e3179e8284c5b8c098ad2bc2a6f06be087 diff --git a/pureboot/README.md b/pureboot/README.md index 21c3266..d3b8e7a 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -48,12 +48,14 @@ it carries the calibration machinery and no clock at all. is 510 bytes, since the slot's last word is the trampoline. The tightest fit in the whole space is the 1284s' autobaud build deployed on a -USART's own pins, 506 of its 512 — they alone carry the far-flash machinery -(ELPM reads, RAMPZ page commands), autobaud alone carries the calibration loop, -and a bit-banged link on a USART's pins alone has to release it (below). The -same build on the default pins is 502. The flash bank riding in a transfer's -selector byte keeps even those chips' addressing the same 16-bit form every -other chip uses, which is why they are no longer the outlier they were. +USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone +carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud +alone carries the calibration loop, a bit-banged link on a USART's pins alone +has to release it (below), and the trim adds its one register write. Without +the trim that build is 504; on the default pins, 502. The flash bank riding +in a transfer's selector byte keeps even those chips' addressing the same +16-bit form every other chip uses, which is why they are no longer the +outlier they were. The software UART enables the RX pull-up; TX idles high. All multi-byte wire quantities are little-endian. @@ -123,12 +125,13 @@ window per reset. Measure with an application in place. A downstream project brings its usual libavr setup (the `libavr` target, the chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and states its deployment — an ATmega328P on its shipped 1 MHz fuses with the -software UART on hand-picked pins, say: +software UART on hand-picked pins, say. A submodule pins the loader version +(the tags name them; this repo pins its own libavr the same way), where +FetchContent tracks whatever `main` is: ```cmake -FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main) -FetchContent_MakeAvailable(bootloader) -add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot) +# git submodule add /avr/bootloader.git bootloader — or FetchContent +add_subdirectory(bootloader/pureboot pureboot) pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5) ``` @@ -512,7 +515,12 @@ Per chip preset, `ctest` runs: too. The timeout is a constant and is no axis; - `pureboot_autobaud.size` — the clock-free build, which has no clock or baud axis of its own: one binary per chip has to serve every point the matrix - below sweeps; + below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock + shape and on the tightest image in the space (autobaud on a USART's own + pins), holding both of the trim write's addressing encodings to the budget; +- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM + object and sits exactly at ram_start, where `--info` reads it: wire + contract, not layout accident; - `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product replacing that compact matrix, on **every** chip: every plausible oscillator (the internal ones, the CKDIV8 floor, the plain and the UART crystals) × @@ -535,6 +543,13 @@ Per chip preset, `ctest` runs: recovery properties, the surgery, the staging composition, the boot-fuse decode, the update preflight over synthetic fuse bytes, and the repairing verify against a fake device; +- `pureboot.scan` — `--scan`'s walk and report logic: the probe order, the + rate arithmetic, and the trim advice's direction. A pty carries bytes at + any termios rate, so the rate physics itself belongs to the hardware + harness, and what the wire would arbitrate is pinned as logic; +- `presets.generated` — CMakePresets.json matches its generator + (`tools/make_presets.py --check`), so a hand edit or a generator change + cannot drift the pair apart; - `pureboot.protocol` — end to end against a simavr device (`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's @@ -567,15 +582,21 @@ Per chip preset, `ctest` runs: - `pureboot.update` — the full `--update-loader` flow, then every power-fail phase: the device is killed mid-write, restarted from its flash dump, and a re-run must complete the update with the application intact; +- `pureboot.osccal` (328P, t85) — a loader built with the `OSCCAL` axis holds + the trim register at the built byte from its first prompt, observed through + the wire on one chip per addressing encoding (`sts` and low-I/O `out`); - `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty bridge: the calibration handshake, a flash + EEPROM + fuse round trip against the simulator's own memory, a data-space round trip, the hand-over — then the same binary again at double the clock, which is the property the backend - exists for. A lone calibration pulse with no knock behind it must still let + exists for. The measured clock `--info` prints is asserted against the + simulator's exact clock, inside the unit encoding's own envelope, at both + points. A lone calibration pulse with no knock behind it must still let the application boot, so no wait in activation can be unbounded. -`size`, `pi`, `planner` and `handshake` are host logic and run anywhere; the -simulator-driven targets need simavr and a pty, so they are POSIX-only. +`size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run +anywhere; the simulator-driven targets need simavr and a pty, so they are +POSIX-only. ## Hardware diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 863aed3..6aa28c5 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -20,6 +20,8 @@ if os.name == "nt": import ctypes from ctypes import wintypes else: + import array + import fcntl import select import termios @@ -158,36 +160,60 @@ class Progress: class PosixPort: - """A raw serial port with deadline-based reads, over termios.""" + """A raw serial port with deadline-based reads, over termios. A rate with + no B-constant — the off-nominal probes `--scan` walks — goes through + Linux's termios2 BOTHER; a platform without that ioctl refuses the rate + by name.""" + + # The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed + # word offsets: four flag words, then a line-discipline byte and 19 + # control chars padded to word 9 (include/uapi/asm-generic/termbits.h). + _TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B + _BOTHER, _CBAUD = 0o010000, 0o010017 + _ISPEED, _OSPEED = 9, 10 @staticmethod def _speed(baud): + return getattr(termios, f"B{baud}", None) + + def _set_arbitrary(self, baud): + buf = array.array("i", [0] * (self._OSPEED + 1)) try: - return getattr(termios, f"B{baud}") - except AttributeError: - raise Error(f"unsupported baud rate {baud}") from None + fcntl.ioctl(self.fd, self._TCGETS2, buf, True) + buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER + buf[self._ISPEED] = buf[self._OSPEED] = baud + fcntl.ioctl(self.fd, self._TCSETS2, buf) + except OSError: + raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None + + def _apply_baud(self, attrs, baud): + speed = self._speed(baud) + attrs[4] = attrs[5] = speed if speed is not None else termios.B38400 + termios.tcsetattr(self.fd, termios.TCSANOW, attrs) + if speed is None: + self._set_arbitrary(baud) + self.baud = baud def __init__(self, path, baud): self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY) - attrs = termios.tcgetattr(self.fd) - attrs[0] = 0 # iflag - attrs[1] = 0 # oflag - attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag - attrs[3] = 0 # lflag - attrs[4] = attrs[5] = self._speed(baud) - attrs[6][termios.VMIN] = 0 - attrs[6][termios.VTIME] = 0 - termios.tcsetattr(self.fd, termios.TCSANOW, attrs) - self.baud = baud + try: + attrs = termios.tcgetattr(self.fd) + attrs[0] = 0 # iflag + attrs[1] = 0 # oflag + attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag + attrs[3] = 0 # lflag + attrs[6][termios.VMIN] = 0 + attrs[6][termios.VTIME] = 0 + self._apply_baud(attrs, baud) + except BaseException: + os.close(self.fd) + raise def set_baud(self, baud): """Retune the port without closing it — the fd stays open, so no DTR pulse and no reset. That matters: the only caller is mid-session with a loader copy that a reset would throw away.""" - attrs = termios.tcgetattr(self.fd) - attrs[4] = attrs[5] = self._speed(baud) - termios.tcsetattr(self.fd, termios.TCSANOW, attrs) - self.baud = baud + self._apply_baud(termios.tcgetattr(self.fd), baud) def close(self): os.close(self.fd) @@ -531,6 +557,7 @@ class Loader: drain absorbs whatever they produced.""" deadline = time.monotonic() + wait knocks = 0 + refusal = None while True: self.port.flush_input() self.port.write(knock) @@ -552,12 +579,18 @@ class Loader: except Error as failed: if "pureboot" in str(failed): raise + # A malformed or unknown identity is retried as noise, but + # it was an answer: if nothing better ever arrives, naming + # it beats reporting silence. + refusal = failed self.info = None if self.info is not None: self._expect_prompt() verbose(f"loader answered {what} {knocks}; identity read") return self.info if time.monotonic() > deadline: + if refusal is not None: + raise Error(f"no usable answer — the last identity reply failed: {refusal}") raise Error("no answer — reset the device within its activation window") def connect(self, wait): @@ -1408,7 +1441,11 @@ def op_scan(port_path, baud, wait, clock=None): for pct in scan_ratios(): rate = scan_rate(baud, pct) print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True) - port = Port(port_path, rate) + try: + port = Port(port_path, rate) + except Error as unmakeable: + print(f"scan: {rate} Bd skipped — {unmakeable}") + continue try: info = Loader(port).connect(wait) except Error: @@ -1504,11 +1541,13 @@ def main(): print("device:") for line in info.lines(): print(f" {line}") - if args.autobaud: + if args.autobaud and info.ram is not None: # The whole of the loader's RAM is the measured bit period at # ram_start; decoded and times the rate this session drives, # that is the true clock — the number to hold an OSCCAL bake - # or a fixed-baud build against (README.md: deployment). + # or a fixed-baud build against (README.md: deployment). The + # autobaud identity path refuses unknown signatures, so ram is + # always known here; the guard states that dependency. unit = int.from_bytes(loader.read_ram(info.ram, 2), "little") cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT clock = cycles * args.baud diff --git a/test/pureboot_device.c b/test/pureboot_device.c index eb53862..ff37a37 100644 --- a/test/pureboot_device.c +++ b/test/pureboot_device.c @@ -193,10 +193,11 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); if (++tx_bit < 8) return when + bit_cycles; - /* The byte is not delivered until its stop bit has passed. A real - * receiver cannot answer sooner, and a host that did would put its - * start bit on the wire while the device is still driving the stop - * bit — which the device, transmitting, is not watching for. */ + /* The byte is delivered at the stop bit's sampling point (9.5 bit + * times), where a hardware receiver raises its RXC — not sooner: a + * host answering before the stop bit would put its start bit on the + * wire while the device is still driving, which the device, + * transmitting, is not watching for. */ return when + bit_cycles; } if (write(pty_master, &tx_shift, 1) != 1) @@ -412,6 +413,14 @@ int main(int argc, char *argv[]) fprintf(stderr, "device: cannot read %s\n", argv[1]); return 1; } + // An image past flash end would smash the simulator's heap and turn + // into phantom peripheral behavior (lessons: believe the size gate + // first) — refuse it loudly instead. + if (base + fw.flashsize > avr->flashend + 1) { + fprintf(stderr, "device: %u B at 0x%x runs past flash end 0x%x — image does not fit its slot\n", + (unsigned)fw.flashsize, base, avr->flashend); + return 1; + } memcpy(avr->flash + base, fw.flash, fw.flashsize); } // The boot-sectioned megas enter the loader in hardware (BOOTRST, not diff --git a/test/test_scan.py b/test/test_scan.py index 6f275ac..d8888a0 100644 --- a/test/test_scan.py +++ b/test/test_scan.py @@ -46,6 +46,24 @@ def main(): if "9984000" not in report: fail(f"the absolute clock must scale with the found ratio:\n{report}") + # The walk's rates mostly have no termios B-constant, so the POSIX port + # must set them through termios2 — probed on a pty, which accepts the + # ioctl without caring about the speed. Without this every off-nominal + # probe would abort the walk on the platform --scan matters most on. + if os.name == "posix": + import pty + + master, slave = pty.openpty() + try: + port = pb.Port(os.ttyname(slave), pb.scan_rate(9600, 4)) + port.set_baud(pb.scan_rate(9600, -4)) + port.close() + except pb.Error as error: + fail(f"PosixPort refused an off-nominal probe rate: {error}") + finally: + os.close(master) + os.close(slave) + print("OK") diff --git a/tools/make_presets.py b/tools/make_presets.py index f373a67..0ac3137 100755 --- a/tools/make_presets.py +++ b/tools/make_presets.py @@ -7,11 +7,14 @@ port's TUs compile identically; the sims prove nothing new there) exist for libavr's reflect spot set only, mirroring its rule: the full reflect matrix is never built, one chip per hardware class and pack vintage is. -Run from the repo root: tools/make_presets.py +Run from the repo root: tools/make_presets.py — or with --check, which +verifies the committed file matches this generator and edits nothing (the +ctest entry `presets.generated` runs that, so drift reds the gate). """ import json import os +import sys CHIPS = [ "attiny13", "attiny13a", "attiny25", "attiny45", "attiny85", @@ -41,7 +44,7 @@ def main(): "hidden": True, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", - "toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake", + "toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", @@ -72,6 +75,9 @@ def main(): for chip in REFLECT_SPOT: add(chip, "reflect") + # CMake rejects unknown fields in the presets root, $comment included, so + # the file cannot carry a generated-file marker; the --check ctest is the + # whole of rule 10's guard here. presets = { "version": 8, "configurePresets": configure, @@ -79,12 +85,19 @@ def main(): "testPresets": test, "workflowPresets": workflows, } + rendered = json.dumps(presets, indent=1) + "\n" path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json") + if "--check" in sys.argv[1:]: + current = open(path).read() if os.path.exists(path) else "" + if current != rendered: + print("CMakePresets.json does not match its generator — run tools/make_presets.py") + return 1 + return 0 with open(path, "w") as f: - json.dump(presets, f, indent=1) - f.write("\n") + f.write(rendered) print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}") + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/tools/pbhw.py b/tools/pbhw.py index 09eff88..fdc45fb 100755 --- a/tools/pbhw.py +++ b/tools/pbhw.py @@ -72,6 +72,35 @@ class Suite: except Exception: pass + def scan(self) -> None: + """The --scan walk against real termios and a real oscillator: every + probe rate must open a port (the off-nominal rates exist only through + termios2), and one probe must answer — the nominal on a healthy board, + a neighbor on a drifted one. The rig injects the one reset per probe + the operator supplies in the field; this is the rate physics the + simulator cannot arbitrate (a pty carries bytes at any rate), pinned + on silicon.""" + module = pbrig.load_pureboot(self.rig.d.pureboot) + found = None + for pct in module.scan_ratios(): + rate = module.scan_rate(self.rig.d.baud, pct) + self.rig.reset() + try: + port = module.Port(self.rig.d.port, rate) + except module.Error as error: + self.check("scan opens every probe rate", False, f"{rate} Bd: {error}") + return + try: + module.Loader(port).connect(min(self.rig.d.wait, 6.0)) + found = pct + break + except module.Error: + continue + finally: + port.close() + self.check("scan finds the board's rate", found is not None, + "no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd") + def eeprom(self, info) -> None: size = info.eeprom_size if not size: @@ -161,6 +190,10 @@ class Suite: print("\nthe loader never answered; nothing below can be trusted") return 1 + if not self.rig.d.autobaud: + print("\nscan") + self.scan() + print("\nEEPROM") self.eeprom(info)