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>
137 lines
6.1 KiB
Python
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())
|