Files
fantemp/test/check_reachability.py
T
BlackMarkandClaude Opus 5.5 999d087eb4 test: the reachability check reads the USART release back too
The README says ctest reads all three hand-over properties out of the image,
and the UCSR0B clear was the one it never read. Every hand-over site must now
store zero to UCSR0B before the call; red with the write removed, green with
it. The docstring states the properties rather than the legacy bug's story.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:50:13 +02:00

143 lines
6.3 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.
Four 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; 0x7800, a 2 KB boot section's base, reads erased on this board.
3. The hand-over releases the USART first. While TXEN0 is set the peripheral
owns PD1, so a loader that bit-bangs the pin answers into nothing.
4. 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.
check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin \
--loader-base 0x7e00
"""
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
UCSR0B = 0xC1
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")
release = re.compile(rf"\bsts\s+0x00{UCSR0B:02X},\s*r1\b", re.I)
sites, unreleased = [], 0
for index, line in enumerate(lines):
if not helper.search(line):
continue
held: dict[str, int] = {}
window = lines[max(0, index - 8):index]
for back in window:
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"])
if not any(release.search(back) for back in window):
unreleased += 1
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})")
if unreleased:
failures.append(f"{unreleased} hand-over site(s) do not clear UCSR0B first - "
f"the USART keeps PD1 and the loader answers into nothing")
elif sites:
print(" ok every hand-over site clears UCSR0B before the jump")
# 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())