19 Commits
v6 ... v8

Author SHA1 Message Date
eb213e1025 one-wire: a lost echo and a dead line are not the same report
The blind-write path said "lost to the device's ack" for any missing echo, and
the count is what distinguishes two different faults. Some bytes lost is the
device's ack winning the line against the host's series resistor — ordinary,
and what the knock retry absorbs. *Every* byte lost is nothing coming back at
all, which means the line is not free: a pin held low, a wedge, or an RX that
is not on it.

Found pointing the wrong way on purpose-built hardware. This rig's LED demo
ends by driving every port pin low, and one of them is the shared link — so a
knock into a finished demo got no echo whatsoever and was told the device had
acked, when nothing had answered and nothing could. Same retry either way, but
blaming an ack that never happened sends the reader to the protocol when the
answer is a pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:18 +02:00
3e4bfbaf48 pbhw: the marker check assumed a board whose port-open is not a reset
Its comment said "opening the port does not reset a board whose DTR is
unwired, so this simply listens" — true of the tiny it was written against,
false of an Arduino, and this is the generic harness. Where DTR is wired to
reset, that open resets the part and the activation window comes first, so a
fixture emitting its banner once says it on the far side of a wait the suite
cannot know the length of: the window is a compile-time constant and nothing
on the wire reports it. The suite read the silence as an application that
never ran, on a board where it demonstrably had.

So --marker-wait, defaulting to the 2.5 s that was hardcoded, and a failure
that names the window as the candidate rather than leaving the next person to
suspect the loader. The other half is the fixture: PUREBOOT_HEARTBEAT makes
the observation independent of when the listener arrives, which is what the
rig's own builds now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:12:08 +02:00
579ca81b27 one-wire: the knock's lost byte is the wiring, and the diagnosis was unreachable
Measured on an ATtiny13A with the link folded onto PB3 and the FTDI's TX
reaching it through 1 k: a knock aimed at a loader already in session loses
its second byte every time, 8 runs of 8, never intermittently. The first byte
draws a prompt while the second is still going out and the device's push-pull
ack wins the line against the resistor, so that byte is destroyed rather than
delayed — which is what the README predicted and the sim bridge cannot show,
since it arbitrates the line by queueing.

The recovery for it existed and could not run. Two defects:

OneWirePort.write read its echo with read_exact, whose contract is to raise, so
the "one-wire echo missing — is the adapter's RX tied to the line?" message was
unreachable on any line that simply fell quiet, and a bare "timeout: got 0 of 1
bytes" surfaced in its place. The one message the class exists to produce could
never be produced. The read is speculative and is now read_available.

And any raise from write aborted _handshake before the retry loop that exists
to absorb exactly this, whose docstring already claimed it "converges into an
already-live session" — true on a pty, impossible on real wiring. The knock is
now the one write marked blind: a missing echo there is a property of the
shared line, counted and reported under -v rather than raised. Every other
write is ack-paced and cannot collide, so a missing echo there still means an
RX that is not on the line, and still raises.

Both gates green on Windows (31/31 m328p, 16/16 t13a); on hardware the
reconnect now converges on the first knock, the surviving prompt being all the
handshake needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:29:17 +02:00
5d520a1ff9 pbhw: --one-wire never reached the suite's own sessions
The flag was plumbed through pbrig.Deployment to the host-tool subprocess
calls and nowhere else, so identity() and scan() opened a raw port and drove
a shared line as though it were two wires. On real one-wire hardware the
adapter's echo answers the knock before the device does, so the suite would
have died at its very first check — "the loader never answered; nothing below
can be trusted" — for the one deployment the flag exists to test, and every
result after it is gated on that check passing.

Both now open through pbrig.Rig.open_port(), which applies the deployment's
link mode. The gap underneath was that only the subprocess path could reach
those facts at all; anything driving the protocol in-process had to restate
them, and did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:25:46 +02:00
f71d76a815 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>
2026-07-31 02:32:11 +02:00
47419400f6 build: the pin advances over the one-wire serial feature
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 02:31:55 +02:00
bad8b6b43e build: the pin advances over the bounded calibration
libavr's calibrate() now bounds its measurement loop, starts the pulse
on an observed edge, and re-arms a rejected pulse on the remaining
budget instead of one-strike booting the application. The autobaud
images pay +16..20 B — every slot still fits, the worst now the 1284s'
502 of 512 — and the stock images are byte-identical, kept so by
fitting the loader's flag set per backend: -fno-ivopts stays on the
fixed-baud bodies it shrinks and comes off the autobaud body, where it
duplicated the calibration countdown into a 9-cycle loop against the
contracted seven.

One deployed constant moved and its gate caught it: the calibrate
wait's budget poll re-laid from ten cycles to nine (the exit branches
land where block layout puts them), so pureboot.window.autobaud
measured -10 % until AUTOBAUD_POLL_CYCLES and the README's derived
seconds were re-measured — the default autobaud window is 36 M cycles,
4.5 s at 8 MHz. Full gate green on all 37 chips; the README's autobaud
column carries each chip's rebuilt worst configuration, machine-checked
against the built trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:49:55 +02:00
5d1b4497d4 build: the libavr pin advances over the drain and delay contracts
The hand-over drains move to the explicit drain_unbounded() — both link
adapters drain only after their own write, so the frame is in flight by
construction and the bounded default's countdown would be dead bytes;
the images stay byte-identical. window_polls() states its arithmetic
through dev::cycles_for with the whole window converted before the
per-poll division — one truncation instead of one per second, same
instructions, only the countdown's immediate moves. Every size in the
matrix is unchanged; the full gate is green on all 37 chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:31:22 +02:00
a743ea64a3 tool: a size collision across build trees is an error, not a coin toss
sizes.py merged every owned tree's rows and let the last one win, so a
stale reflect tree — last built before the window constants moved —
reported the atmega8's old stock size over the fresh build and failed
the README check with yesterday's number. Generated and reflect must
answer with the same bytes (the identity invariant), so the same target
measuring two sizes is a stale tree or an identity breach; collect()
refuses now, naming both trees. The stale reflect trees are removed —
the reflect sweep rebuilds them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:32:35 +02:00
aa66cfccff pureboot: the poll-cost lookup rides the baud parameter
usart_of<0> is an incomplete type on the USART-less chips, and a static
member initializer with only non-dependent operands is checked when the
template is parsed, not when it is instantiated — so the address probe
broke every tiny build without hardware_link ever being named. The
lookup moves into a member function template taking the link's own baud
parameter, the dependence carrier that defers it to instantiation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:13:44 +02:00
459d463283 pureboot: the classic megas' idle poll is a bit-skip — 7 cycles, and their stock window goes wide
The window gate's first full sweep caught it on the atmega8: 6.22 s
measured against 8 declared, the exact 7/9 of a poll modeled as an
extended-I/O lds + skip on a chip whose UCSRA sits in bit-addressable
I/O and compiles to a 2-cycle skip. poll_cycles now follows the status
register's home (7 below 0x40, 9 above). At 16 MHz over 7 cycles the
poll count no longer fits uint24_t, so the classic megas' stock windows
take the wide countdown — 8.000 s measured on all three, +4 B of stock
image (m8 362, m16/m32 364), README stock rows updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:11:34 +02:00
8e7cc86fb3 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>
2026-07-30 16:05:42 +02:00
c8ac61779e tool: survive our own leftovers — drain the fresh port, shorten the identity read
--stay leaves the loader's final prompt in the USB pipeline; a fresh
invocation on a board that resets when its port opens then flushes too
early, trusts the stale prompt, and spends the new activation window on
a 2-second identity read against a device that never heard its knock —
collecting the application's banner as an unknown signature. Three
host-side moves, no device bytes: the line is drained until quiet
(bounded, 250 ms) before the port's first knock — once per port, since a
mid-session re-knock faces no foreign bytes and its own window is
already burning; the identity read_exact drops 2.0 to 0.5 s, dozens of
times the worst real answer, so any false prompt match leaves room for
the retry that already works; and the tool version drifts to 8. The
StaleDTRPort fixture models the whole moment — stale prompt in transit,
reset holding the device off the line, a finite window, the banner —
red against the old tool in exactly the field shape (unknown signature
from banner bytes), green now; LoaderPort answers its prompt to the
knock rather than to a read count, which the drain exposed as a
call-order coupling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:05:16 +02:00
4362886c39 build: the libavr pin advances over the trait projection
The de-string-2 pass upstream: every peripheral block behind generated
instance traits, int-keyed, the string layer gone. The port's share of it
is two spellings — the char usart_digit that existed to be pasted into
register names becomes the int unit the usart template now takes, and the
tsb tiers' one reg<"UBRR0"> is the flat hw::ubrr0 — plus the pbapp
harness probing has_usart<0>() instead of instance-name strings. Nine
loader codegen families rebuilt green through their full workflows (size
matrix and simulator protocol suites included); every image holds its
recorded size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:08:10 +02:00
0513d07e87 build: the libavr pin advances to current main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:24:10 +02:00
d4ab28aa17 build: the libavr pin advances to current main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:48:37 +02:00
b60f182105 review: the port's findings — the version map speaks v7, costs told true
The in-file version window now carries the v7 line its own comment
claimed to hold; the GPIOR note counts words, not instructions; the
USART-release cost and citation match the silicon (two bytes on the
classics, §20.6.3); and the 512-byte claim reads as the slot bound it
is. The libavr pin advances over the review pass — images byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:05:42 +02:00
5bc35a9733 build: the libavr pin advances over the instance traits
Byte-identical images — the traits resolve the same database indices the
retired string forms did; the tightest image is compared outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:18:07 +02:00
8f6319c068 pureboot 7: the same features in fewer words on every chip
Four cuts, none touching what the loader can do. The entry stub stops
re-doing the reset logic's own SP write where the datasheet guarantees
RAMEND (stack::hardware — the classic megas keep theirs). The autobaud
unit moves into GPIOR2:GPIOR1 wherever the chip has the pair: one-word
accesses, no RAM object, and the host's measured-clock peek follows it
by version and geometry. 'J' rides the unified decode, carrying a
selector it ignores so its address is the same two reads as every other
command — the tool sends the bare form to older residents. run_app stops
insisting on a body of its own. The fleet lands at 358–410 B stock and
438–474 B autobaud; the tightest image in the space — the 1284s'
autobaud on a USART's own pins with the OSCCAL trim — drops from 510 to
484 of its 512. Every chip's suite is green on the wire that changed,
and the README's table is machine-checked against the built images.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:02:38 +02:00
22 changed files with 1238 additions and 209 deletions

View File

@@ -202,6 +202,38 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbtest-work)
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 half-duplex loader's window, same gate: its poll runs through
# rx_ready()'s release-line test, whose outlined call re-shapes the
# whole loop — a per-class cycle count (poll_cost() in pureboot.cpp)
# that only the built image can prove, chip by chip.
if(PUREBOOT_HAS_USART)
add_test(NAME pureboot.window.halfduplex
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_hd>
--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-hd-work)
set_tests_properties(pureboot.window.halfduplex PROPERTIES TIMEOUT 300)
endif()
# The position-independence acceptance test: the identical image,
# installed one slot lower, must serve the full command set.
add_test(NAME pureboot.reloc
@@ -283,18 +315,23 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
# The measured unit is the loader's only RAM object and sits at the very
# start of SRAM — where the host reads the bit period from (--info's
# measured clock), so the address is wire contract, not layout accident.
# The measured unit's home is wire contract, not layout accident: the
# host reads the bit period from it (--info's measured clock). In the
# GPIOR home the image must carry no RAM copy at all; in the RAM home it
# is the loader's only RAM object, at the very start of SRAM.
add_test(NAME pureboot_autobaud.unit
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
-DRAM_START=${PUREBOOT_RAM_START} -DGPIOR=${PUREBOOT_UNIT_GPIOR}
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
# One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. `pins` is empty for the
# default pair, or the index of the USART whose own pins a bit-banged
# link sits on. Unreachable rates drop out here rather than aborting the
# configure.
# The optional trailing argument is the one-wire shape of the same link:
# ONE_WIRE folds a software point onto its RX pin (the default, or the
# named USART's RXD), HALF_DUPLEX is the hardware USART's turn-around.
function(pureboot_matrix_point hz baud link pins)
set(_name pbm_${hz}_${baud}_${link})
if(link STREQUAL "software")
@@ -304,9 +341,21 @@ if(PROJECT_IS_TOP_LEVEL)
list(APPEND _args RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_TX})
set(_name ${_name}_on${pins})
endif()
if(ARGC GREATER 4 AND ARGV4 STREQUAL "ONE_WIRE")
if(NOT pins STREQUAL "")
set(_args SERIAL software RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_RX})
else()
list(APPEND _args RX pb0 TX pb0)
endif()
set(_name ${_name}_1w)
endif()
else()
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
set(_args USART ${link})
if(ARGC GREATER 4 AND ARGV4 STREQUAL "HALF_DUPLEX")
list(APPEND _args HALF_DUPLEX)
set(_name ${_name}_hd)
endif()
endif()
if(_ok)
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args})
@@ -348,13 +397,18 @@ if(PROJECT_IS_TOP_LEVEL)
foreach(_matrix_hz IN LISTS _full_clocks)
foreach(_matrix_baud IN LISTS _full_bauds)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "")
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "" ONE_WIRE)
if(PUREBOOT_HAS_USART)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0 ONE_WIRE)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "" HALF_DUPLEX)
endif()
if(PUREBOOT_HAS_USART1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1 ONE_WIRE)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "")
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "" HALF_DUPLEX)
endif()
endforeach()
endforeach()
@@ -420,6 +474,30 @@ if(PROJECT_IS_TOP_LEVEL)
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
endif()
# The one-wire axis at its fixed points, in both matrix modes (the
# exhaustive sweep carries the same shapes across its cross product):
# the software link folded onto one pin, the tightest autobaud image
# likewise — on the default pin and on the USART's own RXD, whose
# release the now-driven shared pin needs where a receive-only link
# would not — and the hardware USART's half-duplex turn-around, stock
# and at the widest fixed-baud shape.
# The two spellings deliberately split across the two points: HALF_DUPLEX
# folds TX onto RX, RX == TX states the same thing directly.
pureboot_size_variant(pureboot_1w SERIAL software RX pb0 HALF_DUPLEX)
pureboot_size_variant(pureboot_1w_autobaud_osccal SERIAL autobaud OSCCAL 0x9c RX pb0 TX pb0)
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_1w_on_usart0 SERIAL software
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
pureboot_size_variant(pureboot_1w_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
pureboot_size_variant(pureboot_hd HALF_DUPLEX)
list(GET _matrix_clocks -1 _hd_top_hz)
pureboot_size_variant(pureboot_hd_wide CLOCK ${_hd_top_hz} BAUD 9600 HALF_DUPLEX)
endif()
if(PUREBOOT_HAS_USART1)
pureboot_size_variant(pureboot_usart1_hd USART 1 HALF_DUPLEX)
endif()
# The trim byte, observed through the wire from the first prompt — one
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
# an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out —
@@ -494,6 +572,58 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbmute-work ${_mute_link})
set_tests_properties(pureboot.mute PROPERTIES TIMEOUT 180)
# The same hand-over against the one-wire deployment on that USART's
# RXD: RXEN forces the shared pin's direction, so a loader that only
# released the transmit-side hold would read the wire and answer into
# a pin it cannot drive. The host runs with the --one-wire echo
# discard, which the bridge's shared-line model feeds for real.
get_target_property(_mute1w_link pureboot_1w_on_usart0 PUREBOOT_LINK)
add_test(NAME pureboot.mute.onewire
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbmute.py
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_on_usart0> ${PUREBOOT_SIM_MCU} ${_mute_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_mute_baud}
$<TARGET_FILE:pbapp_handover>.bin
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbmute-1w-work ${_mute1w_link})
set_tests_properties(pureboot.mute.onewire PROPERTIES TIMEOUT 180)
# The full protocol suite over one shared pin: the loader folded onto
# PB0, the bridge following the pin's direction, the fixture
# bannering as a guest on the same line, and the host discarding its
# own echo throughout.
get_target_property(_1w_hz pureboot_1w PUREBOOT_HZ)
get_target_property(_1w_baud pureboot_1w PUREBOOT_BAUD)
get_target_property(_1w_link pureboot_1w PUREBOOT_LINK)
add_executable(pbapp_1w test/pbapp.cpp)
target_link_libraries(pbapp_1w PRIVATE libavr)
target_compile_definitions(pbapp_1w PRIVATE PUREBOOT_CLOCK_HZ=${_1w_hz}
PUREBOOT_BAUD=${_1w_baud} PUREBOOT_SOFT_SERIAL
PUREBOOT_RX=pb0 PUREBOOT_TX=pb0)
add_custom_command(TARGET pbapp_1w POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:pbapp_1w> $<TARGET_FILE:pbapp_1w>.bin)
add_test(NAME pureboot.onewire
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
${PB_DEVICE} $<TARGET_FILE:pureboot_1w> ${PUREBOOT_SIM_MCU} ${_1w_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_1w_baud} ${PUREBOOT_EEPROM}
$<TARGET_FILE:pbapp_1w>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pb1w-work ${_1w_link})
set_tests_properties(pureboot.onewire PROPERTIES TIMEOUT 180)
# The hardware USART's half-duplex turn-around, end to end: every
# reply byte runs drive-line, TXC-hold, release — against simavr's
# RXEN-gated receiver, which drops input to a disabled receiver the
# way silicon does. The pty is a two-wire transport, so the host
# needs no echo discard here; the off-chip tie itself is the
# hardware bench's item.
add_test(NAME pureboot.halfduplex
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
${PB_DEVICE} $<TARGET_FILE:pureboot_hd> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${PUREBOOT_EEPROM}
$<TARGET_FILE:pbapp>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbhd-work)
set_tests_properties(pureboot.halfduplex PROPERTIES TIMEOUT 180)
endif()
# The second USART, driven for real on one chip: instance selection is
@@ -541,5 +671,44 @@ if(PROJECT_IS_TOP_LEVEL)
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
# The tightest deployment in the space, end to end: the autobaud
# loader folded onto the USART's own RXD with the OSCCAL trim baked
# — one-wire calibration, the receive-side release, and the host's
# echo discard, over the same two-clock sweep. One chip carries it;
# the shape is chip-independent.
if(LIBAVR_MCU STREQUAL "atmega328p")
get_target_property(_ab1w_link pureboot_1w_autobaud_osccal_on_usart0 PUREBOOT_LINK)
add_executable(pbapp_autobaud_1w test/pbapp.cpp)
target_link_libraries(pbapp_autobaud_1w PRIVATE libavr)
target_compile_definitions(pbapp_autobaud_1w PRIVATE PUREBOOT_CLOCK_HZ=1000000
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL
PUREBOOT_RX=pd0 PUREBOOT_TX=pd0)
add_custom_command(TARGET pbapp_autobaud_1w POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:pbapp_autobaud_1w> $<TARGET_FILE:pbapp_autobaud_1w>.bin)
add_test(NAME pureboot.autobaud.onewire
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_autobaud_osccal_on_usart0>
${PUREBOOT_SIM_MCU} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE}
$<TARGET_FILE:pbapp_autobaud_1w>.bin
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-1w-work ${_ab1w_link})
set_tests_properties(pureboot.autobaud.onewire PROPERTIES TIMEOUT 240)
endif()
# 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()

View File

@@ -2,7 +2,7 @@
`master` carries `bootloader.atsln`, so this branch does too: `ide/bootloader.atsln`
builds the loaders from the same sources Ninja does, to a **byte-identical
`.text`** — 400 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
`.text`** — 390 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
its 512-byte section. CMake remains the build system; the solution is here so the
port opens in Studio as its predecessor did.

2
libavr

Submodule libavr updated: 911a87538f...c01b19b08f

View File

@@ -138,15 +138,24 @@ endif()
# Where SRAM begins: the classic megas keep it right after the plain I/O
# registers, the x8/x4 generations push it past their extended I/O file, and
# the tinies match the classics. An autobaud loader's measured unit lives at
# exactly this address (the host reads it there — pureboot.py), and the
# unit-position test holds the layout to it.
# the tinies match the classics. An autobaud loader keeps its measured unit
# in GPIOR2:GPIOR1 wherever the chip has the pair (data 0x32 on the
# t25/45/85, 0x4A from the x8 generation on) and as the first RAM object at
# SRAM start where it does not (the t13s and classic megas). The host reads
# whichever home applies (pureboot.py's geometry), and the unit-position
# test holds the image to the same split.
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
set(_pb_ram 0x60)
set(_pb_unit_gpior "")
elseif(LIBAVR_MCU MATCHES "^atmega")
set(_pb_ram 0x100)
set(_pb_unit_gpior 0x4A)
elseif(LIBAVR_MCU MATCHES "^attiny13")
set(_pb_ram 0x60)
set(_pb_unit_gpior "")
else()
set(_pb_ram 0x60)
set(_pb_unit_gpior 0x32)
endif()
# The function runs in its caller's scope, so everything it needs crosses
@@ -157,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
@@ -169,6 +180,7 @@ set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE)
set(PUREBOOT_UNIT_GPIOR "${_pb_unit_gpior}" PARENT_SCOPE)
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
@@ -226,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
@@ -234,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
@@ -247,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()
@@ -284,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")
@@ -293,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()
@@ -301,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()
@@ -324,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()
@@ -366,9 +408,18 @@ function(pureboot_add_loader name)
# load-immediate it saves. The set is fitted to the loader's body and has to
# be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes
# now that it carries one.
# now that it carries one, and -fno-ivopts is fitted per backend — an
# autobaud body needs ivopts to keep the calibration countdown a single
# induction variable (without it the counter is duplicated and the
# measurement loop runs 9 cycles instead of its contracted 7), while the
# fixed-baud bodies still measure smaller with it off.
if(PB_SERIAL STREQUAL "autobaud")
target_compile_options(${name} PRIVATE
-fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
else()
target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
endif()
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)

View File

@@ -2,8 +2,8 @@
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables
(attributes and compiler flags allowed), **512 bytes on every chip libavr
targets — all 37**. The device speaks primitives; every composite — verify,
(attributes and compiler flags allowed), **a 512-byte slot on every chip
libavr targets — all 37**. The device speaks primitives; every composite — verify,
erase, reset-vector surgery, updating the loader itself — lives in the host
tool (`pureboot.py`).
@@ -19,42 +19,44 @@ come out byte-identical linked at a different base.
## Chips
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves
per build — see *Configuration*. The autobaud column is the clock-free build,
which is the largest the space produces and the tightest fit in the matrix;
it carries the calibration machinery and no clock at all.
The Stock column is the default configuration: the hardware USART0 at 115200
8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at
57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above).
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*). 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 |
|---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 390 B | 460 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 394 B | 464 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 398 B | 468 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 398 B | 468 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 360 B | 474 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 362 B | 480 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 362 B | 480 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 388 B | 464 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 398 B | 474 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 394 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 420 B | 500 B |
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 B |
† 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.
The tightest fit in the whole space is the 1284s' autobaud build deployed on a
USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud
alone carries the calibration loop, a bit-banged link on a USART's pins alone
has to release it (below), and the trim adds its one register write. Without
the trim that build is 504; on the default pins, 500. 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
The tightest fit in the whole space is therefore the 1284s' 502 of their
512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
commands) on top of everything the column already stacks. 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.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire
@@ -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
@@ -88,7 +91,8 @@ the usual one where a board's USB bridge is wired to RXD/TXD: the link's `init`
clears that USART's `UCSRnB` first, because while its `TXEN` is set the USART —
not the port register — owns the TX pin, and a loader entered from an
application that left it enabled would receive and obey while answering nothing
(§20.2). It costs four bytes, and only on those pins.
(§20.6.3). It costs one store — four bytes on the extended-I/O chips, two on
the classic megas — and only on those pins.
`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing
at run time, so `CLOCK` and `BAUD` are not build parameters there and one
@@ -99,7 +103,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
needs its divisor programmed) and that activation counts poll iterations rather
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 nine cycles a
poll (measured, and held by the `pureboot.window.autobaud` gate), so the
default window is 36 M cycles: 4.5 s at 8 MHz, 3.75 s at 9.6 MHz, 36 s at
1 MHz.
**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
@@ -122,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
@@ -187,8 +231,10 @@ reply, repeat.
Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in
the command's selector byte, so no command has to speak word addresses. `J` is
the exception: it takes a word address, because that is what the hardware's own
jump takes. EEPROM and data-space addresses and all counts are bytes.
the exception: its address is a word address, because that is what the
hardware's own jump takes — it still carries a selector byte (reserved,
ignored) so its decode is the same three reads as every other command's.
EEPROM and data-space addresses and all counts are bytes.
The loader trusts the host to keep addresses in range: it does not bound them
against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps —
@@ -203,7 +249,7 @@ better spent on features than on re-checking a bound the host already holds.
| `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) |
| `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `W` | sel8, addr16, then one page of data | — (completion = next prompt) |
| `J` | word address (16-bit) | `+`, then execution continues there |
| `J` | sel8 (reserved), word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`G` and `g` are one letter in two cases, which is the whole command set for
@@ -291,7 +337,17 @@ from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses).
above; the shipped tool speaks both, choosing on the version it reads, so a
deployed pureboot 4 stays drivable and self-updatable to 5. **6** changes
nothing on the wire: it marks the builds that may carry a baked `OSCCAL` trim
(Configuration), so a tool driving an update knows such images exist.
(Configuration), so a tool driving an update knows such images exist. **7**
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. **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
@@ -458,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
@@ -476,11 +537,14 @@ Reads are safe anywhere; **two small regions cannot be written without ending th
session,** because they are what the loader is standing on:
- the **top of SRAM**, where its stack lives — a handful of bytes below RAMEND;
- on an **autobaud** build, the **two bytes at RAMSTART**: the measured bit
period, in `.noinit`, which is the whole of that loader's static RAM. Overwrite
it and its next reply is timed against garbage. On an ATtiny13A that is
`0x60..0x61`, and the symptom is a mangled prompt byte rather than any error —
the loader is fine, it simply is no longer speaking the agreed rate.
- on an **autobaud** build, the **measured bit period**: two bytes in
GPIOR2:GPIOR1 where the chip has the pair (data `0x32..0x33` on the
t25/45/85, `0x4A..0x4B` from the x8 generation on — such a loader has *no*
static RAM at all), and the two bytes at RAMSTART on the chips without one
(the t13s and classic megas), where they are the whole of the loader's
static RAM. Overwrite either home and the next reply is timed against
garbage — the symptom is a mangled prompt byte rather than any error; the
loader is fine, it simply is no longer speaking the agreed rate.
Both are self-inflicted rather than defects, and a reset clears them. Note also
that `--poke` can write OSCCAL, which does take effect — but a session can only
@@ -518,9 +582,10 @@ Per chip preset, `ctest` runs:
below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock
shape and on the tightest image in the space (autobaud on a USART's own
pins), holding both of the trim write's addressing encodings to the budget;
- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
object and sits exactly at ram_start, where `--info` reads it: wire
contract, not layout accident;
- `pureboot_autobaud.unit` — the measured bit period sits where `--info`
reads it (wire contract, not layout accident): in the GPIOR pair, with no
RAM object at all, on the chips that have one; as the loader's only RAM
object at exactly ram_start elsewhere;
- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
replacing that compact matrix, on **every** chip: every plausible oscillator
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×

View File

@@ -1,6 +1,6 @@
// pureboot — a serial bootloader on libavr: one C++ source, no inline
// assembly, no global register variables, 512 bytes on every chip libavr
// targets. The device speaks primitives; every composite (verify, erase,
// assembly, no global register variables, a 512-byte slot on every chip
// libavr targets. The device speaks primitives; every composite (verify, erase,
// reset-vector surgery, self-update) lives in the host tool. Protocol,
// deployment and configuration: README.md next to this file.
//
@@ -10,6 +10,8 @@
// is what makes a copy one slot below able to rewrite the resident one, and
// every change here has to keep it (test/check_pi.py).
#include <chrono>
#include <libavr/libavr.hpp>
using namespace avr::literals;
@@ -75,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 = 6;
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
@@ -155,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
@@ -162,18 +167,50 @@ constexpr std::uint8_t bank_shift = 16 - slot_shift;
#define PUREBOOT_TX pb1
#endif
#if defined(PUREBOOT_USART)
constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr int usart_unit = PUREBOOT_USART;
#else
constexpr char usart_digit = '0';
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_digit, 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: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6).
static constexpr std::uint8_t poll_cycles = 10;
// 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. 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>();
static void init()
{
@@ -197,18 +234,26 @@ struct hardware_link {
static void drain()
{
uart::drain();
// A drain here always follows this link's own write — the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
}
};
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: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6).
static constexpr std::uint8_t poll_cycles = 8;
// The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbis skipping the exit (2), sbiw + sbci + brne (5). 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 = 7;
static void init()
{
@@ -241,7 +286,10 @@ struct software_link {
// every rate. Activation differs in kind from the other two — there is no
// clock to time a window against — so this backend brings its own, below.
struct autobaud_link {
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX>;
// The unit in GPIOR2:GPIOR1 where the chip has them: the loader owns the
// whole chip while it runs, and the pair costs one word per access where
// the RAM word costs two — six words across the image.
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX, avr::uart::unit_home::gpior>;
static void init()
{
@@ -260,19 +308,22 @@ struct autobaud_link {
static void drain()
{
uart::drain();
// A drain here always follows this link's own write — the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
}
};
#if defined(PUREBOOT_AUTOBAUD)
using link = autobaud_link;
#elif defined(PUREBOOT_USART)
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
static_assert(avr::uart::has_usart<usart_unit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
using link = hardware_link<dev::clock, wire_baud>;
#elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock, wire_baud>;
#else
using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
using link = std::conditional_t<avr::uart::has_usart<usart_unit>(), hardware_link<dev::clock, wire_baud>,
software_link<dev::clock, wire_baud>>;
#endif
@@ -288,7 +339,7 @@ extern "C" [[noreturn]] void pureboot_app();
__builtin_unreachable();
}
[[gnu::noinline, noreturn]] void run_app()
[[noreturn]] void run_app()
{
jump(pureboot_app);
}
@@ -314,17 +365,35 @@ void await_host()
}
}
#else
// The window as one 32-bit countdown, divided by the backend's counted
// poll-loop cycles. Whole seconds is all it promises.
// The window as one countdown, divided by the backend's counted poll-loop
// 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)
{
// Whole-window cycles first, then the per-poll division: one truncation
// instead of one per second. Same instructions either way — only the
// countdown's immediate moves.
return static_cast<std::uint32_t>(dev::cycles_for<std::chrono::seconds{timeout_seconds}>() / per_poll);
}
consteval bool narrow_window()
{
return polls_at(link::poll_cycles) <= 0xffffff;
}
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
// cost a wider decrement chain at every poll for range most windows never
// 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()
{
@@ -488,12 +557,6 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
tx_ack();
const std::uint8_t command = link::rx();
switch (command) {
case 'J': { // jump to a wire word address: hand-over and staging transfer
auto target = reinterpret_cast<void (*)()>(rx16());
tx_ack();
link::drain();
jump(target);
}
case 'b': // identity: the version, then the three signature bytes
// Straight out of the stamp, so the wire and the image can never
// disagree about what this loader is. The indices are constant and
@@ -502,18 +565,26 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
link::tx(identity_stamp[at]);
break;
case 'J': // jump: sel8 (reserved), addr16 as a wire word address
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
case 'G': // read: sel8, addr16, n8 (0 = 256)
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
// One decode, one cursor and one loop for every space and both
// directions: a command per memory would carry a copy of all three
// each. 'W' joins the same decode rather than keeping an address
// form of its own, so flash addressing is uniform across every
// command that names it.
// One decode, one cursor and one loop for every space, both
// directions and the jump: a command per memory would carry a copy
// of all three each. 'J' — the hand-over and staging transfer —
// carries a selector it ignores so its address rides the same two
// reads as everything else; 'W' joins the same decode rather than
// keeping an address form of its own, so flash addressing is
// uniform across every command that names it.
const std::uint8_t selector = link::rx();
const std::uint8_t space = space_of(selector);
const std::uint8_t bank = bank_of(selector);
std::uint16_t at = rx16();
if (command == 'J') {
tx_ack();
link::drain();
jump(reinterpret_cast<void (*)()>(at));
}
if (command == 'W') {
fill_page(bank, at);
break;
@@ -540,4 +611,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
} // namespace
} // namespace pureboot
template struct avr::startup::entry<pureboot::run>;
// stack::hardware: activation is reset-only, so the reset logic's own
// SP = RAMEND stands wherever the datasheet guarantees it (the classic
// megas still get the write); a 'J' entry runs on the caller's live stack.
template struct avr::startup::entry<pureboot::run, avr::startup::stack::hardware>;

View File

@@ -26,15 +26,16 @@ else:
import termios
PROMPT = b"+"
VERSION = 6 # 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 = 6
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
@@ -42,14 +43,23 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
# the spaces below. The loader carries one transfer loop instead of four bodies
# — which is what buys the data space and the host-issued SPM operations.
# 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; 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
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
# as delay-loop counts: (bit cycles UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# An autobaud loader keeps its measured bit period readable, encoded as
# delay-loop counts: (bit cycles UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# floored — the spin granule and per-bit overhead of libavr's software UART.
# --info undoes the encoding to report the true clock, which therefore sits
# within one granule below it.
# v5/v6 keep it at ram_start; v7 moves it into GPIOR2:GPIOR1 on the chips
# that have the pair (their data addresses are in the geometry) and keeps
# ram_start only where they do not exist. --info undoes the encoding to
# report the true clock, which therefore sits within one granule below it.
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
# A selector's high nibble is the flash bank — the address bits above the 16-bit
@@ -77,36 +87,40 @@ CALIBRATE = 0xC0
# from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon.
CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector, ram_start)
# signature : (flash, page, eeprom, patch_vector, ram_start, gpior1)
# ram_start is where SRAM begins in data space: the classic megas and the
# tinies keep it right after the plain I/O registers (0x60), the x8/x4
# generations past their extended I/O file (0x100). An autobaud loader's
# measured bit period lives at exactly ram_start (its only RAM object;
# the loader's own build pins the layout), which is what --info reads.
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
# generations past their extended I/O file (0x100). gpior1 is GPIOR1's
# data address — 0x32 on the t25/45/85, 0x4A from the x8 generation on,
# None where the chip has no pair (t13, classic megas). A v7 autobaud
# loader's measured bit period lives in GPIOR2:GPIOR1 where they exist
# and at exactly ram_start elsewhere (its only RAM object; the loader's
# own build pins the layout); v5/v6 always used ram_start. --info reads
# whichever home the answering version implies.
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60, None), # ATtiny13/13A
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60, 0x32), # ATtiny25
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60, 0x32), # ATtiny45
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60, 0x32), # ATtiny85
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48/48A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48P/48PA
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60, None), # ATmega8/8A
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88/88A
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88P/88PA
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60, None), # ATmega16/16A
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168/168A
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168P/168PA
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164P/164PA
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60, None), # ATmega32/32A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328P
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284
}
VERBOSE = False
@@ -423,6 +437,99 @@ 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""
self.lost_echoes = 0
def __getattr__(self, name):
return getattr(self._port, name)
def write(self, data, blind=False):
"""Put `data` on the line and consume its echo.
`blind` marks the protocol's one multi-byte write with no ack between
its bytes — the knock. Aimed at a loader already in session, its first
byte draws a prompt while the second is still going out, and on real
wiring the device's push-pull ack **wins the line** against the host's
1 k series resistor: that second byte is *destroyed, not delayed*, and
its echo never comes. Measured on an ATtiny13A at 57600 — the loader
answers a single byte perfectly and loses the knock's second every
time. So on a blind write a missing echo is a property of the wiring
rather than a fault in it, and the caller's retry is what deals with
it. Every other write is ack-paced and cannot collide, so a missing
echo there really is an RX that is not on the line.
"""
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 and time.monotonic() < deadline:
# Speculative, so it cannot be read_exact, whose contract is to
# raise: doing that made the diagnosis below unreachable on every
# quiet line and surfaced a bare "timeout: got 0 of 1 bytes" in
# its place — the one message this class exists to replace.
for byte in self._port.read_available(0.02):
if remaining and byte == remaining[0]:
remaining = remaining[1:]
else:
self._pending += bytes((byte,))
if not remaining:
return
if not blind:
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?")
self.lost_echoes += len(remaining)
# Which loss this is matters, and the count says it. *Some* bytes lost is
# the device's ack winning the line against the host's series resistor —
# ordinary, and what the retry absorbs. *Every* byte lost is nothing
# coming back at all, which is a line that is not free: an application
# holding the shared pin low (this rig's LED demo ends that way), a
# wedge, or an RX that is not on the line. Same retry either way, but
# blaming an ack that never happened sends the reader to the wrong place.
if len(remaining) == len(data):
verbose(f"one-wire: none of {len(data)} byte(s) echoed — the line is not "
f"coming back. Held low by something? (a pin driven low, a wedge, "
f"or an RX not on the line)")
else:
verbose(f"one-wire: {len(remaining)} of {len(data)} knock byte(s) lost to the "
f"device's ack; retrying")
def write_blind(self, data):
self.write(data, blind=True)
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 ---
@@ -450,7 +557,7 @@ class Info:
if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
flash, page, eeprom, patch, _ = geometry
flash, page, eeprom, patch, _, _ = geometry
base = flash - SLOT
word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base
@@ -491,6 +598,11 @@ class Info:
# permits where from_identity refuses.
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
self.ram = geometry[4] if geometry else None
# Where this loader keeps the measured bit period (None when a fixed
# signature row is missing): the GPIOR pair from v7 where the chip
# has one, ram_start before that and everywhere without the pair.
gpior1 = geometry[5] if geometry else None
self.unit_home = gpior1 if self.version >= 7 and gpior1 is not None else self.ram
def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -531,6 +643,11 @@ class Loader:
# Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked.
self.autobaud = False
# The pre-knock drain runs once per port: the bytes it exists for are
# leftovers from before this process opened the port. Re-knocks later
# in the same session must not pay it — a fresh activation window is
# already burning while they wait.
self._line_drained = False
# The link this session is speaking. It moves when the host follows a
# staging copy built for another one (enter_copy).
self.baud = getattr(port, "baud", None)
@@ -540,10 +657,16 @@ class Loader:
"""The 'b' reply, in either of the two layouts a loader may send.
pureboot 5 answers with its version and the signature; older loaders
answer with a 12-byte block. The version byte cannot be mistaken for
the older block's 'P', so four bytes are enough to tell them apart."""
head = self.port.read_exact(4, 2.0)
the older block's 'P', so four bytes are enough to tell them apart.
The timeout is short on purpose: a real answer follows the prompt
within a frame time or two, so half a second is dozens of times the
worst case — while a *false* prompt match (a stale byte, reset
garbage) makes this read collect noise, and every second spent on it
comes out of the activation window the retry needs."""
head = self.port.read_exact(4, 0.5)
if head[0:2] == b"PB":
return Info(head + self.port.read_exact(8, 2.0))
return Info(head + self.port.read_exact(8, 0.5))
return Info.from_identity(head)
def _handshake(self, wait, knock, what):
@@ -554,13 +677,35 @@ class Loader:
into a fresh window, where a command without its knock is discarded.
Each attempt is therefore the whole handshake. This also converges into
an already-live session: the knock bytes are ignored there and the
drain absorbs whatever they produced."""
drain absorbs whatever they produced.
Before the port's first knock ever, the line is drained until quiet: a
prompt from a previous session (`--stay`) can still be in the USB
pipeline when the port opens, where a flush cannot clear what has not
arrived yet — and on a board that resets when its port opens, trusting
that stale byte would spend the fresh activation window reading noise
from a device that never heard the knock. Once only, and bounded:
later re-knocks in this session face no foreign leftovers, and their
own window is already burning."""
deadline = time.monotonic() + wait
if not self._line_drained:
self._line_drained = True
drain = time.monotonic() + 0.25
while self.port.read_available(0.05):
if time.monotonic() > drain:
break
knocks = 0
refusal = None
# The knock is the only write in the protocol with no ack between its
# bytes, so on a shared line it is the only one whose echo may
# legitimately not come back — the device's ack collides with it and
# wins (OneWirePort.write). Losing a byte here is what the retry below
# is for; raising instead aborted the loop before it ever ran, which on
# real wiring made every reconnect into a live session fail.
knock_out = getattr(self.port, "write_blind", self.port.write)
while True:
self.port.flush_input()
self.port.write(knock)
knock_out(knock)
knocks += 1
if PROMPT in self.port.read_available(0.4):
# Settle: absorb a real loader's trailing bytes before asking
@@ -745,7 +890,12 @@ class Loader:
return self._command(b"F", 4, 2.0)
def jump(self, word_address):
"""The device acks, then execution continues at the word address."""
"""The device acks, then execution continues at the word address.
From v7 'J' rides the unified decode, so it carries a selector byte
the loader ignores; older loaders take the bare address."""
if self.info.version >= 7:
self.port.write(bytes((ord("J"), 0, word_address & 0xFF, word_address >> 8)))
else:
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
self._expect_prompt()
@@ -1432,12 +1582,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)
@@ -1446,6 +1597,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:
@@ -1471,6 +1624,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)")
@@ -1529,11 +1685,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)
@@ -1541,14 +1700,14 @@ def main():
print("device:")
for line in info.lines():
print(f" {line}")
if args.autobaud and info.ram is not None:
# The whole of the loader's RAM is the measured bit period at
# ram_start; decoded and times the rate this session drives,
if args.autobaud and info.unit_home is not None:
# The measured bit period, from wherever this version keeps it
# (unit_home); decoded and times the rate this session drives,
# that is the true clock — the number to hold an OSCCAL bake
# or a fixed-baud build against (README.md: deployment). The
# autobaud identity path refuses unknown signatures, so ram is
# always known here; the guard states that dependency.
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
# autobaud identity path refuses unknown signatures, so the
# home is always known here; the guard states that dependency.
unit = int.from_bytes(loader.read_ram(info.unit_home, 2), "little")
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""

View File

@@ -1,7 +1,10 @@
# Asserts the autobaud loader's measured unit is the first RAM object: the
# host tool reads the bit period from ram_start (--info's measured clock), so
# the unit's address is wire contract. Run as
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> -P check_unit.cmake
# Asserts the autobaud loader's measured unit sits where the host will read
# it (--info's measured clock — the address is wire contract). Two homes: on
# a chip with the GPIOR pair the unit lives there and the image must carry no
# RAM word for it at all; elsewhere it is the first RAM object at SRAM start.
# Run as
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> [-DGPIOR=<data address>]
# -P check_unit.cmake
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
if(NOT _res EQUAL 0)
@@ -9,7 +12,17 @@ if(NOT _res EQUAL 0)
endif()
# The symbol line: "00800100 l O .noinit 00000002 <mangled>unit_E".
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_[^ \t\n]*\n" _line "${_syms}")
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_E\n" _line "${_syms}")
if(GPIOR)
if(_line)
message(FATAL_ERROR "unit_ RAM symbol present although the unit's home is GPIOR ${GPIOR} — "
"the host peeks the pair, and a RAM copy would be dead weight")
endif()
message(STATUS "no unit_ RAM object — the unit lives in the GPIOR pair at ${GPIOR}")
return()
endif()
if(NOT _line)
message(FATAL_ERROR "no unit_ symbol in ${ELF} — is this the autobaud loader?")
endif()

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
@@ -50,7 +53,7 @@ consteval bool use_hardware()
#if defined(PUREBOOT_SOFT_SERIAL)
return false;
#else
return avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART");
return avr::uart::has_usart<0>();
#endif
}
@@ -63,7 +66,11 @@ struct link {
#else
static constexpr avr::baud_t baud{115200};
#endif
using tx_t = avr::uart::usart<'0' + PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
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,17 +60,18 @@ 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:
fail(f"{label}: session output lacks {needed!r}\n{out}")
# The measured clock, decoded from the unit at ram_start. The
# runner's clock is exact, so the figure must land inside the
# The measured clock, decoded from the unit at whichever home this
# version keeps it in. The runner's clock is exact, so the figure
# must land inside the
# encoding's own envelope: the loader floors the bit period to
# 4-cycle spin granules after an 8-cycle discount, and the edge
# poll can shave a few cycles more — one granule of slack below
@@ -79,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:
@@ -98,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,)))
@@ -116,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)
@@ -159,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

@@ -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

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)

137
test/pbwindow.py Normal file
View File

@@ -0,0 +1,137 @@
#!/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 × 9 / 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. A measured fact, not a
# design constant: the wait's exit branches land where the compiler's block
# layout puts them, and the bounded-calibration rework moved the loop from
# ten cycles to nine.
AUTOBAUD_POLL_CYCLES = 9
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

@@ -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;
@@ -62,6 +63,41 @@ 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();
}
// 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") {
@@ -78,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;
}
}
@@ -195,6 +232,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)
@@ -223,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
@@ -251,15 +309,22 @@ 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;
}
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;
}
@@ -271,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;
}
@@ -292,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.
@@ -315,7 +413,17 @@ void bridge_reset()
rx_active = 0;
tx_active = 0;
tx_level = 1;
avr_raise_irq(rx_pin, 1); // idle line
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
// 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.
bridge_drive(0);
bridge_drive(1);
}
void poll_pty()
@@ -329,7 +437,8 @@ void poll_pty()
rx_queue[rx_tail] = chunk[i];
rx_tail = next;
}
if (got > 0)
// Unconditional: a byte held back while the firmware owned a shared
// line restarts from here once the hand-back has happened.
rx_start_next();
}
@@ -364,7 +473,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 +487,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",
@@ -466,8 +581,24 @@ 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)
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
@@ -477,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;
@@ -509,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();
@@ -524,7 +660,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);
}
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Host-tool activation handshake: it must not hang on a flooding target.
"""Host-tool activation handshake: bounded against a line that misbehaves.
`_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
@@ -8,6 +8,13 @@ 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.
The handshake must also survive its own leftovers: after `--stay` the loader's
final prompt can still be in the USB pipeline when the next invocation opens
the port, and on a board wired to reset on open, that opening starts a fresh
activation window the stale prompt then betrays — the tool commits to an
identity read against a device that never heard its knock, and what it finally
collects is the application's banner. StaleDTRPort is that moment as a port.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
beside pureboot.planner.
"""
@@ -49,27 +56,117 @@ class FloodPort:
class LoaderPort:
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the
"""A well-behaved pureboot 5: a 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
self.pending = b""
self.exacts = 0
def flush_input(self):
pass
self.pending = b""
def write(self, data):
pass
if b"p" in data:
self.pending = b"+" # the prompt answers the knock, nothing else
def read_available(self, wait):
self.reads += 1
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet
data, self.pending = self.pending, b""
return data
def read_exact(self, count, timeout):
self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
class StaleDTRPort:
"""`--stay`, then a fresh invocation on a board that resets when its port
opens. Three facts of that moment, all timed from the open: the previous
session's final prompt is still in transit and lands only after the
opening flush has already run; the reset holds the device off the line
at first, eating anything written before it completes; and the fresh
window is finite — once it expires the application boots and prints a
banner whose bytes are what a pending identity read collects. A
handshake that trusts the stale prompt spends the whole window waiting
on a device that never heard its knock; one that drains the line first
knocks into the real window and connects."""
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
READY_AT = 0.05 # reset complete, activation window opens
WINDOW = 1.0 # window length; expiry boots the application
def __init__(self):
self.t0 = time.monotonic()
# (visible-from, bytes): the line as a timed queue.
self.queue = [(self.t0 + self.STALE_AT, b"+")]
self.armed = False # a 'p' heard inside the window arms 'b'
self.booted = False
def _boot_check(self):
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
self.booted = True
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
b"W r libavr tempmon\r\n"))
def _visible(self):
self._boot_check()
now = time.monotonic()
return b"".join(d for t, d in self.queue if t <= now)
def _consume(self, n):
now = time.monotonic()
left = []
for t, d in self.queue:
if t <= now and n:
take = min(n, len(d))
d = d[take:]
n -= take
if d:
left.append((t, d))
self.queue = left
def flush_input(self):
self._consume(len(self._visible()))
def write(self, data):
self._boot_check()
now = time.monotonic()
if now < self.t0 + self.READY_AT or self.booted:
return # still in reset, or the application owns the line
if b"p" in data:
self.armed = True
self.queue.append((now + 0.01, b"+"))
if b"b" in data and self.armed:
# The slim identity (version 5 + m328p signature) and a prompt.
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
def read_available(self, wait):
deadline = time.monotonic() + wait
while True:
data = self._visible()
if data:
self._consume(len(data))
return data
if time.monotonic() >= deadline:
return b""
time.sleep(0.005)
def read_exact(self, count, timeout):
deadline = time.monotonic() + timeout
data = b""
while len(data) < count:
visible = self._visible()
if visible:
take = visible[:count - len(data)]
self._consume(len(take))
data += take
elif time.monotonic() >= deadline:
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
else:
time.sleep(0.005)
return data
def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging."""
@@ -97,6 +194,16 @@ def main():
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5)
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
# fresh window — the pre-knock drain absorbs it and the first real knock
# lands inside the window.
try:
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
except pb.Error as failed:
print(f" ({failed})")
stale_ok = False
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
print(f"\n {P} passed, {F} failed")
return 1 if F else 0

View File

@@ -53,7 +53,7 @@ class Suite:
"""The info block, which every later check takes its bounds from."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
self.rig.reset()
port = module.Port(self.rig.d.port, self.rig.d.baud)
port = self.rig.open_port() # wrapped for the echo where the line is shared
try:
loader = module.Loader(port)
if self.rig.d.autobaud:
@@ -87,7 +87,10 @@ class Suite:
rate = module.scan_rate(self.rig.d.baud, pct)
self.rig.reset()
try:
port = module.Port(self.rig.d.port, rate)
# Same wrap as identity(): on a shared line an undiscarded
# echo answers every rate a scan probes, so the walk would
# report the first one it tried.
port = self.rig.open_port(rate)
except module.Error as error:
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
return
@@ -129,18 +132,28 @@ class Suite:
got = erased.read_bytes() if erased.exists() else b""
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
def application(self, info, app: pathlib.Path, marker: str) -> None:
def application(self, info, app: pathlib.Path, marker: str,
marker_wait: float = 2.5) -> None:
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
if marker:
# The tool hands over as it ends its session, so the application is
# already running; opening the port does not reset a board whose DTR
# is unwired, so this simply listens.
data = self.rig.capture(seconds=2.5)
# already running — but only on a board whose DTR is unwired, where
# opening a port simply listens. Where DTR *is* wired to reset (an
# Arduino, most USB-serial dev boards), this open resets the part
# and the activation window comes first, so a marker emitted once at
# startup happens on the far side of a wait this cannot know the
# length of: the window is a compile-time constant and nothing on
# the wire reports it. Hence --marker-wait, and a fixture that
# repeats its banner (PUREBOOT_HEARTBEAT) rather than saying it once.
data = self.rig.capture(seconds=marker_wait)
seen = marker.encode() in data
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
self.check(f"application runs (emits {marker!r})", seen, f"|{sample}|")
self.check(f"application runs (emits {marker!r})", seen,
f"|{sample}|" if seen or data else
f"nothing in {marker_wait:g} s — if this board resets when its port "
f"opens, that wait has to outlast the activation window")
back = self.work / "app-back.bin"
rc, out = self.rig.pureboot("--read-flash", str(back))
@@ -187,7 +200,7 @@ class Suite:
# ------------------------------------------------------------------- run
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
marker: str) -> int:
marker: str, marker_wait: float = 2.5) -> int:
print("identity")
info = self.identity()
if info is None:
@@ -203,7 +216,7 @@ class Suite:
if app:
print("\napplication")
self.application(info, app, marker)
self.application(info, app, marker, marker_wait)
else:
print("\nskip application checks (pass --app <image.hex>)")
@@ -229,6 +242,10 @@ def main(argv: list[str] | None = None) -> int:
help="the resident loader's .bin, to prove the slot survives an erase")
parser.add_argument("--marker", default="",
help="text the application emits when it runs, e.g. APP")
parser.add_argument("--marker-wait", type=float, default=2.5,
help="seconds to listen for it. On a board whose DTR is wired to "
"reset, opening the port resets the part, so this must outlast "
"the activation window (default 2.5)")
args = parser.parse_args(argv)
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
@@ -236,7 +253,8 @@ def main(argv: list[str] | None = None) -> int:
f"{' (autobaud)' if args.autobaud else ''}")
print("this overwrites the application flash and EEPROM\n")
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker)
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker,
args.marker_wait)
if __name__ == "__main__":

View File

@@ -87,6 +87,7 @@ class Deployment:
port: str = "" # serial device the loader speaks on
baud: int = 57600 # host rate; for autobaud, the rate to drive
autobaud: bool = False # send the calibration pulse instead of p+b
one_wire: bool = False # shared line: the host discards its own echo
programmer: str = "" # avrdude -c
part: str = "" # avrdude -p
avrdude: str = "avrdude"
@@ -101,6 +102,7 @@ class Deployment:
port=os.environ.get("PUREBOOT_PORT", ""),
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
one_wire=os.environ.get("PUREBOOT_ONE_WIRE", "") not in ("", "0"),
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
part=os.environ.get("PUREBOOT_PART", ""),
avrdude=os.environ.get("AVRDUDE", "avrdude"),
@@ -117,6 +119,8 @@ class Deployment:
help="host rate (for autobaud, the rate to drive)")
parser.add_argument("--autobaud", action="store_true", default=env.autobaud,
help="send the calibration pulse instead of the p+b knock")
parser.add_argument("--one-wire", action="store_true", default=env.one_wire,
help="shared line: pass the tool its echo discard")
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
@@ -128,6 +132,7 @@ class Deployment:
@classmethod
def from_args(cls, args: argparse.Namespace) -> "Deployment":
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
one_wire=args.one_wire,
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
@@ -267,6 +272,8 @@ class Rig:
"--wait", str(self.d.wait)]
if self.d.autobaud if autobaud is None else autobaud:
command.append("--autobaud")
if self.d.one_wire:
command.append("--one-wire")
command += [str(a) for a in args]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
@@ -274,6 +281,22 @@ class Rig:
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
return result.returncode, (result.stdout or "") + (result.stderr or "")
def open_port(self, baud: int | None = None):
"""A port opened the way this deployment says to speak to the board.
Everything the rig runs as a *subprocess* gets its flags from
`pureboot()` above; anything that drives the protocol in-process has
to reach the same facts, and until this existed only the subprocess
path could. A shared line is the one where that gap is fatal rather
than untidy: the host reads back every byte it writes, so an
undiscarded echo answers the knock before the device does. Open
through here and a one-wire deployment cannot be silently driven as
a two-wire one.
"""
module = load_pureboot(self.d.pureboot)
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
return module.OneWirePort(port) if self.d.one_wire else port
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
"""Listen to whatever the board is saying, at an arbitrary rate.

View File

@@ -84,6 +84,20 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
# A chip's generated and reflect trees must answer with the same bytes
# (the identity invariant), so the same target measuring two sizes means
# a stale tree — or an identity breach. Either is a finding; picking one
# silently is how a gate reports another build's numbers as today's.
for chip, rows in found.items():
seen: dict[str, tuple[int, str]] = {}
for name, elf, _ in rows:
if elf not in sizes:
continue
if name in seen and seen[name][0] != sizes[elf]:
sys.exit(f"{chip} {name}: {seen[name][0]} B in {seen[name][1]} but "
f"{sizes[elf]} B in {elf} — a stale tree (rebuild or remove it) "
f"or a cross-mode identity breach")
seen.setdefault(name, (sizes[elf], elf))
measured = {
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
key=lambda row: -row[1])
@@ -120,7 +134,11 @@ def cmd_max(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 one-wire fold of the same build is its twin
and competes for the same cell) — the config the Autobaud column
documents."""
readme = (ROOT / "pureboot" / "README.md").read_text()
measured = collect()
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
@@ -132,7 +150,16 @@ def cmd_check_readme(args) -> int:
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
built = {name: text for name, text, _ in measured.get(chip, [])}
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)):
# The on-USART pair defines the column where the chip has a USART;
# the default-pin pair is the whole space elsewhere. Whichever twin
# measures larger is the number the cell must state.
candidates = [name for name in ("pureboot_autobaud_osccal_on_usart0",
"pureboot_1w_autobaud_osccal_on_usart0") if name in built]
if not candidates:
candidates = [name for name in ("pureboot_autobaud_osccal",
"pureboot_1w_autobaud_osccal") if name in built]
worst = max(candidates, key=lambda name: built[name], default="pureboot_autobaud_osccal")
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
if target not in built:
skipped += 1
continue

View File

@@ -263,7 +263,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
// byte and U2X0 need a store. The library still does the datasheet work.
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
// General-purpose registers are undefined at power-on (no crt zeroes them);
// the direction latch must start "not receiving" so the first rx() enables

View File

@@ -202,7 +202,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
{
constexpr auto sol = avr::uart::solve_baud(dev::clock, 115200_Bd);
static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
avr::hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(sol.ubrr));
avr::hw::ubrr0::write(static_cast<std::uint8_t>(sol.ubrr));
avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
}

View File

@@ -240,7 +240,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
// byte and U2X0 need a store. The library still does the datasheet work.
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
// General-purpose registers are undefined at power-on (no crt zeroes them);
// the direction latch must start "not receiving" so the first rx() enables