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

@@ -166,7 +166,9 @@ set_property(GLOBAL PROPERTY PUREBOOT_WRAP "${_pb_wrap}")
set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart})
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1})
set_property(GLOBAL PROPERTY PUREBOOT_USART0_RX ${_pb_usart0_rx})
set_property(GLOBAL PROPERTY PUREBOOT_USART0_TX ${_pb_usart0_tx})
set_property(GLOBAL PROPERTY PUREBOOT_USART1_RX ${_pb_usart1_rx})
set_property(GLOBAL PROPERTY PUREBOOT_USART1_TX ${_pb_usart1_tx})
# The port's own build (tests, the size matrix) reads the geometry from the
@@ -236,7 +238,8 @@ endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>]
# [HALF_DUPLEX])
#
# The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the
@@ -244,6 +247,11 @@ endfunction()
# usart0, usart1, or sw:<RX>,<TX> with a trailing @<n> where those pins are a
# USART's own) — what a test harness speaks to it with.
#
# HALF_DUPLEX is the one-wire deployment, per backend: on the hardware USART
# it enables the library's .half_duplex turn-around (RXD and TXD tied
# together off-chip); on a software or autobaud link it puts both directions
# on the RX pin — the same thing RX == TX spells directly.
#
# SERIAL autobaud measures the host's bit timing at run time, so the image
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
# and one binary per chip serves every F_CPU and every rate. The stamped
@@ -257,7 +265,7 @@ endfunction()
# purely for the application's benefit, its own link being clock-free. No
# value, no code.
function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
cmake_parse_arguments(PB "HALF_DUPLEX" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS)
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
endif()
@@ -294,6 +302,9 @@ function(pureboot_add_loader name)
message(FATAL_ERROR "pureboot_add_loader(${name}): ${LIBAVR_MCU} has no hardware USART")
endif()
set(_serial_defines PUREBOOT_USART=${PB_USART})
if(PB_HALF_DUPLEX)
list(APPEND _serial_defines PUREBOOT_HALF_DUPLEX)
endif()
set(_link usart${PB_USART})
else()
if(PB_SERIAL STREQUAL "auto")
@@ -303,6 +314,9 @@ function(pureboot_add_loader name)
endif()
if(_usart)
set(_link usart0)
if(PB_HALF_DUPLEX)
set(_serial_defines PUREBOOT_HALF_DUPLEX)
endif()
else()
set(PB_SERIAL software)
endif()
@@ -311,6 +325,15 @@ function(pureboot_add_loader name)
if(NOT PB_RX)
set(PB_RX pb0)
endif()
if(PB_HALF_DUPLEX)
# One-wire: both directions on the RX pin. RX == TX spells
# the same deployment directly.
if(PB_TX AND NOT PB_TX STREQUAL PB_RX)
message(FATAL_ERROR "pureboot_add_loader(${name}): HALF_DUPLEX puts both "
"directions on RX (${PB_RX}); TX ${PB_TX} contradicts it")
endif()
set(PB_TX ${PB_RX})
endif()
if(NOT PB_TX)
set(PB_TX pb1)
endif()
@@ -334,10 +357,19 @@ function(pureboot_add_loader name)
string(REPLACE "SW" "sw" _link ${_link})
get_property(_tx0 GLOBAL PROPERTY PUREBOOT_USART0_TX)
get_property(_tx1 GLOBAL PROPERTY PUREBOOT_USART1_TX)
get_property(_rx0 GLOBAL PROPERTY PUREBOOT_USART0_RX)
get_property(_rx1 GLOBAL PROPERTY PUREBOOT_USART1_RX)
if(_usart AND PB_TX STREQUAL _tx0)
set(_link "${_link}@0")
elseif(_usart1 AND PB_TX STREQUAL _tx1)
set(_link "${_link}@1")
elseif(PB_TX STREQUAL PB_RX AND _usart AND PB_RX STREQUAL _rx0)
# One-wire on a USART's RXD: RXEN forces that pin's direction
# (§20.7.3), so the driven shared pin is held exactly like a
# TXD — the harness models the hold either way.
set(_link "${_link}@0")
elseif(PB_TX STREQUAL PB_RX AND _usart1 AND PB_RX STREQUAL _rx1)
set(_link "${_link}@1")
endif()
endif()
endif()

View File

@@ -26,8 +26,10 @@ Every axis moves per build — see *Configuration*. The Autobaud column is the
worst configuration the space produces for the chip: the clock-free build —
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 410 B smaller.
which the loader then has to release (*Pin ownership*). Folding the same
build onto a single pin (*One-wire*) measures identically on every chip, so
the column covers that twin too. On default pins without the trim the same
loaders run 410 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---|
@@ -75,6 +77,7 @@ repo's build and by a downstream project alike:
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 |
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
| `HALF_DUPLEX` | one-wire: both directions on one line (*One-wire* below) | off |
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock
reaches within 2.5 % — the same U2X-included divisor search libavr's baud
@@ -126,6 +129,43 @@ another one and the host's retries eventually catch the pulse. That reads as far
more reliable than the same part with an application resident, which gets one
window per reset. Measure with an application in place.
## One-wire
`HALF_DUPLEX` puts both directions on one line — the deployment for a board
with a single spare pin, or a native-UART bootloader's shared-line wiring.
Each backend has its shape:
- **Software and autobaud links** fold onto the RX pin (`RX == TX` spells
the same deployment directly). The pin idles as the receiver's pull-up
input; each transmitted frame takes the pin's direction and hands it back
with the stop bit's level already on the pull-up, so neither flip makes
an edge. This costs nothing: the frame's direction wrap is exactly what
the dropped second-pin init paid, and the tightest image in the space —
the 1284s' autobaud + `OSCCAL` on their USART's RXD — measures the same
502 bytes one-wire as two-wire. On a USART's own pin the release applies
as ever, RXD included: `RXEN` forces that pin's direction (§20.7.3),
which a receive-only link could live with and a driven shared pin cannot.
- **The hardware USART** (`SERIAL hardware`/`auto` + `HALF_DUPLEX`) uses
libavr's `.half_duplex` turn-around — exactly one direction enabled at a
time, each written byte held to transmit-complete before the line can be
released — and needs RXD and TXD tied together off-chip. It costs
+42…50 B over the stock loader (m8 404, m328P 440, 1284P 460 — all far
inside the slot); the activation window is unchanged, its poll merely
runs through the release-line test (18 cycles a poll in bit-addressable
I/O, 22 in extended — measured, and held per chip by
`pureboot.window.halfduplex`).
Host wiring, for an FTDI-style adapter: **adapter TX through ~1 kΩ to the
line, adapter RX and the MCU pin directly on it.** The resistor lets the MCU
win the line while it answers; the price is that the adapter reads back every
byte it transmits. `pureboot.py --one-wire` consumes that echo byte for byte
— a missing echo is reported as the wiring fault it is, and a device reply
that lands between the echoes of the knock (a loader already in session
re-prompts mid-knock) is held for the reader. The knock is the protocol's
one blind multi-byte write, so on real wiring its second byte can be lost to
that collision outright; the tool's knock retries absorb it. Everything else
is ack-paced and cannot collide.
A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
@@ -302,7 +342,12 @@ moves `J` onto the unified decode — it gains the selector byte the table
shows, which older loaders do not read, so the tool sends each form to the
version that speaks it — and re-homes the autobaud unit into the GPIOR pair
on the chips that have one (Session: what must not be written), which is
where `--info`'s measured clock now reads it on those parts.
where `--info`'s measured clock now reads it on those parts. **8** changes
nothing on the wire either: it marks the builds whose deployment may be
one-wire (*One-wire* above) — the hardware USART's half-duplex turn-around,
or a software link folded onto a single pin. The host-side trace is
`--one-wire`, the echo discard a shared line requires of any tool driving
it.
Every closed generation is tagged in this repo at its era's last commit — the
commit just before the next version bump, so a tag holds everything its
@@ -469,6 +514,11 @@ the loader's bit-period unit, decoded and multiplied by the session rate —
which is the number an `OSCCAL` bake or a fixed-baud build for the part is
held against; `--clock <hz>` states the drift against a nominal.
`--one-wire` marks the link as a shared line (*One-wire* above): the tool
reads back and verifies its own echoed bytes, whatever the backend.
It combines with everything, `--scan` included — undiscarded echoes would
answer every rate a scan probes.
`--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
window — reset the target as each probe announces itself (a board with DTR

View File

@@ -77,7 +77,7 @@ static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL
// The loader's one identity number. The protocol carries none of its own —
// a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 7;
constexpr std::uint8_t version = 8;
// The image's identity stamp, for the host tool rather than for the wire: an
// update image is a bare 512-byte slot, and without this nothing in it says
@@ -157,6 +157,9 @@ constexpr std::uint8_t bank_shift = 16 - slot_shift;
#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART)
#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART"
#endif
#if defined(PUREBOOT_HALF_DUPLEX) && (defined(PUREBOOT_SOFT_SERIAL) || defined(PUREBOOT_AUTOBAUD))
#error "PUREBOOT_HALF_DUPLEX is the hardware USART's one-wire mode; a software link goes one-wire by RX == TX"
#endif
#if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0
#endif
@@ -169,22 +172,42 @@ constexpr int usart_unit = PUREBOOT_USART;
constexpr int usart_unit = 0;
#endif
// One-wire on the hardware USART (PUREBOOT_HALF_DUPLEX): RXD and TXD tied
// together off-chip, exactly one direction enabled at a time — the library's
// .half_duplex turn-around. The activation window is unchanged; only its
// poll grows the release-line test rx_ready() carries in this mode.
constexpr bool hw_half_duplex =
#if defined(PUREBOOT_HALF_DUPLEX)
true;
#else
false;
#endif
template <avr::hertz_t C, avr::baud_t B>
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, .half_duplex = hw_half_duplex}>;
// The compiled idle poll around the window's narrow (uint24_t) countdown:
// the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
// status register's home — a 2-cycle bit-skip where UCSRnA sits in
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
// I/O. 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.
// The lookup rides the baud parameter so it stays dependent: the trait is
// an incomplete type on the USART-less chips, which parse this template
// without ever instantiating it.
// I/O. Half-duplex polls through rx_ready()'s release-line test, which
// -Os outlines: the rcall (3), the UCSR#B read and not-taken skip with
// the jump over the write (I/O 3, extended 5), the ret (4) — and the
// call in the loop body pushes the countdown into call-saved registers,
// where the uint24_t step is ldi+sub+sbc+sbc (4) instead of sbiw+sbci
// (3). Measured off the built loops: 18 a poll in bit-addressable I/O,
// 22 in extended. A uint32_t countdown pays one more sbci —
// window_polls() adds it where the count forces the wide type. Held per
// chip by the pureboot.window gates. The lookup rides the baud parameter
// so it stays dependent: the trait is an incomplete type on the
// USART-less chips, which parse this template without ever instantiating
// it.
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
static consteval std::uint8_t poll_cost()
{
if (hw_half_duplex)
return U::ucsra::addr < 0x40 ? 18 : 22;
return U::ucsra::addr < 0x40 ? 7 : 9;
}
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
@@ -220,8 +243,11 @@ struct hardware_link {
template <avr::hertz_t C, avr::baud_t B>
struct software_link {
// RX == TX is the one-wire deployment: the transmitter becomes a guest
// on the receiver's pull-up line, taking the pin's direction for exactly
// one frame per byte.
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, avr::PUREBOOT_RX == avr::PUREBOOT_TX>;
// The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t

View File

@@ -26,15 +26,16 @@ else:
import termios
PROMPT = b"+"
VERSION = 8 # this tool's own version — free to drift from a loader's
VERSION = 9 # this tool's own version — free to drift from a loader's
# The loader versions this tool can drive. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that
# map lives: the tool keeps a decoder for every generation in it (14 speak
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
# builds and changes nothing on the wire), and a version it has no decoder
# for moves the floor.
# builds and changes nothing on the wire; 8 the one-wire deployments, whose
# only host-side trace is the --one-wire echo discard), and a version it has
# no decoder for moves the floor.
OLDEST_LOADER = 1
NEWEST_LOADER = 7
NEWEST_LOADER = 8
SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
@@ -45,7 +46,10 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# 6 marks the builds that may carry a baked OSCCAL trim, nothing on the wire;
# 7 gives 'J' a selector byte (older loaders take the bare address — jump()
# sends each form to the version that speaks it) and re-homes the autobaud
# unit into the GPIOR pair where the chip has one.
# unit into the GPIOR pair where the chip has one; 8 marks the builds whose
# deployment may be one-wire (hardware half-duplex, or a software link folded
# onto one pin) — nothing on the wire either, but a shared line makes the
# host read its own bytes back, which is what --one-wire consumes.
UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
@@ -433,6 +437,61 @@ if os.name == "nt":
Port = WindowsPort if os.name == "nt" else PosixPort
class OneWirePort:
"""The host side of a shared line (--one-wire): an FTDI-style adapter on
a one-wire link reads back every byte it transmits — its RX is tied to
its own TX through the line. Consume that echo at each write and verify
it, which doubles as a wiring check: an echo that never comes is an RX
not on the line, and is reported as itself instead of decoding as a
device reply.
The device's reply may interleave with the echo of a multi-byte write —
a loader already in session re-prompts after the knock's first byte
while the second is still queued behind that reply — so the echo is
matched byte for byte and anything else arriving in between is device
traffic, held for the next read."""
def __init__(self, port):
self._port = port
self._pending = b""
def __getattr__(self, name):
return getattr(self._port, name)
def write(self, data):
data = bytes(data)
self._port.write(data)
# The echo arrives at line rate — 10 bits a byte — plus adapter
# latency; a generous floor keeps slow rates and USB scheduling out
# of the error path.
deadline = time.monotonic() + 10 * len(data) / self._port.baud + 0.5
remaining = data
while remaining:
budget = deadline - time.monotonic()
if budget <= 0:
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
f"{len(data)} byte(s) — is the adapter's RX tied to the line?")
byte = self._port.read_exact(1, budget)
if byte == remaining[:1]:
remaining = remaining[1:]
else:
self._pending += byte
def read_exact(self, count, timeout):
taken, self._pending = self._pending[:count], self._pending[count:]
if len(taken) == count:
return taken
return taken + self._port.read_exact(count - len(taken), timeout)
def read_available(self, wait):
taken, self._pending = self._pending, b""
return taken + self._port.read_available(0 if taken else wait)
def flush_input(self):
self._pending = b""
self._port.flush_input()
# -------------------------------------------------------------- protocol ---
@@ -1478,12 +1537,13 @@ def scan_report(baud, pct, version, clock=None):
return lines
def op_scan(port_path, baud, wait, clock=None):
def op_scan(port_path, baud, wait, clock=None, one_wire=False):
"""A fixed-baud loader whose oscillator drifted still answers — at the
drifted ratio, since its rate scales with its clock. One probe per
activation window, and with an application resident the window opens
exactly once per reset, so each probe announces itself and expects a
fresh reset before knocking."""
fresh reset before knocking. On a shared line the probes echo back like
everything else; undiscarded they would answer every rate."""
for pct in scan_ratios():
rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
@@ -1492,6 +1552,8 @@ def op_scan(port_path, baud, wait, clock=None):
except Error as unmakeable:
print(f"scan: {rate} Bd skipped — {unmakeable}")
continue
if one_wire:
port = OneWirePort(port)
try:
info = Loader(port).connect(wait)
except Error:
@@ -1517,6 +1579,9 @@ def main():
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--one-wire", action="store_true",
help="the link is a shared line: read back and discard this tool's own "
"echoed bytes (any backend of a one-wire deployment)")
parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)")
@@ -1575,11 +1640,14 @@ def main():
if args.scan:
if args.autobaud:
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
op_scan(args.port, args.baud, args.wait, args.clock)
op_scan(args.port, args.baud, args.wait, args.clock, args.one_wire)
return
port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
if args.one_wire:
port = OneWirePort(port)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted"
+ (", one-wire echo discarded" if args.one_wire else ""))
try:
loader = Loader(port)
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)