From 21336270571ed99485304cea8e38829924143822 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 20 Jul 2026 05:54:15 +0200 Subject: [PATCH] pureboot: host tool and end-to-end protocol tests, all three chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pureboot.py (Python stdlib only): images as raw binary or Intel HEX, flash and EEPROM programming with read-back verify, erase composites, fuse and info readout, activation-timeout configuration, and the tinies' reset-vector surgery — the trampoline word below the loader, page 0 written last. The test spawns a simavr device (pureboot_device.c) — the mega's USART as a pty; on the tinies a cycle-timed GPIO<->pty bridge for the polled software UART plus the NVM module simavr's tiny cores lack (their SPM opcode ioctls into a void and silently does nothing) — and drives it with the real tool: knock from reset (erased-flash walk on the tinies), program and verify both memories, timeout write, session reconnect, an external reset through the patched vector, hand-over, and the fixture application's banner. Results are cross-checked against ground-truth memory dumps and an independent decode of the surgery's rjmp words, red-verified against a sabotaged encoder. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 57 ++- pureboot/README.md | 27 ++ pureboot/__pycache__/pureboot.cpython-313.pyc | Bin 0 -> 28807 bytes pureboot/pureboot.py | 447 ++++++++++++++++++ test/pbapp.cpp | 51 ++ test/pbtest.py | 178 +++++++ test/pureboot_device.c | 309 ++++++++++++ 7 files changed, 1061 insertions(+), 8 deletions(-) create mode 100644 pureboot/__pycache__/pureboot.cpython-313.pyc create mode 100644 pureboot/pureboot.py create mode 100644 test/pbapp.cpp create mode 100644 test/pbtest.py create mode 100644 test/pureboot_device.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 64938c2..9066098 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,16 +25,28 @@ if(PROJECT_IS_TOP_LEVEL) # Python are missing, only the size tests run. find_program(_host_cc NAMES cc gcc) find_package(Python3 COMPONENTS Interpreter) - if(_host_cc AND Python3_FOUND AND LIBAVR_MCU STREQUAL "atmega328p") - set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) + if(_host_cc AND Python3_FOUND) + set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device) execute_process( COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts - -o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c - -lsimavr -lsimavrparts -lelf - RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err) - if(NOT _dev_res EQUAL 0) - message(STATUS "tsb_device not built (${_dev_err}) — protocol tests skipped") - unset(TSB_DEVICE) + -o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c + -lsimavr -lsimavrparts -lelf -lutil + RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err) + if(NOT _pbdev_res EQUAL 0) + message(STATUS "pureboot_device not built (${_pbdev_err}) — protocol tests skipped") + unset(PB_DEVICE) + endif() + if(LIBAVR_MCU STREQUAL "atmega328p") + set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) + execute_process( + COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts + -o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c + -lsimavr -lsimavrparts -lelf + RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err) + if(NOT _dev_res EQUAL 0) + message(STATUS "tsb_device not built (${_dev_err}) — protocol tests skipped") + unset(TSB_DEVICE) + endif() endif() endif() endif() @@ -109,12 +121,24 @@ endif() if(LIBAVR_MCU STREQUAL "attiny13a") set(_pb_flash 1024) set(_pb_wrap "") + set(_pb_page 32) + set(_pb_hz 9600000) + set(_pb_baud 57600) + set(_pb_eeprom 64) elseif(LIBAVR_MCU STREQUAL "attiny85") set(_pb_flash 8192) set(_pb_wrap -Wl,--pmem-wrap-around=8k) + set(_pb_page 64) + set(_pb_hz 8000000) + set(_pb_baud 57600) + set(_pb_eeprom 512) else() set(_pb_flash 32768) set(_pb_wrap -Wl,--pmem-wrap-around=32k) + set(_pb_page 128) + set(_pb_hz 16000000) + set(_pb_baud 115200) + set(_pb_eeprom 1024) endif() math(EXPR _pb_base "${_pb_flash} - 512") math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL) @@ -133,4 +157,21 @@ if(PROJECT_IS_TOP_LEVEL) add_test(NAME pureboot.size COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ -DLIMIT=512 -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) + + # The protocol test flashes this fixture through the loader with the real + # host tool and expects its banner after the hand-over; a normally linked + # application whose reset vector is what the tinies' surgery re-homes. + if(DEFINED PB_DEVICE) + add_executable(pbapp test/pbapp.cpp) + target_link_libraries(pbapp PRIVATE libavr) + add_custom_command(TARGET pbapp POST_BUILD + COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) + add_test(NAME pureboot.protocol + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py + ${PB_DEVICE} $ ${LIBAVR_MCU} ${_pb_hz} ${_pb_base_hex} + ${_pb_page} ${_pb_baud} ${_pb_eeprom} $.bin + ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbtest-work) + set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) + endif() endif() diff --git a/pureboot/README.md b/pureboot/README.md index 4a74cc5..b88fdea 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -94,3 +94,30 @@ word just below the loader (`base - 2`, where `G` jumps), and word 0 is rewritten to `rjmp` to the loader base. Every other vector stays the application's. Page 0 is written last, so an interrupted flash leaves word 0 erased and the chip still falls through to the loader on the next reset. + +## Host tool + +`pureboot.py` — Python 3, standard library only (termios drives any tty, +a USB adapter as well as a simavr pty): + + pureboot.py --port /dev/ttyUSB0 --baud 57600 \ + --info --fuses --flash app.hex --timeout 10 + +Operations run in a fixed order within one session: info, fuses, flash +(erase / program / read / verify), EEPROM (erase / program / read / verify), +timeout — then the loader hands over to the application; `--stay` keeps the +session alive instead, and a later invocation reconnects into it (the knock +converges there too). `--flash` and `--eeprom` verify by read-back unless +`--no-verify`; images are raw binary, or Intel HEX by extension. + +## Tests + +Per chip preset, `ctest` runs the 512-byte size gate and the end-to-end +protocol test: a simavr device (`test/pureboot_device.c` — the mega's USART +as a pty; on the tinies a cycle-timed GPIO⇄pty bridge for the software UART, +plus the SPM/NVM module simavr's tiny cores lack) driven by the real host +tool through knock-from-reset, program + verify of both memories, timeout +configuration, session reconnect, an external reset through the patched +vector, and the hand-over to a fixture application whose banner proves the +launch — cross-checked against the simulator's ground-truth memory dumps and +an independent decode of the surgery's rjmp words. diff --git a/pureboot/__pycache__/pureboot.cpython-313.pyc b/pureboot/__pycache__/pureboot.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c80528d57e25b6e7f93a8e485b8db17935b68f5 GIT binary patch literal 28807 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 literal 0 HcmV?d00001 diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py new file mode 100644 index 0000000..98befab --- /dev/null +++ b/pureboot/pureboot.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""pureboot host tool — the smart half of the pureboot protocol (README.md). + +The device exposes primitives; this tool composes them: image loading (raw +binary or Intel HEX), flash programming with read-back verification, erase as +writing 0xff, EEPROM programming, fuse and info readout, activation-timeout +configuration, and — on chips without a hardware boot section — the +reset-vector surgery that re-homes the application's entry through the +trampoline word below the loader, writing page 0 last so an interrupted +flash still falls through to the loader. + +Python standard library only; the serial port is driven with termios, so any +tty works — a USB adapter as well as a simavr pty. +""" + +import argparse +import os +import select +import sys +import termios +import time + +PROMPT = b"+" +PROTOCOL_VERSION = 1 + + +class Error(Exception): + pass + + +# ---------------------------------------------------------------- serial --- + + +class Port: + """A raw serial port with deadline-based reads.""" + + 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 + try: + speed = getattr(termios, f"B{baud}") + except AttributeError: + raise Error(f"unsupported baud rate {baud}") from None + attrs[4] = attrs[5] = speed + attrs[6][termios.VMIN] = 0 + attrs[6][termios.VTIME] = 0 + termios.tcsetattr(self.fd, termios.TCSANOW, attrs) + + def close(self): + os.close(self.fd) + + def write(self, data): + os.write(self.fd, data) + + def flush_input(self): + termios.tcflush(self.fd, termios.TCIFLUSH) + + def read_available(self, wait): + """Everything that arrives within `wait` seconds of quiet start.""" + ready, _, _ = select.select([self.fd], [], [], wait) + return os.read(self.fd, 4096) if ready else b"" + + def read_exact(self, count, timeout): + data = b"" + deadline = time.monotonic() + timeout + while len(data) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Error(f"timeout: got {len(data)} of {count} bytes") + ready, _, _ = select.select([self.fd], [], [], remaining) + if ready: + data += os.read(self.fd, count - len(data)) + return data + + +# -------------------------------------------------------------- protocol --- + + +class Info: + """The 12-byte info block.""" + + def __init__(self, raw): + if len(raw) != 12 or raw[0:2] != b"PB": + raise Error(f"bad info block: {raw.hex()}") + if raw[2] != PROTOCOL_VERSION: + raise Error(f"protocol version {raw[2]}, tool speaks {PROTOCOL_VERSION}") + self.signature = raw[3:6] + self.page = raw[6] + self.base = raw[7] | (raw[8] << 8) + self.eeprom_size = raw[9] | (raw[10] << 8) + self.patch_vector = bool(raw[11] & 1) + self.flash_size = self.base + 512 + + def describe(self): + sig = " ".join(f"{b:02x}" for b in self.signature) + vector = "host-patched reset vector" if self.patch_vector else "hardware boot section" + return ( + f"signature {sig}, page {self.page} B, " + f"app flash {self.base} B (loader at {self.base:#06x}), " + f"EEPROM {self.eeprom_size} B, {vector}" + ) + + +class Loader: + """A pureboot session. Between commands the loader has prompted `+` and + awaits a command byte; every method restores that invariant.""" + + def __init__(self, port): + self.port = port + self.info = None + + def connect(self, wait): + """Knock until the activation window answers, then read the info + block. Also converges when the loader already sits in its command + loop: the knock bytes are ignored-or-executed there, and the drain + absorbs whatever they produced.""" + self.port.flush_input() + deadline = time.monotonic() + wait + while True: + self.port.write(b"pb") + if PROMPT in self.port.read_available(0.4): + break + if time.monotonic() > deadline: + raise Error("no answer — reset the device within its activation window") + while self.port.read_available(0.3): + pass + self.port.write(b"b") + self.info = Info(self.port.read_exact(12, 2.0)) + self._expect_prompt() + return self.info + + def _expect_prompt(self, timeout=2.0): + byte = self.port.read_exact(1, timeout) + if byte != PROMPT: + raise Error(f"expected prompt, got {byte.hex()}") + + def _command(self, tx, reply_len=0, timeout=2.0): + self.port.write(tx) + reply = self.port.read_exact(reply_len, timeout) if reply_len else b"" + self._expect_prompt(timeout) + return reply + + def _stream_read(self, command, address, count): + data = b"" + while count: + chunk = min(count, 256) + head = bytes((ord(command), address & 0xFF, address >> 8, chunk & 0xFF)) + data += self._command(head, chunk, 5.0) + address += chunk + count -= chunk + return data + + def read_flash(self, address, count): + return self._stream_read("R", address, count) + + def read_eeprom(self, address, count): + return self._stream_read("r", address, count) + + def write_page(self, address, data): + assert len(data) == self.info.page and address % self.info.page == 0 + head = bytes((ord("W"), address & 0xFF, address >> 8)) + self._command(head + data, 0, 2.0) + + def write_eeprom(self, address, data): + offset = 0 + while offset < len(data): + chunk = data[offset : offset + 256] + head = bytes((ord("w"), address & 0xFF, address >> 8, len(chunk) & 0xFF)) + self.port.write(head) + for byte in chunk: + self.port.write(bytes((byte,))) + self._expect_prompt() # per-byte ack: the write has begun + self._expect_prompt() # the next command prompt + address += len(chunk) + offset += len(chunk) + + def read_fuses(self): + return self._command(b"F", 4, 2.0) + + def run_application(self): + self.port.write(b"G") + self._expect_prompt() + + +# ---------------------------------------------------------------- images --- + + +def load_image(path): + """Raw binary, or Intel HEX by extension (.hex/.ihx/.ihex).""" + data = open(path, "rb").read() + if not path.lower().endswith((".hex", ".ihx", ".ihex")): + return data + memory = {} + for number, line in enumerate(data.decode("ascii", "replace").splitlines(), 1): + line = line.strip() + if not line: + continue + if not line.startswith(":"): + raise Error(f"{path}:{number}: not an Intel HEX record") + record = bytes.fromhex(line[1:]) + if sum(record) & 0xFF: + raise Error(f"{path}:{number}: checksum mismatch") + count, address, kind = record[0], (record[1] << 8) | record[2], record[3] + payload = record[4 : 4 + count] + if kind == 0: + for i, byte in enumerate(payload): + memory[address + i] = byte + elif kind == 1: + break + elif kind in (2, 4) and not any(payload): + continue # a zero base extends nothing + elif kind in (3, 5): + continue # start address: irrelevant, reset is the entry + else: + raise Error(f"{path}:{number}: record type {kind} reaches beyond the 16-bit space") + if not memory: + raise Error(f"{path}: empty image") + return bytes(memory.get(i, 0xFF) for i in range(max(memory) + 1)) + + +# --------------------------------------------------------------- surgery --- + + +def rjmp_target(word_address, opcode, flash_words): + return (word_address + 1 + (opcode & 0x0FFF)) % flash_words + + +def rjmp_to(word_address, destination, flash_words): + return 0xC000 | ((destination - word_address - 1) % flash_words % 0x1000) + + +def plan_flash(image, info): + """The pages to program, as {page_address: bytes}, already carrying the + reset-vector surgery where the chip needs it. Page 0 must go last — + callers get it separated.""" + page = info.page + limit = info.base - (2 if info.patch_vector else 0) + if len(image) > limit: + raise Error(f"image is {len(image)} B, application flash ends at {limit}") + final = bytearray(image) + bytearray([0xFF] * (-len(image) % page)) + + if info.patch_vector: + flash_words = info.flash_size // 2 + word0 = final[0] | (final[1] << 8) + if word0 & 0xF000 != 0xC000: + raise Error( + "the image's reset vector is not an rjmp — pureboot's vector " + "surgery cannot re-home it (crt-less entry at address 0?)" + ) + entry = rjmp_target(0, word0, flash_words) + if entry >= info.base // 2: + raise Error( + "the image's reset vector already targets the loader — this " + "is a read-back of a patched image; flash the original" + ) + trampoline_word = (info.base - 2) // 2 + patch = rjmp_to(0, info.base // 2, flash_words) + final[0], final[1] = patch & 0xFF, patch >> 8 + trampoline_page = info.base - page + if len(final) < trampoline_page + page: + final += bytearray([0xFF] * (trampoline_page + page - len(final))) + jump = rjmp_to(trampoline_word, entry, flash_words) + final[info.base - 2], final[info.base - 1] = jump & 0xFF, jump >> 8 + + pages = {a: bytes(final[a : a + page]) for a in range(0, len(final), page)} + return pages + + +def covered(pages, skip_blank): + """Pages in programming order: ascending, page 0 last; optionally + dropping all-0xff pages (sound only over erased flash) — never the + load-bearing page 0.""" + rest = [a for a in sorted(pages) if a != 0] + if skip_blank: + rest = [a for a in rest if pages[a].count(0xFF) != len(pages[a])] + return rest + [0] + + +# ------------------------------------------------------------ operations --- + + +def op_erase_flash(loader): + """0xff over the whole application area, page 0 first — an interrupted + erase leaves the entry word blank and the chip still boots the loader.""" + blank = bytes([0xFF] * loader.info.page) + for address in range(0, loader.info.base, loader.info.page): + loader.write_page(address, blank) + print(f"erase: {loader.info.base // loader.info.page} pages") + + +def op_erase_eeprom(loader): + loader.write_eeprom(0, bytes([0xFF] * loader.info.eeprom_size)) + print(f"erase: {loader.info.eeprom_size} B of EEPROM") + + +def op_flash(loader, path, erase, verify): + image = load_image(path) + pages = plan_flash(image, loader.info) + if erase: + op_erase_flash(loader) + order = covered(pages, skip_blank=erase) + for address in order: + loader.write_page(address, pages[address]) + print(f"flash: {path}: {len(order)} pages") + if verify: + verify_pages(loader, pages) + + +def verify_pages(loader, pages): + for address in sorted(pages): + got = loader.read_flash(address, loader.info.page) + if got != pages[address]: + first = next(i for i in range(len(got)) if got[i] != pages[address][i]) + raise Error( + f"verify failed at {address + first:#06x}: " + f"wrote {pages[address][first]:02x}, read {got[first]:02x}" + ) + print(f"verify: {len(pages)} pages ok") + + +def op_verify_flash(loader, path): + verify_pages(loader, plan_flash(load_image(path), loader.info)) + + +def op_read_flash(loader, path): + data = loader.read_flash(0, loader.info.base) + open(path, "wb").write(data) + print(f"read flash: {len(data)} B -> {path}") + + +def op_eeprom(loader, path, erase, verify): + image = load_image(path) + if len(image) > loader.info.eeprom_size: + raise Error(f"EEPROM image is {len(image)} B, device has {loader.info.eeprom_size}") + if erase: + op_erase_eeprom(loader) + loader.write_eeprom(0, image) + print(f"eeprom: {path}: {len(image)} B") + if verify: + got = loader.read_eeprom(0, len(image)) + if got != image: + first = next(i for i in range(len(got)) if got[i] != image[i]) + raise Error(f"verify failed at EEPROM {first:#06x}: wrote {image[first]:02x}, read {got[first]:02x}") + print(f"verify: {len(image)} B ok") + + +def op_verify_eeprom(loader, path): + image = load_image(path) + got = loader.read_eeprom(0, len(image)) + if got != image: + first = next(i for i in range(len(got)) if got[i] != image[i]) + raise Error(f"verify failed at EEPROM {first:#06x}: expected {image[first]:02x}, read {got[first]:02x}") + print(f"verify: {len(image)} B of EEPROM ok") + + +def op_read_eeprom(loader, path): + data = loader.read_eeprom(0, loader.info.eeprom_size) + open(path, "wb").write(data) + print(f"read EEPROM: {len(data)} B -> {path}") + + +def op_timeout(loader, seconds): + loader.write_eeprom(loader.info.eeprom_size - 1, bytes((seconds,))) + label = f"{seconds} s" if seconds else "the device default" + print(f"activation timeout: {label}") + + +def op_fuses(loader): + low, lock, extended, high = loader.read_fuses() + print(f"fuses: low {low:02x} high {high:02x} extended {extended:02x} lock {lock:02x}") + + +# -------------------------------------------------------------------- cli --- + + +def main(): + parser = argparse.ArgumentParser( + description="pureboot host tool", epilog="operations run in the order listed above" + ) + parser.add_argument("--port", required=True, help="serial device (or simavr pty)") + parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies") + parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking") + parser.add_argument("--info", action="store_true", help="print the device info block") + parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes") + parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash") + parser.add_argument("--flash", metavar="FILE", help="program an application (bin or ihex)") + parser.add_argument("--no-verify", action="store_true", help="skip read-back after writes") + parser.add_argument("--read-flash", metavar="FILE", help="dump the application flash") + parser.add_argument("--verify-flash", metavar="FILE", help="compare flash against an image") + parser.add_argument("--erase-eeprom", action="store_true", help="0xff over the EEPROM") + 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("--timeout", type=int, metavar="S", help="activation window, 1-254 s (0: default)") + parser.add_argument("--stay", action="store_true", help="leave the loader in its session") + args = parser.parse_args() + + if args.timeout is not None and not 0 <= args.timeout <= 254: + parser.error("--timeout must be 0..254 (255 is the erased cell)") + + port = Port(args.port, args.baud) + try: + loader = Loader(port) + info = loader.connect(args.wait) + if args.info: + print(f"device: {info.describe()}") + if args.fuses: + op_fuses(loader) + if args.flash: + op_flash(loader, args.flash, args.erase_flash, not args.no_verify) + elif args.erase_flash: + op_erase_flash(loader) + if args.read_flash: + op_read_flash(loader, args.read_flash) + if args.verify_flash: + op_verify_flash(loader, args.verify_flash) + if args.eeprom: + op_eeprom(loader, args.eeprom, args.erase_eeprom, not args.no_verify) + elif args.erase_eeprom: + op_erase_eeprom(loader) + if args.read_eeprom: + op_read_eeprom(loader, args.read_eeprom) + if args.verify_eeprom: + op_verify_eeprom(loader, args.verify_eeprom) + if args.timeout is not None: + op_timeout(loader, args.timeout) + if args.stay: + print("loader stays in its session (reset to leave)") + else: + loader.run_application() + print("application running") + finally: + port.close() + + +if __name__ == "__main__": + try: + main() + except Error as error: + print(f"error: {error}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(130) diff --git a/test/pbapp.cpp b/test/pbapp.cpp new file mode 100644 index 0000000..fbe3155 --- /dev/null +++ b/test/pbapp.cpp @@ -0,0 +1,51 @@ +// Test-fixture application for the pureboot protocol test: prints "APP" on +// the chip's serial link (the same link the loader uses) and idles — the +// proof that the loader's hand-over, and on the tinies the host's +// reset-vector surgery, actually launched it. Linked normally (crt, vectors +// at 0); on the tinies its reset vector is the rjmp the host re-homes. +#include + +using namespace avr::literals; + +namespace { + +consteval avr::hertz_t clock() +{ + if (avr::hw::db.name == "ATtiny13A") + return 9.6_MHz; + if (avr::hw::db.name == "ATtiny85") + return 8_MHz; + return 16_MHz; +} + +using dev = avr::device<{.clock = clock()}>; + +template +struct link { + using tx_t = avr::uart::usart0; + static void tx(char c) + { + tx_t::write(static_cast(c)); + } +}; + +template +struct link { + using tx_t = avr::uart::software_tx; + static void tx(char c) + { + tx_t::write(static_cast(c)); + } +}; + +} // namespace + +int main() +{ + avr::init::tx_t>(); + link::tx('A'); + link::tx('P'); + link::tx('P'); + while (true) { + } +} diff --git a/test/pbtest.py b/test/pbtest.py new file mode 100644 index 0000000..f923026 --- /dev/null +++ b/test/pbtest.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""End-to-end pureboot protocol test: spawn the simavr device, then drive it +with the real host tool (pureboot.py, as a subprocess over the device's pty) +through flash + EEPROM + timeout + fuse + hand-over scenarios, and cross-check +the tool's view against the simulator's ground-truth memory dumps. + +Usage: pbtest.py + +Exits 0 if every scenario passes. +""" + +import os +import signal +import subprocess +import sys +import time + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) + + +def rjmp_decode(word, at, flash_words): + """Where an rjmp word at word-address `at` lands — deliberately written + against the instruction-set definition (12-bit signed offset), not with + the host tool's encoder, so an encoding bug cannot verify itself.""" + if word & 0xF000 != 0xC000: + fail(f"word at {at * 2:#06x} is {word:#06x}, not an rjmp") + offset = word & 0x0FFF + if offset >= 0x800: + offset -= 0x1000 + return (at + 1 + offset) % flash_words + + +class Device: + def __init__(self, binary, elf, mcu, hz, base, page, baud, dump): + self.proc = subprocess.Popen( + [binary, elf, mcu, hz, base, str(page), str(baud), dump], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self.dump = dump + self.pty = None + deadline = time.time() + 5 + while time.time() < deadline: + line = self.proc.stdout.readline() + if not line: + break + if line.startswith("PB_PTY"): + self.pty = line.split()[1] + break + if not self.pty: + self.stop() + raise RuntimeError("device did not report a pty") + + def stop(self): + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def run_tool(tool, pty, baud, *args): + result = subprocess.run( + [sys.executable, tool, "--port", pty, "--baud", str(baud), "--wait", "20", *args], + capture_output=True, + text=True, + timeout=120, + ) + print(result.stdout, end="") + if result.returncode != 0: + fail(f"tool exited {result.returncode}: {result.stderr.strip()}") + return result.stdout + + +def main(): + (device_bin, elf, mcu, hz, base_hex, page, baud, eeprom_size, app_bin, tool, workdir) = sys.argv[1:] + base, page, baud, eeprom_size = int(base_hex, 0), int(page), int(baud), int(eeprom_size) + sys.path.insert(0, os.path.dirname(os.path.abspath(tool))) + import pureboot as pb + + os.makedirs(workdir, exist_ok=True) + ee_image = bytes(range(0xA0, 0xB0)) + ee_path = os.path.join(workdir, "ee.bin") + open(ee_path, "wb").write(ee_image) + dump = os.path.join(workdir, "flash_dump.bin") + read_flash = os.path.join(workdir, "readback_flash.bin") + read_eeprom = os.path.join(workdir, "readback_eeprom.bin") + + # The geometry the host will discover, for computing the expected image. + info = pb.Info( + bytes([ord("P"), ord("B"), 1, 0, 0, 0, page]) + + bytes([base & 0xFF, base >> 8, eeprom_size & 0xFF, eeprom_size >> 8]) + + bytes([0 if mcu == "atmega328p" else 1]) + ) + + device = Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump) + try: + # Session 1: knock from reset, identify, program everything, stay. + out = run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin, + "--eeprom", ee_path, "--timeout", "8", "--stay") + for needed in ("device: signature", "fuses:", "verify:", "activation timeout: 8 s", "stays"): + if needed not in out: + fail(f"session 1 output lacks {needed!r}") + + # Session 2: reconnect into the live session, verify, dump, hand over + # is deferred — the pty must be reopened for the APP banner first. + out = run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path, + "--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay") + if out.count("verify:") != 2: + fail("session 2 did not verify both memories") + + eeprom_back = open(read_eeprom, "rb").read() + if eeprom_back[: len(ee_image)] != ee_image: + fail("EEPROM read-back mismatch") + if eeprom_back[-1] != 8: + fail(f"timeout cell reads {eeprom_back[-1]}, expected 8") + + # The expected post-surgery flash, straight from the tool's planner. + pages = pb.plan_flash(open(app_bin, "rb").read(), info) + flash_back = open(read_flash, "rb").read() + for address, data in pages.items(): + if flash_back[address : address + page] != data: + fail(f"flash read-back mismatch in page {address:#06x}") + + # An external reset re-enters through the patched word 0 (tinies; the + # runner resets them to address 0 like silicon) or BOOTRST (mega). + # The loader must answer a fresh knock, and 'G' must land in the + # application, which banners on the same link. + device.proc.send_signal(signal.SIGUSR1) + port = pb.Port(device.pty, baud) + try: + loader = pb.Loader(port) + loader.connect(15) + port.write(b"G") + if port.read_exact(1, 5.0) != pb.PROMPT: + fail("no ack for G") + banner = port.read_exact(3, 5.0) + if banner != b"APP": + fail(f"application banner was {banner!r}") + finally: + port.close() + finally: + device.stop() + + # Ground truth: the simulator's own memories, against the host's view. + flash_true = open(dump, "rb").read() + if flash_true[:base] != flash_back: + fail("host flash read-back differs from the simulator's flash") + if flash_true[base] == 0xFF and flash_true[base + 1] == 0xFF: + fail("loader region looks erased in the ground-truth dump") + + # The surgery, decoded independently: the patched vector must land on the + # loader, the trampoline on the application's own entry. + if mcu != "atmega328p": + flash_words = (base + 512) // 2 + app = open(app_bin, "rb").read() + word0 = flash_true[0] | (flash_true[1] << 8) + if rjmp_decode(word0, 0, flash_words) != base // 2: + fail("patched reset vector does not land on the loader base") + trampoline = flash_true[base - 2] | (flash_true[base - 1] << 8) + original = app[0] | (app[1] << 8) + if rjmp_decode(trampoline, (base - 2) // 2, flash_words) != rjmp_decode(original, 0, flash_words): + fail("trampoline does not land on the application's own entry") + ee_true_path = dump + ".eeprom" + if os.path.exists(ee_true_path): + ee_true = open(ee_true_path, "rb").read() + if ee_true[: len(ee_image)] != ee_image or ee_true[-1] != 8: + fail("ground-truth EEPROM does not match what was programmed") + + print("pbtest: all scenarios pass") + + +if __name__ == "__main__": + main() diff --git a/test/pureboot_device.c b/test/pureboot_device.c new file mode 100644 index 0000000..d14352e --- /dev/null +++ b/test/pureboot_device.c @@ -0,0 +1,309 @@ +// simavr "device" for the pureboot protocol tests, all three chips. Loads +// the boot-linked ELF at the loader base, starts execution there (BOOTRST / +// the patched vector are not what is under test), and exposes the loader's +// serial link as a pty for the real host tool: +// +// - ATmega328P: the hardware USART0 through simavr's uart_pty. +// - Tinies: an 8N1 bridge between a pty and the GPIO software UART +// (drives PB0, the loader's RX; decodes PB1, its TX), timed against the +// simulated cycle counter. +// +// simavr's tiny cores decode the SPM opcode but attach no NVM module — SPM +// is a silent no-op (the mega's boot section has one, avr_flash). The +// missing module is supplied here: the SPM ioctl reads SPMCSR/Z/r1:r0 and +// implements buffer fill, page erase, page write, and CTPB, completing +// instantly. RFLB's LPM diversion (fuse readout) stays unmodeled, so the +// 'F' command answers with flash bytes — the tests assert transport only. +// +// On exit (or SIGTERM) the flash and EEPROM are dumped to files for a +// ground-truth cross-check against what the host read back. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "avr_eeprom.h" +#include "avr_flash.h" +#include "avr_ioport.h" +#include "avr_uart.h" +#include "sim_avr.h" +#include "sim_elf.h" +#include "sim_io.h" +#include "uart_pty.h" + +static avr_t *avr; +static uart_pty_t uart_pty; +static int use_uart_pty; +static const char *dump_path; +static uint32_t reset_pc; +static volatile sig_atomic_t reset_requested; + +static void request_reset(int sig) +{ + (void)sig; + reset_requested = 1; +} + +// ------------------------------------------------------------- tiny NVM --- + +typedef struct { + avr_io_t io; + uint8_t buffer[128]; + unsigned page; +} tiny_nvm_t; + +static tiny_nvm_t nvm; + +static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param) +{ + (void)param; + if (ctl != AVR_IOCTL_FLASH_SPM) + return -1; + tiny_nvm_t *n = (tiny_nvm_t *)io; + avr_t *mcu = io->avr; + uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies + uint16_t z = (uint16_t)(mcu->data[30] | (mcu->data[31] << 8)); + uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1); + if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0 + unsigned offset = z & (n->page - 1) & ~1u; + n->buffer[offset] = mcu->data[0]; + n->buffer[offset + 1] = mcu->data[1]; + } else if (command == 0x03) { // PGERS + memset(mcu->flash + page_base, 0xff, n->page); + } else if (command == 0x05) { // PGWRT: programming only clears bits + for (unsigned i = 0; i < n->page; i++) + mcu->flash[page_base + i] &= n->buffer[i]; + memset(n->buffer, 0xff, n->page); + } else if (command == 0x11) { // CTPB + memset(n->buffer, 0xff, n->page); + } + mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly + return 0; +} + +// ----------------------------------------------------------- GPIO bridge --- + +static int pty_master = -1; +static avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty +static avr_cycle_count_t bit_cycles; + +static int tx_level = 1, tx_active, tx_bit; +static uint8_t tx_shift; + +static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param) +{ + (void)mcu; + (void)param; + tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); + if (++tx_bit < 8) + return when + bit_cycles; + if (write(pty_master, &tx_shift, 1) != 1) + fprintf(stderr, "device: pty write lost a byte\n"); + tx_active = 0; + return 0; +} + +static void tx_hook(avr_irq_t *irq, uint32_t value, void *param) +{ + (void)irq; + (void)param; + int level = value & 1; + if (!tx_active && tx_level == 1 && level == 0) { // start edge + tx_active = 1; + tx_bit = 0; + avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, NULL); + } + tx_level = level; +} + +static uint8_t rx_queue[8192]; +static unsigned rx_head, rx_tail; // ring: head = next to send +static int rx_active, rx_bit; +static uint8_t rx_byte; + +static void rx_start_next(void); + +static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param) +{ + (void)mcu; + (void)param; + if (rx_bit < 8) { + avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1); + rx_bit++; + return when + bit_cycles; + } + if (rx_bit == 8) { // stop bit, plus one idle bit of margin + avr_raise_irq(rx_pin, 1); + rx_bit++; + return when + 2 * bit_cycles; + } + rx_active = 0; + rx_start_next(); + return 0; +} + +static void rx_start_next(void) +{ + if (rx_active || rx_head == rx_tail) + return; + rx_byte = rx_queue[rx_head]; + rx_head = (rx_head + 1) % sizeof(rx_queue); + rx_active = 1; + rx_bit = 0; + avr_raise_irq(rx_pin, 0); // start bit + avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL); +} + +static void poll_pty(void) +{ + uint8_t chunk[256]; + ssize_t got = read(pty_master, chunk, sizeof(chunk)); + for (ssize_t i = 0; i < got; i++) { + unsigned next = (rx_tail + 1) % sizeof(rx_queue); + if (next == rx_head) + break; // full: the host will retry on timeout + rx_queue[rx_tail] = chunk[i]; + rx_tail = next; + } + if (got > 0) + rx_start_next(); +} + +// ------------------------------------------------------------------ main --- + +static void finish(int sig) +{ + (void)sig; + if (dump_path) { + FILE *f = fopen(dump_path, "wb"); + if (f) { + fwrite(avr->flash, 1, avr->flashend + 1, f); + fclose(f); + } + avr_eeprom_desc_t ee = {.ee = NULL, .offset = 0, .size = 0}; + if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) { + char path[512]; + snprintf(path, sizeof(path), "%s.eeprom", dump_path); + f = fopen(path, "wb"); + if (f) { + fwrite(ee.ee, 1, ee.size, f); + fclose(f); + } + } + } + if (use_uart_pty) + uart_pty_stop(&uart_pty); + _exit(0); +} + +int main(int argc, char *argv[]) +{ + if (argc != 8) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const char *mcu_name = argv[2]; + uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0); + unsigned page = (unsigned)atoi(argv[5]); + unsigned baud = (unsigned)atoi(argv[6]); + dump_path = argv[7]; + use_uart_pty = strcmp(mcu_name, "atmega328p") == 0; + + avr = avr_make_mcu_by_name(mcu_name); + if (!avr) { + fprintf(stderr, "device: no %s core\n", mcu_name); + return 1; + } + avr_init(avr); + avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0); + memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased + + elf_firmware_t fw = {0}; + if (elf_read_firmware(argv[1], &fw) != 0) { + fprintf(stderr, "device: cannot read %s\n", argv[1]); + return 1; + } + memcpy(avr->flash + base, fw.flash, fw.flashsize); + // The mega enters the loader in hardware (BOOTRST, not modeled); the + // tinies reset to word 0 like silicon — erased flash walks up into the + // loader, and after the host's surgery the patched vector routes there. + reset_pc = use_uart_pty ? base : 0; + avr->pc = reset_pc; + avr->codeend = avr->flashend; + + // Erased EEPROM, as hardware powers up (simavr zeroes it). + uint8_t blank[1024]; + memset(blank, 0xff, sizeof(blank)); + avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0}; + if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) { + seed.ee = blank; + avr_ioctl(avr, AVR_IOCTL_EEPROM_SET, &seed); + } + + if (use_uart_pty) { + // POLL_SLEEP paces an idle-polling loader in host real time (a + // no-hardware CPU-saving hack); clear it so cycles run free. + uint32_t flags = 0; + avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &flags); + flags &= ~AVR_UART_FLAG_POLL_SLEEP; + avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &flags); + uart_pty_init(avr, &uart_pty); + uart_pty_connect(&uart_pty, '0'); + printf("PB_PTY %s\n", uart_pty.pty.slavename); + } else { + nvm.page = page; + memset(nvm.buffer, 0xff, sizeof(nvm.buffer)); + nvm.io.kind = "tiny_nvm"; + nvm.io.ioctl = nvm_ioctl; + avr_register_io(avr, &nvm.io); + + bit_cycles = avr->frequency / baud; + rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ('B'), 0); + avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ('B'), 1), tx_hook, NULL); + avr_raise_irq(rx_pin, 1); // idle line + + int slave; + struct termios raw; + cfmakeraw(&raw); + if (openpty(&pty_master, &slave, NULL, &raw, NULL) != 0) { + fprintf(stderr, "device: openpty failed\n"); + return 1; + } + fcntl(pty_master, F_SETFL, O_NONBLOCK); + printf("PB_PTY %s\n", ttyname(slave)); + } + fflush(stdout); + + signal(SIGTERM, finish); + signal(SIGINT, finish); + signal(SIGUSR1, request_reset); // an external reset line, for the tests + + long since_poll = 0; + for (;;) { + int state = avr_run(avr); + if (state == cpu_Done || state == cpu_Crashed) + break; + if (reset_requested) { + reset_requested = 0; + avr_reset(avr); + avr->pc = reset_pc; + if (use_uart_pty) { // reset restores the pacing hack; re-clear it + uint32_t flags = 0; + avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &flags); + flags &= ~AVR_UART_FLAG_POLL_SLEEP; + avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &flags); + } + } + if (!use_uart_pty && ++since_poll >= 2000) { + since_poll = 0; + poll_pty(); + } + } + finish(0); + return 0; +}