Files
fantemp/test/check_reachability.py
BlackMark 491ff76447 build: pureboot rides as a pinned submodule, and the libavr pin advances
pureboot moved to its own repo, so the loader this board hands over to stopped
being reachable through a sibling checkout of the bootloader repo. It is a
submodule here now: this board has no reset line and no programming header, so
the resident loader is the only way in, and the commit naming the firmware
should name the loader it has to reach.

The reachability check stops carrying its own copy of where that loader is.
0x7e00 was a literal beside pureboot's own geometry, which the submodule
exports as PUREBOOT_BASE_HEX - one source for the fact now, and the check reads
whichever slot the pinned loader actually has. The boot-section bound stays a
literal, being a fuse fact rather than a loader one.

Built and tested at both pins on the bench: 5/5, cross-mode identity included,
and the image deployed to the board verifies byte-for-byte through its loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:38:19 +02:00

137 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""The board's only way in, checked in the emitted image.
This board has no reset line and no programming header. The single route to the
bootloader is the running firmware's `bootloader` command, so a firmware that
gets that route wrong is a board that cannot be reflashed - and the failure is
silent, because everything else still works.
It has been wrong before. The firmware this one replaces probed and jumped to
`0x7800`, the base of a 2 KB boot section, while the board's loader sits at
`0x7e00`; `check()` therefore read an erased byte, was false, and the command
never arrived anywhere. Nothing about that is visible short of trying it on the
hardware, which is what this replaces.
Three properties, all read out of the disassembly rather than the source:
1. The image ends below the boot section. `hfuse d4` puts that at 0x7c00, so an
application reaching into it would be overwritten by the loader - or worse,
executed at reset, since BOOTRST points there.
2. The hand-over targets the loader base. A word address of 0x3f00 is byte
0x7e00; anything else is the 0x7800 bug again.
3. The hand-over does not arm the watchdog. pureboot hands straight back on
WDRF by design, so a reset-based route reaches it and opens no window. The
legacy firmware's route was exactly that, and it is the one change that
cannot be walked back from the host.
check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin
"""
from __future__ import annotations
import argparse
import pathlib
import re
import subprocess
import sys
BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words - a fuse fact, not a loader one
WDTCSR = 0x60
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--objdump", required=True)
parser.add_argument("--elf", type=pathlib.Path, required=True)
parser.add_argument("--image", type=pathlib.Path, required=True)
# pureboot's own geometry, from the pinned submodule that exports it, so the
# slot's base is stated once for the loader this firmware is deployed with.
parser.add_argument("--loader-base", required=True,
type=lambda v: int(v, 0), metavar="ADDR")
args = parser.parse_args()
loader_base = args.loader_base
failures = []
size = args.image.stat().st_size
if size >= BOOT_SECTION:
failures.append(f"the image is {size} B and reaches 0x{size - 1:04x}, "
f"into the boot section at 0x{BOOT_SECTION:04x}")
else:
print(f" ok image {size} B, ends 0x{size - 1:04x}, "
f"{BOOT_SECTION - size} B clear of the boot section")
text = subprocess.run([args.objdump, "-d", str(args.elf)],
capture_output=True, text=True, check=True).stdout
# The address the hand-over actually targets, read at its call sites - not
# "does the image contain this byte somewhere", which proves nothing: 0x3f is
# an ordinary constant that appears in the curve tables, so a check like that
# passes just as happily on the 0x7800 bug it is supposed to catch.
#
# bootloader::call() takes the target as a function pointer, so each call site
# loads the *word* address into a register pair immediately before it.
lines = text.splitlines()
helper = re.compile(r"\b(?:r?call)\b.*<_ZN3app10bootloader4call")
sites = []
for index, line in enumerate(lines):
if not helper.search(line):
continue
held: dict[str, int] = {}
for back in lines[max(0, index - 8):index]:
if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", back):
held[m.group(1)] = int(m.group(2), 16)
# The AVR ABI passes the pointer in r25:r24.
if "r24" in held and "r25" in held:
sites.append(held["r25"] << 8 | held["r24"])
want = loader_base // 2
if not sites:
failures.append("no call to bootloader::call with a loaded target - the "
"hand-over could not be read out of the image")
elif wrong := [a for a in sites if a != want]:
failures.append(f"the hand-over targets word {[hex(a) for a in wrong]} "
f"(byte {[hex(a * 2) for a in wrong]}), not the loader at "
f"0x{loader_base:04x}")
else:
print(f" ok all {len(sites)} hand-over site(s) target word 0x{want:04x} "
f"(byte 0x{loader_base:04x})")
# An icall/ijmp has to exist for that address to be jumped to indirectly.
if not re.search(r"\b(icall|ijmp)\b", text):
failures.append("no icall/ijmp - the hand-over cannot reach across flash")
else:
print(" ok an indirect call exists (a relative one cannot reach)")
# What actually reaches WDTCSR, not what the image happens to load somewhere.
# A timed disable writes WDCE|WDE (0x18) and then zero. Arming writes WDE
# *without* WDCE - including 0x08, a 16 ms timeout with every prescaler bit
# clear, which is precisely what the legacy route used and is why this cannot
# be a check for "a prescaler is present".
WDCE, WDE = 0x10, 0x08
values, held = [], {}
for line in text.splitlines():
if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", line):
held[m.group(1)] = int(m.group(2), 16)
elif m := re.search(rf"\bsts\s+0x00{WDTCSR:02X},\s*(r\d+)", line, re.I):
reg = m.group(1)
values.append(0 if reg == "r1" else held.get(reg))
armed = [v for v in values if v is not None and (v & WDE) and not (v & WDCE)]
if armed:
failures.append(f"WDTCSR is written {[hex(v) for v in armed]} - WDE without "
f"WDCE is arming the watchdog, and a reset-based hand-over "
f"opens no pureboot window")
elif not values:
print(" ok the watchdog is never written")
else:
print(f" ok WDTCSR writes are {[hex(v) if v is not None else '?' for v in values]}"
f" - unlock and clear, never an arm")
for line in failures:
print(f" FAIL {line}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())