5 Commits

Author SHA1 Message Date
0f00f6bdeb test: move the handshake regression in beside the rest
It was written next to the loader source; the harness lives at the repo root.
Not registered with ctest yet — it belongs beside pureboot.planner, which is
the other test of the host tool's pure logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:22:19 +02:00
3990f53180 pureboot: take the running slot from avr::startup::caller_page
The write guard's anchor was costing a materialised pointer and a byte swap to
use one byte of it. The libavr primitive answers it in a single load, which is
eight bytes off every build — and what lets the USART release fit the tightest
configuration in the space: the 1284 autobaud on USART-shared pins was 514 of
its 512 and is now 506, with the default pinning down from 510 to 502.

Verified on silicon: the guard still refuses an erase aimed at the slot it runs
from, and still permits one in the application region.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:47 +02:00
3899e8ef40 pureboot: release a USART left enabled on the software link's pins
A software or autobaud link on a USART's own pins (PD0/PD1 on the mega328P, so
the Uno's USB bridge reaches it) was mute after an application handed over with
that USART still enabled: its TXEN keeps the USART owning the TX pin, so the
bit-banged transmitter cannot drive it — the loader locked and obeyed commands
but never answered. The link's init now clears the UCSRnB of the USART whose
TXD is its TX pin. Guarded with if constexpr on that pin match, so a link on
non-USART pins emits nothing: +4 bytes on a USART-pin build (494 of 512 for the
mega328P autobaud), zero on the default pb0/pb1 matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:22:48 +02:00
0ddc65ca28 pureboot.py: bump the tool version to 5
The drain fix changes the tool's activation behaviour; mark it. The loader
version window is unchanged — the wire protocol did not move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:11:03 +02:00
142de8775d pureboot.py: bound the activation drain against a flooding target
The post-prompt settle loop in _handshake had no deadline, so a target that
never falls quiet — a board stuck in a reset loop, whose UART-reset garbage
carries a stray prompt byte — spun the tool forever. Bound it by the handshake
deadline; a real loader still settles on its first quiet read. Regression:
test/test_handshake.py (flood terminates, valid loader still connects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:10:40 +02:00
3 changed files with 135 additions and 4 deletions

View File

@@ -166,6 +166,27 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0'; constexpr char usart_digit = '0';
#endif #endif
// Release a hardware USART the application may have left enabled onto a
// bit-banged link's pins. A software transmitter drives its TX pin through the
// port register, but while that USART's TXEN is set the USART owns the pin and
// the port write does nothing — the loader would receive and obey yet never
// answer. Writing UCSRnB zero hands the pin back to the port. Guarded on the
// pin actually being a USART's TXD, so a link on non-USART pins emits nothing.
template <char Inst, avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usart_on()
{
if constexpr (avr::uart::has_usart<Inst>())
if constexpr (avr::uart::detail::usart_pin<Inst>("TXD") == Tx)
avr::hw::reg_impl<avr::uart::detail::ureg<Inst, "UCSR#B">()>::write(0);
}
template <avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usarts_on()
{
release_usart_on<'0', Tx>();
release_usart_on<'1', Tx>();
}
template <avr::hertz_t C, avr::baud_t B> template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
@@ -212,6 +233,7 @@ struct software_link {
static void init() static void init()
{ {
avr::init<rx_t, tx_t>(); avr::init<rx_t, tx_t>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static bool pending() static bool pending()
@@ -245,6 +267,7 @@ struct autobaud_link {
static void init() static void init()
{ {
avr::init<uart>(); avr::init<uart>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static std::uint8_t rx() static std::uint8_t rx()
@@ -465,8 +488,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
// address is a word address and a slot is half as many words as bytes, so // address is a word address and a slot is half as many words as bytes, so
// its high byte is the slot index outright. No absolute address is ever // its high byte is the slot index outright. No absolute address is ever
// formed, so the image stays position-independent. // formed, so the image stays position-independent.
const auto return_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0)); const auto slot_high = avr::startup::caller_page();
const auto slot_high = static_cast<std::uint8_t>(return_words >> 8);
await_host(); await_host();

View File

@@ -24,7 +24,7 @@ else:
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 4 # this tool's own version — free to drift from a loader's VERSION = 5 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire # The loader versions this tool speaks. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that # protocol, which carries no number of its own, so this window is where that
# map lives: every version so far speaks the same protocol, and one that # map lives: every version so far speaks the same protocol, and one that
@@ -484,8 +484,13 @@ class Loader:
self.port.write(knock) self.port.write(knock)
knocks += 1 knocks += 1
if PROMPT in self.port.read_available(0.4): if PROMPT in self.port.read_available(0.4):
# Settle: absorb a real loader's trailing bytes before asking
# for the identity. Bounded by the deadline so a target that
# never falls quiet — a board stuck in a reset loop, whose
# garbage carries a stray prompt — cannot spin here forever.
while self.port.read_available(0.3): while self.port.read_available(0.3):
pass if time.monotonic() > deadline:
break
self.port.write(b"b") self.port.write(b"b")
try: try:
# A version the tool cannot speak is the loader's own # A version the tool cannot speak is the loader's own

104
test/test_handshake.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Host-tool activation handshake: it must not hang on a flooding target.
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
trailing bytes before it asks for the identity. That drain must be bounded: a
target that never falls quiet — a board stuck in a reset loop presents exactly
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects.
Stdlib only; run with `python test/test_handshake.py`. Not yet wired into ctest.
"""
import importlib.util
import pathlib
import threading
import time
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
_spec = importlib.util.spec_from_file_location("pureboot", PB)
pb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(pb)
P = F = 0
def check(name, ok):
global P, F
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
class FloodPort:
"""A line that never falls quiet: read_available always returns bytes, and
they contain a prompt. No identity ever completes."""
def flush_input(self):
pass
def write(self, data):
pass
def read_available(self, wait):
time.sleep(0.01) # a real read waits; keep the busy loop off a core
return b"+\x00\xff"
def read_exact(self, count, timeout):
raise pb.Error("no identity")
class LoaderPort:
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the
slim identity (version 5 + m328p signature) and a closing prompt."""
def __init__(self):
self.reads = self.exacts = 0
def flush_input(self):
pass
def write(self, data):
pass
def read_available(self, wait):
self.reads += 1
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet
def read_exact(self, count, timeout):
self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging."""
done = threading.Event()
def run():
try:
pb.Loader(port).connect_autobaud(wait)
except Exception:
pass
finally:
done.set()
threading.Thread(target=run, daemon=True).start()
return done.wait(budget)
def main():
# the hang: a flooding target must not spin the drain forever. With wait=0.5
# the whole handshake has to give up well inside a few seconds.
check("flooding target: handshake terminates, drain is bounded",
terminates(FloodPort(), wait=0.5, budget=4.0))
# the control: a real loader still connects and reads identity.
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5)
print(f"\n {P} passed, {F} failed")
return 1 if F else 0
if __name__ == "__main__":
raise SystemExit(main())