8 Commits
v3 ... autobaud

Author SHA1 Message Date
f98ed406b8 pureboot 5: one command pair for every memory, and a clock-free backend
R/r/w/F collapse into G and g over a selector byte naming the space — flash,
EEPROM, data, fuse, SPM — with the flash bank in its high nibble. Four command
bodies, four transfer loops and four argument decodes become one of each, and
W joins the same decode instead of keeping an address form of its own. The
loader shrinks while gaining everything below: on the 1284P the stock build
goes 480 -> 432 B and the software one 496 -> 450.

What the freed space buys:

  - Data space. On AVR one pointer spans SRAM, the register file and the whole
    I/O space, so G over space 2 reads all three. pureboot keeps zero static
    RAM and pushes no register, so at loader entry an application's SRAM is
    still what the application left there — this is a post-mortem, not just a
    poke hole. As its own command it needed a dispatch arm and a loop; as one
    more space it is a single ld/st.
  - Host-issued SPM. W fills the page buffer and stops; erase, write and RWW
    re-enable are writes to space 4, which reach the same fused store-and-SPM
    pair through the transfer's own address and data. Any SPM operation, lock
    bits included, is now reachable and the loader carries no page-commit logic.
    The four-cycle SPMCSR-to-SPM window is why that primitive stays fused: no
    host can hit it across a serial link, and that — not the byte count — is
    the floor on how low-level a bootloader's primitives can go.
  - Byte addresses everywhere. The bank in the selector retires the
    word-addressed wire the >64 KiB parts needed, so the 1284s stop being the
    outlier.

SERIAL autobaud is a third backend on the same loader, over libavr's
software_autobaud: no clock, no baud, one binary per chip for every F_CPU and
every rate. Activation counts poll iterations rather than seconds and bounds
every wait, so a stray pulse cannot hold an unattended device.

b answers with the version and signature only; the host derives geometry from
the signature, which is what an autobaud build requires anyway. An update image
is a bare slot with no device to ask, so every image carries a six-byte stamp —
the same bytes b answers with, and the source of both — that the loader never
reads from flash and the host refuses to install a mismatch against. The
running-slot write guard moved onto the SPM commit, which covers erase and
write both where guarding W covered neither directly.

The position-independence lint now proves the property instead of a proxy for
it: the image must come out byte-identical linked at a different base.
-fno-move-loop-invariants left the tuned flag set — it was fitted to a command
loop carrying four transfer bodies and costs bytes now that it carries one.

Verified: the exhaustive matrix on all 37 chips (every clock x every baud x
every backend, non-standard rates included, plus the autobaud build) —
8174 size checks, no failures, tightest fit the 1284s' autobaud at 510 of 512.
Behavioral suites green on every chip class: t13a 10/10, t85 11/11, m8 13/13,
m16a 13/13, m48pa 13/13, 328P 23/23, 644A 17/17, 1284P 17/17. Data-space
round trip through --peek/--poke and the autobaud handshake are both red-green
proven.

The two prototype sources and their findings file go; the README carries the
protocol and dev/done.md in libavr carries the reasoning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:50:48 +02:00
a4da885e36 pureboot: the unified autobaud loader, and the hang that settled the decision
Hardware testing found that a lone calibration pulse wedged the autobaud loader:
run() budgeted only the start-edge wait in measure(), and the rx() that read the
knock behind it was unbudgeted, so one stray low pulse held an unattended device
in the loader and the application never ran. Bound the whole activation — an
expired knock budget returns a byte that cannot be the knock, so control falls
back into the budgeted measure() and an idle line boots the app there.

That fix costs ~22 B, which neither version under review could absorb: the pure
one goes 508 -> 530 on the 1284P and the register one 512 -> 534, both over a
512 B slot. Their margin was never spare capacity, it was the space the missing
fix should have occupied. So the choice between them is moot; both are kept for
the record and no longer built.

pureboot_autobaud_uni.cpp replaces them at 464 B. It is pureboot 5: one read
command and one write command over named spaces (G/g, sel8, addr16, n8) instead
of four per-memory bodies, which collapses four transfer loops into one. The
selector's high nibble carries flash's bank, so the shared cursor stays 16 bits
and no command speaks word addresses. Three things fall out of the freed space:
RAM read/write — the missing feature, and with it arbitrary I/O access, since
AVR maps peripherals into the data space; host-issued SPM, so W's hardcoded
erase/write/RWW tail becomes three writes to a space and any SPM operation is
reachable; and W on the same selector-and-address decode as everything else.

Strictly pure throughout: no inline asm, no global register variable, and no
GPIOR either — the unit lives in a .noinit static, so the loader claims no chip
resource and the chips without GPIOR stop being a special case.

pureboot.py speaks both generations, keyed on the version, so the fixed-baud
path is untouched; --peek/--poke reach the new data space. pbautobaud.py adds a
RAM round-trip and a regression for the hang: a lone pulse must still let the
app boot. All 37 chips plus the 12-preset reflect spot set build and size-test
green, 444-466 B, worst case 46 B under budget. Sim suites 100%: 1284P 17/17,
328P 23/23. Only real-hardware acceptance remains (pureboot/autobaud.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 00:48:54 +02:00
335e494a31 pureboot: autobaud host support and simavr end-to-end for both variants
pureboot.py --autobaud sends the 0xC0 calibration pulse and a single knock at
the host's chosen baud, reads the slimmed info block, and derives the full
geometry from the signature (AUTOBAUD_GEOMETRY, a table over every pureboot
chip). Everything downstream — flash, EEPROM, fuses, hand-over, verify — is the
fixed-baud path unchanged; the dropped write guard is host-transparent.

test/pbautobaud.py drives each variant over the GPIO⇄pty software-UART bridge
through the calibration handshake and a flash + EEPROM + fuse round-trip
cross-checked against the simulator's ground-truth memory, then repeats at
double the F_CPU with the same binary — the clock-agnostic property autobaud
exists for. Wired as pureboot.autobaud_pure/reg on the near-flash 328P and the
word-addressed 1284P. A wrong measured unit fails the flash/verify, so the test
also pins the codegen-coupled calibration constant against a toolchain bump.

Both variants green in sim on both chips at two clocks each; the fixed-baud
suite is unaffected. Only real-hardware acceptance on an RC part remains
(pureboot/autobaud.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:45:22 +02:00
799709efcf pureboot: autobaud variant, two versions for review
Measure the host's bit timing at runtime from a 0xC0 calibration pulse, so one
clock-agnostic image per chip runs at any F_CPU — the RC-oscillator deployments
no longer need a per-clock build.

Two source files, differing only in the write-guard/purity tradeoff:
pureboot_autobaud_pure.cpp (the measured unit in the GPIOR I/O scratch
registers, running-slot write guard dropped, 508 B on the 1284) stays strictly
pure; pureboot_autobaud_reg.cpp (unit in one global register variable, guard
kept, 512 B) keeps every feature at the cost of that single GRV. Both fit
512/510 on all 37 chips and share two licensed simplifications: a slimmed info
block (version + signature; the host derives geometry from the chip database)
and a single-byte activation knock.

pureboot/autobaud.md records the decision, the hand-assembly floor (506 B) that
set the target, and the compiler-knob path to it. Size-tested on every chip via
pureboot_add_autobaud(); the fixed-baud loader is untouched. Sim validation, the
host calibration handshake, and real-hardware acceptance remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:15:38 +02:00
7ae80087b3 docs: the watchdog-lockout and EEPROM-wrap gotchas
A sticky WDRF diverts every reset past the activation window (deliberate, so
an app can reboot instantly, at the cost of a possible lockout); an EEPROM
address past E2END wraps onto low EEPROM (the host bounds it, not the loader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:01:33 +02:00
b477f53ca5 pureboot: the info block reads as a table, one wire byte per line
clang-format bin-packs braced lists to the column limit, collapsing the
'b' reply's byte layout into dense rows. A minimal clang-format-off span
keeps each wire byte on its own line, where the layout is legible against
the protocol. Whitespace only; image byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:19:09 +02:00
84d3f679c2 style: clang-format the W-fix line
Layout only, byte-identical output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:13:51 +02:00
b5020a1e20 pureboot 4: the loader carries its fixes' identity
The unaligned-W and U2X-hand-over fixes change the loader's observable
on-wire behavior, and the --stay reconnect fix changes the host tool, so
both move: loader version 3 -> 4, tool VERSION 2 -> 3. The protocol and info
block are unchanged, so OLDEST_LOADER stays 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:09:38 +02:00
11 changed files with 1090 additions and 392 deletions

View File

@@ -153,7 +153,10 @@ if(PROJECT_IS_TOP_LEVEL)
if(Python3_FOUND)
add_test(NAME pureboot.pi
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${PUREBOOT_BASE_HEX})
${CMAKE_OBJDUMP} ${CMAKE_OBJCOPY} ${CMAKE_CXX_COMPILER} ${LIBAVR_MCU}
$<TARGET_FILE:pureboot>
${CMAKE_BINARY_DIR}/CMakeFiles/pureboot.dir/pureboot/pureboot.cpp.obj
${PUREBOOT_BASE_HEX})
add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
@@ -248,6 +251,15 @@ if(PROJECT_IS_TOP_LEVEL)
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
endfunction()
# The autobaud loader: one clock-agnostic image per chip, so it has no
# clock x baud axis of its own — the matrix below sweeps those for the
# fixed-baud builds, and this one binary has to serve all of them at run
# time. Size-tested against the same per-chip budget as every other variant.
pureboot_add_loader(pureboot_autobaud SERIAL autobaud)
add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
# One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. Unreachable rates drop
# out here rather than aborting the configure.
@@ -287,16 +299,15 @@ if(PROJECT_IS_TOP_LEVEL)
# sites), the largest image the space produces and a shape the ladder
# default — always the *fastest* rate a clock reaches — never picks.
#
# Bounded to one chip per size-bearing class: flash addressing (the
# word-addressed 1284), hand-over shape (the patched vector on the tinies
# and m48s), page size, and USART inventory. Everything else in the image
# is chip-independent code, so a further chip buys builds and no
# coverage; every chip outside the set carries the compact matrix.
# Every chip runs the full cross product: the size-bearing classes (flash
# addressing, hand-over shape, page size, USART inventory) are what make
# the image differ, and a chip outside them is expected to match its class
# — but "expected" is what a matrix is for, and the whole sweep is cheap
# enough to run rather than reason about. PUREBOOT_FULL_MATRIX is what
# selects it; the compact matrix below is the per-commit default.
get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
list(APPEND _full_bauds 16000 4800 2400 1200)
set(_matrix_spot attiny13a attiny85 atmega48pa atmega8a atmega168pa
atmega328p atmega164a atmega644a atmega1284p)
if(DEFINED ENV{PUREBOOT_FULL_MATRIX} AND LIBAVR_MCU IN_LIST _matrix_spot)
if(DEFINED ENV{PUREBOOT_FULL_MATRIX})
foreach(_matrix_hz IN LISTS _full_clocks)
foreach(_matrix_baud IN LISTS _full_bauds)
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software)
@@ -378,4 +389,28 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbusart1-work usart1)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif()
# The autobaud variants driven end to end over the software-UART bridge (both
# under review — pureboot/autobaud.md): the host sends the 0xC0 calibration
# pulse, the loader times it, locks, and programs. Run on the near-flash 328P
# 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
# autobaud exists for (test/pbautobaud.py). The fixture application banners
# over the same software link at the first clock's rate.
if(LIBAVR_MCU MATCHES "^atmega(328p|1284p)$" AND DEFINED PB_DEVICE)
add_executable(pbapp_autobaud test/pbapp.cpp)
target_link_libraries(pbapp_autobaud PRIVATE libavr)
target_compile_definitions(pbapp_autobaud PRIVATE PUREBOOT_CLOCK_HZ=1000000
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1)
add_custom_command(TARGET pbapp_autobaud POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:pbapp_autobaud> $<TARGET_FILE:pbapp_autobaud>.bin)
add_test(NAME pureboot.autobaud
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
${PB_DEVICE} $<TARGET_FILE:pureboot_autobaud> ${PUREBOOT_SIM_MCU}
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $<TARGET_FILE:pbapp_autobaud>.bin
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
endif()
endif()

2
libavr

Submodule libavr updated: a8ed8c4851...6cfc7a8eee

View File

@@ -194,13 +194,19 @@ function(pureboot_default_baud clock software outvar)
endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software] [USART <n>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>])
#
# The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the
# target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
# usart0, usart1 or sw:<RX>,<TX>) — what a test harness speaks to it with.
#
# SERIAL autobaud measures the host's bit timing at run time, so the image
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
# and one binary per chip serves every F_CPU and every rate. The stamped
# PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
# not what it was built for.
function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS)
@@ -222,8 +228,8 @@ function(pureboot_add_loader name)
if(NOT PB_SERIAL)
set(PB_SERIAL auto)
endif()
if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software")
if(DEFINED PB_USART AND NOT PB_SERIAL MATCHES "^(auto|hardware)$")
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL ${PB_SERIAL}")
endif()
if(DEFINED PB_USART)
set(PB_SERIAL hardware)
@@ -252,7 +258,7 @@ function(pureboot_add_loader name)
set(PB_SERIAL software)
endif()
endif()
if(PB_SERIAL STREQUAL "software")
if(PB_SERIAL MATCHES "^(software|autobaud)$")
if(NOT PB_RX)
set(PB_RX pb0)
endif()
@@ -264,7 +270,11 @@ function(pureboot_add_loader name)
message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1")
endif()
endforeach()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
if(PB_SERIAL STREQUAL "autobaud")
set(_serial_defines PUREBOOT_AUTOBAUD PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
else()
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
endif()
# sw:<RX>,<TX> as port letter and bit, upcased.
string(SUBSTRING ${PB_RX} 1 2 _rx_pin)
string(SUBSTRING ${PB_TX} 1 2 _tx_pin)
@@ -280,23 +290,29 @@ function(pureboot_add_loader name)
endif()
endif()
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines})
if(PB_SERIAL STREQUAL "autobaud")
# No clock and no baud reach the image; the window is a poll budget.
set(_defines ${_serial_defines})
else()
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines})
endif()
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr)
target_compile_definitions(${name} PRIVATE ${_defines})
# Codegen shaping for the loader TU only, worth 1436 B depending on the
# chip. At -Os GCC otherwise rewrites the byte-stream loops' counters into
# end-pointer forms that cost registers (-fno-ivopts,
# -fno-split-wide-types), leaves register pressure on the table with the
# default allocator (-fira-algorithm=priority), and keeps loop-invariant
# immediates and expression temporaries in registers
# (-fno-move-loop-invariants, -fno-tree-ter) — but every loop body here
# contains a call, so a register held across it costs more than the
# load-immediate it saves.
# Codegen shaping for the loader TU only. At -Os GCC otherwise rewrites the
# byte-stream loops' counters into end-pointer forms that cost registers
# (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the
# table with the default allocator (-fira-algorithm=priority), and keeps
# expression temporaries in registers (-fno-tree-ter) — but every loop body
# here contains a call, so a register held across it costs more than the
# load-immediate it saves. The set is fitted to the loader's body and has to
# be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes
# now that it carries one.
target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-move-loop-invariants -fno-tree-ter -fno-split-wide-types)
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
@@ -310,3 +326,4 @@ function(pureboot_add_loader name)
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
PUREBOOT_LINK ${_link})
endfunction()

View File

@@ -8,46 +8,51 @@ erase, reset-vector surgery, updating the loader itself — lives in the host
tool (`pureboot.py`).
The image is **position-independent**: control flow is PC-relative, the
read/write paths take wire addresses, the write guard protects the slot the
code is *running* in (from the runtime return address), the info block is
addressed from that same anchor, and the application jump is an indirect call
transfer paths take wire addresses, the write guard protects the slot the code
is *running* in (from the runtime return address), nothing else is
flash-resident to address at all, and the application jump is an indirect call
to an absolute entry. The identical binary therefore runs from any slot with
every command intact, which makes pureboot **its own staging loader**: the
host installs the same binary one slot below the resident, jumps into it, and
lets it rewrite the resident.
every command intact, which makes pureboot **its own staging loader**: the host
installs the same binary one slot below the resident, jumps into it, and lets
it rewrite the resident. The lint holds it to that literally — the image must
come out byte-identical linked at a different base.
## Chips
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves
per build — see *Configuration*; the largest image any of them produces is a
software UART at a slow baud, which on the 1284s is 494 B, the tightest fit in
the whole matrix at 18 B spare.
per build — see *Configuration*. The autobaud column is the clock-free build,
which is the largest the space produces and the tightest fit in the matrix;
it carries the calibration machinery and no clock at all.
| Chip | Flash | Loader at | Link | Size |
|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 416 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 420 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 424 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 424 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 396 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 400 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 400 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 414 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 434 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 438 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 438 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 438 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 438 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 432 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 478 B |
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 402 B | 472 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 406 B | 476 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 410 B | 480 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 410 B | 480 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 372 B | 486 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 374 B | 490 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 374 B | 490 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 400 B | 476 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 410 B | 486 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 406 B | 484 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 432 B | 510 B |
† No hardware boot section: the host patches the reset vector, and the budget
is 510 bytes, since the slot's last word is the trampoline.
The 1284s are the heaviest because they alone carry the far-flash machinery —
ELPM reads, RAMPZ page commands, a word-addressed wire.
The tightest fit in the whole space is the 1284s' autobaud build, 510 of its
512 — they alone carry the far-flash machinery (ELPM reads, RAMPZ page
commands) and autobaud alone carries the calibration loop. Everything else has
20 B of headroom or more. The flash bank riding in a transfer's selector byte
keeps even those chips' addressing the same 16-bit form every other chip uses,
which is why they are no longer the outlier they were.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire
quantities are little-endian.
@@ -62,7 +67,7 @@ repo's build and by a downstream project alike:
|---|---|---|
| `CLOCK <hz>` | the clock the board runs | 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s |
| `BAUD <bd>` | the wire rate | the ladder below |
| `SERIAL auto\|hardware\|software` | the link backend | `auto`: the hardware USART where the chip has one |
| `SERIAL auto\|hardware\|software\|autobaud` | the link backend | `auto`: the hardware USART where the chip has one |
| `USART <n>` | the USART instance (x4 megas carry two) | 0 |
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 |
@@ -74,6 +79,17 @@ receiver's 100-cycles-a-bit floor. Whatever is picked or overridden is
re-checked in the compile: an infeasible combination, or a USART the chip does
not have, fails with a named static assert.
`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
binary per chip serves every clock and every rate. It is for the deployments
whose clock is not known at build time and does not hold still — the internal
RC oscillator, ±10 % from the factory and moving with supply and temperature —
where a fixed-baud software build has to be rebuilt per clock and still drifts
out of tolerance. The cost is that it is software-serial only (a hardware USART
needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000).
A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
@@ -98,17 +114,35 @@ speak to the build. This exact deployment runs the full protocol suite in CI
Reset enters the loader (BOOTRST on the boot-sectioned megas, the patched
reset vector elsewhere) — except a watchdog reset, which hands straight to the
application, since the application owns its watchdog and must clear WDRF
itself.
application with no activation window, since the application owns its watchdog.
This is deliberate: it lets an application reboot itself instantly rather than
sit through the window. The application must clear WDRF itself (libavr's
`watchdog::disable()` does). **Gotcha:** WDRF is sticky (cleared only by
software, not by a later reset), so an application that watchdog-resets and
never clears it diverts *every* subsequent reset — external ones included —
past the window too, and the loader becomes reachable only through an external
programmer until the flag is cleared. A serial recovery path therefore assumes
the application clears WDRF on its own reset path.
The host then knocks `p` then `b`, each awaited byte under a fresh activation
window; any other byte is discarded and awaited again, so line noise can delay
the loader but never lock it. A window expiring on an idle line boots the
application.
An autobaud build opens differently, because it has to learn the rate before it
can read a byte at all: the host sends the **calibration byte 0xC0** — a start
bit plus six zero data bits form one low pulse of seven bit-times — and the
loader times that pulse into its bit period. A single `p` then activates; the
pulse has already proven a host is present, which the two-byte knock exists to
establish elsewhere. Both waits are bounded, so a stray low pulse with no host
behind it costs one window and then boots the application rather than holding
the loader.
The window is a compile-time constant (`TIMEOUT`, 8 s by default), so the whole
EEPROM belongs to the application — pureboot keeps no state of its own.
Re-timing a deployed loader is a self-update with a re-timed build.
Re-timing a deployed loader is a self-update with a re-timed build. An autobaud
build counts poll iterations instead (`PUREBOOT_AUTOBAUD_POLLS`), there being
no clock to turn into seconds.
## Session
@@ -118,68 +152,116 @@ write and sends the prompt `+` (0x2b), which is therefore also the previous
command's completion ack. A session is: await `+`, send a command, read its
reply, repeat.
On chips whose flash exceeds 64 KiB (the 1284s — info-block flag bit 1) the
`R`/`W` flash addresses are **word** addresses; everywhere else they are byte
addresses (the 644s' 64 KiB is exactly the 16-bit byte space). EEPROM
addresses and all counts are bytes.
Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in
the command's selector byte, so no command has to speak word addresses. `J` is
the exception: it takes a word address, because that is what the hardware's own
jump takes. EEPROM and data-space addresses and all counts are bytes.
The 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 —
EEAR is only as wide as the array, so an address past the end truncates onto
low EEPROM and the write silently overwrites it. Keeping transfers within the
real sizes is the host's job (the shipped tool does); the flash budget is
better spent on features than on re-checking a bound the host already holds.
| Cmd | Arguments | Reply |
|---|---|---|
| `b` | — | the 12-byte info block |
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) |
| `W` | addr16 (any address in the page), then one page of data | — (completion = next prompt) |
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
| `b` | — | 4 bytes: the pureboot version, then the three signature bytes |
| `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) |
| `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
| `W` | sel8, addr16, then one page of data | — (completion = next prompt) |
| `J` | word address (16-bit) | `+`, then execution continues there |
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
`W` streams exactly one SPM page (size from the info block) into the buffer,
then erases and programs — except pages inside the 512-byte slot
the loader is *running* in, which are drained and left alone, so a broken host
cannot brick the running copy and a staged copy may rewrite the resident.
`G` and `g` are one letter in two cases, which is the whole command set for
every memory: the **selector** byte's low nibble names the space and its high
nibble carries the flash bank.
| Space | | |
|---|---|---|
| 0 | flash | read-only here; it is written through `W` and the SPM space |
| 1 | EEPROM | |
| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR |
| 3 | fuse and lock | index 0..3 in the hardware's own Z order: low, lock, extended, high |
| 4 | SPM | write-only: the byte goes to SPMCSR and fires the instruction at the address |
The data space is worth more than it looks. pureboot keeps **zero static RAM**
and pushes no register, so at loader entry an application's SRAM is still
whatever the application left there, bar the handful of bytes of return-address
stack — which makes `G` over space 2 a post-mortem of a running application,
not just a poke hole. The same address space carries the register file and the
I/O registers, so peripheral state is readable too; reading some of those has
side effects (reading UDR clears its flags), which is the host's business to
know.
Programming a page is therefore `W` to fill the buffer, then a `g` to the SPM
space for the erase, another for the write, and on a boot-sectioned chip a
third to re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR
encodings every part pureboot targets shares. The loader carries no page-commit
logic of its own, and the same primitive reaches every other SPM operation,
lock bits included.
The SPM store and the SPM instruction must issue within four cycles of each
other (§26.2), which no host can hit across a serial link — so this one
primitive is *fused* rather than being a poke of SPMCSR followed by a poke of
something else. That four-cycle window is the floor on how low-level a
bootloader's primitives can go; it is not a byte-count decision.
An SPM command aimed at the 512-byte slot the loader is **running in** is
dropped, so a broken host cannot brick the running copy, while a staged copy
one slot lower may rewrite the resident — which is what a self-update is.
The loader never clears the SPM buffer before a fill, so **one `W` may program
the wrong bytes, and the host is what fixes it**. The buffer is write-once per
word until cleared, and two things leave words in it: a refused page, and —
where SPM runs from anywhere, the tinies and the m48s — an application that
self-programmed before entering. The next `W` takes those stale words and
clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on the
tinies), so repeating it programs correctly. The host therefore verifies every
page it writes and rewrites what comes back wrong (three retries, then it
self-programmed before entering. The next page write takes those stale words
and clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on
the tinies), so repeating it programs correctly. The host therefore verifies
every page it writes and rewrites what comes back wrong (three retries, then it
stops).
`w` is host-paced: send the next byte only after the previous byte's `+`. `F`
returns the bytes in the hardware's Z order; on a chip without an extended
fuse byte that slot carries no meaning. Fuse *writing* does not exist — SPM
reaches flash and boot lock bits only.
`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse
*writing* does not exist — SPM reaches flash and boot lock bits only.
`J` is the one control-transfer primitive: it runs the application (word 0 or
the trampoline word, both known from the info block) and moves between loader
the trampoline word, both derived from the chip) and moves between loader
copies during a self-update. A jump to a slot's base re-enters that copy's own
startup, which must then be knocked afresh.
The info block (`b`):
`b` answers with the loader's identity — its version and the chip's signature —
and nothing else. Everything else the host needs (page size, loader base,
EEPROM size, whether the reset vector must be patched, how many flash banks)
follows from the signature, and the host holds that table; the loader derived
the same facts from its own chip database at build time, so nothing is guessed,
it is simply not sent twice.
| Offset | Content |
|---|---|
| 02 | `'P'`, `'B'`, pureboot version (3) |
| 35 | device signature |
| 6 | SPM page size in bytes (0 means 256) |
| 78 | loader base — application flash ends here (a word address when bit 1 is set) |
| 910 | EEPROM size |
| 11 | bit 0: host must patch the reset vector (no hardware boot section); bit 1: flash wire addresses are word addresses |
An update image, though, is a bare 512-byte slot with no device to ask, and
installing one built for another chip bricks the target. Every loader image
therefore carries a six-byte **stamp**`'P'`, `'B'`, the version, the three
signature bytes — which the loader itself never reads and the host tool refuses
to install a mismatch against.
## Version
The info block's third byte is the **pureboot version** — the loader's one
identity number, and the only way to tell what a deployed loader is. Nothing
else is numbered: the wire protocol has no version, a pureboot version implies
it, and the host tool holds that map. The tool states the window of loader
versions it speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a
version that changes the protocol becomes the new floor there. None has so
far: 1 through 3 speak the identical session. A loader newer than the tool is
refused by name rather than decoded on the assumption that nothing moved.
`b`'s first byte is the **pureboot version** — the loader's one identity
number, and the only way to tell what a deployed loader is. Nothing else is
numbered: the wire protocol has no version, a pureboot version implies it, and
the host tool holds that map. The tool states the window of loader versions it
speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a version that
changes the protocol becomes the new floor there. A loader newer than the tool
is refused by name rather than decoded on the assumption that nothing moved.
Two generations exist. **1 through 4** speak one session — a 12-byte info block
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
above; the shipped tool speaks both, choosing on the version it reads, so a
deployed pureboot 4 stays drivable and self-updatable to 5.
Collapsing four command bodies into one transfer loop is what paid for the
version: the data space, the host-issued SPM operations and the fuses now share
the loop, the cursor and the argument decode that `R`/`r`/`w` each carried a
copy of. The loader shrank while gaining all three.
The tool carries its own version, free to drift; `--version` prints it and the
window.
@@ -238,11 +320,10 @@ with any pureboot build — a re-timed window, a newer version — using the
loader itself as its own staging loader. The image is the loader's own 512
bytes as a raw binary, or the Intel HEX the build emits beside it.
The preflight refuses an image built for another chip: the info block embedded
in every pureboot binary (signature, page size, loader base, EEPROM size,
flags) must match the device's own, and the error names both. Die revisions
share their base signature and geometry, so their images are interchangeable —
as the silicon is.
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
both. Die revisions share their base signature and geometry, so their images
are interchangeable — as the silicon is.
1. The staging slot `[base512, base)` is saved to a host-side state file (on
the 1 KB tiny13s that is the whole application, vectors included).
@@ -279,8 +360,8 @@ to reset gets its reset pulse and opens the activation window by itself.
--info --fuses --flash app.hex
Operations run in a fixed order within one session: info, fuses, loader
update, flash (erase / program / read / verify), EEPROM (the same) then the
loader hands over to the application. `--stay` keeps the session alive
update, flash (erase / program / read / verify), EEPROM (the same), then
`--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive
instead, and a later invocation reconnects into it. `--flash` and `--eeprom`
verify by read-back unless `--no-verify`, and a flash page that reads back
wrong is rewritten up to three times before the run stops (see `W` above).
@@ -288,9 +369,18 @@ wrong is rewritten up to three times before the run stops (see `W` above).
extension. `--force` overrides the refusable safety checks — today, flashing
application data into a mega's reset walk region.
Readouts come one fact per line: `--info` decodes the info block field by
field, `--fuses` each fuse byte plus, on a boot-sectioned mega, its decoded
meaning. Transfers that take wire time draw a transient progress bar on stderr
`--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
whatever `--baud` the host chose.
`--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
register. Reading an I/O register can have side effects (reading UDR clears its
flags), which is the caller's business to know.
Readouts come one fact per line: `--info` prints the device's version and
signature and the geometry that follows from them, `--fuses` each fuse byte
plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr
when it is a tty. `-v`/`--verbose` adds the decisions as they happen: knock
counts, the programming plan, update state handling and per-phase page counts.
@@ -309,15 +399,19 @@ Per chip preset, `ctest` runs:
the configuration space produces, and a shape the ladder default (always the
*fastest* rate a clock reaches) never picks. Pins are immediate operands and
the timeout is a constant: neither is an axis;
- `pbm_*.size` — under `--full`, the exhaustive cross product replacing that
compact matrix: every plausible oscillator (the internal ones, the CKDIV8
floor, the plain and the UART crystals) × every rate reachable from it ×
every backend, unreachable combinations dropping out rather than aborting
the configure. Bounded to one chip per size-bearing class — flash
addressing, hand-over shape, page size, USART inventory — since everything
else in the image is chip-independent code;
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, the
info block within the image's first 256 bytes;
- `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
below sweeps;
- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
replacing that compact matrix, on **every** chip: every plausible oscillator
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×
every rate reachable from it × every backend, unreachable combinations
dropping out rather than aborting the configure. Thousands of points per
chip, and cheap enough to run rather than reason about;
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, no
flash-resident section but `.text`, and the image byte-identical when linked
at a different base — which is position independence itself rather than a
proxy for it;
- `pureboot.planner` — the host tool's pure logic: programming orders and their
recovery properties, the surgery, the staging composition, the boot-fuse
decode, the update preflight over synthetic fuse bytes, and the repairing
@@ -347,7 +441,13 @@ Per chip preset, `ctest` runs:
anywhere, which is what makes the path constructible;
- `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
re-run must complete the update with the application intact.
re-run must complete the update with the application intact;
- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
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
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
the application boot, so no wait in activation can be unbounded.
`size`, `pi` and `planner` are host logic and run anywhere; the
simulator-driven targets need simavr and a pty, so they are POSIX-only.

View File

@@ -26,14 +26,17 @@ constexpr std::uint8_t ack = '+';
// Deployment parameters come from the build (pureboot_add_loader()). The
// signature is not one of them: the chip database is the only universal
// source — a tiny13A cannot read its own signature row from code.
#if !defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)
// source — a tiny13A cannot read its own signature row from code. An autobaud
// build carries no clock and no baud at all; it measures both.
#if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD))
#error \
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader() (README.md)"
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)"
#endif
#if !defined(PUREBOOT_AUTOBAUD)
using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
#endif
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
consteval std::int16_t wdrf_field()
@@ -47,49 +50,110 @@ consteval std::int16_t wdrf_field()
// runs from anywhere (Atmel-8271 §26) — keep the application's relocated
// reset vector in the word under the slot.
constexpr std::uint16_t slot_bytes = 512;
constexpr std::uint32_t base = spm::flash_bytes - slot_bytes;
constexpr std::uint16_t page = spm::page_bytes;
constexpr bool boot_section = avr::hw::curated::has_boot_section();
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash
// addresses there are word addresses ('J' always was one). A slot is 256 of
// those — one value of a wire address's high byte, where 512 bytes span two.
constexpr bool word_flash = spm::flash_bytes > 65536;
constexpr std::uint16_t wire_base =
word_flash ? static_cast<std::uint16_t>(base / 2) : static_cast<std::uint16_t>(base);
// Past 64 KiB one bank of flash does not cover the chip, so a transfer's
// selector byte carries the bank and the wire address stays a byte address
// within it. 'J' is the exception: it is a word address everywhere, because
// that is what the hardware's own jump takes.
constexpr bool banked_flash = spm::flash_bytes > 65536;
// A compile-time window, so the whole EEPROM belongs to the application;
// re-timing a deployed loader is a self-update with a re-timed build.
// re-timing a deployed loader is a self-update with a re-timed build. An
// autobaud build has no clock to convert seconds against and counts polls.
#if !defined(PUREBOOT_TIMEOUT)
#define PUREBOOT_TIMEOUT 8
#endif
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
#if !defined(PUREBOOT_AUTOBAUD_POLLS)
#define PUREBOOT_AUTOBAUD_POLLS 4000000
#endif
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// The loader's one identity number. The protocol carries none of its own —
// a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 3;
constexpr std::uint8_t version = 5;
// The 'b' reply, byte for byte (layout: README.md). Flash-resident because
// no crt copies a .data image — and flash_table's storage carries the word
// alignment 'b' needs to halve the address on the large chips.
inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{
'P', 'B', version, avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
static_cast<std::uint8_t>(page), // 0 means 256
wire_base & 0xff, wire_base >> 8, avr::hw::db.mem.eeprom_size & 0xff, avr::hw::db.mem.eeprom_size >> 8,
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), // patch-vector, word-addressed
}>
info_data;
// 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
// which chip it was built for. The tool refuses to install an image whose
// stamp does not match the device — flashing a foreign loader bricks the
// target, and the loader itself cannot check what has already replaced it.
//
// Never read from flash by the loader — 'b' answers out of this array, but at
// constant indices, so those fold to immediates and no runtime address of it
// is ever formed. `used` keeps the compiler from dropping the copy the host
// needs and `retain` keeps --gc-sections from collecting it.
// clang-format off
[[gnu::used, gnu::retain, gnu::section(".text.stamp")]]
inline constexpr std::uint8_t identity_stamp[]{
'P', 'B', // the magic the host scans an image for
version, // and from here on, exactly what 'b' answers
avr::hw::db.signature[0],
avr::hw::db.signature[1],
avr::hw::db.signature[2],
};
// clang-format on
// Where the identity proper starts: past the magic the host scans for.
constexpr std::uint8_t stamp_identity = 2;
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL,
// defaulting to the chip's USART0 where it has one. The software receiver is
// the polled one: the vector table belongs to the application. Templates on
// the clock, so only the selected backend instantiates. pending() is the
// cheap line test the activation window polls; drain() holds until the last
// frame is off the wire, so a hand-over cannot let the target's re-init clip
// the ack.
// The address spaces a transfer can name, in a selector byte's low nibble.
// Flash is 0 so it is the cheapest to select.
//
// spm_ops is the one that is not memory: a write there hands its byte to
// SPMCSR and fires the instruction at the transfer's address, which is how
// page erase, page write and RWW re-enable reach the wire without the loader
// carrying a command for each. The hardware's four-cycle store-to-SPM window
// is why this is one fused primitive and not a poke of SPMCSR — no host can
// hit that window across a serial link.
enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 };
// A selector's high nibble is the flash bank — the address bits above the
// 16-bit wire address, RAMPZ on the chips that have one. Keeping it here
// rather than widening the wire address is what lets one 16-bit cursor serve
// every space: a 24-bit cursor would pay its extra byte on EEPROM and data
// reads that can never need it.
[[gnu::always_inline]] inline std::uint8_t space_of(std::uint8_t selector)
{
return selector & 0x0f;
}
[[gnu::always_inline]] inline std::uint8_t bank_of(std::uint8_t selector)
{
return static_cast<std::uint8_t>(selector >> 4);
}
// The slot a flash address falls in, as one byte. A slot is half as many words
// as bytes, so the word address's high byte is exactly this index — which is
// what lets the write guard compare a single byte, and what the running copy's
// own return address yields for free.
constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes);
constexpr std::uint8_t bank_shift = 16 - slot_shift;
[[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
{
const auto within = static_cast<std::uint8_t>(at >> slot_shift);
if constexpr (banked_flash)
return static_cast<std::uint8_t>((bank << bank_shift) | within);
else
return within;
}
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL /
// PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The
// software receiver is the polled one: the vector table belongs to the
// application. Templates on the clock, so only the selected backend
// instantiates. pending() is the cheap line test the activation window polls;
// drain() holds until the last frame is off the wire, so a hand-over cannot
// let the target's re-init clip the ack.
#if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART)
#error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends"
#endif
#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART)
#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART"
#endif
#if !defined(PUREBOOT_RX)
#define PUREBOOT_RX pb0
#endif
@@ -102,9 +166,9 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
constexpr char usart_digit = '0';
#endif
template <avr::hertz_t C>
template <avr::hertz_t C, avr::baud_t B>
struct hardware_link {
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>;
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
// sbiw + sbci + sbci + brne (6).
@@ -136,10 +200,10 @@ struct hardware_link {
}
};
template <avr::hertz_t C>
template <avr::hertz_t C, avr::baud_t B>
struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, wire_baud>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, wire_baud>;
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>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
// sbci + brne (6).
@@ -171,14 +235,44 @@ struct software_link {
}
};
#if defined(PUREBOOT_USART)
// The clock-free link: the bit period is measured from the host's calibration
// pulse instead of derived from a clock, so one image serves every F_CPU and
// every rate. Activation differs in kind from the other two — there is no
// clock to time a window against — so this backend brings its own, below.
struct autobaud_link {
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX>;
static void init()
{
avr::init<uart>();
}
static std::uint8_t rx()
{
return uart::template read<off>();
}
static void tx(std::uint8_t byte)
{
uart::template write<off>(byte);
}
static void drain()
{
uart::drain();
}
};
#if defined(PUREBOOT_AUTOBAUD)
using link = autobaud_link;
#elif defined(PUREBOOT_USART)
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
using link = hardware_link<dev::clock>;
using link = hardware_link<dev::clock, wire_baud>;
#elif defined(PUREBOOT_SOFT_SERIAL)
using link = software_link<dev::clock>;
using link = software_link<dev::clock, wire_baud>;
#else
using link =
std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock>, software_link<dev::clock>>;
using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
software_link<dev::clock, wire_baud>>;
#endif
// The application's entry, pinned by the linker (--defsym): word 0 on a
@@ -198,6 +292,27 @@ extern "C" [[noreturn]] void pureboot_app();
jump(pureboot_app);
}
// Activation: a bounded wait for the host, then the knock. Both forms boot the
// application when the window closes on an idle line, and both bound *every*
// wait — a knock awaited without a deadline would let one stray edge hold an
// unattended device in the loader forever.
#if defined(PUREBOOT_AUTOBAUD)
// The window is a fixed poll budget: with no clock, whole seconds cannot be
// timed. A uint24_t holds it — a fourth byte would cost two words at every
// countdown step for range never used.
void await_host()
{
for (;;) {
if (!link::uart::calibrate(autobaud_budget))
run_app();
// The calibration pulse has already proven a host is there, so one
// byte activates. A knock that never arrives falls back to calibrate(),
// whose own budget then boots the application.
if (link::uart::template read<off>(autobaud_budget) == 'p')
return;
}
}
#else
// The window as one 32-bit countdown, divided by the backend's counted
// poll-loop cycles. Whole seconds is all it promises.
consteval std::uint32_t window_polls()
@@ -224,6 +339,14 @@ std::uint8_t rx_deadline()
return link::rx();
}
void await_host()
{
// 'p' then 'b', each under a fresh window; anything else is line noise.
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
}
}
#endif
// Inlined: read across a call, the first byte strands in a call-saved
// register the caller has to push and pop.
[[gnu::always_inline]] inline std::uint16_t rx16()
@@ -241,133 +364,92 @@ std::uint8_t rx_deadline()
return std::bit_cast<std::uint16_t>(pair);
}
// Counts arrive in the wire's 8-bit form: 0 means 256. Both streamers fold
// into the one command that reads flash, which is what lets the far one's
// 24-bit cursor sit in the command loop's own call-saved registers.
[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count)
{
do
link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++)));
while (--count);
}
// The 24-bit cursor as the machine holds it — the RAMPZ byte and a 16-bit Z,
// carried apart; the reassembled address folds away inside the far load.
[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count)
{
std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15);
std::uint16_t z = static_cast<std::uint16_t>(address << 1);
do {
link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z));
// Carrying the wrap is smaller than the flat 32-bit cursor GCC
// builds without it.
if (++z == 0)
++rampz;
} while (--count);
}
[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count)
{
if constexpr (word_flash)
send_flash_far(address, count);
else
send_flash_near(address, count);
}
// Out of line: three sites send it, and a call is shorter than three
// load-immediates.
// Out of line: several sites send it, and a call is shorter than a
// load-immediate at each.
[[gnu::noinline]] void tx_ack()
{
link::tx(ack);
}
void send_eeprom(std::uint16_t address, std::uint8_t count)
// A wire address and its selector's bank as the flash address they name.
[[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
{
do
link::tx(ee::read(address++));
while (--count);
if constexpr (banked_flash)
return (static_cast<spm::flash_address_t>(bank) << 16) | at;
else
return at;
}
// Host-paced: the ack goes out once the write has begun, so the next byte
// arrives while it completes and nothing is missed without a buffer.
void store_eeprom(std::uint16_t address, std::uint8_t count)
// One byte out of any space. Every accessor shares the transfer's cursor, its
// loop and its call site, so a space costs only its own instruction rather
// than a body, a loop and a dispatch arm of its own.
[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank,
std::uint16_t at)
{
do {
ee::write<off>(address++, link::rx());
tx_ack();
} while (--count);
if (space == sp_eeprom)
return ee::read(at);
if (space == sp_data)
return *reinterpret_cast<volatile std::uint8_t *>(at);
if (space == sp_fuse)
return spm::read_fuse<off>(static_cast<spm::fuse>(at));
if constexpr (banked_flash)
return avr::flash_load_far<std::uint8_t>(flash_address(bank, at));
else
return avr::flash_load(reinterpret_cast<const std::uint8_t *>(at));
}
// One page into the SPM buffer, then erase and program — except the slot
// this code is running in (`slot_high`, from run()), which is drained and
// left alone. A broken host therefore cannot brick the running loader, and a
// copy one slot lower may rewrite the resident one.
// One byte into a writable space. Flash is not one of them — it arrives a
// page at a time through 'W' and is committed through sp_spm — and the fuses
// are not writable at all: SPM reaches flash and boot lock bits only.
[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value,
std::uint8_t slot_high)
{
if (space == sp_data) {
*reinterpret_cast<volatile std::uint8_t *>(at) = value;
return;
}
if (space == sp_spm) {
// The running-slot write guard. An SPM command aimed at the slot this
// code executes from is dropped, so a broken host cannot brick the
// running loader — while a copy one slot lower may still rewrite the
// resident one, which is what a self-update is. Guarding the commit
// rather than the page fill covers erase and write both, and leaves a
// refused page's words in the buffer: harmless, since the next page
// write auto-erases it (§26.2.1).
if (slot_of(bank, at) != slot_high)
spm::command<off>(value, flash_address(bank, at));
// Only a boot-sectioned mega runs on while its RWW section programs;
// everywhere else the CPU halts through erase and write, so the wait
// is already over by the time it returns.
if constexpr (boot_section)
spm::wait();
return;
}
// Host-paced: the ack goes out once the write has begun, so the next byte
// arrives while it completes and nothing is missed without a buffer.
ee::write<off>(at, value);
}
// One page into the SPM buffer, and only that: the erase and the write that
// commit it are host-issued sp_spm stores, which reach the same fused
// store-and-SPM pair through the transfer path's own address and data.
//
// Nothing discards the buffer first: it is write-once per word (§26.2.1), so
// filling over a refused page or an application's leavings programs stale
// words — but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so
// that write clears the condition and the host's read-back rewrites the page.
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
void fill_page(std::uint8_t bank, std::uint16_t at)
{
// The address names a page, so its in-page bits are dropped and the walk
// starts at the page base — one induction either way: a byte-addressed
// wire address walks the page itself (the offset bits wrap back to zero),
// while a word one becomes a byte cursor once. The slot index is the wire
// address's high byte — on byte-addressed chips the byte address's, with
// the low bit dropped, since a slot is two of those.
spm::flash_address_t address;
std::uint8_t page_high;
if constexpr (word_flash) {
// A page is aligned, so it never crosses 64 KiB: RAMPZ is a per-page
// constant and the 16-bit Z's low byte is the whole in-page offset.
const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15);
const std::uint16_t z0 =
static_cast<std::uint16_t>(wire_address << 1) & ~static_cast<std::uint16_t>(page - 1);
std::uint16_t z = z0;
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>((static_cast<spm::flash_address_t>(rampz) << 16) | z, word_of({low, high}));
z += 2;
} while (static_cast<std::uint8_t>(z));
address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0;
page_high = static_cast<std::uint8_t>(wire_address >> 8);
} else {
address = static_cast<spm::flash_address_t>(wire_address & ~static_cast<std::uint16_t>(page - 1));
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(address, word_of({low, high}));
address += 2;
} while (static_cast<std::uint8_t>(address) & (page - 1));
address -= 2; // back inside the page — erase and write ignore the word bits
page_high = static_cast<std::uint8_t>(address >> 8) & 0xfe;
}
if (page_high != slot_high) {
// Only a boot-sectioned mega runs on while its RWW section programs;
// everywhere else the CPU halts through erase and write.
spm::erase_page<off>(address);
if constexpr (boot_section)
spm::wait();
spm::write_page<off>(address);
if constexpr (boot_section)
spm::wait();
}
// Programming leaves the RWW section disabled; reads need it back on. The
// same store discards the buffer (§26.2.2), so a boot-sectioned mega never
// meets the stale-word case above.
if constexpr (boot_section)
spm::rww_enable<off>();
}
// The four fuse and lock bytes in the hardware's own Z order: low, lock,
// extended, high.
void send_fuses()
{
std::uint8_t which = 0;
do
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
while (++which != 4);
// starts at the page base; the low byte of the cursor is the whole in-page
// offset, since a page is aligned and never crosses a bank.
std::uint16_t z = at & ~static_cast<std::uint16_t>(page - 1);
do {
std::uint8_t low = link::rx();
std::uint8_t high = link::rx();
spm::fill<off>(flash_address(bank, z), word_of({low, high}));
z += 2;
} while (static_cast<std::uint8_t>(z) & (page - 1));
}
[[noreturn]] void run()
@@ -379,19 +461,14 @@ void send_fuses()
link::init();
// The high byte of the slot this copy runs at, which the write guard and
// the info block both follow: the return address is a word address, so its
// high byte is the 256-word slot index, doubled back into byte terms where
// the wire counts bytes. Taken as byteswap's low byte — the builtin already
// swaps the two stacked bytes, and the double swap folds away, where `>> 8`
// would leave the swap materialized.
const std::uint16_t ra_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
const std::uint8_t ra_high = static_cast<std::uint8_t>(std::byteswap(ra_words));
const std::uint8_t slot_high = word_flash ? ra_high : static_cast<std::uint8_t>(ra_high << 1);
// The slot this copy runs in, which the write guard follows: the return
// address is a word address and a slot is half as many words as bytes, so
// its high byte is the slot index outright. No absolute address is ever
// formed, so the image stays position-independent.
const auto return_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
const auto slot_high = static_cast<std::uint8_t>(return_words >> 8);
// 'p' then 'b', each under a fresh window; anything else is line noise.
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
}
await_host();
for (;;) {
// No prompt while an EEPROM write runs: it blocks SPM and fuse reads
@@ -406,47 +483,43 @@ void send_fuses()
link::drain();
jump(target);
}
case 'b': // info block, read relative to the running slot
case 'R': // read flash: addr16, n8 (0 = 256)
case 'r': // read EEPROM: addr16, n8
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
// One address-and-count path for all four: 'b' is a flash read
// whose arguments the loader already knows, so it joins the
// wire-argument three rather than streaming from a call site of its
// own. That leaves one flash streamer in the image, and lets its
// cursor live in this never-returning loop's own call-saved
// registers instead of being saved and restored around a call.
std::uint16_t address;
std::uint8_t count;
if (command == 'b') {
// The block sits in the image's first 256 bytes (check_pi.py
// asserts it) and slots are 512-aligned, so the low byte of its
// link address is its offset in any slot — halved where wire
// units are words. The high byte is runtime data, so no
// absolute address is ever materialized.
const auto link_byte =
static_cast<std::uint8_t>(reinterpret_cast<std::uint16_t>(info_data.storage.data()));
const std::uint8_t low = word_flash ? static_cast<std::uint8_t>(link_byte >> 1) : link_byte;
address = static_cast<std::uint16_t>(low | (slot_high << 8));
count = static_cast<std::uint8_t>(info_data.size());
} else {
address = rx16();
count = link::rx();
case 'b': // identity: the version, then the three signature bytes
// Straight out of the stamp, so the wire and the image can never
// disagree about what this loader is. The indices are constant and
// the array is constexpr, so these are immediates, not flash reads:
// nothing here needs the stamp's runtime address.
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
link::tx(identity_stamp[at]);
break;
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
case 'G': // read: sel8, addr16, n8 (0 = 256)
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
// One decode, one cursor and one loop for every space and both
// directions: a command per memory would carry a copy of all three
// each. 'W' joins the same decode rather than keeping an address
// form of its own, so flash addressing is uniform across every
// command that names it.
const std::uint8_t selector = link::rx();
const std::uint8_t space = space_of(selector);
const std::uint8_t bank = bank_of(selector);
std::uint16_t at = rx16();
if (command == 'W') {
fill_page(bank, at);
break;
}
if (command == 'r')
send_eeprom(address, count);
else if (command == 'w')
store_eeprom(address, count);
else
send_flash(address, count);
std::uint8_t count = link::rx();
do {
// Read and write are one letter apart in case, so the direction
// is a single bit and the loop picks it with a one-word skip.
if (command & 0x20) {
store(space, bank, at, link::rx(), slot_high);
tx_ack();
} else
link::tx(load(space, bank, at));
++at;
} while (--count);
break;
}
case 'W': // program one flash page: addr16, page bytes
program_flash(rx16(), slot_high);
break;
case 'F': // fuse and lock bytes
send_fuses();
break;
default: // unknown bytes are ignored; the loop re-acks
break;
}

View File

@@ -24,16 +24,75 @@ else:
import termios
PROMPT = b"+"
VERSION = 2 # this tool's own version — free to drift from a loader's
VERSION = 4 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire
# 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
# changes it becomes the new floor here.
OLDEST_LOADER = 1
NEWEST_LOADER = 3
NEWEST_LOADER = 5
SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
# the spaces below. The loader carries one transfer loop instead of four bodies
# — which is what buys the data space and the host-issued SPM operations.
UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# 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
# no command has to speak word addresses. No single transfer may cross a bank
# boundary; the host chunks to keep that true.
def selector(space, address):
return space | ((address >> 16) << 4)
# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its
# byte to SPMCSR and fires the instruction at the selected flash address. Every
# part pureboot targets agrees on these encodings.
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero
# data bits — one low pulse of seven bit-times, which the loader times into its
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
CALIBRATE = 0xC0
# pureboot 5 answers 'b' with its version and the chip signature; the host
# derives the rest of the geometry from the signature rather than reading a
# table off the device. flash, page, eeprom, patch-vector per distinct
# signature, over every chip pureboot targets (the loader computes the same
# from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon.
CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284
}
VERBOSE = False
@@ -301,6 +360,36 @@ Port = WindowsPort if os.name == "nt" else PosixPort
class Info:
"""The 12-byte info block."""
@classmethod
def from_identity(cls, raw):
"""pureboot 5's reply: the version and the chip signature. The rest of
the geometry is looked up from the signature — the loader derived the
same facts from its chip database at build time, so nothing is guessed,
it is simply not sent. Reconstructs a block in the older layout, so
every derived attribute below is shared with the loaders that do send
one.
The base is where application flash ends, which is a property of the
chip and not of the copy answering: a loader staged one slot lower
reports the same geometry the resident one does, exactly as the loaders
that send a block do. Which slot a copy runs in matters only to its own
write guard, which is the loader's business."""
if len(raw) != 4:
raise Error(f"bad identity reply: {raw.hex()}")
version, signature = raw[0], tuple(raw[1:4])
geometry = CHIP_GEOMETRY.get(signature)
if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
flash, page, eeprom, patch = geometry
base = flash - SLOT
word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
raw12 = bytes((ord("P"), ord("B"), version, *signature, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, eeprom & 0xFF, eeprom >> 8, flags))
return cls(raw12)
def __init__(self, raw):
if len(raw) != 12 or raw[0:2] != b"PB":
raise Error(f"bad info block: {raw.hex()}")
@@ -314,8 +403,11 @@ class Info:
self.signature = raw[3:6]
self.page = raw[6] or 256 # the wire count convention: 0 means 256
self.patch_vector = bool(raw[11] & 1)
# Bit 1: flash addresses are words on the wire. Every address here
# stays a byte address and converts at the wire.
# Bit 1: the flash runs past what one 16-bit address covers. Through
# pureboot 4 that made flash addresses words on the wire; pureboot 5
# keeps them bytes and carries the bank in the selector instead. Every
# address in this tool stays a byte address either way and converts at
# the wire.
self.word_flash = bool(raw[11] & 2)
scale = 2 if self.word_flash else 1
self.base = (raw[7] | (raw[8] << 8)) * scale
@@ -345,7 +437,7 @@ class Info:
f"version pureboot {self.version}",
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
f"flash {self.flash_size} B, {self.page} B pages"
+ (", word-addressed wire" if self.word_flash else ""),
+ (", past one 16-bit bank" if self.word_flash else ""),
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
f"loader {self.base:#06x} ({SLOT} B slot)",
f"staging {self.stage:#06x}",
@@ -362,40 +454,70 @@ class Loader:
def __init__(self, port):
self.port = port
self.info = None
# Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked.
self.autobaud = False
def connect(self, wait):
"""Knock until the info block comes back. The block is what proves the
loader is listening — a prompt byte alone does not, since one left over
from a previous session can still be in the pipeline while the port
opening resets the device into a fresh activation window, where a
command without its knock is discarded. Each attempt is therefore the
whole handshake, retried until it produces the block or the window
closes. Also converges into a live session: the knock bytes are ignored
there and the drain absorbs whatever they produced."""
def _read_identity(self):
"""The 'b' reply, in either of the two layouts a loader may send.
pureboot 5 answers with its version and the signature; older loaders
answer with a 12-byte block. The version byte cannot be mistaken for
the older block's 'P', so four bytes are enough to tell them apart."""
head = self.port.read_exact(4, 2.0)
if head[0:2] == b"PB":
return Info(head + self.port.read_exact(8, 2.0))
return Info.from_identity(head)
def _handshake(self, wait, knock, what):
"""One activation, retried until the loader answers or the window
closes. The identity reply is what proves the loader is listening — a
prompt byte alone does not, since one left over from a previous session
can still be in the pipeline while the port opening resets the device
into a fresh window, where a command without its knock is discarded.
Each attempt is therefore the whole handshake. This also converges into
an already-live session: the knock bytes are ignored there and the
drain absorbs whatever they produced."""
deadline = time.monotonic() + wait
knocks = 0
while True:
self.port.flush_input()
self.port.write(b"pb")
self.port.write(knock)
knocks += 1
if PROMPT in self.port.read_available(0.4):
while self.port.read_available(0.3):
pass
self.port.write(b"b")
try:
block = self.port.read_exact(12, 2.0)
except Error:
block = b""
# A version the tool cannot speak is the loader's own answer,
# not a failed knock: Info reports it rather than retrying.
if block[0:2] == b"PB":
self.info = Info(block)
# A version the tool cannot speak is the loader's own
# answer, not a failed knock: Info reports it rather than
# sending the tool round the loop again.
self.info = self._read_identity()
except Error as failed:
if "pureboot" in str(failed):
raise
self.info = None
if self.info is not None:
self._expect_prompt()
verbose(f"loader answered knock {knocks}; info block read")
verbose(f"loader answered {what} {knocks}; identity read")
return self.info
if time.monotonic() > deadline:
raise Error("no answer — reset the device within its activation window")
def connect(self, wait):
"""Knock 'p' then 'b' and read the identity."""
return self._handshake(wait, b"pb", "knock")
def connect_autobaud(self, wait):
"""The autobaud handshake. In place of the p+b knock the host sends the
calibration pulse — one seven-bit-time low pulse at the host's chosen
baud, which the loader times into its per-bit unit — then a single 'p'
the loader decodes at the rate it just measured. A lost pulse, or a
knock landing while the loader is mid-frame, simply fails to answer and
leaves the measurement loop waiting for the next pulse, so the retry in
_handshake covers it."""
self.autobaud = True
return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration")
def _expect_prompt(self, timeout=2.0):
byte = self.port.read_exact(1, timeout)
if byte != PROMPT:
@@ -418,7 +540,58 @@ class Loader:
count -= chunk
return data
@property
def unified(self):
"""pureboot 5 and later: one 'G'/'g' pair over selector-named spaces."""
return self.info is not None and self.info.version >= UNIFIED_LOADER
def _read_space(self, space, address, count):
"""A run out of any space, chunked to 256 bytes and to bank bounds."""
data = b""
while count:
chunk = min(count, 256, 0x10000 - (address & 0xFFFF))
head = bytes((ord("G"), selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, chunk & 0xFF))
data += self._command(head, chunk, 5.0)
address += chunk
count -= chunk
return data
def _write_space(self, space, address, data, progress=None):
"""A run into any space. Each byte is acked as its write begins — an
EEPROM cell and an SPM operation both need that pacing, and the ack is
what the loader sends in place of a completion status."""
offset = 0
while offset < len(data):
chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))]
head = bytes((ord("g"), selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, len(chunk) & 0xFF))
self.port.write(head)
for byte in chunk:
self.port.write(bytes((byte,)))
self._expect_prompt()
if progress:
progress.step()
self._expect_prompt() # the next command prompt
address += len(chunk)
offset += len(chunk)
def spm(self, operation, address):
"""One SPM operation at a flash address — the erase, write and RWW
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here."""
self._write_space(SP_SPM, address, bytes((operation,)))
def read_ram(self, address, count):
"""Data space: SRAM, and with it the register file and every I/O
register, which share the address space on AVR. New in pureboot 5."""
return self._read_space(SP_RAM, address, count)
def write_ram(self, address, data):
self._write_space(SP_RAM, address, data)
def read_flash(self, address, count):
if self.unified:
return self._read_space(SP_FLASH, address, count)
if not self.info.word_flash:
return self._stream_read("R", address, count)
# Word-addressed wire: widen to even bounds and never let one read
@@ -436,15 +609,32 @@ class Loader:
return data[address - start : address - start + count]
def read_eeprom(self, address, count):
if self.unified:
return self._read_space(SP_EEPROM, address, count)
return self._stream_read("r", address, count)
def write_page(self, address, data):
assert len(data) == self.info.page and address % self.info.page == 0
if self.unified:
# 'W' fills the page buffer and stops there; the erase and the write
# are host-issued SPM operations. Only a chip with a boot section
# has RWW to re-enable — on the others bit 4 of SPMCSR means
# something else entirely, so it must not be sent.
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
self._command(head + data, 0, 2.0)
self.spm(SPM_ERASE, address)
self.spm(SPM_WRITE, address)
if not self.info.patch_vector:
self.spm(SPM_RWWSRE, address)
return
wire = address // (2 if self.info.word_flash else 1)
head = bytes((ord("W"), wire & 0xFF, wire >> 8))
self._command(head + data, 0, 2.0)
def write_eeprom(self, address, data, progress=None):
if self.unified:
self._write_space(SP_EEPROM, address, data, progress)
return
offset = 0
while offset < len(data):
chunk = data[offset : offset + 256]
@@ -460,6 +650,8 @@ class Loader:
offset += len(chunk)
def read_fuses(self):
if self.unified:
return self._read_space(SP_FUSE, 0, 4)
return self._command(b"F", 4, 2.0)
def jump(self, word_address):
@@ -470,8 +662,9 @@ class Loader:
def enter_copy(self, byte_address, wait):
"""Jump into the loader copy at `byte_address` and knock it — a slot
base is that copy's entry stub, so it can only land there."""
autobaud = self.autobaud
self.jump(byte_address // 2)
return self.connect(wait)
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
def run_application(self):
self.jump(self.info.app_entry_word)
@@ -638,12 +831,26 @@ def mega_boot(info, fuse_bytes):
def image_info(image):
"""The info block embedded in a pureboot binary, or None. Searched once
per known version, so the magic stays three selective bytes rather than
two that code could carry by chance."""
"""What a pureboot binary says about itself, or None.
An update image is a bare slot: nothing about it names the chip it was
built for, and installing a foreign one bricks the target — so every
loader carries a stamp for this. Through pureboot 4 the stamp is the
12-byte info block the device also serves; pureboot 5 serves its identity
from immediates and carries a 6-byte stamp (magic, version, signature)
that only this exists for, from which the geometry is looked up exactly as
it is for a live device.
Searched once per known version, so the magic stays three selective bytes
rather than two that code could carry by chance."""
for version in range(OLDEST_LOADER, NEWEST_LOADER + 1):
at = image.find(b"PB" + bytes((version,)))
if 0 <= at <= len(image) - 12:
if at < 0:
continue
if version >= UNIFIED_LOADER:
if at <= len(image) - 6:
return Info.from_identity(image[at + 2 : at + 6])
elif at <= len(image) - 12:
return Info(image[at : at + 12])
return None
@@ -833,7 +1040,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
# it and matching byte for byte, and the slot unchanged since this update
# began, so a half-written install takes the path below instead.
current = loader.read_flash(info.stage, SLOT)
staged_loader = image_info(current[:268])
# The whole slot is searched: a loader's stamp sits wherever its image put
# it, which is the end of the code on pureboot 5 and the front of it
# before that.
staged_loader = image_info(current)
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
print("staging slot already holds a loader — left in place")
else:
@@ -1016,6 +1226,37 @@ def op_read_eeprom(loader, path):
print(f"read EEPROM: {len(data)} B -> {path}")
def _require_unified(loader, what):
if not loader.unified:
raise Error(f"{what} needs pureboot {UNIFIED_LOADER} or later; this loader is {loader.info.version}")
def _peek_spec(spec):
"""ADDR[:N] — addresses and counts in any Python integer base."""
address, _, count = spec.partition(":")
return int(address, 0), int(count, 0) if count else 1
def op_peek(loader, spec):
_require_unified(loader, "--peek")
address, count = _peek_spec(spec)
data = loader.read_ram(address, count)
for offset in range(0, len(data), 16):
row = data[offset : offset + 16]
text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
print(f"{address + offset:#06x} {row.hex(' '):<47} {text}")
def op_poke(loader, spec):
_require_unified(loader, "--poke")
address, _, payload = spec.partition(":")
if not payload:
raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef")
data = bytes.fromhex(payload.replace(" ", ""))
loader.write_ram(int(address, 0), data)
print(f"poke: {len(data)} B at {int(address, 0):#06x}")
def op_fuses(loader):
low, lock, extended, high = loader.read_fuses()
print("fuses:")
@@ -1049,6 +1290,9 @@ def main():
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)")
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("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1064,6 +1308,10 @@ def main():
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, "
"I/O) — pureboot 5 and later")
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space — "
"pureboot 5 and later")
parser.add_argument("--force", action="store_true", help="override refusable safety checks")
parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
parser.add_argument("-v", "--verbose", action="store_true",
@@ -1086,7 +1334,7 @@ def main():
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try:
loader = Loader(port)
info = loader.connect(args.wait)
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
if args.info:
print("device:")
for line in info.lines():
@@ -1115,6 +1363,10 @@ def main():
op_read_eeprom(loader, args.read_eeprom)
if args.verify_eeprom:
op_verify_eeprom(loader, args.verify_eeprom)
if args.poke:
op_poke(loader, args.poke)
if args.peek:
op_peek(loader, args.peek)
if args.stay:
print("loader stays in its session (reset to leave)")
else:

View File

@@ -1,47 +1,75 @@
#!/usr/bin/env python3
"""Position-independence lint: the two link-time facts that let the identical
image run from any slot, asserted from the built ELF.
"""Position-independence lint: the property that lets the identical image run
from any slot, asserted from the built ELF and its object.
1. No absolute jmp/call — -mrelax normally guarantees it, but a branch that
grows out of relaxation range would break it silently.
2. The info block within the image's first 256 bytes: 'b' rebuilds its
address as (running slot high byte : link address low byte).
2. Nothing flash-resident to address: the image is .text alone, so there is
no table whose runtime address has to be reconstructed.
3. The image is byte-identical when linked at a different base. This is
position independence itself rather than a proxy for it — an absolute
address anywhere in the image would move with the link and show up as a
differing byte.
Usage: check_pi.py <objdump> <nm> <elf> <text_start_hex>
Usage: check_pi.py <objdump> <objcopy> <cxx> <mcu> <elf> <object> <text_start_hex>
"""
import os
import re
import subprocess
import sys
import tempfile
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
objdump, nm, elf, text_start = sys.argv[1:]
objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:]
text_start = int(text_start, 0)
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
absolute = [
line
for line in listing.splitlines()
if re.search(r"\t(jmp|call)\t", line)
]
absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)]
if absolute:
print("FAIL: absolute control flow in the image:")
print("\n".join(absolute))
sys.exit(1)
fail("absolute control flow in the image:\n" + "\n".join(absolute))
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
if len(info) != 1:
print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
sys.exit(1)
address = int(info[0].split()[0], 16)
offset = address - text_start
if not 0 <= offset < 256:
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
sys.exit(1)
# Allocated flash beyond .text would be data the running copy has to find.
# Only ALLOC sections reach the device at all; .comment and the debug
# sections ride along in the ELF container and are never flashed. objdump
# prints each section's flags on the line following its header.
headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines()
for index, line in enumerate(headers):
fields = line.split()
if len(fields) < 6 or not fields[0].isdigit():
continue
name, size = fields[1], int(fields[2], 16)
flags = headers[index + 1] if index + 1 < len(headers) else ""
if "ALLOC" not in flags or not size:
continue
if name not in (".text", ".noinit", ".bss"):
fail(f"flash-resident section {name} ({size} bytes): the image must be .text alone")
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}")
# Relink at a different base and compare the bytes.
with tempfile.TemporaryDirectory() as work:
elsewhere = text_start - 0x200 if text_start >= 0x200 else text_start + 0x200
images = []
for base, tag in ((text_start, "here"), (elsewhere, "there")):
relinked = os.path.join(work, f"{tag}.elf")
binary = os.path.join(work, f"{tag}.bin")
subprocess.run(
[cxx, f"-mmcu={mcu}", "-nostartfiles", f"-Wl,--section-start=.text={base:#x}",
"-Wl,--defsym=pureboot_app=0", "-mrelax", obj, "-o", relinked],
check=True, capture_output=True)
subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True)
images.append(open(binary, "rb").read())
if images[0] != images[1]:
differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b]
fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: "
f"{len(differing)} byte(s) differ, first at offset {differing[0]:#x}")
print(f"PI lint: control flow PC-relative, .text only, identical linked at {text_start:#x} and {elsewhere:#x}")
if __name__ == "__main__":

154
test/pbautobaud.py Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""End-to-end autobaud test: drive an autobaud loader in simavr through the
calibration handshake and a flash + EEPROM + fuse round-trip, cross-checked
against the simulator's ground-truth memory — then repeat at a second F_CPU with
the *same* loader binary, which is the property autobaud exists for: one
clock-agnostic image that locks onto whatever rate the host sends.
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
<app_bin> <app_hz> <app_baud> <tool_py> <workdir>
The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's
default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). The
app fixture is built for (app_hz, app_baud); the hand-over is checked at that
point, and a second point at half the clock proves the lock is measured, not
baked in.
"""
import os
import sys
import time
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:]
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
ee_image = bytes(range(0xA0, 0xB0))
ee_path = os.path.join(workdir, "ee.bin")
open(ee_path, "wb").write(ee_image)
# The geometry the surgery planner needs, from the chip class the runner is
# told — the same derivation pbtest.py makes: the boot-sectioned megas need
# no vector surgery, the tinies and the boot-section-less m48s do, and the
# large chips speak word addresses.
mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48")
word_flash = base + pb.SLOT > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
ground_truth = pb.Info(bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, 0, 0, flags]))
def round_trip(hz, baud, label, hand_over):
"""One clock point: reset, calibrate + knock, program, verify against the
simulator's own flash, and (at the app's point) hand over to the fixture."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link="sw:B0,B1")
try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses",
"--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{out}")
# Read both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--verify-flash", app_bin,
"--verify-eeprom", ee_path, "--read-flash", read_flash,
"--read-eeprom", read_eeprom, "--stay")
if out.count("verify:") != 2:
fail(f"{label}: did not verify both memories\n{out}")
if open(read_eeprom, "rb").read()[: len(ee_image)] != ee_image:
fail(f"{label}: EEPROM read-back mismatch")
if hand_over:
# Regression: a calibration pulse with no knock behind it must
# not wedge the loader. The knock's edge wait used to be
# unbudgeted, so one stray low pulse — EMI, or a host that opens
# the port and never knocks — held the loader forever and the
# application never ran. The whole activation is bounded now, so
# the window closes and the app boots; the banner is the proof.
# (The pause lets the loader reach its measurement loop, so the
# pulse is genuinely seen and the test cannot pass vacuously.)
device.reset()
port = pb.Port(device.pty, baud)
try:
time.sleep(0.2)
port.write(bytes((pb.CALIBRATE,)))
# Accumulate rather than match exactly: the reset leaves the
# idle line a framing artefact ahead of the banner, which is
# noise here — the question is only whether the app ran.
seen = b""
deadline = time.monotonic() + 180.0
while b"APP" not in seen and time.monotonic() < deadline:
seen += port.read_available(1.0)
if b"APP" not in seen:
fail(f"{label}: lone calibration pulse wedged the loader — app never bannered, saw {seen!r}")
print(f" {label}: lone calibration pulse does not wedge the loader")
finally:
port.close()
device.reset()
port = pb.Port(device.pty, baud)
try:
loader = pb.Loader(port)
live = loader.connect_autobaud(15)
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
fail(f"{label}: loader reports pureboot {live.version}")
if loader.unified:
# pureboot 5's data space. 0x0200 is clear of the
# loader's own .noinit unit at the bottom of SRAM and of
# the stack at the top. Reading it back over the same
# locked link proves both directions of the new space.
probe = bytes(range(0x30, 0x40))
loader.write_ram(0x0200, probe)
if loader.read_ram(0x0200, len(probe)) != probe:
fail(f"{label}: RAM round-trip mismatch")
# The register file and the I/O space share the data
# address space on AVR, so the same command reaches a
# peripheral register. SPMCSR reads back as idle here.
verbose_ram = loader.read_ram(0x0200, 4)
print(f" {label}: RAM read/write ok ({verbose_ram.hex()})")
loader.run_application()
banner = port.read_exact(3, 5.0)
if banner != b"APP":
fail(f"{label}: application banner was {banner!r}")
finally:
port.close()
finally:
device.stop()
# Ground truth (read after the runner exits and writes its dump): what
# the tool programmed must be what the simulator actually holds.
pages = pb.plan_flash(open(app_bin, "rb").read(), ground_truth)
flash_true = open(dump, "rb").read()
for address, data in pages.items():
if flash_true[address : address + page] != data:
fail(f"{label}: simulator flash differs from the programmed image at {address:#06x}")
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
+ (", hand-over ok" if hand_over else ""))
# The app fixture is built for one clock; the hand-over banners there. A
# second point at double that clock, same loader binary, proves the lock is
# measured, not baked in — the whole point of autobaud. (Doubling keeps the
# bit period healthy; halving would drop it below the software UART's floor.)
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks")
if __name__ == "__main__":
main()

View File

@@ -55,6 +55,12 @@ def main():
# the page byte is the wire's 0-means-256.
mega = mcu.startswith("atmega")
patch = not mega or mcu.startswith("atmega48")
# Where SRAM begins: the x8 and x4 megas push it past their extended I/O
# space, everything else starts right after the plain I/O registers. The
# loader keeps no statics and its stack sits at RAMEND, so the first SRAM
# byte is free for the data-space probe below.
classic = mcu in ("atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a")
ram_base = 0x0100 if mega and not classic else 0x0060
word_flash = base + pb.SLOT > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
@@ -73,12 +79,20 @@ def main():
if needed not in out:
fail(f"session 1 output lacks {needed!r}")
# Session 2: reconnect into the live session, verify, dump, hand over
# is deferred — the pty must be reopened for the APP banner first.
# Session 2: reconnect into the live session, verify, dump, exercise
# the data space; hand over is deferred — the pty must be reopened for
# the APP banner first.
probe = "c0ffee"
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay")
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
if out.count("verify:") != 2:
fail("session 2 did not verify both memories")
# What went into SRAM must come back out of it: the data space is one
# more selector on the same transfer as flash and EEPROM, so a wrong
# selector decode would show up here and nowhere else.
if probe not in out.replace(" ", ""):
fail(f"data-space round trip at {ram_base:#x} did not read back {probe}\n{out}")
eeprom_back = open(read_eeprom, "rb").read()
if eeprom_back[: len(ee_image)] != ee_image:
@@ -100,19 +114,26 @@ def main():
try:
loader = pb.Loader(port)
live = loader.connect(15)
# The loader built from this tree and the tool beside it must
# agree on where the version numbering stands: a bump the tool
# was never told about is a loader it would refuse to speak to.
if live.version != pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}")
# The loader built from this tree must report a version the tool
# beside it speaks — a bump the tool was never told about is a
# loader it would refuse to talk to. Not equality with the newest:
# the tool now spans two loader generations, the fixed-baud one
# here and the unified autobaud loader that follows it.
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
fail(f"loader reports pureboot {live.version}, the tool speaks "
f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}")
# A W addressed inside a page rather than at its base must still
# consume exactly one page and prompt. The loader's own slot is
# the target — it is drained and never programmed — and the
# payload is erased-state bytes, so the probe can disturb neither
# the image nor the page buffer it leaves behind.
wire = wire_base + 1
port.write(bytes((ord("W"), wire & 0xFF, wire >> 8)) + b"\xff" * page)
# consume exactly one page and prompt. The loader's own slot is the
# target — the guard refuses to commit it — and the payload is
# erased-state bytes, so the probe can disturb neither the image nor
# the page buffer it leaves behind. Hand-built rather than through
# write_page(), which would follow the fill with its erase and
# write; the point here is that the fill alone consumes exactly one
# page whatever the address's low bits say.
wire = base + 1
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
+ b"\xff" * page)
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("unaligned W did not return to the prompt")

View File

@@ -182,9 +182,16 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par
{
(void)mcu;
(void)param;
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8)
if (tx_bit < 8) {
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8)
return when + bit_cycles;
/* The byte is not delivered until its stop bit has passed. A real
* receiver cannot answer sooner, and a host that did would put its
* start bit on the wire while the device is still driving the stop
* bit — which the device, transmitting, is not watching for. */
return when + bit_cycles;
}
if (write(pty_master, &tx_shift, 1) != 1)
fprintf(stderr, "device: pty write lost a byte\n");
tx_active = 0;

View File

@@ -30,8 +30,14 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla
scale = 2 if word_flash else 1
wire_base = base // scale
flags = (1 if patch else 0) | (2 if word_flash else 0)
# The EEPROM size comes from the signature, as it must: pureboot 5 derives
# the whole geometry from the signature rather than sending it, so a
# synthetic block that disagreed with its own signature would describe a
# chip that cannot exist.
eeprom = pb.CHIP_GEOMETRY[signature][2]
raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version,
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags))
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
eeprom & 0xFF, eeprom >> 8, flags))
info = pb.Info(raw)
if info.flash_size != flash:
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
@@ -162,11 +168,16 @@ def main():
fail("mega staging content should be the bare image")
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
# The embedded info block: found in a synthetic binary, absent in noise.
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10
# The image stamp: found in a synthetic binary, absent in noise. pureboot
# 5 stamps the magic, its version and the signature, and the geometry is
# looked up from there — so what comes back must equal what a live device
# of the same chip reports.
stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature)
binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10
found = pb.image_info(binary)
if found is None or found.raw != tiny.raw:
fail("image_info misses the embedded block")
fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: "
f"{found.raw.hex() if found else None} != {tiny.raw.hex()}")
if pb.image_info(bytes((0xAA,)) * 40) is not None:
fail("image_info invents a block")
# An older loader's image stays readable, so a deployed build can be