22 Commits
v5 ... v7

Author SHA1 Message Date
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
a3ea099105 pureboot: the detail reaches become the library's own API
The three things this repo took from under libavr's counter are now over
it, and the local copies fold away. The USART release on a software
link's pins is the library's init contract (its guard here becomes a
deletion, byte-identical images held by the gate); the WDRF routing test
is power::peek_reset_cause().watchdog instead of a hand lookup of the
flag's register; the tsb tiers' baud arithmetic is the public solver.
libavr pin advances over those three additions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:12:35 +02:00
c535c4756c pureboot: the activation countdown in the narrowest type that holds it
The fixed-baud window counted down a uint32 where almost every window fits
24 bits; the countdown now takes avr::uint24_t when the poll budget allows
(the autobaud budget's own choice), uint32 past 16.7M polls — four bytes
off every fixed-baud image on every chip, the full suites green on the
changed window. The README size table is refreshed — its autobaud column
had also gone stale by the no-assembly pass's measurement-loop win, which
nothing gated: sizes.py check-readme now runs as the gate's final stage,
where every tree is freshly built and the table can actually be held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:47:29 +02:00
321ff8a4ee test: the device runners speak the C++ the rest of the repo does
pureboot_device and the tsb device, C until now, rewritten in C++23 with
every modeled behavior intact — the PGERS Z-mask and m48-discard ioctl
wraps, the GPIO bridge's timing and pacing, the tiny NVM's write-once
buffer, pin ownership, and the PB_PTY/TSB_PTY lines the harnesses parse.
The one linkage fact worth a comment: simavr's parts headers (uart_pty.h)
carry no C++ guards where its core headers do, so those includes sit in an
extern "C" block. Warning-clean at -Wall -Wextra on the build line; the
full protocol suites on all four sim-driven chips prove the conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:59:03 +02:00
531ae6c8dc build: the libavr pin advances over the audit rounds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:52:05 +02:00
b3f41caf6e audit: round three on the port — the hardware scan check fails soft
A rig hiccup mid-walk records the scan check as failed and lets the suite
continue, matching its siblings' envelope; a nonexistent --port path
reports as an error instead of a traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:31:32 +02:00
7716e1e291 audit: the port's pass — the scan that could not walk, and the drift a generator ends
--scan's walk was unwalkable on POSIX: probe rates have no termios
B-constant, so the first off-nominal probe raised out of the loop. The port
speaks termios2 BOTHER now (red-proven on a pty at 9984 Bd), the probe's
open lives inside the walk's error handling, an fd no longer leaks on an
unmakeable rate, and the swallowed unknown-signature reply is named at
timeout instead of reported as silence. CMakePresets.json's generator emits
the submodule toolchain path it had drifted from — a hand edit on a
generated file, exactly the class rule 10 exists for — and presets.generated
gates the pair from here on (the  marker CMake rejects at the
presets root stayed out; the check is the guard). The over-slot image guard
the tsb runner gained reaches the pureboot runner too; the GPIO bridge's
delivery comment states the hardware truth (RXC at the stop bit's sampling
point); the hardware suite gains the scan check — the one place the rate
physics is real; and the libavr pin advances over both audit rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:12:17 +02:00
1b18f10f4e docs: the OSCCAL axis, --scan, and the RC-oscillator deployment risk
Configuration gains the OSCCAL row; Deployment says what the build cannot
see (±10 % factory trim against a frame's ~±4 %, and silence that reads as
wiring); the update section names an OSCCAL bake as a link change in effect,
declared with --staged-baud; the host-tool section documents --scan and the
measured clock --info adds on an autobaud session; the version map gains 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:03:15 +02:00
52c4cdab32 pureboot.py: --scan walks a silent loader's rate; --info decodes the clock
The RC-oscillator answer's host half. --scan probes ±10 % around the built
rate in 2 % steps, nearest first, one activation window (one reset) per
probe: a fixed-baud loader whose oscillator drifted answers at the ratio,
and the report gives the session workaround (--baud), the offset, the
OSCCAL direction at ~1 %/step, and the autobaud way out. The walk and the
advice are logic-tested (test_scan.py, red-proven on the trim direction) —
a pty carries bytes at any rate, so the wire cannot arbitrate them.

On an autobaud session --info now reads the measured bit period from
ram_start — the geometry table gains that column — and undoes the unit's
encoding ((cycles − 8) / 4, floored: libavr's spin granule and per-bit
overhead), so the printed clock is the true one within a granule; --clock
turns it into a stated drift. The autobaud end-to-end asserts the figure
inside exactly that envelope at both clock points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:01:35 +02:00
69f089e53a pureboot 6: a build-time OSCCAL trim, applied ahead of every reset path
The RC-oscillator answer's device half (dev/tasks.md in libavr): OSCCAL joins
pureboot_add_loader() as one optional byte, written at the top of run() before
the WDRF bail so the watchdog hand-over inherits the corrected clock too.
Orthogonal to the backend — an autobaud build may carry it purely for the
application. No value, no code: the stock image differs from v5 in exactly
the version's two bytes (the stamp and the 'b' immediate).

Measured: +6 B where OSCCAL takes sts (328P, 404→410), +4 B in low I/O
(t85, 402→406); the tightest image in the space (1284 autobaud on USART
pins, 504) carries the sts form at 510 of 512. New gates: the OSCCAL size
points on every chip, the wire-observed trim byte on both addressing
classes (test/pbosccal.py, red-green), and the autobaud unit pinned to
ram_start (test/check_unit.cmake, red-green) — the address --info's
measured-clock read is about to rely on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:53:14 +02:00
25 changed files with 1385 additions and 409 deletions

View File

@@ -22,15 +22,17 @@ if(PROJECT_IS_TOP_LEVEL)
# The behavioral tests drive the real wire protocols over a simavr pty # The behavioral tests drive the real wire protocols over a simavr pty
# (as the host tools do) and actually flash the device. The runners are # (as the host tools do) and actually flash the device. The runners are
# host programs built at configure time against libsimavr; if they or # host programs built at configure time against libsimavr (C++23 — what
# Python are missing, only the size tests run. # the distribution's compiler speaks in full); if they or Python are
find_program(_host_cc NAMES cc gcc) # missing, only the size tests run.
find_program(_host_cxx NAMES c++ g++)
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(_host_cc AND Python3_FOUND) if(_host_cxx AND Python3_FOUND)
set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device) set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device)
execute_process( execute_process(
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c -I/usr/include/simavr -I/usr/include/simavr/parts
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.cpp
-lsimavr -lsimavrparts -lelf -lutil -lsimavr -lsimavrparts -lelf -lutil
RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err) RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err)
if(NOT _pbdev_res EQUAL 0) if(NOT _pbdev_res EQUAL 0)
@@ -40,8 +42,9 @@ if(PROJECT_IS_TOP_LEVEL)
if(LIBAVR_MCU STREQUAL "atmega328p") if(LIBAVR_MCU STREQUAL "atmega328p")
set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device) set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device)
execute_process( execute_process(
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c -I/usr/include/simavr -I/usr/include/simavr/parts
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.cpp
-lsimavr -lsimavrparts -lelf -lsimavr -lsimavrparts -lelf
RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err) RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err)
if(NOT _dev_res EQUAL 0) if(NOT _dev_res EQUAL 0)
@@ -66,16 +69,18 @@ function(add_image_outputs name)
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin) $<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin)
endfunction() endfunction()
# The TinySafeBoot protocol reimplemented on libavr in three variants that trade # The TinySafeBoot protocol reimplemented on libavr in variants that trade
# clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects # clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects
# its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled # its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled
# loader has no use for the crt or the vector table. The naked entry sits in # loader has no use for the crt or the vector table. The entry sits in
# .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section # .vectors, laid first, and runs — avr::startup::entry on the policy tier,
# size; the linker section-start and the source's boot_bytes agree. tsb_app is # the experiment tiers' own naked stubs elsewhere, each documented in its
# source. The boot base is FLASHEND+1 minus the section size; the linker
# section-start and the source's boot_bytes agree. tsb_app is
# the application's reset vector, pinned to 0 here so the loaders jump to a # the application's reset vector, pinned to 0 here so the loaders jump to a
# named function; --pmem-wrap-around lets relaxation turn that absolute jump # named function; --pmem-wrap-around lets relaxation turn that absolute jump
# into the wrapped rjmp AVR's modulo-flash PC actually executes. # into the wrapped rjmp AVR's modulo-flash PC actually executes.
# All three implement the full oracle feature set (see oracle/README.md): # All four implement the full oracle feature set (see oracle/README.md):
# watchdog bail, one-wire half-duplex, config-page activation timeout, password # watchdog bail, one-wire half-duplex, config-page activation timeout, password
# gate, emergency erase, config/flash/EEPROM read-write. They differ only in how, # gate, emergency erase, config/flash/EEPROM read-write. They differ only in how,
# and the size gradient is the cost of that "how" — see dev/lessons.md. # and the size gradient is the cost of that "how" — see dev/lessons.md.
@@ -167,6 +172,14 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.planner add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
add_test(NAME pureboot.scan
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
# CMakePresets.json is generated; hand edits drift the moment the
# generator reruns, so the gate holds the pair together.
add_test(NAME presets.generated
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/make_presets.py
--check)
add_test(NAME pureboot.handshake add_test(NAME pureboot.handshake
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
add_test(NAME pureboot.updatelink add_test(NAME pureboot.updatelink
@@ -189,6 +202,21 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbtest-work) ${CMAKE_BINARY_DIR}/pbtest-work)
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
# The activation window as a measured duration: application installed,
# line idle, the first transmit is the application's banner — its
# cycle is the window the source declares, held to ±2 % (one
# mis-counted cycle per poll is a 10 % shift).
add_test(NAME pureboot.window
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot>
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
--seconds ${PUREBOOT_TIMEOUT}
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
# The position-independence acceptance test: the identical image, # The position-independence acceptance test: the identical image,
# installed one slot lower, must serve the full command set. # installed one slot lower, must serve the full command set.
add_test(NAME pureboot.reloc add_test(NAME pureboot.reloc
@@ -270,6 +298,14 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot_autobaud.size add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud> COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
# 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} -DGPIOR=${PUREBOOT_UNIT_GPIOR}
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
# One point of the exhaustive matrix, named from its resolved parameters # One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. `pins` is empty for the # so the enumeration cannot collide with itself. `pins` is empty for the
@@ -390,6 +426,38 @@ if(PROJECT_IS_TOP_LEVEL)
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX}) RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
endif() endif()
# The OSCCAL axis at its fixed points: the stock shape, and the tightest
# image in the space with the trim on top — the axis adds one register
# write, and these points hold both of its addressing encodings to every
# chip's budget.
pureboot_size_variant(pureboot_osccal OSCCAL 0x9c)
pureboot_size_variant(pureboot_autobaud_osccal SERIAL autobaud OSCCAL 0x9c)
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
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 —
# Atmel-2586 §21).
if(LIBAVR_MCU MATCHES "^(atmega328p|attiny85)$" AND DEFINED PB_DEVICE)
if(LIBAVR_MCU STREQUAL "atmega328p")
set(_osccal_addr 0x66)
else()
set(_osccal_addr 0x51)
endif()
get_target_property(_osccal_hz pureboot_osccal PUREBOOT_HZ)
get_target_property(_osccal_baud pureboot_osccal PUREBOOT_BAUD)
add_test(NAME pureboot.osccal
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbosccal.py
${PB_DEVICE} $<TARGET_FILE:pureboot_osccal> ${PUREBOOT_SIM_MCU}
${_osccal_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_osccal_baud}
${_osccal_addr} 0x9c ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbosccal-work)
set_tests_properties(pureboot.osccal PROPERTIES TIMEOUT 120)
endif()
# One configured deployment end to end — a real board's shape rather # One configured deployment end to end — a real board's shape rather
# than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses,
# the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder
@@ -468,9 +536,9 @@ if(PROJECT_IS_TOP_LEVEL)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif() endif()
# The autobaud variants driven end to end over the software-UART bridge (both # The autobaud loader driven end to end over the software-UART bridge:
# under review — pureboot/autobaud.md): the host sends the 0xC0 calibration # the host sends the 0xC0 calibration pulse, the loader times it, locks,
# pulse, the loader times it, locks, and programs. Run on the near-flash 328P # and programs. Run on the near-flash 328P
# and the word-addressed 1284P — the two flash-addressing classes — and each # and the word-addressed 1284P — the two flash-addressing classes — and each
# at two clocks with the one binary, which is the clock-agnostic property # at two clocks with the one binary, which is the clock-agnostic property
# autobaud exists for (test/pbautobaud.py). The fixture application banners # autobaud exists for (test/pbautobaud.py). The fixture application banners
@@ -490,5 +558,19 @@ if(PROJECT_IS_TOP_LEVEL)
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work) ${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240) set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
# The autobaud window: the calibration poll budget, at the measured
# 10 cycles a poll (pbwindow.py pins the constant the README's
# seconds arithmetic uses; the budget itself is the clock-free knob).
add_test(NAME pureboot.window.autobaud
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_autobaud>
--mcu ${PUREBOOT_SIM_MCU} --hz 1000000
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud 9600 --app $<TARGET_FILE:pbapp_autobaud>.bin
--autobaud-polls 4000000 --link sw
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-autobaud-work)
set_tests_properties(pureboot.window.autobaud PROPERTIES TIMEOUT 300)
endif() endif()
endif() endif()

View File

@@ -2,7 +2,7 @@
`master` carries `bootloader.atsln`, so this branch does too: `ide/bootloader.atsln` `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 builds the loaders from the same sources Ninja does, to a **byte-identical
`.text`** — 404 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 its 512-byte section. CMake remains the build system; the solution is here so the
port opens in Studio as its predecessor did. port opens in Studio as its predecessor did.

2
libavr

Submodule libavr updated: 43b1f34ed1...a9fe6bed50

View File

@@ -136,6 +136,28 @@ elseif(LIBAVR_MCU STREQUAL "atmega644pa")
set(_pb_sim_mcu atmega644p) set(_pb_sim_mcu atmega644p)
endif() 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 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 # The function runs in its caller's scope, so everything it needs crosses
# scopes as global properties. # scopes as global properties.
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex}) set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
@@ -155,6 +177,8 @@ set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE)
set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE) set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE) set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} 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_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE) set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE) set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
@@ -212,7 +236,7 @@ endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>] # pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>] # [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>]) # [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
# #
# The loader target plus its flashable images (<name>.hex for a programmer, # The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the # <name>.bin for --update-loader). The resolved deployment is stamped on the
@@ -225,8 +249,15 @@ endfunction()
# and one binary per chip serves every F_CPU and every rate. The stamped # and one binary per chip serves every F_CPU and every rate. The stamped
# PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at, # PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
# not what it was built for. # not what it was built for.
#
# OSCCAL bakes a measured oscillator trim into the loader (README.md: the
# RC-oscillator deployment answer): the byte is written at the top of run(),
# so every reset path — the watchdog hand-over included — runs on the
# corrected clock. Orthogonal to the backend: an autobaud build may carry it
# purely for the application's benefit, its own link being clock-free. No
# value, no code.
function(pureboot_add_loader name) function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN}) cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS) if(PB_UNPARSED_ARGUMENTS)
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}") message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
endif() endif()
@@ -325,6 +356,13 @@ function(pureboot_add_loader name)
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines}) ${_serial_defines})
endif() endif()
if(DEFINED PB_OSCCAL)
math(EXPR _osccal "${PB_OSCCAL}" OUTPUT_FORMAT DECIMAL)
if(_osccal LESS 0 OR _osccal GREATER 255)
message(FATAL_ERROR "pureboot_add_loader(${name}): OSCCAL ${PB_OSCCAL} is not one byte")
endif()
list(APPEND _defines PUREBOOT_OSCCAL=${_osccal})
endif()
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr) target_link_libraries(${name} PRIVATE libavr)
@@ -338,9 +376,18 @@ function(pureboot_add_loader name)
# load-immediate it saves. The set is fitted to the loader's body and has to # 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 # be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes # 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
target_compile_options(${name} PRIVATE # autobaud body needs ivopts to keep the calibration countdown a single
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types) # 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} target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap}) -Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>) 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 A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
constraint: one C++ source, no inline assembly, no global register variables constraint: one C++ source, no inline assembly, no global register variables
(attributes and compiler flags allowed), **512 bytes on every chip libavr (attributes and compiler flags allowed), **a 512-byte slot on every chip
targets — all 37**. The device speaks primitives; every composite — verify, libavr targets — all 37**. The device speaks primitives; every composite — verify,
erase, reset-vector surgery, updating the loader itself — lives in the host erase, reset-vector surgery, updating the loader itself — lives in the host
tool (`pureboot.py`). tool (`pureboot.py`).
@@ -19,41 +19,43 @@ come out byte-identical linked at a different base.
## Chips ## Chips
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a The Stock column is the default configuration: the hardware USART0 at 115200
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on 8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves 57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above).
per build — see *Configuration*. The autobaud column is the clock-free build, Every axis moves per build — see *Configuration*. The Autobaud column is the
which is the largest the space produces and the tightest fit in the matrix; worst configuration the space produces for the chip: the clock-free build —
it carries the calibration machinery and no clock at all. it alone carries the calibration machinery — with the `OSCCAL` trim baked
and, where the chip has a USART, the link deployed on that USART's own pins,
which the loader then has to release (*Pin ownership*). On default pins
without the trim the same loaders run 410 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud | | Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 394 B | 464 B | | ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 398 B | 468 B | | ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 402 B | 472 B | | ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 402 B | 472 B | | ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 364 B | 478 B | | ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 366 B | 482 B | | ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 366 B | 482 B | | ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 392 B | 468 B | | ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 402 B | 478 B | | ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 404 B | 482 B | | ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 404 B | 482 B | | ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 404 B | 482 B | | ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 404 B | 482 B | | ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 398 B | 476 B | | ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 424 B | 502 B | | ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 B |
† No hardware boot section: the host patches the reset vector, and the budget † No hardware boot section: the host patches the reset vector, and the budget
is 510 bytes, since the slot's last word is the trampoline. is 510 bytes, since the slot's last word is the trampoline.
The tightest fit in the whole space is the 1284s' autobaud build deployed on a The tightest fit in the whole space is therefore the 1284s' 502 of their
USART's own pins, 506 of its 512 they alone carry the far-flash machinery 512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
(ELPM reads, RAMPZ page commands), autobaud alone carries the calibration loop, commands) on top of everything the column already stacks. The flash bank
and a bit-banged link on a USART's pins alone has to release it (below). The riding in a transfer's selector byte keeps even those chips' addressing the
same build on the default pins is 502. The flash bank riding in a transfer's same 16-bit form every other chip uses, which is why they are no longer the
selector byte keeps even those chips' addressing the same 16-bit form every outlier they were.
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 The software UART enables the RX pull-up; TX idles high. All multi-byte wire
quantities are little-endian. quantities are little-endian.
@@ -72,6 +74,7 @@ repo's build and by a downstream project alike:
| `USART <n>` | the USART instance (x4 megas carry two) | 0 | | `USART <n>` | the USART instance (x4 megas carry two) | 0 |
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` | | `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 | | `TIMEOUT <s>` | the activation window | 8 |
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock 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 reaches within 2.5 % — the same U2X-included divisor search libavr's baud
@@ -85,7 +88,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 — 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 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 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 `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 at run time, so `CLOCK` and `BAUD` are not build parameters there and one
@@ -96,7 +100,10 @@ where a fixed-baud software build has to be rebuilt per clock and still drifts
out of tolerance. The cost is that it is software-serial only (a hardware USART out of tolerance. The cost is that it is software-serial only (a hardware USART
needs its divisor programmed) and that activation counts poll iterations rather needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). (`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). The wait spends 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 **Pick the rate by cycles a bit, and leave the oscillator room.** What the
calibration can measure is bounded by how many clock cycles one bit lasts, so a calibration can measure is bounded by how many clock cycles one bit lasts, so a
@@ -122,12 +129,13 @@ window per reset. Measure with an application in place.
A downstream project brings its usual libavr setup (the `libavr` target, the A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
software UART on hand-picked pins, say: software UART on hand-picked pins, say. A submodule pins the loader version
(the tags name them; this repo pins its own libavr the same way), where
FetchContent tracks whatever `main` is:
```cmake ```cmake
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main) # git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent
FetchContent_MakeAvailable(bootloader) add_subdirectory(bootloader/pureboot pureboot)
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)
pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5) pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
``` ```
@@ -183,8 +191,10 @@ reply, repeat.
Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in 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 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 the exception: its address is a word address, because that is what the
jump takes. EEPROM and data-space addresses and all counts are bytes. 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 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 — against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps —
@@ -199,7 +209,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 | 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 | | `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) | | `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) | | 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 `G` and `g` are one letter in two cases, which is the whole command set for
@@ -285,7 +295,14 @@ Two generations exist. **1 through 4** speak one session — a 12-byte info bloc
from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses). from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses).
**5** replaced those with the single `G`/`g` pair over selector-named spaces **5** replaced those with the single `G`/`g` pair over selector-named spaces
above; the shipped tool speaks both, choosing on the version it reads, so a above; the shipped tool speaks both, choosing on the version it reads, so a
deployed pureboot 4 stays drivable and self-updatable to 5. 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. **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.
Every closed generation is tagged in this repo at its era's last commit — the 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 commit just before the next version bump, so a tag holds everything its
@@ -350,6 +367,18 @@ mega (SPM only executes from the boot section — reflash the .hex), but *runs*
on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it
into the top slot from there (`pureboot.rehome`). into the top slot from there (`pureboot.rehome`).
**Fixed-baud on an internal RC oscillator is a deployment risk the build
cannot see.** The factory trim is ±10 % where an 8N1 frame survives about
±4: a part at the edge answers nothing at the built rate, and the symptom —
silence — reads as a wiring fault (a real ATtiny13A measured 5.5 %, outside
every standard rate at its own documented default). The **autobaud build is
the deployment-proof backend**: it has no rate to miss. Where fixed-baud on
RC is wanted anyway, measure first and bake the trim: an autobaud session's
`--info` prints the part's true clock from the loader's own measured bit
period, OSCCAL moves the oscillator about 1 % per step, and `OSCCAL <byte>`
builds the correction in — one buildmeasure iteration converges. A loader
already deployed and silent is diagnosed with `--scan` (Host tool).
## Updating the loader ## Updating the loader
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader `pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
@@ -374,6 +403,11 @@ The host retunes on the open port, so no DTR pulse resets the copy it is talking
to. Omit them against a changed link and the update stops after installing the to. Omit them against a changed link and the update stops after installing the
staging copy, saying so and naming this as the cause. staging copy, saying so and naming this as the cause.
An `OSCCAL`-baked image is a link change in effect even at an unchanged rate
on paper: the staging copy shifts the physical clock the moment its `run()`
starts, and from then on speaks exactly what it was built for. Declare it
like any other link change — `--staged-baud` with the new build's rate.
The preflight refuses an image built for another chip: the stamp every pureboot The preflight refuses an image built for another chip: the stamp every pureboot
binary carries must resolve to the device's own geometry, and the error names binary carries must resolve to the device's own geometry, and the error names
both. Die revisions share their base signature and geometry, so their images both. Die revisions share their base signature and geometry, so their images
@@ -430,7 +464,19 @@ application data into a mega's reset walk region.
`--autobaud` opens with the calibration pulse instead of the plain knock, for a `--autobaud` opens with the calibration pulse instead of the plain knock, for a
loader built `SERIAL autobaud`; the rest of the session is identical, at loader built `SERIAL autobaud`; the rest of the session is identical, at
whatever `--baud` the host chose. whatever `--baud` the host chose. Its `--info` adds the **measured clock**
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.
`--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
wired to reset is pulsed by the probe's own port-open). A loader
off-frequency answers at its oscillator's ratio, and the report gives the
found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it.
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) — `--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) —
SRAM, and through the same address space the register file and every I/O SRAM, and through the same address space the register file and every I/O
@@ -441,11 +487,14 @@ Reads are safe anywhere; **two small regions cannot be written without ending th
session,** because they are what the loader is standing on: session,** because they are what the loader is standing on:
- the **top of SRAM**, where its stack lives — a handful of bytes below RAMEND; - 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 - on an **autobaud** build, the **measured bit period**: two bytes in
period, in `.noinit`, which is the whole of that loader's static RAM. Overwrite GPIOR2:GPIOR1 where the chip has the pair (data `0x32..0x33` on the
it and its next reply is timed against garbage. On an ATtiny13A that is t25/45/85, `0x4A..0x4B` from the x8 generation on — such a loader has *no*
`0x60..0x61`, and the symptom is a mangled prompt byte rather than any error — static RAM at all), and the two bytes at RAMSTART on the chips without one
the loader is fine, it simply is no longer speaking the agreed rate. (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 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 that `--poke` can write OSCCAL, which does take effect — but a session can only
@@ -480,7 +529,13 @@ Per chip preset, `ctest` runs:
too. The timeout is a constant and is no axis; too. The timeout is a constant and is no axis;
- `pureboot_autobaud.size` — the clock-free build, which has no clock or baud - `pureboot_autobaud.size` — the clock-free build, which has no clock or baud
axis of its own: one binary per chip has to serve every point the matrix axis of its own: one binary per chip has to serve every point the matrix
below sweeps; 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 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 - `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
replacing that compact matrix, on **every** chip: every plausible oscillator replacing that compact matrix, on **every** chip: every plausible oscillator
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) × (the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×
@@ -503,8 +558,15 @@ Per chip preset, `ctest` runs:
recovery properties, the surgery, the staging composition, the boot-fuse recovery properties, the surgery, the staging composition, the boot-fuse
decode, the update preflight over synthetic fuse bytes, and the repairing decode, the update preflight over synthetic fuse bytes, and the repairing
verify against a fake device; verify against a fake device;
- `pureboot.scan``--scan`'s walk and report logic: the probe order, the
rate arithmetic, and the trim advice's direction. A pty carries bytes at
any termios rate, so the rate physics itself belongs to the hardware
harness, and what the wire would arbitrate is pinned as logic;
- `presets.generated` — CMakePresets.json matches its generator
(`tools/make_presets.py --check`), so a hand edit or a generator change
cannot drift the pair apart;
- `pureboot.protocol` — end to end against a simavr device - `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed (`test/pureboot_device.cpp`: a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's
tiny cores lack) driven by the real host tool through knock-from-reset, tiny cores lack) driven by the real host tool through knock-from-reset,
program + verify of both memories, session reconnect, an external reset program + verify of both memories, session reconnect, an external reset
@@ -535,15 +597,21 @@ Per chip preset, `ctest` runs:
- `pureboot.update` — the full `--update-loader` flow, then every power-fail - `pureboot.update` — the full `--update-loader` flow, then every power-fail
phase: the device is killed mid-write, restarted from its flash dump, and a phase: the device is killed mid-write, restarted from its flash dump, and a
re-run must complete the update with the application intact; re-run must complete the update with the application intact;
- `pureboot.osccal` (328P, t85) — a loader built with the `OSCCAL` axis holds
the trim register at the built byte from its first prompt, observed through
the wire on one chip per addressing encoding (`sts` and low-I/O `out`);
- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty - `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
bridge: the calibration handshake, a flash + EEPROM + fuse round trip against bridge: the calibration handshake, a flash + EEPROM + fuse round trip against
the simulator's own memory, a data-space round trip, the hand-over — then the the simulator's own memory, a data-space round trip, the hand-over — then the
same binary again at double the clock, which is the property the backend same binary again at double the clock, which is the property the backend
exists for. A lone calibration pulse with no knock behind it must still let exists for. The measured clock `--info` prints is asserted against the
simulator's exact clock, inside the unit encoding's own envelope, at both
points. A lone calibration pulse with no knock behind it must still let
the application boot, so no wait in activation can be unbounded. the application boot, so no wait in activation can be unbounded.
`size`, `pi`, `planner` and `handshake` are host logic and run anywhere; the `size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run
simulator-driven targets need simavr and a pty, so they are POSIX-only. anywhere; the simulator-driven targets need simavr and a pty, so they are
POSIX-only.
## Hardware ## Hardware

View File

@@ -1,6 +1,6 @@
// pureboot — a serial bootloader on libavr: one C++ source, no inline // pureboot — a serial bootloader on libavr: one C++ source, no inline
// assembly, no global register variables, 512 bytes on every chip libavr // assembly, no global register variables, a 512-byte slot on every chip
// targets. The device speaks primitives; every composite (verify, erase, // libavr targets. The device speaks primitives; every composite (verify, erase,
// reset-vector surgery, self-update) lives in the host tool. Protocol, // reset-vector surgery, self-update) lives in the host tool. Protocol,
// deployment and configuration: README.md next to this file. // 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 // 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). // every change here has to keep it (test/check_pi.py).
#include <chrono>
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
using namespace avr::literals; using namespace avr::literals;
@@ -38,13 +40,6 @@ using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD}; constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
#endif #endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
{
auto reg = std::string_view{avr::hw::db.regs[static_cast<std::size_t>(avr::power::detail::reset_reg())].name};
return avr::hw::db.field_index(reg, "WDRF");
}
// The loader owns the top 512 bytes; a staging copy goes in the slot below. // The loader owns the top 512 bytes; a staging copy goes in the slot below.
// Chips without a hardware boot section — the tinies and the m48s, whose SPM // Chips without a hardware boot section — the tinies and the m48s, whose SPM
// runs from anywhere (Atmel-8271 §26) — keep the application's relocated // runs from anywhere (Atmel-8271 §26) — keep the application's relocated
@@ -72,9 +67,17 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
#endif #endif
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// A build may bake a measured oscillator trim (README.md: the RC-oscillator
// deployment answer); the byte is applied at the top of run(). Orthogonal to
// the serial backend — an autobaud build may carry it for the application's
// benefit alone.
#if defined(PUREBOOT_OSCCAL)
static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
#endif
// The loader's one identity number. The protocol carries none of its own — // 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). // a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 5; constexpr std::uint8_t version = 7;
// The image's identity stamp, for the host tool rather than for the wire: an // 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 // update image is a bare 512-byte slot, and without this nothing in it says
@@ -161,39 +164,30 @@ constexpr std::uint8_t bank_shift = 16 - slot_shift;
#define PUREBOOT_TX pb1 #define PUREBOOT_TX pb1
#endif #endif
#if defined(PUREBOOT_USART) #if defined(PUREBOOT_USART)
constexpr char usart_digit = '0' + PUREBOOT_USART; constexpr int usart_unit = PUREBOOT_USART;
#else #else
constexpr char usart_digit = '0'; constexpr int usart_unit = 0;
#endif #endif
// Release a hardware USART the application may have left enabled onto a
// bit-banged link's pins. A software transmitter drives its TX pin through the
// port register, but while that USART's TXEN is set the USART owns the pin and
// the port write does nothing — the loader would receive and obey yet never
// answer. Writing UCSRnB zero hands the pin back to the port. Guarded on the
// pin actually being a USART's TXD, so a link on non-USART pins emits nothing.
template <char Inst, avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usart_on()
{
if constexpr (avr::uart::has_usart<Inst>())
if constexpr (avr::uart::detail::usart_pin<Inst>("TXD") == Tx)
avr::hw::reg_impl<avr::uart::detail::ureg<Inst, "UCSR#B">()>::write(0);
}
template <avr::io::pin Tx>
[[gnu::always_inline]] inline void release_usarts_on()
{
release_usart_on<'0', Tx>();
release_usart_on<'1', Tx>();
}
template <avr::hertz_t C, avr::baud_t B> template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { 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}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbiw + sbci + sbci + brne (6). // the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
static constexpr std::uint8_t poll_cycles = 10; // status register's home — a 2-cycle bit-skip where UCSRnA sits in
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
// I/O. A uint32_t countdown pays one more sbci — window_polls() adds it
// where the count forces the wide type. Held by the pureboot.window gate.
// The lookup rides the baud parameter so it stays dependent: the trait is
// an incomplete type on the USART-less chips, which parse this template
// without ever instantiating it.
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
static consteval std::uint8_t poll_cost()
{
return U::ucsra::addr < 0x40 ? 7 : 9;
}
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
static void init() static void init()
{ {
@@ -217,7 +211,10 @@ struct hardware_link {
static void drain() 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();
} }
}; };
@@ -226,14 +223,15 @@ struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>; using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>; using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbci + brne (6). // sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
static constexpr std::uint8_t poll_cycles = 8; // countdown pays one more sbci — window_polls() adds it where the count
// forces the wide type. Held by the pureboot.window gate.
static constexpr std::uint8_t poll_cycles = 7;
static void init() static void init()
{ {
avr::init<rx_t, tx_t>(); avr::init<rx_t, tx_t>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static bool pending() static bool pending()
@@ -262,12 +260,14 @@ struct software_link {
// every rate. Activation differs in kind from the other two — there is no // 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. // clock to time a window against — so this backend brings its own, below.
struct autobaud_link { 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() static void init()
{ {
avr::init<uart>(); avr::init<uart>();
release_usarts_on<avr::PUREBOOT_TX>();
} }
static std::uint8_t rx() static std::uint8_t rx()
@@ -282,19 +282,22 @@ struct autobaud_link {
static void drain() 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) #if defined(PUREBOOT_AUTOBAUD)
using link = autobaud_link; using link = autobaud_link;
#elif defined(PUREBOOT_USART) #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>; using link = hardware_link<dev::clock, wire_baud>;
#elif defined(PUREBOOT_SOFT_SERIAL) #elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock, wire_baud>; using link = software_link<dev::clock, wire_baud>;
#else #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>>; software_link<dev::clock, wire_baud>>;
#endif #endif
@@ -310,7 +313,7 @@ extern "C" [[noreturn]] void pureboot_app();
__builtin_unreachable(); __builtin_unreachable();
} }
[[gnu::noinline, noreturn]] void run_app() [[noreturn]] void run_app()
{ {
jump(pureboot_app); jump(pureboot_app);
} }
@@ -336,16 +339,39 @@ void await_host()
} }
} }
#else #else
// The window as one 32-bit countdown, divided by the backend's counted // The window as one countdown, divided by the backend's counted poll-loop
// poll-loop cycles. Whole seconds is all it promises. // cycles. Whole seconds is all it promises. The per-poll cost depends on the
// countdown's own width (a uint32_t decrement chain is one sbci longer), and
// the width depends on the poll count — solved narrow-first: a count that
// fits 24 bits at the narrow cost keeps the narrow loop, anything else takes
// the wide loop at its own cost. A count fitting 24 bits only at the wide
// cost stays wide, so the choice cannot oscillate on the boundary.
consteval std::uint32_t polls_at(std::uint32_t per_poll)
{
// 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() 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<narrow_window(), avr::uint24_t, std::uint32_t>;
bool pending_before_deadline() bool pending_before_deadline()
{ {
std::uint32_t polls = window_polls(); window_t polls = window_polls();
do { do {
if (link::pending()) if (link::pending())
return true; return true;
@@ -477,9 +503,15 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
[[noreturn]] void run() [[noreturn]] void run()
{ {
#if defined(PUREBOOT_OSCCAL)
// The build's oscillator trim, ahead of everything — the WDRF bail
// included — so every path out of reset, the watchdog hand-over to the
// application first among them, runs on the corrected clock.
avr::clock::calibrate(PUREBOOT_OSCCAL);
#endif
// A watchdog reset belongs to the application, whose watchdog stays forced // A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF — no activation window in its way. // on until it clears WDRF — no activation window in its way.
if (avr::hw::field_impl<wdrf_field()>::test()) if (avr::power::peek_reset_cause().watchdog)
run_app(); run_app();
link::init(); link::init();
@@ -499,12 +531,6 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
tx_ack(); tx_ack();
const std::uint8_t command = link::rx(); const std::uint8_t command = link::rx();
switch (command) { 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 case 'b': // identity: the version, then the three signature bytes
// Straight out of the stamp, so the wire and the image can never // Straight out of the stamp, so the wire and the image can never
// disagree about what this loader is. The indices are constant and // disagree about what this loader is. The indices are constant and
@@ -513,18 +539,26 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at) for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
link::tx(identity_stamp[at]); link::tx(identity_stamp[at]);
break; 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 'W': // fill one flash page buffer: sel8, addr16, then page bytes
case 'G': // read: sel8, addr16, n8 (0 = 256) case 'G': // read: sel8, addr16, n8 (0 = 256)
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
// One decode, one cursor and one loop for every space and both // One decode, one cursor and one loop for every space, both
// directions: a command per memory would carry a copy of all three // directions and the jump: a command per memory would carry a copy
// each. 'W' joins the same decode rather than keeping an address // of all three each. 'J' — the hand-over and staging transfer —
// form of its own, so flash addressing is uniform across every // carries a selector it ignores so its address rides the same two
// command that names it. // 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 selector = link::rx();
const std::uint8_t space = space_of(selector); const std::uint8_t space = space_of(selector);
const std::uint8_t bank = bank_of(selector); const std::uint8_t bank = bank_of(selector);
std::uint16_t at = rx16(); std::uint16_t at = rx16();
if (command == 'J') {
tx_ack();
link::drain();
jump(reinterpret_cast<void (*)()>(at));
}
if (command == 'W') { if (command == 'W') {
fill_page(bank, at); fill_page(bank, at);
break; break;
@@ -551,4 +585,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
} // namespace } // namespace
} // namespace pureboot } // 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

@@ -20,17 +20,21 @@ if os.name == "nt":
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
else: else:
import array
import fcntl
import select import select
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 5 # this tool's own version — free to drift from a loader's VERSION = 8 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire # 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 # protocol, which carries no number of its own, so this window is where that
# map lives: every version so far speaks the same protocol, and one that # map lives: the tool keeps a decoder for every generation in it (14 speak
# changes it becomes the new floor here. # 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.
OLDEST_LOADER = 1 OLDEST_LOADER = 1
NEWEST_LOADER = 5 NEWEST_LOADER = 7
SLOT = 512 # the loader slot, on every chip SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
@@ -38,9 +42,22 @@ 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 # '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 # 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. # — 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.
UNIFIED_LOADER = 5 UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# 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.
# 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 # A selector's high nibble is the flash bank — the address bits above the 16-bit
# wire address — so a transfer names a byte address within one 64 KiB bank and # wire address — so a transfer names a byte address within one 64 KiB bank and
# no command has to speak word addresses. No single transfer may cross a bank # no command has to speak word addresses. No single transfer may cross a bank
@@ -66,31 +83,40 @@ CALIBRATE = 0xC0
# from its chip database at build time). Die revisions that share a signature # from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon. # share this row, as they share the silicon.
CHIP_GEOMETRY = { CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector) # signature : (flash, page, eeprom, patch_vector, ram_start, gpior1)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A # ram_start is where SRAM begins in data space: the classic megas and the
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25 # tinies keep it right after the plain I/O registers (0x60), the x8/x4
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45 # generations past their extended I/O file (0x100). gpior1 is GPIOR1's
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85 # data address — 0x32 on the t25/45/85, 0x4A from the x8 generation on,
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A # None where the chip has no pair (t13, classic megas). A v7 autobaud
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA # loader's measured bit period lives in GPIOR2:GPIOR1 where they exist
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A # and at exactly ram_start elsewhere (its only RAM object; the loader's
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A # own build pins the layout); v5/v6 always used ram_start. --info reads
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA # whichever home the answering version implies.
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A (0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60, None), # ATtiny13/13A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A (0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60, 0x32), # ATtiny25
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA (0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60, 0x32), # ATtiny45
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA (0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60, 0x32), # ATtiny85
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A (0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48/48A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A (0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48P/48PA
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P (0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60, None), # ATmega8/8A
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328 (0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88/88A
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P (0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88P/88PA
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA (0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60, None), # ATmega16/16A
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A (0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168/168A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A (0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168P/168PA
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA (0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164P/164PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P (0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164A
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284 (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 VERBOSE = False
@@ -144,36 +170,60 @@ class Progress:
class PosixPort: class PosixPort:
"""A raw serial port with deadline-based reads, over termios.""" """A raw serial port with deadline-based reads, over termios. A rate with
no B-constant — the off-nominal probes `--scan` walks — goes through
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
by name."""
# The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed
# word offsets: four flag words, then a line-discipline byte and 19
# control chars padded to word 9 (include/uapi/asm-generic/termbits.h).
_TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B
_BOTHER, _CBAUD = 0o010000, 0o010017
_ISPEED, _OSPEED = 9, 10
@staticmethod @staticmethod
def _speed(baud): def _speed(baud):
return getattr(termios, f"B{baud}", None)
def _set_arbitrary(self, baud):
buf = array.array("i", [0] * (self._OSPEED + 1))
try: try:
return getattr(termios, f"B{baud}") fcntl.ioctl(self.fd, self._TCGETS2, buf, True)
except AttributeError: buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER
raise Error(f"unsupported baud rate {baud}") from None buf[self._ISPEED] = buf[self._OSPEED] = baud
fcntl.ioctl(self.fd, self._TCSETS2, buf)
except OSError:
raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None
def _apply_baud(self, attrs, baud):
speed = self._speed(baud)
attrs[4] = attrs[5] = speed if speed is not None else termios.B38400
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
if speed is None:
self._set_arbitrary(baud)
self.baud = baud
def __init__(self, path, baud): def __init__(self, path, baud):
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY) self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
attrs = termios.tcgetattr(self.fd) try:
attrs[0] = 0 # iflag attrs = termios.tcgetattr(self.fd)
attrs[1] = 0 # oflag attrs[0] = 0 # iflag
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag attrs[1] = 0 # oflag
attrs[3] = 0 # lflag attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
attrs[4] = attrs[5] = self._speed(baud) attrs[3] = 0 # lflag
attrs[6][termios.VMIN] = 0 attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 0 attrs[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, attrs) self._apply_baud(attrs, baud)
self.baud = baud except BaseException:
os.close(self.fd)
raise
def set_baud(self, baud): def set_baud(self, baud):
"""Retune the port without closing it — the fd stays open, so no DTR """Retune the port without closing it — the fd stays open, so no DTR
pulse and no reset. That matters: the only caller is mid-session with a pulse and no reset. That matters: the only caller is mid-session with a
loader copy that a reset would throw away.""" loader copy that a reset would throw away."""
attrs = termios.tcgetattr(self.fd) self._apply_baud(termios.tcgetattr(self.fd), baud)
attrs[4] = attrs[5] = self._speed(baud)
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
self.baud = baud
def close(self): def close(self):
os.close(self.fd) os.close(self.fd)
@@ -410,7 +460,7 @@ class Info:
if geometry is None: if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature) sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it") 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 base = flash - SLOT
word_flash = flash > 0x10000 word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base wire_base = base // 2 if word_flash else base
@@ -446,6 +496,16 @@ class Info:
# The hand-over target as 'J' takes it: the trampoline below the # The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware. # loader, or word 0 where BOOTRST re-vectors reset in hardware.
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0 self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
# Where SRAM begins, from the signature — None only for a chip this
# tool has no geometry row for, which the wire-block path (v14)
# 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): def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature) sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -486,6 +546,11 @@ class Loader:
# Set once a session is established over an autobaud link, so a # Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked. # re-entry after 'J' repeats the handshake that worked.
self.autobaud = False 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 # The link this session is speaking. It moves when the host follows a
# staging copy built for another one (enter_copy). # staging copy built for another one (enter_copy).
self.baud = getattr(port, "baud", None) self.baud = getattr(port, "baud", None)
@@ -495,10 +560,16 @@ class Loader:
"""The 'b' reply, in either of the two layouts a loader may send. """The 'b' reply, in either of the two layouts a loader may send.
pureboot 5 answers with its version and the signature; older loaders pureboot 5 answers with its version and the signature; older loaders
answer with a 12-byte block. The version byte cannot be mistaken for 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.""" the older block's 'P', so four bytes are enough to tell them apart.
head = self.port.read_exact(4, 2.0)
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": 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) return Info.from_identity(head)
def _handshake(self, wait, knock, what): def _handshake(self, wait, knock, what):
@@ -509,9 +580,25 @@ class Loader:
into a fresh window, where a command without its knock is discarded. into a fresh window, where a command without its knock is discarded.
Each attempt is therefore the whole handshake. This also converges into Each attempt is therefore the whole handshake. This also converges into
an already-live session: the knock bytes are ignored there and the 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 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 knocks = 0
refusal = None
while True: while True:
self.port.flush_input() self.port.flush_input()
self.port.write(knock) self.port.write(knock)
@@ -533,12 +620,18 @@ class Loader:
except Error as failed: except Error as failed:
if "pureboot" in str(failed): if "pureboot" in str(failed):
raise raise
# A malformed or unknown identity is retried as noise, but
# it was an answer: if nothing better ever arrives, naming
# it beats reporting silence.
refusal = failed
self.info = None self.info = None
if self.info is not None: if self.info is not None:
self._expect_prompt() self._expect_prompt()
verbose(f"loader answered {what} {knocks}; identity read") verbose(f"loader answered {what} {knocks}; identity read")
return self.info return self.info
if time.monotonic() > deadline: if time.monotonic() > deadline:
if refusal is not None:
raise Error(f"no usable answer — the last identity reply failed: {refusal}")
raise Error("no answer — reset the device within its activation window") raise Error("no answer — reset the device within its activation window")
def connect(self, wait): def connect(self, wait):
@@ -693,8 +786,13 @@ class Loader:
return self._command(b"F", 4, 2.0) return self._command(b"F", 4, 2.0)
def jump(self, word_address): 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.
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8))) 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() self._expect_prompt()
def enter_copy(self, byte_address, wait, link=None): def enter_copy(self, byte_address, wait, link=None):
@@ -1351,6 +1449,62 @@ def op_fuses(loader):
return fuse_bytes return fuse_bytes
def scan_ratios():
"""The probe walk, in percent of the built rate: the built rate itself
first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near
its trim is the common case, and each probe costs a reset."""
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
def scan_rate(baud, pct):
return round(baud * (100 + pct) / 100)
def scan_report(baud, pct, version, clock=None):
"""The findings, one per line: the found rate is the session workaround,
its ratio to the built rate is the oscillator's offset, and the fixes are
the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build."""
rate = scan_rate(baud, pct)
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}",
f" session --baud {rate}"]
if clock:
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
if pct:
direction = "lower" if pct > 0 else "higher"
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
"or the autobaud build")
else:
lines.append(" fix none — the built rate answers; check the earlier wiring instead")
return lines
def op_scan(port_path, baud, wait, clock=None):
"""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."""
for pct in scan_ratios():
rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
try:
port = Port(port_path, rate)
except Error as unmakeable:
print(f"scan: {rate} Bd skipped — {unmakeable}")
continue
try:
info = Loader(port).connect(wait)
except Error:
continue
finally:
port.close()
for line in scan_report(baud, pct, info.version, clock):
print(line)
return
raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the "
"autobaud build, which has no rate to miss (README.md)")
# -------------------------------------------------------------------- cli --- # -------------------------------------------------------------------- cli ---
@@ -1366,6 +1520,12 @@ def main():
parser.add_argument("--autobaud", action="store_true", parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single " 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)") "knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--scan", action="store_true",
help="walk ±10%% around --baud for a fixed-baud loader gone silent — one "
"reset per probe, standalone (README.md: deployment)")
parser.add_argument("--clock", type=int, metavar="HZ",
help="the clock the loader was built for — lets --scan and an autobaud "
"--info state drift in absolute terms")
parser.add_argument("--info", action="store_true", help="print the device info block") parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes") parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary") parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1412,6 +1572,12 @@ def main():
except (ValueError, AssertionError): except (ValueError, AssertionError):
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
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)
return
port = Port(args.port, args.baud) port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted") verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try: try:
@@ -1421,6 +1587,18 @@ def main():
print("device:") print("device:")
for line in info.lines(): for line in info.lines():
print(f" {line}") print(f" {line}")
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 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 ""
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
fuse_bytes = fuse_override fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader) read = op_fuses(loader)
@@ -1468,7 +1646,7 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
try: try:
main() main()
except Error as error: except (Error, OSError) as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
sys.exit(1) sys.exit(1)
except KeyboardInterrupt: except KeyboardInterrupt:

36
test/check_unit.cmake Normal file
View File

@@ -0,0 +1,36 @@
# 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)
message(FATAL_ERROR "${OBJDUMP} -t ${ELF} failed")
endif()
# The symbol line: "00800100 l O .noinit 00000002 <mangled>unit_E".
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()
# AVR data-space symbols carry the 0x800000 VMA offset.
math(EXPR _want "0x800000 + ${RAM_START}" OUTPUT_FORMAT HEXADECIMAL)
math(EXPR _have "0x${CMAKE_MATCH_1}" OUTPUT_FORMAT HEXADECIMAL)
if(NOT _have STREQUAL _want)
message(FATAL_ERROR "unit_ sits at ${_have}, ram_start is ${_want} — the host peeks ram_start")
endif()
message(STATUS "unit_ at ${_have} == ram_start")

View File

@@ -7,62 +7,71 @@
// SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM) // SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM)
// we dump the flash image to a file for a ground-truth cross-check against // we dump the flash image to a file for a ground-truth cross-check against
// what the client read back through the bootloader. // what the client read back through the bootloader.
#include <signal.h> #include <csignal>
#include <stdint.h> #include <cstdint>
#include <stdio.h> #include <cstdio>
#include <stdlib.h> #include <cstdlib>
#include <string.h> #include <cstring>
#include <print>
#include <unistd.h> #include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers — the block covers both harmlessly.
extern "C" {
#include "avr_uart.h" #include "avr_uart.h"
#include "sim_avr.h" #include "sim_avr.h"
#include "sim_elf.h" #include "sim_elf.h"
#include "uart_pty.h" #include "uart_pty.h"
}
static avr_t *avr; namespace {
static uart_pty_t uart_pty;
static const char *dump_path;
static void finish(int sig) avr_t *avr;
uart_pty_t uart_pty;
const char *dump_path;
[[noreturn]] void finish(int)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
FILE *f = fopen(dump_path, "wb"); std::FILE *f = std::fopen(dump_path, "wb");
if (f) { if (f) {
fwrite(avr->flash, 1, avr->flashend + 1, f); std::fwrite(avr->flash, 1, avr->flashend + 1, f);
fclose(f); std::fclose(f);
} }
} }
uart_pty_stop(&uart_pty); uart_pty_stop(&uart_pty);
_exit(0); _exit(0);
} }
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
if (argc < 3) { if (argc < 3) {
fprintf(stderr, "usage: %s <tsb.elf> <boot_base_hex> [flash_dump.bin]\n", argv[0]); std::println(stderr, "usage: {} <tsb.elf> <boot_base_hex> [flash_dump.bin]", argv[0]);
return 2; return 2;
} }
uint32_t boot_base = (uint32_t)strtoul(argv[2], NULL, 0); auto boot_base = static_cast<std::uint32_t>(std::strtoul(argv[2], nullptr, 0));
dump_path = argc >= 4 ? argv[3] : NULL; dump_path = argc >= 4 ? argv[3] : nullptr;
avr = avr_make_mcu_by_name("atmega328p"); avr = avr_make_mcu_by_name("atmega328p");
if (!avr) { if (!avr) {
fprintf(stderr, "device: no ATmega328P core\n"); std::println(stderr, "device: no ATmega328P core");
return 1; return 1;
} }
avr_init(avr); avr_init(avr);
avr->frequency = 16000000; avr->frequency = 16000000;
// Real flash powers up erased (0xff); the app region must look erased // Real flash powers up erased (0xff); the app region must look erased
// before the bootloader programs it. // before the bootloader programs it.
memset(avr->flash, 0xff, avr->flashend + 1); std::memset(avr->flash, 0xff, avr->flashend + 1);
// simavr's ELF loader flattens the flash base to 0 (it expects an app at // simavr's ELF loader flattens the flash base to 0 (it expects an app at
// 0x0), but it hands back the boot code in fw.flash; place it at the boot // 0x0), but it hands back the boot code in fw.flash; place it at the boot
// section base ourselves and enter there (BOOTRST is not modelled). // section base ourselves and enter there (BOOTRST is not modelled).
elf_firmware_t fw = {0}; elf_firmware_t fw{};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
fprintf(stderr, "device: cannot read %s\n", argv[1]); std::println(stderr, "device: cannot read {}", argv[1]);
return 1; return 1;
} }
// An image that runs past flash end cannot execute on hardware, and a // An image that runs past flash end cannot execute on hardware, and a
@@ -70,23 +79,23 @@ int main(int argc, char *argv[])
// the simulation misbehaves in ways that point everywhere but here. // the simulation misbehaves in ways that point everywhere but here.
// Refuse it loudly instead. // Refuse it loudly instead.
if (boot_base + fw.flashsize > avr->flashend + 1) { if (boot_base + fw.flashsize > avr->flashend + 1) {
fprintf(stderr, "device: %u B at 0x%x runs past flash end 0x%x — image does not fit its slot\n", std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
(unsigned)fw.flashsize, boot_base, avr->flashend); fw.flashsize, boot_base, avr->flashend);
return 1; return 1;
} }
memcpy(avr->flash + boot_base, fw.flash, fw.flashsize); std::memcpy(avr->flash + boot_base, fw.flash, fw.flashsize);
avr->pc = boot_base; avr->pc = boot_base;
avr->codeend = avr->flashend; avr->codeend = avr->flashend;
// Optional: seed the config page (one page below the boot section) with a // Optional: seed the config page (one page below the boot section) with a
// hex byte string, so the password gate and emergency erase can be tested. // hex byte string, so the password gate and emergency erase can be tested.
// Layout: [appjump lo][appjump hi][timeout][password...][0xff]. // Layout: [appjump lo][appjump hi][timeout][password...][0xff].
const char *cfg = getenv("TSB_CONFIG"); const char *cfg = std::getenv("TSB_CONFIG");
if (cfg) { if (cfg) {
uint32_t app_end = boot_base - 128; // config page sits directly below the boot code std::uint32_t app_end = boot_base - 128; // config page sits directly below the boot code
for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) { for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) {
char b[3] = {cfg[i], cfg[i + 1], 0}; char b[3] = {cfg[i], cfg[i + 1], 0};
avr->flash[app_end + i / 2] = (uint8_t)strtoul(b, NULL, 16); avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(b, nullptr, 16));
} }
} }
@@ -95,18 +104,18 @@ int main(int argc, char *argv[])
// tight-polling loader (one that releases TX between bytes, as one-wire does) // tight-polling loader (one that releases TX between bytes, as one-wire does)
// in real time, distorting protocol timing. Clear it so the loader runs at // in real time, distorting protocol timing. Clear it so the loader runs at
// true cycle speed. // true cycle speed.
uint32_t uflags = 0; std::uint32_t uflags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &uflags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &uflags);
uflags &= ~AVR_UART_FLAG_POLL_SLEEP; uflags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &uflags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &uflags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, '0'); uart_pty_connect(&uart_pty, '0');
printf("TSB_PTY %s\n", uart_pty.pty.slavename); std::println("TSB_PTY {}", uart_pty.pty.slavename);
fflush(stdout); std::fflush(stdout);
signal(SIGTERM, finish); std::signal(SIGTERM, finish);
signal(SIGINT, finish); std::signal(SIGINT, finish);
for (;;) { for (;;) {
int state = avr_run(avr); int state = avr_run(avr);
@@ -114,5 +123,4 @@ int main(int argc, char *argv[])
break; break;
} }
finish(0); finish(0);
return 0;
} }

View File

@@ -50,7 +50,7 @@ consteval bool use_hardware()
#if defined(PUREBOOT_SOFT_SERIAL) #if defined(PUREBOOT_SOFT_SERIAL)
return false; return false;
#else #else
return avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART"); return avr::uart::has_usart<0>();
#endif #endif
} }
@@ -63,7 +63,7 @@ struct link {
#else #else
static constexpr avr::baud_t baud{115200}; static constexpr avr::baud_t baud{115200};
#endif #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 tx(char c) static void tx(char c)
{ {
tx_t::write(static_cast<std::uint8_t>(c)); tx_t::write(static_cast<std::uint8_t>(c));

View File

@@ -16,6 +16,7 @@ baked in.
""" """
import os import os
import re
import sys import sys
import time import time
@@ -58,11 +59,24 @@ def main():
try: try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse # The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it. # and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses", out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
"--flash", app_bin, "--eeprom", ee_path, "--stay") "--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"): for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out: if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{out}") fail(f"{label}: session output lacks {needed!r}\n{out}")
# 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
# the true clock, none above (in cycles per bit, times the rate).
measured = re.search(r"measured\s+(\d+) Hz", out)
if not measured:
fail(f"{label}: --info lacks the measured clock\n{out}")
measured = int(measured.group(1))
if not hz - 19 * baud <= measured <= hz + 4 * baud:
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
# Read both memories back over the locked link and check them. # Read both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin") read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin") read_eeprom = os.path.join(workdir, f"re_{label}.bin")

View File

@@ -10,7 +10,7 @@ The state is reached the way silicon reaches it — an application that sets up
its USART and jumps in with no reset between, so nothing clears UCSRnB for it. its USART and jumps in with no reset between, so nothing clears UCSRnB for it.
The pin ownership itself is modelled by the device runner: simavr wires a The pin ownership itself is modelled by the device runner: simavr wires a
USART through IRQs alone and never takes the pin from the port, so without USART through IRQs alone and never takes the pin from the port, so without
that the mute could not happen here at all (test/pureboot_device.c). that the mute could not happen here at all (test/pureboot_device.cpp).
Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page> Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <app_bin> <tool_py> <workdir> <link> <baud> <app_bin> <tool_py> <workdir> <link>

46
test/pbosccal.py Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""The build-time OSCCAL trim, observed through the wire: a loader built with
the OSCCAL axis holds the trim register at the built byte from its first
prompt on — the write sits at the top of run(), ahead of the WDRF bail, so
every path out of reset runs on the corrected clock. simavr's clock does not
follow OSCCAL, which is what makes the value assertable at all: the register
is plain state there, and the peek must return exactly what the build
declared rather than whatever the oscillator needed.
Usage: pbosccal.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <osccal_addr> <osccal_value> <tool_py> <workdir>
[link]
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
args = sys.argv[1:]
link = args.pop() if len(args) == 12 else None
(device_bin, elf, mcu, hz, base_hex, page, baud, addr, value, tool, workdir) = args
addr, value, baud = int(addr, 0), int(value, 0), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "flash_dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
out = pbsim.run_tool(tool, device.pty, baud, "--peek", f"{addr:#x}:1")
want = f"{addr:#06x} {value:02x}"
if want not in out:
fail(f"OSCCAL at {addr:#x} did not read back {value:#04x}:\n{out}")
finally:
device.stop()
print("OK")
if __name__ == "__main__":
main()

View File

@@ -8,10 +8,13 @@ import subprocess
class Device: class Device:
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None): def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None,
window=False):
cmd = [binary] cmd = [binary]
if link: if link:
cmd += ["-l", link] cmd += ["-l", link]
if window:
cmd.append("-w") # report the first-transmit cycle, free-run idle
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump] cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None: if reset_hex is not None or resume is not None:
# Chips without a hardware boot section — the tinies and the # Chips without a hardware boot section — the tinies and the

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 × 10 / f_cpu, the
measured cost of the calibrate() wait loop this gate pins.
"""
import argparse
import importlib.util
import pathlib
import select
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from pbsim import Device
# The calibrate() budget loop's cycles per poll in the built image — what the
# README's window arithmetic rests on, verified here. 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

@@ -23,16 +23,22 @@
// //
// On exit (or SIGTERM) the flash and EEPROM are dumped to files for a // On exit (or SIGTERM) the flash and EEPROM are dumped to files for a
// ground-truth cross-check against what the host read back. // ground-truth cross-check against what the host read back.
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <print>
#include <string_view>
#include <fcntl.h> #include <fcntl.h>
#include <pty.h> #include <pty.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers — the block covers both harmlessly.
extern "C" {
#include "avr_eeprom.h" #include "avr_eeprom.h"
#include "avr_flash.h" #include "avr_flash.h"
#include "avr_ioport.h" #include "avr_ioport.h"
@@ -41,31 +47,58 @@
#include "sim_elf.h" #include "sim_elf.h"
#include "sim_io.h" #include "sim_io.h"
#include "uart_pty.h" #include "uart_pty.h"
}
static avr_t *avr; namespace {
static uart_pty_t uart_pty;
static int link_software;
static char uart_digit = '0';
static char sw_rx_port = 'B', sw_tx_port = 'B';
static int sw_rx_bit = 0, sw_tx_bit = 1;
static char sw_tx_owner = 0; // the USART whose TXD the software link sits on
static const char *dump_path;
static uint32_t reset_pc;
static volatile sig_atomic_t reset_requested;
static int parse_link(const char *spec) avr_t *avr;
uart_pty_t uart_pty;
bool link_software;
char uart_digit = '0';
char sw_rx_port = 'B', sw_tx_port = 'B';
int sw_rx_bit = 0, sw_tx_bit = 1;
char sw_tx_owner = 0; // the USART whose TXD the software link sits on
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 (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) { if (!window_report || window_tx_seen)
link_software = 0; return;
window_tx_seen = true;
std::println("PB_WINDOW_TX {}", avr->cycle);
std::fflush(stdout);
}
void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
{
window_first_tx();
}
int parse_link(std::string_view spec)
{
if (spec == "usart0" || spec == "usart1") {
link_software = false;
uart_digit = spec[5]; uart_digit = spec[5];
return 0; return 0;
} }
if (strncmp(spec, "sw", 2) == 0) { if (spec.starts_with("sw")) {
link_software = 1; link_software = true;
if (spec[2] == '\0') if (spec.size() == 2)
return 0; return 0;
char owner = 0; char owner = 0;
int fields = sscanf(spec + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner); int fields =
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) { if (fields == 4 || fields == 5) {
sw_tx_owner = owner; sw_tx_owner = owner;
return 0; return 0;
@@ -87,19 +120,19 @@ static int parse_link(const char *spec)
// core — so the discard store falls through into the buffer-fill branch and // core — so the discard store falls through into the buffer-fill branch and
// plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard // plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard
// here instead. // here instead.
static avr_flash_t *mega_flash; avr_flash_t *mega_flash;
static int (*mega_flash_ioctl)(avr_io_t *io, uint32_t ctl, void *param); int (*mega_flash_ioctl)(avr_io_t *io, std::uint32_t ctl, void *param);
static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param) int fixed_flash_ioctl(avr_io_t *io, std::uint32_t ctl, void *param)
{ {
if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) { if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) {
uint16_t z = (uint16_t)(io->avr->data[30] | (io->avr->data[31] << 8)); auto z = static_cast<std::uint16_t>(io->avr->data[30] | (io->avr->data[31] << 8));
uint16_t masked = (uint16_t)(z & ~(mega_flash->spm_pagesize - 1)); auto masked = static_cast<std::uint16_t>(z & ~(mega_flash->spm_pagesize - 1));
io->avr->data[30] = (uint8_t)masked; io->avr->data[30] = static_cast<std::uint8_t>(masked);
io->avr->data[31] = (uint8_t)(masked >> 8); io->avr->data[31] = static_cast<std::uint8_t>(masked >> 8);
int result = mega_flash_ioctl(io, ctl, param); int result = mega_flash_ioctl(io, ctl, param);
io->avr->data[30] = (uint8_t)z; io->avr->data[30] = static_cast<std::uint8_t>(z);
io->avr->data[31] = (uint8_t)(z >> 8); io->avr->data[31] = static_cast<std::uint8_t>(z >> 8);
return result; return result;
} }
if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) && if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) &&
@@ -114,46 +147,44 @@ static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param)
return mega_flash_ioctl(io, ctl, param); return mega_flash_ioctl(io, ctl, param);
} }
static void fix_mega_flash_erase(void) void fix_mega_flash_erase()
{ {
for (avr_io_t *io = avr->io_port; io; io = io->next) { for (avr_io_t *io = avr->io_port; io; io = io->next) {
if (io->kind && strcmp(io->kind, "flash") == 0) { if (io->kind && std::string_view{io->kind} == "flash") {
mega_flash = (avr_flash_t *)io; mega_flash = reinterpret_cast<avr_flash_t *>(io);
mega_flash_ioctl = io->ioctl; mega_flash_ioctl = io->ioctl;
io->ioctl = fixed_flash_ioctl; io->ioctl = fixed_flash_ioctl;
return; return;
} }
} }
fprintf(stderr, "device: no flash module to fix — SPM page erases may misalign\n"); std::println(stderr, "device: no flash module to fix — SPM page erases may misalign");
} }
static void request_reset(int sig) void request_reset(int)
{ {
(void)sig;
reset_requested = 1; reset_requested = 1;
} }
// ------------------------------------------------------------- tiny NVM --- // ------------------------------------------------------------- tiny NVM ---
typedef struct { struct tiny_nvm_t {
avr_io_t io; avr_io_t io;
uint8_t buffer[128]; std::uint8_t buffer[128];
uint8_t used[128]; // a buffer word loads once until erased — like silicon std::uint8_t used[128]; // a buffer word loads once until erased — like silicon
unsigned page; unsigned page;
} tiny_nvm_t; };
static tiny_nvm_t nvm; tiny_nvm_t nvm;
static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param) int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
{ {
(void)param;
if (ctl != AVR_IOCTL_FLASH_SPM) if (ctl != AVR_IOCTL_FLASH_SPM)
return -1; return -1;
tiny_nvm_t *n = (tiny_nvm_t *)io; auto *n = reinterpret_cast<tiny_nvm_t *>(io);
avr_t *mcu = io->avr; avr_t *mcu = io->avr;
uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
uint16_t z = (uint16_t)(mcu->data[30] | (mcu->data[31] << 8)); auto z = static_cast<std::uint16_t>(mcu->data[30] | (mcu->data[31] << 8));
uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1); std::uint32_t page_base = static_cast<std::uint32_t>(z & ~(n->page - 1)) % (mcu->flashend + 1);
if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0 if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0
unsigned offset = z & (n->page - 1) & ~1u; unsigned offset = z & (n->page - 1) & ~1u;
if (!n->used[offset]) { // first write wins until the buffer clears if (!n->used[offset]) { // first write wins until the buffer clears
@@ -162,45 +193,58 @@ static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
n->used[offset] = 1; n->used[offset] = 1;
} }
} else if (command == 0x03) { // PGERS } else if (command == 0x03) { // PGERS
memset(mcu->flash + page_base, 0xff, n->page); std::memset(mcu->flash + page_base, 0xff, n->page);
} else if (command == 0x05) { // PGWRT: programming only clears bits } else if (command == 0x05) { // PGWRT: programming only clears bits
for (unsigned i = 0; i < n->page; i++) for (unsigned i = 0; i < n->page; i++)
mcu->flash[page_base + i] &= n->buffer[i]; mcu->flash[page_base + i] &= n->buffer[i];
memset(n->buffer, 0xff, n->page); std::memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page); std::memset(n->used, 0, n->page);
} else if (command == 0x11) { // CTPB } else if (command == 0x11) { // CTPB
memset(n->buffer, 0xff, n->page); std::memset(n->buffer, 0xff, n->page);
memset(n->used, 0, n->page); std::memset(n->used, 0, n->page);
} }
mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly
return 0; return 0;
} }
// ----------------------------------------------------------- GPIO bridge --- // ----------------------------------------------------------- GPIO bridge ---
static int pty_master = -1; int pty_master = -1;
static avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
static avr_cycle_count_t bit_cycles; avr_cycle_count_t bit_cycles;
static int tx_level = 1, tx_active, tx_bit; int tx_level = 1, tx_active, tx_bit;
static uint8_t tx_shift; std::uint8_t tx_shift;
static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param) avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
{ {
(void)mcu; if (tx_bit < 0) {
(void)param; // Half a bit into the start bit: a real receiver re-samples here and
// abandons a false start. The device's own init produces one — DDR
// drives the pin low for the instructions until the idle level is
// written — and without this check that glitch decodes as a stray
// byte (and would read as first transmit activity under -w).
if (tx_level) {
tx_active = 0;
return 0;
}
window_first_tx();
tx_bit = 0;
return when + bit_cycles;
}
if (tx_bit < 8) { if (tx_bit < 8) {
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8) if (++tx_bit < 8)
return when + bit_cycles; return when + bit_cycles;
/* The byte is not delivered until its stop bit has passed. A real // The byte is delivered at the stop bit's sampling point (9.5 bit
* receiver cannot answer sooner, and a host that did would put its // times), where a hardware receiver raises its RXC — not sooner: a
* start bit on the wire while the device is still driving the stop // host answering before the stop bit would put its start bit on the
* bit which the device, transmitting, is not watching for. */ // wire while the device is still driving, which the device,
// transmitting, is not watching for.
return when + bit_cycles; return when + bit_cycles;
} }
if (write(pty_master, &tx_shift, 1) != 1) if (write(pty_master, &tx_shift, 1) != 1)
fprintf(stderr, "device: pty write lost a byte\n"); std::println(stderr, "device: pty write lost a byte");
tx_active = 0; tx_active = 0;
return 0; return 0;
} }
@@ -212,9 +256,9 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par
// model, so the ownership does not exist there and the mute cannot happen: // model, so the ownership does not exist there and the mute cannot happen:
// supply it, or the very state this models is untestable. The link spec's // supply it, or the very state this models is untestable. The link spec's
// trailing @n names the USART; without one the pins are nobody's. // trailing @n names the USART; without one the pins are nobody's.
static avr_uart_t *tx_owner; avr_uart_t *tx_owner;
static int tx_pin_taken(void) bool tx_pin_taken()
{ {
return tx_owner && avr_regbit_get(avr, tx_owner->txen); return tx_owner && avr_regbit_get(avr, tx_owner->txen);
} }
@@ -224,51 +268,48 @@ static int tx_pin_taken(void)
// enabled, making a freshly reset chip mute for reasons hardware does not // enabled, making a freshly reset chip mute for reasons hardware does not
// have. Reset it the way the datasheet does, so the ownership starts from // have. Reset it the way the datasheet does, so the ownership starts from
// nobody's and only an application that really enables the USART takes it. // nobody's and only an application that really enables the USART takes it.
static void reset_tx_owner(void) void reset_tx_owner()
{ {
if (tx_owner) if (tx_owner)
avr_regbit_clear(avr, tx_owner->txen); avr_regbit_clear(avr, tx_owner->txen);
} }
static void find_tx_owner(void) void find_tx_owner()
{ {
for (avr_io_t *io = avr->io_port; io; io = io->next) for (avr_io_t *io = avr->io_port; io; io = io->next)
if (io->kind && strcmp(io->kind, "uart") == 0 && ((avr_uart_t *)io)->name == sw_tx_owner) { if (io->kind && std::string_view{io->kind} == "uart" &&
tx_owner = (avr_uart_t *)io; reinterpret_cast<avr_uart_t *>(io)->name == sw_tx_owner) {
tx_owner = reinterpret_cast<avr_uart_t *>(io);
reset_tx_owner(); reset_tx_owner();
return; return;
} }
fprintf(stderr, "device: no USART%c to own the software link's TX pin\n", sw_tx_owner); std::println(stderr, "device: no USART{} to own the software link's TX pin", sw_tx_owner);
} }
static void tx_hook(avr_irq_t *irq, uint32_t value, void *param) void tx_hook(avr_irq_t *, std::uint32_t value, void *)
{ {
(void)irq;
(void)param;
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
tx_level = 1; tx_level = 1;
return; return;
} }
int level = value & 1; int level = value & 1;
if (!tx_active && tx_level == 1 && level == 0) { // start edge if (!tx_active && tx_level == 1 && level == 0) { // start edge, confirmed mid-bit
tx_active = 1; tx_active = 1;
tx_bit = 0; tx_bit = -1;
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, NULL); avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
} }
tx_level = level; tx_level = level;
} }
static uint8_t rx_queue[8192]; std::uint8_t rx_queue[8192];
static unsigned rx_head, rx_tail; // ring: head = next to send unsigned rx_head, rx_tail; // ring: head = next to send
static int rx_active, rx_bit; int rx_active, rx_bit;
static uint8_t rx_byte; std::uint8_t rx_byte;
static void rx_start_next(void); void rx_start_next();
static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param) avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
{ {
(void)mcu;
(void)param;
if (rx_bit < 8) { if (rx_bit < 8) {
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1); avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
rx_bit++; rx_bit++;
@@ -284,7 +325,7 @@ static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param
return 0; return 0;
} }
static void rx_start_next(void) void rx_start_next()
{ {
if (rx_active || rx_head == rx_tail) if (rx_active || rx_head == rx_tail)
return; return;
@@ -293,7 +334,7 @@ static void rx_start_next(void)
rx_active = 1; rx_active = 1;
rx_bit = 0; rx_bit = 0;
avr_raise_irq(rx_pin, 0); // start bit avr_raise_irq(rx_pin, 0); // start bit
avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL); avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
} }
// A reset abandons whatever the bridge was mid-transfer: bytes still queued // A reset abandons whatever the bridge was mid-transfer: bytes still queued
@@ -303,20 +344,29 @@ static void rx_start_next(void)
// output latch, whose falling edge starts a spurious decode before this // output latch, whose falling edge starts a spurious decode before this
// runs, and a stale tx_sample would then interleave with the loader's first // runs, and a stale tx_sample would then interleave with the loader's first
// real answer through the shared shift state, corrupting it. // real answer through the shared shift state, corrupting it.
static void bridge_reset(void) void bridge_reset()
{ {
avr_cycle_timer_cancel(avr, tx_sample, NULL); avr_cycle_timer_cancel(avr, tx_sample, nullptr);
avr_cycle_timer_cancel(avr, rx_step, NULL); avr_cycle_timer_cancel(avr, rx_step, nullptr);
rx_head = rx_tail = 0; rx_head = rx_tail = 0;
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
tx_level = 1; tx_level = 1;
avr_raise_irq(rx_pin, 1); // idle line // Re-drive the idle line through a forced transition: ioport pin irqs are
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
// keeps its pre-reset cached value — so a plain raise(1) against a cached
// 1 is dropped and the device reads the line stuck low. A loader entering
// calibration on that line measures reset-to-first-edge as one giant
// pulse and mis-locks or boots the application on the first real knock.
// No cycles run between the two raises, so the device only ever sees the
// final idle-high.
avr_raise_irq(rx_pin, 0);
avr_raise_irq(rx_pin, 1);
} }
static void poll_pty(void) void poll_pty()
{ {
uint8_t chunk[256]; std::uint8_t chunk[256];
ssize_t got = read(pty_master, chunk, sizeof(chunk)); ssize_t got = read(pty_master, chunk, sizeof(chunk));
for (ssize_t i = 0; i < got; i++) { for (ssize_t i = 0; i < got; i++) {
unsigned next = (rx_tail + 1) % sizeof(rx_queue); unsigned next = (rx_tail + 1) % sizeof(rx_queue);
@@ -331,23 +381,22 @@ static void poll_pty(void)
// ------------------------------------------------------------------ main --- // ------------------------------------------------------------------ main ---
static void finish(int sig) [[noreturn]] void finish(int)
{ {
(void)sig;
if (dump_path) { if (dump_path) {
FILE *f = fopen(dump_path, "wb"); std::FILE *f = std::fopen(dump_path, "wb");
if (f) { if (f) {
fwrite(avr->flash, 1, avr->flashend + 1, f); std::fwrite(avr->flash, 1, avr->flashend + 1, f);
fclose(f); std::fclose(f);
} }
avr_eeprom_desc_t ee = {.ee = NULL, .offset = 0, .size = 0}; avr_eeprom_desc_t ee = {.ee = nullptr, .offset = 0, .size = 0};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) { if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) {
char path[512]; char path[512];
snprintf(path, sizeof(path), "%s.eeprom", dump_path); std::snprintf(path, sizeof(path), "%s.eeprom", dump_path);
f = fopen(path, "wb"); f = std::fopen(path, "wb");
if (f) { if (f) {
fwrite(ee.ee, 1, ee.size, f); std::fwrite(ee.ee, 1, ee.size, f);
fclose(f); std::fclose(f);
} }
} }
} }
@@ -356,77 +405,93 @@ static void finish(int sig)
_exit(0); _exit(0);
} }
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
int link_given = 0; bool link_given = false;
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) { for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) {
if (opt == 'w') {
window_report = true;
continue;
}
if (opt != 'l' || parse_link(optarg) != 0) { if (opt != 'l' || parse_link(optarg) != 0) {
fprintf(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)\n"); std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
return 2; return 2;
} }
link_given = 1; link_given = true;
} }
int args = argc - optind; int args = argc - optind;
if (args < 7 || args > 9) { if (args < 7 || args > 9) {
fprintf(stderr, std::print(stderr,
"usage: %s [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>" "usage: {} [-l link] [-w] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
" [reset_hex] [resume_flash]\n" " [reset_hex] [resume_flash]\n"
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n" " -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
" them); default: the chip's own\n" " them); default: the chip's own\n"
" reset_hex: reset vector (default: base with a boot section, else 0)\n" " -w: print PB_WINDOW_TX <cycle> at the first transmit activity and\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n" " free-run idle time (window measurement mode)\n"
" run's dump, for power-fail resume tests\n", " reset_hex: reset vector (default: base with a boot section, else 0)\n"
argv[0]); " resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" run's dump, for power-fail resume tests\n",
argv[0]);
return 2; return 2;
} }
argv += optind - 1; // argv[1] is the ELF again, whatever was parsed argv += optind - 1; // argv[1] is the ELF again, whatever was parsed
const char *mcu_name = argv[2]; const std::string_view mcu_name = argv[2];
uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0); auto base = static_cast<std::uint32_t>(std::strtoul(argv[4], nullptr, 0));
unsigned page = (unsigned)atoi(argv[5]); auto page = static_cast<unsigned>(std::atoi(argv[5]));
unsigned baud = (unsigned)atoi(argv[6]); auto baud = static_cast<unsigned>(std::atoi(argv[6]));
dump_path = argv[7]; dump_path = argv[7];
int is_mega = strncmp(mcu_name, "atmega", 6) == 0; const bool is_mega = mcu_name.starts_with("atmega");
if (!link_given) if (!link_given)
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1 link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
avr = avr_make_mcu_by_name(mcu_name); avr = avr_make_mcu_by_name(mcu_name.data());
if (!avr) { if (!avr) {
fprintf(stderr, "device: no %s core\n", mcu_name); std::println(stderr, "device: no {} core", mcu_name);
return 1; return 1;
} }
avr_init(avr); avr_init(avr);
avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0); avr->frequency = static_cast<std::uint32_t>(std::strtoul(argv[3], nullptr, 0));
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased std::memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
if (args > 8) { if (args > 8) {
// Resume: the full flash image of an interrupted prior run. // Resume: the full flash image of an interrupted prior run.
FILE *f = fopen(argv[9], "rb"); std::FILE *f = std::fopen(argv[9], "rb");
if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) { if (!f || std::fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
fprintf(stderr, "device: cannot read %s\n", argv[9]); std::println(stderr, "device: cannot read {}", argv[9]);
return 1; return 1;
} }
fclose(f); std::fclose(f);
} else { } else {
elf_firmware_t fw = {0}; elf_firmware_t fw{};
if (elf_read_firmware(argv[1], &fw) != 0) { if (elf_read_firmware(argv[1], &fw) != 0) {
fprintf(stderr, "device: cannot read %s\n", argv[1]); std::println(stderr, "device: cannot read {}", argv[1]);
return 1; return 1;
} }
memcpy(avr->flash + base, fw.flash, fw.flashsize); // An image past flash end would smash the simulator's heap and turn
// into phantom peripheral behavior (lessons: believe the size gate
// first) — refuse it loudly instead.
if (base + fw.flashsize > avr->flashend + 1) {
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
fw.flashsize, base, avr->flashend);
return 1;
}
std::memcpy(avr->flash + base, fw.flash, fw.flashsize);
} }
// The boot-sectioned megas enter the loader in hardware (BOOTRST, not // The boot-sectioned megas enter the loader in hardware (BOOTRST, not
// modeled — the argument picks the modeled fuse's target); the tinies // modeled — the argument picks the modeled fuse's target); the tinies
// and the boot-section-less m48s reset to word 0 like silicon — erased // and the boot-section-less m48s reset to word 0 like silicon — erased
// flash walks up into the loader, and after the host's surgery the // flash walks up into the loader, and after the host's surgery the
// patched vector routes there. // patched vector routes there.
int boot_section = is_mega && strncmp(mcu_name, "atmega48", 8) != 0; const bool boot_section = is_mega && !mcu_name.starts_with("atmega48");
reset_pc = args > 7 ? (uint32_t)strtoul(argv[8], NULL, 0) : (boot_section ? base : 0); reset_pc = args > 7 ? static_cast<std::uint32_t>(std::strtoul(argv[8], nullptr, 0)) : (boot_section ? base : 0);
avr->pc = reset_pc; avr->pc = reset_pc;
avr->codeend = avr->flashend; avr->codeend = avr->flashend;
// Erased EEPROM, as hardware powers up (simavr zeroes it). // Erased EEPROM, as hardware powers up (simavr zeroes it).
uint8_t blank[1024]; std::uint8_t blank[1024];
memset(blank, 0xff, sizeof(blank)); std::memset(blank, 0xff, sizeof(blank));
avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0}; avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) { if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) {
seed.ee = blank; seed.ee = blank;
@@ -440,7 +505,7 @@ int main(int argc, char *argv[])
fix_mega_flash_erase(); fix_mega_flash_erase();
} else { } else {
nvm.page = page; nvm.page = page;
memset(nvm.buffer, 0xff, sizeof(nvm.buffer)); std::memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
nvm.io.kind = "tiny_nvm"; nvm.io.kind = "tiny_nvm";
nvm.io.ioctl = nvm_ioctl; nvm.io.ioctl = nvm_ioctl;
avr_register_io(avr, &nvm.io); avr_register_io(avr, &nvm.io);
@@ -449,37 +514,41 @@ int main(int argc, char *argv[])
if (!link_software) { if (!link_software) {
// POLL_SLEEP paces an idle-polling loader in host real time (a // POLL_SLEEP paces an idle-polling loader in host real time (a
// no-hardware CPU-saving hack); clear it so cycles run free. // no-hardware CPU-saving hack); clear it so cycles run free.
uint32_t flags = 0; std::uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP; flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit); uart_pty_connect(&uart_pty, uart_digit);
printf("PB_PTY %s\n", uart_pty.pty.slavename); 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 { } else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
if (sw_tx_owner) if (sw_tx_owner)
find_tx_owner(); find_tx_owner();
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), (unsigned)sw_rx_bit); rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), static_cast<unsigned>(sw_rx_bit));
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), (unsigned)sw_tx_bit), tx_hook, avr_irq_register_notify(
NULL); 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 avr_raise_irq(rx_pin, 1); // idle line
int slave; int slave;
struct termios raw; struct termios raw;
cfmakeraw(&raw); cfmakeraw(&raw);
if (openpty(&pty_master, &slave, NULL, &raw, NULL) != 0) { if (openpty(&pty_master, &slave, nullptr, &raw, nullptr) != 0) {
fprintf(stderr, "device: openpty failed\n"); std::println(stderr, "device: openpty failed");
return 1; return 1;
} }
fcntl(pty_master, F_SETFL, O_NONBLOCK); fcntl(pty_master, F_SETFL, O_NONBLOCK);
printf("PB_PTY %s\n", ttyname(slave)); std::println("PB_PTY {}", ttyname(slave));
} }
fflush(stdout); std::fflush(stdout);
signal(SIGTERM, finish); std::signal(SIGTERM, finish);
signal(SIGINT, finish); std::signal(SIGINT, finish);
signal(SIGUSR1, request_reset); // an external reset line, for the tests std::signal(SIGUSR1, request_reset); // an external reset line, for the tests
long since_poll = 0; long since_poll = 0;
for (;;) { for (;;) {
@@ -491,7 +560,7 @@ int main(int argc, char *argv[])
avr_reset(avr); avr_reset(avr);
avr->pc = reset_pc; avr->pc = reset_pc;
if (!link_software) { // reset restores the pacing hack; re-clear it if (!link_software) { // reset restores the pacing hack; re-clear it
uint32_t flags = 0; std::uint32_t flags = 0;
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP; flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
@@ -510,10 +579,9 @@ int main(int argc, char *argv[])
// entirely. Pace the simulation only while the bridge is fully // entirely. Pace the simulation only while the bridge is fully
// quiet (nothing decoding, nothing queued); transfers keep full // quiet (nothing decoding, nothing queued); transfers keep full
// speed, and a quiet window stretches toward real time. // speed, and a quiet window stretches toward real time.
if (!rx_active && !tx_active && rx_head == rx_tail) if (!window_report && !rx_active && !tx_active && rx_head == rx_tail)
usleep(200); usleep(200);
} }
} }
finish(0); finish(0);
return 0;
} }

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 `_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 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 prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects. 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 Stdlib only, no device: host-tool logic, so it runs on every chip's preset
beside pureboot.planner. beside pureboot.planner.
""" """
@@ -49,27 +56,117 @@ class FloodPort:
class LoaderPort: 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.""" slim identity (version 5 + m328p signature) and a closing prompt."""
def __init__(self): def __init__(self):
self.reads = self.exacts = 0 self.pending = b""
self.exacts = 0
def flush_input(self): def flush_input(self):
pass self.pending = b""
def write(self, data): 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): def read_available(self, wait):
self.reads += 1 data, self.pending = self.pending, b""
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet return data
def read_exact(self, count, timeout): def read_exact(self, count, timeout):
self.exacts += 1 self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt 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): def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within """Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging.""" `budget` seconds rather than hanging."""
@@ -97,6 +194,16 @@ def main():
info = pb.Loader(LoaderPort()).connect_autobaud(2.0) info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5) 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") print(f"\n {P} passed, {F} failed")
return 1 if F else 0 return 1 if F else 0

71
test/test_scan.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""--scan's walk and report logic, no simulator: the probe order, the rate
arithmetic, and the advice's direction. The rate physics itself is not
sim-testable — a pty carries bytes at any termios rate — so what the wire
would arbitrate is pinned here as logic instead.
Usage: test_scan.py <tool_py>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
import pureboot as pb
walk = pb.scan_ratios()
if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]:
fail(f"probe walk is not built-rate-first, nearest-out: {walk}")
if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216:
fail("probe rate arithmetic")
if pb.scan_rate(115200, 0) != 115200:
fail("the built rate must probe unchanged")
# A loader answering fast means a fast oscillator: the trim goes down.
report = "\n".join(pb.scan_report(9600, 4, 6))
for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"):
if needle not in report:
fail(f"+4 % report lacks {needle!r}:\n{report}")
report = "\n".join(pb.scan_report(9600, -6, 6))
if "6 steps higher" not in report:
fail(f"-6 % report advises the wrong direction:\n{report}")
report = "\n".join(pb.scan_report(9600, 0, 6))
if "none" not in report or "steps" in report:
fail(f"an on-rate answer must advise no trim:\n{report}")
report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000))
if "9984000" not in report:
fail(f"the absolute clock must scale with the found ratio:\n{report}")
# The walk's rates mostly have no termios B-constant, so the POSIX port
# must set them through termios2 — probed on a pty, which accepts the
# ioctl without caring about the speed. Without this every off-nominal
# probe would abort the walk on the platform --scan matters most on.
if os.name == "posix":
import pty
master, slave = pty.openpty()
try:
port = pb.Port(os.ttyname(slave), pb.scan_rate(9600, 4))
port.set_baud(pb.scan_rate(9600, -4))
port.close()
except pb.Error as error:
fail(f"PosixPort refused an off-nominal probe rate: {error}")
finally:
os.close(master)
os.close(slave)
print("OK")
if __name__ == "__main__":
main()

View File

@@ -36,4 +36,10 @@ if ((full)); then
done done
fi fi
# Every tree is freshly built now — the one moment the README's size table
# can be held to what the images measure (a per-preset ctest sees only its
# own chip; the table needs all of them, and ungated it drifts: a
# common-code shave moves every row at once with nothing over budget).
python3 tools/sizes.py check-readme
echo "check: every chip green" echo "check: every chip green"

View File

@@ -7,11 +7,14 @@ port's TUs compile identically; the sims prove nothing new there) exist for
libavr's reflect spot set only, mirroring its rule: the full reflect matrix libavr's reflect spot set only, mirroring its rule: the full reflect matrix
is never built, one chip per hardware class and pack vintage is. is never built, one chip per hardware class and pack vintage is.
Run from the repo root: tools/make_presets.py Run from the repo root: tools/make_presets.py — or with --check, which
verifies the committed file matches this generator and edits nothing (the
ctest entry `presets.generated` runs that, so drift reds the gate).
""" """
import json import json
import os import os
import sys
CHIPS = [ CHIPS = [
"attiny13", "attiny13a", "attiny25", "attiny45", "attiny85", "attiny13", "attiny13a", "attiny25", "attiny45", "attiny85",
@@ -41,7 +44,7 @@ def main():
"hidden": True, "hidden": True,
"generator": "Ninja", "generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}", "binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake", "toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
@@ -72,6 +75,9 @@ def main():
for chip in REFLECT_SPOT: for chip in REFLECT_SPOT:
add(chip, "reflect") add(chip, "reflect")
# CMake rejects unknown fields in the presets root, $comment included, so
# the file cannot carry a generated-file marker; the --check ctest is the
# whole of rule 10's guard here.
presets = { presets = {
"version": 8, "version": 8,
"configurePresets": configure, "configurePresets": configure,
@@ -79,12 +85,19 @@ def main():
"testPresets": test, "testPresets": test,
"workflowPresets": workflows, "workflowPresets": workflows,
} }
rendered = json.dumps(presets, indent=1) + "\n"
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json") path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json")
if "--check" in sys.argv[1:]:
current = open(path).read() if os.path.exists(path) else ""
if current != rendered:
print("CMakePresets.json does not match its generator — run tools/make_presets.py")
return 1
return 0
with open(path, "w") as f: with open(path, "w") as f:
json.dump(presets, f, indent=1) f.write(rendered)
f.write("\n")
print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}") print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}")
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())

View File

@@ -72,6 +72,39 @@ class Suite:
except Exception: except Exception:
pass pass
def scan(self) -> None:
"""The --scan walk against real termios and a real oscillator: every
probe rate must open a port (the off-nominal rates exist only through
termios2), and one probe must answer — the nominal on a healthy board,
a neighbor on a drifted one. The rig injects the one reset per probe
the operator supplies in the field; this is the rate physics the
simulator cannot arbitrate (a pty carries bytes at any rate), pinned
on silicon."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
found = None
try:
for pct in module.scan_ratios():
rate = module.scan_rate(self.rig.d.baud, pct)
self.rig.reset()
try:
port = module.Port(self.rig.d.port, rate)
except module.Error as error:
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
return
try:
module.Loader(port).connect(min(self.rig.d.wait, 6.0))
found = pct
break
except module.Error:
continue
finally:
port.close()
except Exception as error: # noqa: BLE001 — a rig hiccup is a result
self.check("scan walks the probe ladder", False, str(error)[:70])
return
self.check("scan finds the board's rate", found is not None,
"no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd")
def eeprom(self, info) -> None: def eeprom(self, info) -> None:
size = info.eeprom_size size = info.eeprom_size
if not size: if not size:
@@ -161,6 +194,10 @@ class Suite:
print("\nthe loader never answered; nothing below can be trusted") print("\nthe loader never answered; nothing below can be trusted")
return 1 return 1
if not self.rig.d.autobaud:
print("\nscan")
self.scan()
print("\nEEPROM") print("\nEEPROM")
self.eeprom(info) self.eeprom(info)

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

View File

@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off
@@ -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 // 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. // 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"); 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)); hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
// General-purpose registers are undefined at power-on (no crt zeroes them); // General-purpose registers are undefined at power-on (no crt zeroes them);
// the direction latch must start "not receiving" so the first rx() enables // the direction latch must start "not receiving" so the first rx() enables

View File

@@ -200,9 +200,9 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
// only the divisor low byte and U2X0 need a store. The solver still does // only the divisor low byte and U2X0 need a store. The solver still does
// the datasheet work; the asserts pin the reset-state assumptions. // the datasheet work; the asserts pin the reset-state assumptions.
{ {
constexpr auto sol = avr::uart::detail::solve_baud(dev::clock, 115200_Bd); 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"); 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)); avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
} }

View File

@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20; constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud. // Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd); constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
// The 16-byte device-info block, streamed out on activation. // The 16-byte device-info block, streamed out on activation.
// clang-format off // clang-format off
@@ -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 // 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. // 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"); 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)); hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
// General-purpose registers are undefined at power-on (no crt zeroes them); // General-purpose registers are undefined at power-on (no crt zeroes them);
// the direction latch must start "not receiving" so the first rx() enables // the direction latch must start "not receiving" so the first rx() enables