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>
This commit is contained in:
BlackMarkandClaude Opus 5.5 committed 2026-10-23 21:50:13 +02:00
1 parent c58ff23e90
commit 999d087eb4
2 files changed
+25 -18

No files matched your search

+4 -3
View File
@@ -47,9 +47,10 @@ stopped itself on the `\n` it arrived with, one reading in.
that bit-bangs the same pin receives perfectly and answers into nothing. that bit-bangs the same pin receives perfectly and answers into nothing.
`ctest` reads all three back out of the emitted image (`test/check_reachability.py`), `ctest` reads all three back out of the emitted image (`test/check_reachability.py`),
because none of them is visible from the source alone and the failure mode is an with the image's clearance below the boot section, because none of them is
unreflashable board. Both the address and the watchdog checks are red-proven visible from the source alone and the failure mode is an unreflashable board.
against the legacy behaviour they exist to catch. Each is red-proven: the address and the watchdog checks against the legacy
behaviour they exist to catch, the USART check against the write removed.
The Steinhart–Hart math of the legacy firmware (runtime doubles + libm The Steinhart–Hart math of the legacy firmware (runtime doubles + libm
log) is gone: the Beta equation and the cubic fan curve are evaluated log) is gone: the Beta equation and the cubic fan curve are evaluated
+21 -15
View File
@@ -6,25 +6,20 @@ 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 gets that route wrong is a board that cannot be reflashed - and the failure is
silent, because everything else still works. silent, because everything else still works.
It has been wrong before. The firmware this one replaces probed and jumped to Four properties, all read out of the disassembly rather than the source:
`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 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, application reaching into it would be overwritten by the loader - or worse,
executed at reset, since BOOTRST points there. executed at reset, since BOOTRST points there.
2. The hand-over targets the loader base. A word address of 0x3f00 is byte 2. The hand-over targets the loader base. A word address of 0x3f00 is byte
0x7e00; anything else is the 0x7800 bug again. 0x7e00; 0x7800, a 2 KB boot section's base, reads erased on this board.
3. The hand-over does not arm the watchdog. pureboot hands straight back on 3. The hand-over releases the USART first. While TXEN0 is set the peripheral
WDRF by design, so a reset-based route reaches it and opens no window. The owns PD1, so a loader that bit-bangs the pin answers into nothing.
legacy firmware's route was exactly that, and it is the one change that 4. The hand-over does not arm the watchdog. pureboot hands straight back on
cannot be walked back from the host. 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 check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin \
--loader-base 0x7e00
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,6 +31,7 @@ import subprocess
import sys import sys
BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words - a fuse fact, not a loader one BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words - a fuse fact, not a loader one
UCSR0B = 0xC1
WDTCSR = 0x60 WDTCSR = 0x60
@@ -73,17 +69,21 @@ def main() -> int:
# loads the *word* address into a register pair immediately before it. # loads the *word* address into a register pair immediately before it.
lines = text.splitlines() lines = text.splitlines()
helper = re.compile(r"\b(?:r?call)\b.*<_ZN3app10bootloader4call") helper = re.compile(r"\b(?:r?call)\b.*<_ZN3app10bootloader4call")
sites = [] release = re.compile(rf"\bsts\s+0x00{UCSR0B:02X},\s*r1\b", re.I)
sites, unreleased = [], 0
for index, line in enumerate(lines): for index, line in enumerate(lines):
if not helper.search(line): if not helper.search(line):
continue continue
held: dict[str, int] = {} held: dict[str, int] = {}
for back in lines[max(0, index - 8):index]: 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): 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) held[m.group(1)] = int(m.group(2), 16)
# The AVR ABI passes the pointer in r25:r24. # The AVR ABI passes the pointer in r25:r24.
if "r24" in held and "r25" in held: if "r24" in held and "r25" in held:
sites.append(held["r25"] << 8 | held["r24"]) sites.append(held["r25"] << 8 | held["r24"])
if not any(release.search(back) for back in window):
unreleased += 1
want = loader_base // 2 want = loader_base // 2
if not sites: if not sites:
@@ -97,6 +97,12 @@ def main() -> int:
print(f" ok all {len(sites)} hand-over site(s) target word 0x{want:04x} " print(f" ok all {len(sites)} hand-over site(s) target word 0x{want:04x} "
f"(byte 0x{loader_base: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. # An icall/ijmp has to exist for that address to be jumped to indirectly.
if not re.search(r"\b(icall|ijmp)\b", text): if not re.search(r"\b(icall|ijmp)\b", text):
failures.append("no icall/ijmp - the hand-over cannot reach across flash") failures.append("no icall/ijmp - the hand-over cannot reach across flash")