pureboot v8: one-wire on every backend

HALF_DUPLEX deploys a shared line per backend. The hardware USART takes
the library's .half_duplex turn-around — RXD and TXD tied off-chip, each
reply byte held to transmit-complete before the line can be released
(m8 404 B, m328P 440, 1284P 460; the window poll runs through the
outlined release-line call at 18 or 22 cycles a poll, measured off the
built loops and held per chip by pureboot.window.halfduplex). The
software and autobaud links fold onto the RX pin — RX == TX spells the
same — and cost nothing: the frame's direction wrap is what the dropped
second-pin init paid, and the worst image in the space is unchanged at
the 1284s' 502 of 512, now with its one-wire twin proven equal across
the exhaustive matrix. The host gains --one-wire, the echo discard a
shared line requires: the adapter's echo is matched byte for byte and a
reply interleaving a blind write — a loader already in session
re-prompts inside the knock — is held for the reader. The device runner
models the shared line by direction (drives only while the firmware's
DDR reads input, decodes only while the firmware owns it, supplies the
host-side echo), extends the USART pin-ownership model to RXEN's hold
on RXD, and starts the pty USART from the datasheet's zeroed UCSR#B:
simavr's TXEN-set reset plus its clear-UDRE-on-TXEN-drop otherwise
wedges the first transmitter after a receiver-only program, which the
half-duplex window gate caught as a banner that never came. v7 is
tagged at its era's last commit; v8 changes nothing on the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 02:32:11 +02:00
parent 47419400f6
commit f71d76a815
13 changed files with 507 additions and 50 deletions

View File

@@ -41,6 +41,9 @@ consteval avr::hertz_t clock()
#if !defined(PUREBOOT_TX)
#define PUREBOOT_TX pb1
#endif
#if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0
#endif
#if !defined(PUREBOOT_USART)
#define PUREBOOT_USART 0
#endif
@@ -64,6 +67,10 @@ struct link {
static constexpr avr::baud_t baud{115200};
#endif
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
static void init()
{
avr::init<tx_t>();
}
static void tx(char c)
{
tx_t::write(static_cast<std::uint8_t>(c));
@@ -110,7 +117,21 @@ struct link<C, false> {
#else
static constexpr avr::baud_t baud{57600};
#endif
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud>;
// A shared-pin deployment (RX == TX) banners as a guest on its own line:
// the pull-up input is the released line, the transmitter takes the pin
// for exactly one frame per byte — the shape a real one-wire application
// beside this loader uses.
static constexpr bool one_wire = avr::PUREBOOT_RX == avr::PUREBOOT_TX;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud, one_wire>;
static void init()
{
// The guest transmitter configures no pin; the released line — the
// pull-up input a receiver would own — is established here.
if constexpr (one_wire)
avr::init<avr::io::input<avr::PUREBOOT_TX, avr::io::pull::up>, tx_t>();
else
avr::init<tx_t>();
}
static void tx(char c)
{
tx_t::write(static_cast<std::uint8_t>(c));
@@ -145,7 +166,7 @@ struct link<C, false> {
int main()
{
avr::init<typename link<dev::clock>::tx_t>();
link<dev::clock>::init();
#if !defined(PUREBOOT_HANDOVER)
link<dev::clock>::tx('A');
link<dev::clock>::tx('P');

View File

@@ -6,13 +6,14 @@ the *same* loader binary, which is the property autobaud exists for: one
clock-agnostic image that locks onto whatever rate the host sends.
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
<app_bin> <app_hz> <app_baud> <tool_py> <workdir>
<app_bin> <app_hz> <app_baud> <tool_py> <workdir> [link]
The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's
default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). The
app fixture is built for (app_hz, app_baud); the hand-over is checked at that
point, and a second point at half the clock proves the lock is measured, not
baked in.
The loader is a software-serial build, driven over the GPIO⇄pty bridge; the
optional link overrides the default -l sw:B0,B1 — RX == TX in it is the
one-wire deployment, and every session then runs with the host's echo
discard on. The app fixture is built for (app_hz, app_baud); the hand-over
is checked at that point, and a second point at half the clock proves the
lock is measured, not baked in.
"""
import os
@@ -27,8 +28,12 @@ def fail(message):
def main():
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:]
args = sys.argv[1:]
link = args.pop() if len(args) == 11 else "sw:B0,B1"
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = args
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
extra = ("--one-wire",) if one_wire else ()
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
@@ -55,11 +60,11 @@ def main():
"""One clock point: reset, calibrate + knock, program, verify against the
simulator's own flash, and (at the app's point) hand over to the fixture."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link="sw:B0,B1")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link=link)
try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--info", "--clock", str(hz),
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out:
@@ -80,7 +85,7 @@ def main():
# Read both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--verify-flash", app_bin,
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--verify-flash", app_bin,
"--verify-eeprom", ee_path, "--read-flash", read_flash,
"--read-eeprom", read_eeprom, "--stay")
if out.count("verify:") != 2:
@@ -99,6 +104,8 @@ def main():
# pulse is genuinely seen and the test cannot pass vacuously.)
device.reset()
port = pb.Port(device.pty, baud)
if one_wire:
port = pb.OneWirePort(port)
try:
time.sleep(0.2)
port.write(bytes((pb.CALIBRATE,)))
@@ -117,6 +124,8 @@ def main():
device.reset()
port = pb.Port(device.pty, baud)
if one_wire:
port = pb.OneWirePort(port)
try:
loader = pb.Loader(port)
live = loader.connect_autobaud(15)
@@ -160,9 +169,11 @@ def main():
the question is only whether the loader can still measure the pulse."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump,
link="sw:B0,B1")
link=link)
try:
port = pb.Port(device.pty, baud)
if one_wire:
port = pb.OneWirePort(port)
try:
live = pb.Loader(port).connect_autobaud(15)
if live.version != pb.NEWEST_LOADER:

View File

@@ -17,6 +17,7 @@ Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
"""
import os
import re
import sys
@@ -35,6 +36,9 @@ def main():
if "@" not in link:
fail(f"the link {link} names no owning USART — nothing would be under test")
# A shared line (RX == TX) echoes the host's own bytes; discard them the
# way the shipped --one-wire mode does.
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1@[01]", link) is not None
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "dump.bin")
@@ -42,6 +46,8 @@ def main():
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
port = pb.Port(device.pty, baud)
if one_wire:
port = pb.OneWirePort(port)
loader = pb.Loader(port)
loader.connect(25)
resident = loader.info.version

View File

@@ -11,6 +11,7 @@ loader built off the chip's natural serial default.
"""
import os
import re
import sys
@@ -70,10 +71,15 @@ def main():
+ bytes([flags])
)
# A shared-line link (RX == TX in the -l spec) makes the host read every
# byte it sends back off the line; all sessions then discard the echo.
one_wire = bool(link) and re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
extra = ("--one-wire",) if one_wire else ()
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
# Session 1: knock from reset, identify, program everything, stay.
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--info", "--fuses", "--flash", app_bin,
"--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out:
@@ -83,7 +89,7 @@ def main():
# the data space; hand over is deferred — the pty must be reopened for
# the APP banner first.
probe = "c0ffee"
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
if out.count("verify:") != 2:
@@ -111,6 +117,8 @@ def main():
# land in the application, which banners on the same link.
device.reset()
port = pb.Port(device.pty, baud)
if one_wire:
port = pb.OneWirePort(port)
try:
loader = pb.Loader(port)
live = loader.connect(15)

View File

@@ -13,7 +13,7 @@ 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
(--autobaud-polls); the seconds it amounts to are budget × 9 / f_cpu, the
measured cost of the calibrate() wait loop this gate pins.
"""
import argparse

View File

@@ -54,6 +54,7 @@ namespace {
avr_t *avr;
uart_pty_t uart_pty;
bool link_software;
avr_uart_t *hw_uart; // the pty-driven USART, for the datasheet-reset fix below
char uart_digit = '0';
char sw_rx_port = 'B', sw_tx_port = 'B';
int sw_rx_bit = 0, sw_tx_bit = 1;
@@ -85,6 +86,18 @@ void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
window_first_tx();
}
// One-wire (RX == TX in the link spec): both directions on one GPIO line
// idling on the firmware's pull-up. The bridge then follows the pin's
// direction the way the real wiring does: it drives only while the
// firmware's DDR bit reads input, decodes transitions as the firmware's
// transmit only while the firmware owns the line, ignores its own raises
// coming back through the shared irq — and echoes every byte it drives back
// to the pty, which is what the host-side FTDI tie does and what the host
// tool's --one-wire mode reads back and discards.
bool link_one_wire;
bool mcu_owns_line;
bool self_drive;
int parse_link(std::string_view spec)
{
if (spec == "usart0" || spec == "usart1") {
@@ -101,6 +114,7 @@ int parse_link(std::string_view spec)
std::sscanf(spec.data() + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner);
if (fields == 4 || fields == 5) {
sw_tx_owner = owner;
link_one_wire = sw_rx_port == sw_tx_port && sw_rx_bit == sw_tx_bit;
return 0;
}
}
@@ -260,7 +274,14 @@ avr_uart_t *tx_owner;
bool tx_pin_taken()
{
return tx_owner && avr_regbit_get(avr, tx_owner->txen);
if (!tx_owner)
return false;
if (avr_regbit_get(avr, tx_owner->txen))
return true;
// One-wire on the USART's RXD: RXEN forces the shared pin's direction to
// input (§20.7.3), so the firmware's drive goes nowhere until the
// release — the receive-side twin of the TXD hold.
return link_one_wire && avr_regbit_get(avr, tx_owner->rxen);
}
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
@@ -288,6 +309,13 @@ void find_tx_owner()
void tx_hook(avr_irq_t *, std::uint32_t value, void *)
{
if (link_one_wire && (self_drive || !mcu_owns_line)) {
// The bridge's own drive coming back through the shared irq, or a
// transition while the line is the bridge's — either way not the
// firmware talking: the decoder sees an idle line.
tx_level = 1;
return;
}
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
tx_level = 1;
return;
@@ -308,15 +336,29 @@ std::uint8_t rx_byte;
void rx_start_next();
// Every level the bridge itself puts on the line goes through here, so the
// shared-pin decoder can tell its own drive from the firmware's.
void bridge_drive(int level)
{
self_drive = true;
avr_raise_irq(rx_pin, static_cast<std::uint32_t>(level));
self_drive = false;
}
avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
{
if (rx_bit < 8) {
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
bridge_drive((rx_byte >> rx_bit) & 1);
rx_bit++;
return when + bit_cycles;
}
if (rx_bit == 8) { // stop bit, plus one idle bit of margin
avr_raise_irq(rx_pin, 1);
bridge_drive(1);
// The host-side tie: an FTDI adapter on a one-wire line reads every
// byte it transmits — supply that echo, which the host tool's
// --one-wire mode consumes as its wiring check.
if (link_one_wire && write(pty_master, &rx_byte, 1) != 1)
std::println(stderr, "device: pty echo lost a byte");
rx_bit++;
return when + 2 * bit_cycles;
}
@@ -329,14 +371,33 @@ void rx_start_next()
{
if (rx_active || rx_head == rx_tail)
return;
// The firmware is answering on the shared line: hold the byte — a real
// host's transmission waits out the reply on the wire too. The next
// poll_pty tick retries once the line is handed back.
if (link_one_wire && mcu_owns_line)
return;
rx_byte = rx_queue[rx_head];
rx_head = (rx_head + 1) % sizeof(rx_queue);
rx_active = 1;
rx_bit = 0;
avr_raise_irq(rx_pin, 0); // start bit
bridge_drive(0); // start bit
avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
}
// The shared pin's direction is the line's ownership: DDR-out is the
// firmware driving a frame, DDR-in hands the line back to the bridge.
void on_ddr(avr_irq_t *, std::uint32_t value, void *)
{
const bool owns = (value >> sw_rx_bit) & 1;
if (mcu_owns_line && !owns)
bridge_drive(1); // hand-back: a turn-based host idles here, and the cache stays truthful
mcu_owns_line = owns;
// A byte held back while the firmware answered starts from the next
// poll_pty tick, never from inside the DDR write itself — the port
// model's own pull-up re-derivation runs right after this notify and
// would erase a start edge raised here.
}
// A reset abandons whatever the bridge was mid-transfer: bytes still queued
// for a chip that no longer has the context to receive them meaningfully,
// and a decode in progress on a TX line the reset may have already changed.
@@ -352,6 +413,7 @@ void bridge_reset()
rx_active = 0;
tx_active = 0;
tx_level = 1;
mcu_owns_line = false; // avr_reset zeroed DDR: every pin reads input again
// 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
@@ -360,8 +422,8 @@ void bridge_reset()
// 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);
bridge_drive(0);
bridge_drive(1);
}
void poll_pty()
@@ -375,8 +437,9 @@ void poll_pty()
rx_queue[rx_tail] = chunk[i];
rx_tail = next;
}
if (got > 0)
rx_start_next();
// Unconditional: a byte held back while the firmware owned a shared
// line restarts from here once the hand-back has happened.
rx_start_next();
}
// ------------------------------------------------------------------ main ---
@@ -518,6 +581,19 @@ int main(int argc, char *argv[])
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
// simavr leaves TXEN set out of reset where silicon clears the whole
// UCSR#B (§20.11.3). Harmless to a loader that enables TXEN itself —
// but a half-duplex build's receiver-only init then *drops* TXEN,
// and this uart model clears UDRE on that edge and never re-raises
// it on a later enable: the first transmitter after the hand-over
// waits UDRE forever, a wedge silicon does not have. Start from the
// datasheet's zero, as the software bridge's tx-owner model does.
for (avr_io_t *io = avr->io_port; io; io = io->next)
if (io->kind && std::string_view{io->kind} == "uart" &&
reinterpret_cast<avr_uart_t *>(io)->name == uart_digit)
hw_uart = reinterpret_cast<avr_uart_t *>(io);
if (hw_uart)
avr_regbit_clear(avr, hw_uart->txen);
uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit);
if (window_report)
@@ -532,7 +608,10 @@ int main(int argc, char *argv[])
avr_irq_register_notify(
avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), static_cast<unsigned>(sw_tx_bit)), tx_hook,
nullptr);
avr_raise_irq(rx_pin, 1); // idle line
if (link_one_wire)
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), IOPORT_IRQ_DIRECTION_ALL),
on_ddr, nullptr);
bridge_drive(1); // idle line
int slave;
struct termios raw;
@@ -564,6 +643,8 @@ int main(int argc, char *argv[])
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
if (hw_uart) // and simavr's bogus reset TXEN (§20.11.3: zero)
avr_regbit_clear(avr, hw_uart->txen);
} else {
bridge_reset();
reset_tx_owner();