console: the terminal is the original's again, and the way out is a jump
Six things the port had dropped or got wrong, and the one that matters is the last. The help is a table again — name, dots, description, one command per line — instead of a single line of bare words that said nothing about what any of them did. The layout is the original's, colons at column 12, which `bootloader` at ten characters is what sets. Abbreviations are back, and they were a feature: any prefix resolves to the first command it matches, so `up` is uptime and `st` is statistics. Order does the disambiguating, which is why the table is in the original's dispatch order and new entries go on the end — appending cannot take an abbreviation that already meant something. `reset` keeps the original's exception and must be typed in full: `r` should not be able to clear the histogram. The histogram gets its resolution back. The bar was capped at 40 columns where the original scaled to 100, and on a distribution this narrow that threw away most of the difference between neighbouring buckets. Same normalisation as before: divide by whatever makes the tallest bucket fit. The sample count moves to a fixed ten-column field before the bar, so the numbers read as a table instead of trailing off the ragged right end. `version` exists again, and this is 2.1 — 2.0 being the port as it stood. Added while here: `save`, to force the writeback that otherwise waits up to thirty minutes; the resistance in `show`, which is the one number that says *why* a temperature is wrong and which the original printed; a report when a line overflows the buffer rather than silently acting on its head; "no data yet" where there is none; and a blank line after each command's output. And the way out. `bootloader` now jumps rather than resetting, because pureboot hands straight back on WDRF by design — so the legacy watchdog-reset hand-over reaches it and opens no window, which on a board with no reset line is a board that cannot be reflashed. Two more bugs in the same three lines: the target was 0x7800, a 2 KB boot section's base, which on this board's 512-byte section reads erased and made the check false and the command a no-op; and UCSR0B was left set, which mutes a loader that bit-bangs the pin the USART still owns. All three are now read back out of the emitted image by ctest, the address and the watchdog red-proven against exactly the legacy behaviour they exist to catch. libavr advances to 71cfb2f. Verified on the board: FanTemp v2.1, min 0 C / max 74 C matching what 1.8b reported off the same EEPROM, the fan curve within one percentage point of the legacy double-precision one at every 5 C from 15 to 60, and `bootloader` -> pureboot 7 -> back to a running application. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
12
test/CMakeLists.txt
Normal file
12
test/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
# The board has no reset line and no programming header, so the loader-entry
|
||||
# route in the emitted image is the only thing standing between a firmware change
|
||||
# and an unreflashable board. It has been wrong before — see the script.
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_FOUND)
|
||||
add_test(NAME fantemp.reachability
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check_reachability.py
|
||||
--objdump ${CMAKE_OBJDUMP} --elf $<TARGET_FILE:fantemp>
|
||||
--image $<TARGET_FILE_DIR:fantemp>/fantemp.bin)
|
||||
else()
|
||||
message(STATUS "Python not found — the reachability check is skipped")
|
||||
endif()
|
||||
132
test/check_reachability.py
Normal file
132
test/check_reachability.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user