Files
bootloader/test/pbtest.py
BlackMark 6f3eb06233 pureboot: a version number for the loader, not for the protocol
The info block's third byte was a protocol version that never moved in the
loader's lifetime. It is the pureboot version now, and this change is
version 2: the one number that says what a deployed loader is. Every loader
already in the field answers 1.

The protocol keeps no number of its own — a pureboot version implies it, and
the host tool is what holds that map. pureboot.py states the loader-version
window it speaks (OLDEST_LOADER/NEWEST_LOADER; a version that changes the
protocol becomes the new floor there), so a loader newer than the tool is
refused by name rather than decoded on the assumption nothing moved, while an
older one is read, identified and installed like any other. The tool carries
its own version, free to drift from the loader's: --version prints it and the
window, --info leads with the device's, --update-loader names the version it
installs.

Tests: the planner unit pins the window — every version in it decodes, one
above it is refused, an older loader's image is still found — and the live
suite pins the built loader against the tool beside it, so a bump that reaches
only one of them fails. The image is byte-identical to the previous build but
for that byte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:20:34 +02:00

150 lines
6.6 KiB
Python

#!/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 + fuse + hand-over scenarios, and cross-check
the tool's view against the simulator's ground-truth memory dumps.
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link]
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...) for a
loader built off the chip's natural serial default.
Exits 0 if every scenario passes.
"""
import os
import sys
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
def main():
args = sys.argv[1:]
link = args.pop() if len(args) == 12 else None
(device_bin, elf, mcu, hz, base_hex, page, baud, eeprom_size, app_bin, tool, workdir) = args
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)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
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:
# the boot-sectioned megas need no vector surgery (the tinies and the
# boot-section-less m48s do), the large chips speak word addresses, and
# the page byte is the wire's 0-means-256.
mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48")
word_flash = base + 512 > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
info = pb.Info(
bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF])
+ bytes([wire_base & 0xFF, wire_base >> 8, eeprom_size & 0xFF, eeprom_size >> 8])
+ bytes([flags])
)
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
# Session 1: knock from reset, identify, program everything, stay.
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
"--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "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 = pbsim.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")
# 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 the 'J' hand-over must
# land in the application, which banners on the same link.
device.reset()
port = pb.Port(device.pty, baud)
try:
loader = pb.Loader(port)
live = loader.connect(15)
# The loader built from this tree and the tool beside it must
# agree on where the version numbering stands: a bump the tool
# was never told about is a loader it would refuse to speak to.
if live.version != pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}")
loader.run_application()
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 (patched-vector
# chips only — a boot-sectioned mega's word 0 stays the application's).
if patch:
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:
fail("ground-truth EEPROM does not match what was programmed")
print("pbtest: all scenarios pass")
if __name__ == "__main__":
main()