Files
fantemp/test/check_reachability.py
BlackMark b1caf49522 build: the libavr pin advances past phase 6
The renames land (interrupt_guard, consume_reset_cause, set_duty), the
sampler binds its input in the new converter shape (the input pack plus
in<>::start() as free-running's one kick), and the console states
.allow_baud_error = true for the 115200-at-16-MHz this board has always
spoken - the receiver-tolerance table libavr now enforces is stricter
than the rate's own +2.1 %. The loader probe reads through
avr::flash_load instead of raw pgmspace, the terminal's line buffer is
std::array with backspace and delete named, the tree is reformatted
under InsertBraces, and the sources are ASCII.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:45:07 +02:00

133 lines
5.8 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
LOADER_BASE = 0x7E00 # pureboot's 512-byte slot, at the top
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)
args = parser.parse_args()
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())