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:
2026-07-30 16:05:42 +02:00
parent c8ac61779e
commit 8e7cc86fb3
7 changed files with 297 additions and 50 deletions

View File

@@ -202,6 +202,21 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbtest-work) ${CMAKE_BINARY_DIR}/pbtest-work)
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
# The activation window as a measured duration: application installed,
# line idle, the first transmit is the application's banner — its
# cycle is the window the source declares, held to ±2 % (one
# mis-counted cycle per poll is a 10 % shift).
add_test(NAME pureboot.window
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot>
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
--seconds ${PUREBOOT_TIMEOUT}
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
# The position-independence acceptance test: the identical image, # The position-independence acceptance test: the identical image,
# installed one slot lower, must serve the full command set. # installed one slot lower, must serve the full command set.
add_test(NAME pureboot.reloc add_test(NAME pureboot.reloc
@@ -543,5 +558,19 @@ if(PROJECT_IS_TOP_LEVEL)
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work) ${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240) set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
# The autobaud window: the calibration poll budget, at the measured
# 10 cycles a poll (pbwindow.py pins the constant the README's
# seconds arithmetic uses; the budget itself is the clock-free knob).
add_test(NAME pureboot.window.autobaud
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_autobaud>
--mcu ${PUREBOOT_SIM_MCU} --hz 1000000
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud 9600 --app $<TARGET_FILE:pbapp_autobaud>.bin
--autobaud-polls 4000000 --link sw
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-autobaud-work)
set_tests_properties(pureboot.window.autobaud PROPERTIES TIMEOUT 300)
endif() endif()
endif() endif()

View File

@@ -19,42 +19,42 @@ come out byte-identical linked at a different base.
## Chips ## Chips
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a The Stock column is the default configuration: the hardware USART0 at 115200
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on 8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves 57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above).
per build — see *Configuration*. The autobaud column is the clock-free build, Every axis moves per build — see *Configuration*. The Autobaud column is the
which is the largest the space produces and the tightest fit in the matrix; worst configuration the space produces for the chip: the clock-free build —
it carries the calibration machinery and no clock at all. it alone carries the calibration machinery — with the `OSCCAL` trim baked
and, where the chip has a USART, the link deployed on that USART's own pins,
which the loader then has to release (*Pin ownership*). On default pins
without the trim the same loaders run 1030 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud | | Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 452 B | | ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 456 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 442 B | | ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 446 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 442 B | | ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 446 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 442 B | | ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 446 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 358 B | 470 B | | ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 358 B | 476 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 360 B | 474 B | | ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 360 B | 480 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 360 B | 474 B | | ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 360 B | 480 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 438 B | | ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 450 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 448 B | | ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 460 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 454 B | | ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 464 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 454 B | | ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 464 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 454 B | | ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 464 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 454 B | | ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 464 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 448 B | | ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 458 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 474 B | | ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 484 B |
† No hardware boot section: the host patches the reset vector, and the budget † No hardware boot section: the host patches the reset vector, and the budget
is 510 bytes, since the slot's last word is the trampoline. is 510 bytes, since the slot's last word is the trampoline.
The tightest fit in the whole space is the 1284s' autobaud build deployed on a The tightest fit in the whole space is therefore the 1284s' 484 of their
USART's own pins with the `OSCCAL` trim baked, 484 of its 512 — they alone 512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud commands) on top of everything the column already stacks. The flash bank
alone carries the calibration loop, a bit-banged link on a USART's pins alone riding in a transfer's selector byte keeps even those chips' addressing the
has to release it (below), and the trim adds its one register write. Without same 16-bit form every other chip uses, which is why they are no longer the
the trim that build is 478; on the default pins, 474. The flash bank riding
in a transfer's selector byte keeps even those chips' addressing the same
16-bit form every other chip uses, which is why they are no longer the
outlier they were. outlier they were.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire The software UART enables the RX pull-up; TX idles high. All multi-byte wire
@@ -100,7 +100,10 @@ where a fixed-baud software build has to be rebuilt per clock and still drifts
out of tolerance. The cost is that it is software-serial only (a hardware USART out of tolerance. The cost is that it is software-serial only (a hardware USART
needs its divisor programmed) and that activation counts poll iterations rather needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). (`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). The wait spends ten cycles a
poll (measured, and held by the `pureboot.window.autobaud` gate), so the
default window is 40 M cycles: 5 s at 8 MHz, about 4.2 s at 9.6 MHz, 40 s at
1 MHz.
**Pick the rate by cycles a bit, and leave the oscillator room.** What the **Pick the rate by cycles a bit, and leave the oscillator room.** What the
calibration can measure is bounded by how many clock cycles one bit lasts, so a calibration can measure is bounded by how many clock cycles one bit lasts, so a

View File

@@ -171,9 +171,11 @@ template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbiw + sbci + sbci + brne (6). // lds UCSR0A (2), sbrc skipping the exit (2), sbiw + sbci + brne (5).
static constexpr std::uint8_t poll_cycles = 10; // A uint32_t countdown pays one more sbci — window_polls() adds it where
// the count forces the wide type. Held by the pureboot.window gate.
static constexpr std::uint8_t poll_cycles = 9;
static void init() static void init()
{ {
@@ -206,9 +208,11 @@ struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>; using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>; using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbci + brne (6). // sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
static constexpr std::uint8_t poll_cycles = 8; // countdown pays one more sbci — window_polls() adds it where the count
// forces the wide type. Held by the pureboot.window gate.
static constexpr std::uint8_t poll_cycles = 7;
static void init() static void init()
{ {
@@ -317,17 +321,32 @@ void await_host()
} }
} }
#else #else
// The window as one 32-bit countdown, divided by the backend's counted // The window as one countdown, divided by the backend's counted poll-loop
// poll-loop cycles. Whole seconds is all it promises. // cycles. Whole seconds is all it promises. The per-poll cost depends on the
// countdown's own width (a uint32_t decrement chain is one sbci longer), and
// the width depends on the poll count — solved narrow-first: a count that
// fits 24 bits at the narrow cost keeps the narrow loop, anything else takes
// the wide loop at its own cost. A count fitting 24 bits only at the wide
// cost stays wide, so the choice cannot oscillate on the boundary.
consteval std::uint32_t polls_at(std::uint32_t per_poll)
{
return timeout_seconds * (dev::clock.hz / per_poll);
}
consteval bool narrow_window()
{
return polls_at(link::poll_cycles) <= 0xffffff;
}
consteval std::uint32_t window_polls() consteval std::uint32_t window_polls()
{ {
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles); return polls_at(narrow_window() ? link::poll_cycles : link::poll_cycles + 1u);
} }
// The countdown in the narrowest type that holds it: a fourth byte would // The countdown in the narrowest type that holds it: a fourth byte would
// cost a wider decrement chain at every poll for range most windows never // cost a wider decrement chain at every poll for range most windows never
// use (the autobaud budget makes the same choice). // use (the autobaud budget makes the same choice).
using window_t = std::conditional_t<window_polls() <= 0xffffff, avr::uint24_t, std::uint32_t>; using window_t = std::conditional_t<narrow_window(), avr::uint24_t, std::uint32_t>;
bool pending_before_deadline() bool pending_before_deadline()
{ {

View File

@@ -8,10 +8,13 @@ import subprocess
class Device: 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] cmd = [binary]
if link: if link:
cmd += ["-l", 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] cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None: if reset_hex is not None or resume is not None:
# Chips without a hardware boot section — the tinies and the # Chips without a hardware boot section — the tinies and the

134
test/pbwindow.py Normal file
View 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())

View File

@@ -62,6 +62,29 @@ const char *dump_path;
std::uint32_t reset_pc; std::uint32_t reset_pc;
volatile std::sig_atomic_t reset_requested; 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) int parse_link(std::string_view spec)
{ {
if (spec == "usart0" || spec == "usart1") { 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 *) 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) { if (tx_bit < 8) {
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0)); tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8) if (++tx_bit < 8)
@@ -256,10 +293,10 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
return; return;
} }
int level = value & 1; 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_active = 1;
tx_bit = 0; tx_bit = -1;
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr); avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
} }
tx_level = level; tx_level = level;
} }
@@ -315,7 +352,16 @@ void bridge_reset()
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
tx_level = 1; 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() void poll_pty()
@@ -364,7 +410,11 @@ void poll_pty()
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
bool link_given = false; 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) { if (opt != 'l' || parse_link(optarg) != 0) {
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)"); std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
return 2; return 2;
@@ -374,10 +424,12 @@ int main(int argc, char *argv[])
int args = argc - optind; int args = argc - optind;
if (args < 7 || args > 9) { if (args < 7 || args > 9) {
std::print(stderr, 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" " [reset_hex] [resume_flash]\n"
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n" " -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
" them); default: the chip's own\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" " 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" " resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" run's dump, for power-fail resume tests\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); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit); 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); std::println("PB_PTY {}", uart_pty.pty.slavename);
} else { } else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly 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 // entirely. Pace the simulation only while the bridge is fully
// quiet (nothing decoding, nothing queued); transfers keep full // quiet (nothing decoding, nothing queued); transfers keep full
// speed, and a quiet window stretches toward real time. // 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); usleep(200);
} }
} }

View File

@@ -120,7 +120,9 @@ def cmd_max(args) -> int:
def cmd_check_readme(args) -> int: def cmd_check_readme(args) -> int:
"""The README's per-chip table, against the stock and autobaud builds.""" """The README's per-chip table, against the stock build and the worst
autobaud configuration (OSCCAL baked, plus the USART-pin release where
the chip has a USART) — the config the Autobaud column documents."""
readme = (ROOT / "pureboot" / "README.md").read_text() readme = (ROOT / "pureboot" / "README.md").read_text()
measured = collect() measured = collect()
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$", rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
@@ -132,7 +134,9 @@ def cmd_check_readme(args) -> int:
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base. # "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower()) chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
built = {name: text for name, text, _ in measured.get(chip, [])} built = {name: text for name, text, _ in measured.get(chip, [])}
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)): worst = ("pureboot_autobaud_osccal_on_usart0"
if "pureboot_autobaud_osccal_on_usart0" in built else "pureboot_autobaud_osccal")
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
if target not in built: if target not in built:
skipped += 1 skipped += 1
continue continue