pureboot: the activation window gets a behavioral gate, and honest per-poll constants under it
The window's per-poll cycle counts were hand-counted for a uint32_t countdown, but every default window fits uint24_t, whose decrement chain is one sbci shorter — so deployed loaders ran 9/10ths of their stated seconds (a 328P's 8 s was 7.2 s on the wire). No golden-asm pin can hold this: the loops compile in consumer context. pbwindow.py measures the behavior instead: it installs a real application beside the loader through the host tool's own plan_flash (surgery included), starts the simulator with the line idle, and reads the cycle of the first transmit — the application's banner, so that cycle is the window. Held at plus or minus 2 percent per chip (pureboot.window), red at -10.0 percent against the old constants, green with poll_cycles now counted for the narrow countdown (hardware 9, software 7; window_polls() solves narrow-first and adds the wide loop's cycle where the count forces uint32_t — a count narrow only at the wide cost stays wide, so the choice cannot oscillate). The autobaud window is its poll budget at the measured ten cycles a poll, gated the same way (pureboot.window.autobaud), and the README carries that arithmetic now. No version bump: timing-window precision is not meaningful behavior, v7 stays. The gate flushed out two runner gaps. The software bridge accepted any falling edge as a start bit, so the device's own TX-init glitch decoded as a stray byte; it re-samples mid-bit now and abandons a false start, as silicon does. And after avr_reset, the idle-line re-raise was silently dropped: ioport pin irqs are IRQ_FLAG_FILTERED and the irq's cached value survives the reset the port latch does not, so the device read the line stuck low, calibrate() measured reset-to-first-edge as one wrapping pulse, and the first knock after a reset could boot the application instead of locking — the intermittent autobaud failure. bridge_reset forces a real transition (0 then 1, no cycles between). The README's Autobaud column now carries each chip's worst configuration — autobaud with OSCCAL baked, on a USART's own pins where the chip has one (tinies: autobaud + OSCCAL) — the numbers the existing pureboot_autobaud_osccal[_on_usart0] matrix points already gate; sizes.py checks the column against exactly those targets. Tool sizes and window prose updated with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,13 @@ import subprocess
|
||||
|
||||
|
||||
class Device:
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None):
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None,
|
||||
window=False):
|
||||
cmd = [binary]
|
||||
if link:
|
||||
cmd += ["-l", link]
|
||||
if window:
|
||||
cmd.append("-w") # report the first-transmit cycle, free-run idle
|
||||
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
|
||||
if reset_hex is not None or resume is not None:
|
||||
# Chips without a hardware boot section — the tinies and the
|
||||
|
||||
134
test/pbwindow.py
Normal file
134
test/pbwindow.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The activation window as a behavioral duration gate.
|
||||
|
||||
The loader's window is a counted poll loop whose per-poll cost is hand-counted
|
||||
in the source (`link::poll_cycles`) — but the loop compiles in consumer
|
||||
context, so only the running image can prove the count. This test installs a
|
||||
real application beside the loader (the host tool's own `plan_flash` supplies
|
||||
the reset-vector surgery), starts the simulator with the line idle, and reads
|
||||
the cycle of the first transmit activity: nothing talks until the window
|
||||
closes and the application banners, so that cycle *is* the window, give or
|
||||
take a banner lead measured in microseconds. Asserted at ±2 % — one
|
||||
mis-counted cycle per poll shifts a window by 10 % and more.
|
||||
|
||||
Fixed-baud loaders declare their window in seconds (--seconds, the build's
|
||||
TIMEOUT). The autobaud loader's window is its calibration poll budget
|
||||
(--autobaud-polls); the seconds it amounts to are budget × 10 / f_cpu, the
|
||||
measured cost of the calibrate() wait loop this gate pins.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
from pbsim import Device
|
||||
|
||||
# The calibrate() budget loop's cycles per poll in the built image — what the
|
||||
# README's window arithmetic rests on, verified here.
|
||||
AUTOBAUD_POLL_CYCLES = 10
|
||||
|
||||
|
||||
def load_tool(path):
|
||||
spec = importlib.util.spec_from_file_location("pureboot", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def compose_flash(pb, loader_bytes, app_bytes, mcu, base, page):
|
||||
"""The flash image a completed programming session leaves: application
|
||||
(with the tinies' vector surgery), loader at base — built through the
|
||||
host tool's own planner so the surgery is the shipped one, not a copy."""
|
||||
flash_size = base + pb.SLOT
|
||||
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
|
||||
word_flash = flash_size > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
raw = bytes((ord("P"), ord("B"), 5, 0, 0, 0, page & 0xFF,
|
||||
wire_base & 0xFF, wire_base >> 8, 0, 0, flags))
|
||||
info = pb.Info(raw)
|
||||
|
||||
flash = bytearray(b"\xff" * flash_size)
|
||||
for address, content in pb.plan_flash(app_bytes, info).items():
|
||||
flash[address:address + len(content)] = content
|
||||
flash[base:base + len(loader_bytes)] = loader_bytes
|
||||
return bytes(flash)
|
||||
|
||||
|
||||
def first_tx_cycle(device, deadline):
|
||||
"""The PB_WINDOW_TX report, or None. The runner prints it once."""
|
||||
stream = device.proc.stdout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
ready, _, _ = select.select([stream], [], [], remaining)
|
||||
if not ready:
|
||||
return None
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith("PB_WINDOW_TX"):
|
||||
return int(line.split()[1])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--device", required=True)
|
||||
parser.add_argument("--loader", required=True)
|
||||
parser.add_argument("--mcu", required=True)
|
||||
parser.add_argument("--hz", type=int, required=True)
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--page", type=int, required=True)
|
||||
parser.add_argument("--baud", type=int, required=True)
|
||||
parser.add_argument("--app", required=True)
|
||||
parser.add_argument("--tool", required=True)
|
||||
parser.add_argument("--workdir", required=True)
|
||||
parser.add_argument("--link", default=None)
|
||||
parser.add_argument("--seconds", type=float, default=None)
|
||||
parser.add_argument("--autobaud-polls", type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
if (args.seconds is None) == (args.autobaud_polls is None):
|
||||
parser.error("exactly one of --seconds / --autobaud-polls")
|
||||
|
||||
pb = load_tool(args.tool)
|
||||
base = int(args.base, 0)
|
||||
expected = (args.seconds if args.seconds is not None
|
||||
else args.autobaud_polls * AUTOBAUD_POLL_CYCLES / args.hz)
|
||||
|
||||
work = pathlib.Path(args.workdir)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
# Every loader target objcopies its slot content beside the ELF (.bin).
|
||||
loader_bytes = pathlib.Path(args.loader + ".bin").read_bytes()
|
||||
app_bytes = pathlib.Path(args.app).read_bytes()
|
||||
flash_file = work / "window-flash.bin"
|
||||
flash_file.write_bytes(compose_flash(pb, loader_bytes, app_bytes, args.mcu, base, args.page))
|
||||
|
||||
device = Device(args.device, args.loader, args.mcu, str(args.hz), args.base, args.page,
|
||||
args.baud, str(work / "window-dump.bin"), resume=str(flash_file),
|
||||
link=args.link, window=True)
|
||||
try:
|
||||
# Simulation speed is machine-dependent; a few hundred thousand
|
||||
# cycles per wall second is the pessimistic floor.
|
||||
budget = max(60.0, expected * args.hz / 300000)
|
||||
cycle = first_tx_cycle(device, time.monotonic() + budget)
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
if cycle is None:
|
||||
print(f" [FAIL] no transmit activity within {budget:.0f} s wall "
|
||||
f"(expected a {expected:.2f} s window)")
|
||||
return 1
|
||||
measured = cycle / args.hz
|
||||
error = (measured - expected) / expected
|
||||
ok = abs(error) <= 0.02
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] window {measured:.3f} s vs declared "
|
||||
f"{expected:.3f} s ({error:+.1%}, gate ±2%)")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -62,6 +62,29 @@ const char *dump_path;
|
||||
std::uint32_t reset_pc;
|
||||
volatile std::sig_atomic_t reset_requested;
|
||||
|
||||
// -w: report the cycle of the first transmit activity, once. What the
|
||||
// activation-window gate reads — with an idle line and an application
|
||||
// installed, the first thing that ever talks is the application's banner,
|
||||
// so this cycle *is* the loader's window plus a banner lead measured in
|
||||
// microseconds. Idle pacing is skipped in this mode: there is no real-time
|
||||
// host in the loop, and a paced multi-second window would take hours.
|
||||
bool window_report;
|
||||
bool window_tx_seen;
|
||||
|
||||
void window_first_tx()
|
||||
{
|
||||
if (!window_report || window_tx_seen)
|
||||
return;
|
||||
window_tx_seen = true;
|
||||
std::println("PB_WINDOW_TX {}", avr->cycle);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
|
||||
{
|
||||
window_first_tx();
|
||||
}
|
||||
|
||||
int parse_link(std::string_view spec)
|
||||
{
|
||||
if (spec == "usart0" || spec == "usart1") {
|
||||
@@ -195,6 +218,20 @@ std::uint8_t tx_shift;
|
||||
|
||||
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
if (tx_bit < 0) {
|
||||
// Half a bit into the start bit: a real receiver re-samples here and
|
||||
// abandons a false start. The device's own init produces one — DDR
|
||||
// drives the pin low for the instructions until the idle level is
|
||||
// written — and without this check that glitch decodes as a stray
|
||||
// byte (and would read as first transmit activity under -w).
|
||||
if (tx_level) {
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
window_first_tx();
|
||||
tx_bit = 0;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (tx_bit < 8) {
|
||||
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
@@ -256,10 +293,10 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
|
||||
return;
|
||||
}
|
||||
int level = value & 1;
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge, confirmed mid-bit
|
||||
tx_active = 1;
|
||||
tx_bit = 0;
|
||||
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr);
|
||||
tx_bit = -1;
|
||||
avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
|
||||
}
|
||||
tx_level = level;
|
||||
}
|
||||
@@ -315,7 +352,16 @@ void bridge_reset()
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
tx_level = 1;
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
// Re-drive the idle line through a forced transition: ioport pin irqs are
|
||||
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
|
||||
// keeps its pre-reset cached value — so a plain raise(1) against a cached
|
||||
// 1 is dropped and the device reads the line stuck low. A loader entering
|
||||
// calibration on that line measures reset-to-first-edge as one giant
|
||||
// pulse and mis-locks or boots the application on the first real knock.
|
||||
// No cycles run between the two raises, so the device only ever sees the
|
||||
// final idle-high.
|
||||
avr_raise_irq(rx_pin, 0);
|
||||
avr_raise_irq(rx_pin, 1);
|
||||
}
|
||||
|
||||
void poll_pty()
|
||||
@@ -364,7 +410,11 @@ void poll_pty()
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
bool link_given = false;
|
||||
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) {
|
||||
for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) {
|
||||
if (opt == 'w') {
|
||||
window_report = true;
|
||||
continue;
|
||||
}
|
||||
if (opt != 'l' || parse_link(optarg) != 0) {
|
||||
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
|
||||
return 2;
|
||||
@@ -374,10 +424,12 @@ int main(int argc, char *argv[])
|
||||
int args = argc - optind;
|
||||
if (args < 7 || args > 9) {
|
||||
std::print(stderr,
|
||||
"usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
"usage: {} [-l link] [-w] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
" [reset_hex] [resume_flash]\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
|
||||
" them); default: the chip's own\n"
|
||||
" -w: print PB_WINDOW_TX <cycle> at the first transmit activity and\n"
|
||||
" free-run idle time (window measurement mode)\n"
|
||||
" reset_hex: reset vector (default: base with a boot section, else 0)\n"
|
||||
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
|
||||
" run's dump, for power-fail resume tests\n",
|
||||
@@ -468,6 +520,9 @@ int main(int argc, char *argv[])
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, uart_digit);
|
||||
if (window_report)
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_UART_GETIRQ(uart_digit), UART_IRQ_OUTPUT),
|
||||
window_uart_hook, nullptr);
|
||||
std::println("PB_PTY {}", uart_pty.pty.slavename);
|
||||
} else {
|
||||
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
|
||||
@@ -524,7 +579,7 @@ int main(int argc, char *argv[])
|
||||
// entirely. Pace the simulation only while the bridge is fully
|
||||
// quiet (nothing decoding, nothing queued); transfers keep full
|
||||
// speed, and a quiet window stretches toward real time.
|
||||
if (!rx_active && !tx_active && rx_head == rx_tail)
|
||||
if (!window_report && !rx_active && !tx_active && rx_head == rx_tail)
|
||||
usleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user