Compare commits
11 Commits
v7
...
0cb83ff36f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb83ff36f | |||
| 8aa1721709 | |||
| 2655d058f4 | |||
| e9c3897d4f | |||
| 546b1589a3 | |||
| eb213e1025 | |||
| 3e4bfbaf48 | |||
| 579ca81b27 | |||
| 5d520a1ff9 | |||
| f71d76a815 | |||
| 47419400f6 |
160
CMakeLists.txt
160
CMakeLists.txt
@@ -217,6 +217,23 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
|
||||
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
|
||||
|
||||
# The half-duplex loader's window, same gate: its poll runs through
|
||||
# rx_ready()'s release-line test, whose outlined call re-shapes the
|
||||
# whole loop — a per-class cycle count (poll_cost() in pureboot.cpp)
|
||||
# that only the built image can prove, chip by chip.
|
||||
if(PUREBOOT_HAS_USART)
|
||||
add_test(NAME pureboot.window.halfduplex
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_hd>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
|
||||
--seconds ${PUREBOOT_TIMEOUT}
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-hd-work)
|
||||
set_tests_properties(pureboot.window.halfduplex PROPERTIES TIMEOUT 300)
|
||||
endif()
|
||||
|
||||
# The position-independence acceptance test: the identical image,
|
||||
# installed one slot lower, must serve the full command set.
|
||||
add_test(NAME pureboot.reloc
|
||||
@@ -228,6 +245,28 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180
|
||||
ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}")
|
||||
|
||||
# The seal against the one command that proves it: erasing the page the
|
||||
# loader runs from, refused unsealed and honoured sealed. Destroys the
|
||||
# loader by design, so it gets a device of its own.
|
||||
add_test(NAME pureboot.selfwrite
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbselfwrite.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbselfwrite-work)
|
||||
set_tests_properties(pureboot.selfwrite PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The seal against a link that damages bytes on purpose: every header
|
||||
# field flipped after sealing must be refused, and the identical flip
|
||||
# applied before sealing must be obeyed.
|
||||
add_test(NAME pureboot.glitch
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbglitch.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbglitch-work)
|
||||
set_tests_properties(pureboot.glitch PROPERTIES TIMEOUT 180)
|
||||
|
||||
# Entering the loader from a running application with no reset
|
||||
# between, over a page buffer the application dirtied — the case the
|
||||
# loader declines to guard and the host repairs. Hardware forbids the
|
||||
@@ -312,6 +351,9 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
# default pair, or the index of the USART whose own pins a bit-banged
|
||||
# link sits on. Unreachable rates drop out here rather than aborting the
|
||||
# configure.
|
||||
# The optional trailing argument is the one-wire shape of the same link:
|
||||
# ONE_WIRE folds a software point onto its RX pin (the default, or the
|
||||
# named USART's RXD), HALF_DUPLEX is the hardware USART's turn-around.
|
||||
function(pureboot_matrix_point hz baud link pins)
|
||||
set(_name pbm_${hz}_${baud}_${link})
|
||||
if(link STREQUAL "software")
|
||||
@@ -321,9 +363,21 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
list(APPEND _args RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_TX})
|
||||
set(_name ${_name}_on${pins})
|
||||
endif()
|
||||
if(ARGC GREATER 4 AND ARGV4 STREQUAL "ONE_WIRE")
|
||||
if(NOT pins STREQUAL "")
|
||||
set(_args SERIAL software RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_RX})
|
||||
else()
|
||||
list(APPEND _args RX pb0 TX pb0)
|
||||
endif()
|
||||
set(_name ${_name}_1w)
|
||||
endif()
|
||||
else()
|
||||
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
|
||||
set(_args USART ${link})
|
||||
if(ARGC GREATER 4 AND ARGV4 STREQUAL "HALF_DUPLEX")
|
||||
list(APPEND _args HALF_DUPLEX)
|
||||
set(_name ${_name}_hd)
|
||||
endif()
|
||||
endif()
|
||||
if(_ok)
|
||||
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args})
|
||||
@@ -365,13 +419,18 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
foreach(_matrix_hz IN LISTS _full_clocks)
|
||||
foreach(_matrix_baud IN LISTS _full_bauds)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "")
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "" ONE_WIRE)
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0 ONE_WIRE)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "" HALF_DUPLEX)
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1 ONE_WIRE)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "")
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "" HALF_DUPLEX)
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
@@ -437,6 +496,30 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
|
||||
endif()
|
||||
|
||||
# The one-wire axis at its fixed points, in both matrix modes (the
|
||||
# exhaustive sweep carries the same shapes across its cross product):
|
||||
# the software link folded onto one pin, the tightest autobaud image
|
||||
# likewise — on the default pin and on the USART's own RXD, whose
|
||||
# release the now-driven shared pin needs where a receive-only link
|
||||
# would not — and the hardware USART's half-duplex turn-around, stock
|
||||
# and at the widest fixed-baud shape.
|
||||
# The two spellings deliberately split across the two points: HALF_DUPLEX
|
||||
# folds TX onto RX, RX == TX states the same thing directly.
|
||||
pureboot_size_variant(pureboot_1w SERIAL software RX pb0 HALF_DUPLEX)
|
||||
pureboot_size_variant(pureboot_1w_autobaud_osccal SERIAL autobaud OSCCAL 0x9c RX pb0 TX pb0)
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_size_variant(pureboot_1w_on_usart0 SERIAL software
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
|
||||
pureboot_size_variant(pureboot_1w_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
|
||||
pureboot_size_variant(pureboot_hd HALF_DUPLEX)
|
||||
list(GET _matrix_clocks -1 _hd_top_hz)
|
||||
pureboot_size_variant(pureboot_hd_wide CLOCK ${_hd_top_hz} BAUD 9600 HALF_DUPLEX)
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_size_variant(pureboot_usart1_hd USART 1 HALF_DUPLEX)
|
||||
endif()
|
||||
|
||||
# The trim byte, observed through the wire from the first prompt — one
|
||||
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
|
||||
# an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out —
|
||||
@@ -511,6 +594,58 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbmute-work ${_mute_link})
|
||||
set_tests_properties(pureboot.mute PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The same hand-over against the one-wire deployment on that USART's
|
||||
# RXD: RXEN forces the shared pin's direction, so a loader that only
|
||||
# released the transmit-side hold would read the wire and answer into
|
||||
# a pin it cannot drive. The host runs with the --one-wire echo
|
||||
# discard, which the bridge's shared-line model feeds for real.
|
||||
get_target_property(_mute1w_link pureboot_1w_on_usart0 PUREBOOT_LINK)
|
||||
add_test(NAME pureboot.mute.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbmute.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_on_usart0> ${PUREBOOT_SIM_MCU} ${_mute_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_mute_baud}
|
||||
$<TARGET_FILE:pbapp_handover>.bin
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbmute-1w-work ${_mute1w_link})
|
||||
set_tests_properties(pureboot.mute.onewire PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The full protocol suite over one shared pin: the loader folded onto
|
||||
# PB0, the bridge following the pin's direction, the fixture
|
||||
# bannering as a guest on the same line, and the host discarding its
|
||||
# own echo throughout.
|
||||
get_target_property(_1w_hz pureboot_1w PUREBOOT_HZ)
|
||||
get_target_property(_1w_baud pureboot_1w PUREBOOT_BAUD)
|
||||
get_target_property(_1w_link pureboot_1w PUREBOOT_LINK)
|
||||
add_executable(pbapp_1w test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_1w PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_1w PRIVATE PUREBOOT_CLOCK_HZ=${_1w_hz}
|
||||
PUREBOOT_BAUD=${_1w_baud} PUREBOOT_SOFT_SERIAL
|
||||
PUREBOOT_RX=pb0 PUREBOOT_TX=pb0)
|
||||
add_custom_command(TARGET pbapp_1w POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_1w> $<TARGET_FILE:pbapp_1w>.bin)
|
||||
add_test(NAME pureboot.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w> ${PUREBOOT_SIM_MCU} ${_1w_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_1w_baud} ${PUREBOOT_EEPROM}
|
||||
$<TARGET_FILE:pbapp_1w>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pb1w-work ${_1w_link})
|
||||
set_tests_properties(pureboot.onewire PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The hardware USART's half-duplex turn-around, end to end: every
|
||||
# reply byte runs drive-line, TXC-hold, release — against simavr's
|
||||
# RXEN-gated receiver, which drops input to a disabled receiver the
|
||||
# way silicon does. The pty is a two-wire transport, so the host
|
||||
# needs no echo discard here; the off-chip tie itself is the
|
||||
# hardware bench's item.
|
||||
add_test(NAME pureboot.halfduplex
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_hd> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${PUREBOOT_EEPROM}
|
||||
$<TARGET_FILE:pbapp>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbhd-work)
|
||||
set_tests_properties(pureboot.halfduplex PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# The second USART, driven for real on one chip: instance selection is
|
||||
@@ -559,6 +694,31 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
${CMAKE_BINARY_DIR}/pbautobaud-work)
|
||||
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
|
||||
|
||||
# The tightest deployment in the space, end to end: the autobaud
|
||||
# loader folded onto the USART's own RXD with the OSCCAL trim baked
|
||||
# — one-wire calibration, the receive-side release, and the host's
|
||||
# echo discard, over the same two-clock sweep. One chip carries it;
|
||||
# the shape is chip-independent.
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p")
|
||||
get_target_property(_ab1w_link pureboot_1w_autobaud_osccal_on_usart0 PUREBOOT_LINK)
|
||||
add_executable(pbapp_autobaud_1w test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_autobaud_1w PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_autobaud_1w PRIVATE PUREBOOT_CLOCK_HZ=1000000
|
||||
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL
|
||||
PUREBOOT_RX=pd0 PUREBOOT_TX=pd0)
|
||||
add_custom_command(TARGET pbapp_autobaud_1w POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_autobaud_1w> $<TARGET_FILE:pbapp_autobaud_1w>.bin)
|
||||
add_test(NAME pureboot.autobaud.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_autobaud_osccal_on_usart0>
|
||||
${PUREBOOT_SIM_MCU} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE}
|
||||
$<TARGET_FILE:pbapp_autobaud_1w>.bin
|
||||
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbautobaud-1w-work ${_ab1w_link})
|
||||
set_tests_properties(pureboot.autobaud.onewire PROPERTIES TIMEOUT 240)
|
||||
endif()
|
||||
|
||||
# The autobaud window: the calibration poll budget, at the measured
|
||||
# 10 cycles a poll (pbwindow.py pins the constant the README's
|
||||
# seconds arithmetic uses; the budget itself is the clock-free knob).
|
||||
|
||||
2
libavr
2
libavr
Submodule libavr updated: a9fe6bed50...b719ed74d8
@@ -166,7 +166,9 @@ set_property(GLOBAL PROPERTY PUREBOOT_WRAP "${_pb_wrap}")
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART0_RX ${_pb_usart0_rx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART0_TX ${_pb_usart0_tx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART1_RX ${_pb_usart1_rx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART1_TX ${_pb_usart1_tx})
|
||||
|
||||
# The port's own build (tests, the size matrix) reads the geometry from the
|
||||
@@ -236,7 +238,8 @@ endfunction()
|
||||
|
||||
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
|
||||
# [SERIAL auto|hardware|software|autobaud] [USART <n>]
|
||||
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
|
||||
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>]
|
||||
# [HALF_DUPLEX])
|
||||
#
|
||||
# The loader target plus its flashable images (<name>.hex for a programmer,
|
||||
# <name>.bin for --update-loader). The resolved deployment is stamped on the
|
||||
@@ -244,6 +247,11 @@ endfunction()
|
||||
# usart0, usart1, or sw:<RX>,<TX> with a trailing @<n> where those pins are a
|
||||
# USART's own) — what a test harness speaks to it with.
|
||||
#
|
||||
# HALF_DUPLEX is the one-wire deployment, per backend: on the hardware USART
|
||||
# it enables the library's .half_duplex turn-around (RXD and TXD tied
|
||||
# together off-chip); on a software or autobaud link it puts both directions
|
||||
# on the RX pin — the same thing RX == TX spells directly.
|
||||
#
|
||||
# SERIAL autobaud measures the host's bit timing at run time, so the image
|
||||
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
|
||||
# and one binary per chip serves every F_CPU and every rate. The stamped
|
||||
@@ -257,7 +265,7 @@ endfunction()
|
||||
# purely for the application's benefit, its own link being clock-free. No
|
||||
# value, no code.
|
||||
function(pureboot_add_loader name)
|
||||
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
|
||||
cmake_parse_arguments(PB "HALF_DUPLEX" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
|
||||
if(PB_UNPARSED_ARGUMENTS)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
|
||||
endif()
|
||||
@@ -294,6 +302,9 @@ function(pureboot_add_loader name)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): ${LIBAVR_MCU} has no hardware USART")
|
||||
endif()
|
||||
set(_serial_defines PUREBOOT_USART=${PB_USART})
|
||||
if(PB_HALF_DUPLEX)
|
||||
list(APPEND _serial_defines PUREBOOT_HALF_DUPLEX)
|
||||
endif()
|
||||
set(_link usart${PB_USART})
|
||||
else()
|
||||
if(PB_SERIAL STREQUAL "auto")
|
||||
@@ -303,6 +314,9 @@ function(pureboot_add_loader name)
|
||||
endif()
|
||||
if(_usart)
|
||||
set(_link usart0)
|
||||
if(PB_HALF_DUPLEX)
|
||||
set(_serial_defines PUREBOOT_HALF_DUPLEX)
|
||||
endif()
|
||||
else()
|
||||
set(PB_SERIAL software)
|
||||
endif()
|
||||
@@ -311,6 +325,15 @@ function(pureboot_add_loader name)
|
||||
if(NOT PB_RX)
|
||||
set(PB_RX pb0)
|
||||
endif()
|
||||
if(PB_HALF_DUPLEX)
|
||||
# One-wire: both directions on the RX pin. RX == TX spells
|
||||
# the same deployment directly.
|
||||
if(PB_TX AND NOT PB_TX STREQUAL PB_RX)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): HALF_DUPLEX puts both "
|
||||
"directions on RX (${PB_RX}); TX ${PB_TX} contradicts it")
|
||||
endif()
|
||||
set(PB_TX ${PB_RX})
|
||||
endif()
|
||||
if(NOT PB_TX)
|
||||
set(PB_TX pb1)
|
||||
endif()
|
||||
@@ -334,10 +357,19 @@ function(pureboot_add_loader name)
|
||||
string(REPLACE "SW" "sw" _link ${_link})
|
||||
get_property(_tx0 GLOBAL PROPERTY PUREBOOT_USART0_TX)
|
||||
get_property(_tx1 GLOBAL PROPERTY PUREBOOT_USART1_TX)
|
||||
get_property(_rx0 GLOBAL PROPERTY PUREBOOT_USART0_RX)
|
||||
get_property(_rx1 GLOBAL PROPERTY PUREBOOT_USART1_RX)
|
||||
if(_usart AND PB_TX STREQUAL _tx0)
|
||||
set(_link "${_link}@0")
|
||||
elseif(_usart1 AND PB_TX STREQUAL _tx1)
|
||||
set(_link "${_link}@1")
|
||||
elseif(PB_TX STREQUAL PB_RX AND _usart AND PB_RX STREQUAL _rx0)
|
||||
# One-wire on a USART's RXD: RXEN forces that pin's direction
|
||||
# (§20.7.3), so the driven shared pin is held exactly like a
|
||||
# TXD — the harness models the hold either way.
|
||||
set(_link "${_link}@0")
|
||||
elseif(PB_TX STREQUAL PB_RX AND _usart1 AND PB_RX STREQUAL _rx1)
|
||||
set(_link "${_link}@1")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -8,10 +8,10 @@ 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
|
||||
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
|
||||
transfer paths take wire addresses, nothing is flash-resident to address at
|
||||
all, and the application jump is an indirect call to an absolute entry. It does
|
||||
not know which slot it occupies and does not need to. 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. The lint holds it to that literally — the image must
|
||||
@@ -26,31 +26,38 @@ Every axis moves per build — see *Configuration*. The Autobaud column is the
|
||||
worst configuration the space produces for the chip: the clock-free build —
|
||||
it alone carries the calibration machinery — with the `OSCCAL` trim baked
|
||||
and, where the chip has a USART, the link deployed on that USART's own pins,
|
||||
which the loader then has to release (*Pin ownership*). On default pins
|
||||
without the trim the same loaders run 4–10 B smaller.
|
||||
which the loader then has to release (*Pin ownership*). Folding the same
|
||||
build onto a single pin (*One-wire*) measures identically on every chip, so
|
||||
the column covers that twin too. On default pins without the trim the same
|
||||
loaders run 4–10 B smaller.
|
||||
|
||||
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|
||||
|---|---|---|---|---|---|
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 B |
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 372 B | 460 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 376 B | 452 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 376 B | 452 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 376 B | 452 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 350 B | 480 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 352 B | 484 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 352 B | 484 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 366 B | 454 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 376 B | 464 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 372 B | 462 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 390 B | 480 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.
|
||||
† No hardware boot section: the host patches the reset vector, and an image's
|
||||
budget is 510 bytes. The last word of the **lower** slot belongs to the host —
|
||||
it is the trampoline holding the application's relocated reset vector, and it
|
||||
is a staging copy's own last word during a self-update — so an image must fit
|
||||
below it. The resident loader has all 512 bytes of the slot it runs in; 510 is
|
||||
what an image must fit so that a copy of it staged one slot down leaves that
|
||||
word alone.
|
||||
|
||||
The tightest fit in the whole space is therefore the 1284s' 502 of their
|
||||
The tightest fit in the whole space is therefore the 1284s' 480 of their
|
||||
512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
|
||||
commands) on top of everything the column already stacks. The flash bank
|
||||
riding in a transfer's selector byte keeps even those chips' addressing the
|
||||
@@ -75,6 +82,7 @@ repo's build and by a downstream project alike:
|
||||
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
|
||||
| `TIMEOUT <s>` | the activation window | 8 |
|
||||
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
|
||||
| `HALF_DUPLEX` | one-wire: both directions on one line (*One-wire* below) | off |
|
||||
|
||||
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock
|
||||
reaches within 2.5 % — the same U2X-included divisor search libavr's baud
|
||||
@@ -126,6 +134,43 @@ another one and the host's retries eventually catch the pulse. That reads as far
|
||||
more reliable than the same part with an application resident, which gets one
|
||||
window per reset. Measure with an application in place.
|
||||
|
||||
## One-wire
|
||||
|
||||
`HALF_DUPLEX` puts both directions on one line — the deployment for a board
|
||||
with a single spare pin, or a native-UART bootloader's shared-line wiring.
|
||||
Each backend has its shape:
|
||||
|
||||
- **Software and autobaud links** fold onto the RX pin (`RX == TX` spells
|
||||
the same deployment directly). The pin idles as the receiver's pull-up
|
||||
input; each transmitted frame takes the pin's direction and hands it back
|
||||
with the stop bit's level already on the pull-up, so neither flip makes
|
||||
an edge. This costs nothing: the frame's direction wrap is exactly what
|
||||
the dropped second-pin init paid, and the tightest image in the space —
|
||||
the 1284s' autobaud + `OSCCAL` on their USART's RXD — measures the same
|
||||
502 bytes one-wire as two-wire. On a USART's own pin the release applies
|
||||
as ever, RXD included: `RXEN` forces that pin's direction (§20.7.3),
|
||||
which a receive-only link could live with and a driven shared pin cannot.
|
||||
- **The hardware USART** (`SERIAL hardware`/`auto` + `HALF_DUPLEX`) uses
|
||||
libavr's `.half_duplex` turn-around — exactly one direction enabled at a
|
||||
time, each written byte held to transmit-complete before the line can be
|
||||
released — and needs RXD and TXD tied together off-chip. It costs
|
||||
+42…50 B over the stock loader (m8 404, m328P 440, 1284P 460 — all far
|
||||
inside the slot); the activation window is unchanged, its poll merely
|
||||
runs through the release-line test (18 cycles a poll in bit-addressable
|
||||
I/O, 22 in extended — measured, and held per chip by
|
||||
`pureboot.window.halfduplex`).
|
||||
|
||||
Host wiring, for an FTDI-style adapter: **adapter TX through ~1 kΩ to the
|
||||
line, adapter RX and the MCU pin directly on it.** The resistor lets the MCU
|
||||
win the line while it answers; the price is that the adapter reads back every
|
||||
byte it transmits. `pureboot.py --one-wire` consumes that echo byte for byte
|
||||
— a missing echo is reported as the wiring fault it is, and a device reply
|
||||
that lands between the echoes of the knock (a loader already in session
|
||||
re-prompts mid-knock) is held for the reader. The knock is the protocol's
|
||||
one blind multi-byte write, so on real wiring its second byte can be lost to
|
||||
that collision outright; the tool's knock retries absorb it. Everything else
|
||||
is ack-paced and cannot collide.
|
||||
|
||||
A downstream project brings its usual libavr setup (the `libavr` target, the
|
||||
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
|
||||
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
|
||||
@@ -181,17 +226,76 @@ 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.
|
||||
|
||||
### Entering from a running application
|
||||
|
||||
A window opens at a reset and nowhere else, which assumes a reset edge exists —
|
||||
a button, or DTR wired to it. On a board with neither, the way in has to come
|
||||
from the application, by jumping to the loader base.
|
||||
|
||||
Deciding *when* to jump is the application's business and deliberately not
|
||||
pureboot's: a console command, a held pin, a magic byte, an idle timeout — each
|
||||
board's answer differs, and none of them belongs in a 512-byte loader. pureboot
|
||||
offers no knock detector and no consumable header for this. What follows is the
|
||||
handful of non-obvious mechanics.
|
||||
|
||||
Give the linker the base as a symbol rather than casting a literal to a
|
||||
function pointer, so the address is stated once and in the same units the
|
||||
loader is linked at:
|
||||
|
||||
```cmake
|
||||
target_link_options(app PRIVATE "LINKER:--defsym=pureboot_loader=0x7e00")
|
||||
```
|
||||
|
||||
```cpp
|
||||
extern "C" [[noreturn]] void pureboot_loader();
|
||||
|
||||
// ... then, with interrupts off and the watchdog disabled:
|
||||
pureboot_loader();
|
||||
```
|
||||
|
||||
That is the whole jump: an ordinary call to an absolute symbol, which the
|
||||
linker resolves and `-mrelax` shortens where it can — `call 0x7e00` in four
|
||||
bytes, or an `rjmp` on a part small enough for one.
|
||||
|
||||
It is worth saying what *not* to copy here, because pureboot's own hand-over
|
||||
(`run_app()`) looks different: it launders its target through a
|
||||
`[[gnu::noipa]]` indirect call. That is a position-independence measure, and it
|
||||
belongs to the loader alone — the same image runs from either slot, so it must
|
||||
never bake an absolute address. An application is linked at a fixed base and
|
||||
has no such problem; using the indirect form costs two `ldi`s and a helper call
|
||||
to reach the same place a plain call reaches in four bytes.
|
||||
|
||||
Three things must be true before the jump:
|
||||
|
||||
- **Interrupts off and the watchdog disabled.** The loader is polled and
|
||||
vector-less; an ISR landing in it vectors into the application's table.
|
||||
- **WDRF clear.** pureboot hands straight back to the application on a watchdog
|
||||
reset (above), and it only *peeks* MCUSR — so a jump arriving with WDRF still
|
||||
set opens no window at all.
|
||||
- **Release any peripheral holding the link.** A loader entered by a jump
|
||||
inherits the application's registers rather than reset values: with `TXEN0`
|
||||
still set the USART owns TxD, and a bit-banging loader then receives
|
||||
perfectly and answers into a pin it does not control. Clearing `UCSR0B`
|
||||
before jumping is the whole fix, and the symptom without it is a loader that
|
||||
is mute rather than deaf, which reads as a dead board.
|
||||
|
||||
## Session
|
||||
|
||||
After the knock the loader stays in its command loop until `J` jumps away or
|
||||
the chip resets. Before reading each command it waits for any pending EEPROM
|
||||
After the knock the loader stays in its command loop until a jump takes it away
|
||||
or the chip resets. Before reading each command it waits for any pending EEPROM
|
||||
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.
|
||||
verdict, then its reply, repeat.
|
||||
|
||||
There is no invalid opcode: every byte begins a command, and it is the seal
|
||||
rather than a table of known letters that rejects noise. The knock is the one
|
||||
thing that must survive being sent into a loader already in session, which is
|
||||
why both its bytes — `p` and `b` — carry the identify bit: they answer the
|
||||
identity and consume nothing else.
|
||||
|
||||
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: its address is a word address, because that is what the
|
||||
the command's selector byte, so no command has to speak word addresses. The jump
|
||||
is the exception: its address is a word address, because that is what the
|
||||
hardware's own jump takes — it still carries a selector byte (reserved,
|
||||
ignored) so its decode is the same three reads as every other command's.
|
||||
EEPROM and data-space addresses and all counts are bytes.
|
||||
@@ -203,22 +307,38 @@ 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` | — | 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` | sel8 (reserved), word address (16-bit) | `+`, then execution continues there |
|
||||
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
|
||||
Every command but the identity has **one header shape** — six bytes, the last
|
||||
of them a seal over the other five:
|
||||
|
||||
`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.
|
||||
```text
|
||||
op8 sel8 addr_lo8 addr_hi8 n8 seal8
|
||||
seal = op ^ sel ^ addr_lo ^ addr_hi ^ n ^ 0x5A
|
||||
```
|
||||
|
||||
The loader folds those fields and compares before it decodes what the command
|
||||
is, then **answers the seal**: `+` accepts, `0xD4` refuses and nothing has
|
||||
happened. The verdict comes before any payload, which is what keeps a refusal
|
||||
local — a fill or a write burst sends its data only once the header is in, so
|
||||
a rejected header never leaves the host pushing bytes into a loader that has
|
||||
gone back to reading commands.
|
||||
|
||||
The opcode is bits, not a letter. A transfer is the absence of the other three,
|
||||
and its direction is the low bit.
|
||||
|
||||
| Opcode | | Arguments after the header | Reply |
|
||||
|---|---|---|---|
|
||||
| 0x00 | read | none | verdict, then n bytes from the selected space (n = 0 means 256), then `+` |
|
||||
| 0x01 | write | n data bytes | verdict, then `+` per byte once its write has begun, then `+` |
|
||||
| 0x04 | fill | one page of data | verdict, then `+` when the page is in |
|
||||
| 0x08 | jump | none | verdict, then execution continues at the word address |
|
||||
| 0x20 | identify | *unsealed, one byte on its own* | 4 bytes: the version, then the three signature bytes, then `+` |
|
||||
|
||||
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 |
|
||||
| 0 | flash | read-only here; it is written through the fill and the SPM command |
|
||||
| 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 |
|
||||
@@ -227,18 +347,26 @@ nibble carries the flash bank.
|
||||
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,
|
||||
stack — which makes a read 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.
|
||||
Programming a page is therefore a fill to load the buffer, then an SPM command
|
||||
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.
|
||||
|
||||
An SPM command has **no data phase**: its SPMCSR byte rides the header's count
|
||||
field, where the seal covers it. That is the whole reason the field is
|
||||
overloaded — a byte arriving behind the header would arrive after the seal had
|
||||
been checked, and the one command that cannot be taken back is exactly the one
|
||||
that must not be decided by an unchecked byte. Setting the lock bits is
|
||||
therefore an ordinary sealed command (`0x09`) rather than something the loader
|
||||
refuses: deliberate is expressible, accidental is not reachable.
|
||||
|
||||
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
|
||||
@@ -246,11 +374,21 @@ 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.
|
||||
Nothing refuses an address, the loader's own slot included. Through pureboot 8
|
||||
a running-slot write was dropped; the seal replaced that guard, because what
|
||||
the guard defended against was a wire fault naming an address, and a wire fault
|
||||
can no longer name one. What it costs is that a host bug aimed at the running
|
||||
slot now lands. What it buys is that a resident copy can write its own slot —
|
||||
which is the only route a self-update has on a chip whose boot section *is* the
|
||||
512-byte slot, where no staged copy can run SPM at all: the resident plants a
|
||||
primitive in its own spare space and an application-side installer drives it.
|
||||
A copy that erases the page it is executing from does not come back, so which
|
||||
page matters; erasing any other page of its own slot it survives. That needs a
|
||||
page the image does not reach into, which the stock builds have and the biggest
|
||||
do not: a 378 B loader on a 128-byte-page mega leaves 384..511 entirely free,
|
||||
while the 480 B autobaud build reaches into it and has none.
|
||||
|
||||
The loader never clears the SPM buffer before a fill, so **one `W` may program
|
||||
The loader never clears the SPM buffer before a fill, so **one fill 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
|
||||
@@ -260,15 +398,15 @@ 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).
|
||||
|
||||
`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse
|
||||
A write 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 jump is the one control-transfer primitive: it runs the application (word 0 or
|
||||
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.
|
||||
|
||||
`b` answers with the loader's identity — its version and the chip's signature —
|
||||
Identify 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
|
||||
@@ -283,7 +421,7 @@ to install a mismatch against.
|
||||
|
||||
## Version
|
||||
|
||||
`b`'s first byte is the **pureboot version** — the loader's one identity
|
||||
The identity'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
|
||||
@@ -302,7 +440,19 @@ moves `J` onto the unified decode — it gains the selector byte the table
|
||||
shows, which older loaders do not read, so the tool sends each form to the
|
||||
version that speaks it — and re-homes the autobaud unit into the GPIOR pair
|
||||
on the chips that have one (Session: what must not be written), which is
|
||||
where `--info`'s measured clock now reads it on those parts.
|
||||
where `--info`'s measured clock now reads it on those parts. **8** changes
|
||||
nothing on the wire either: it marks the builds whose deployment may be
|
||||
one-wire (*One-wire* above) — the hardware USART's half-duplex turn-around,
|
||||
or a software link folded onto a single pin. The host-side trace is
|
||||
`--one-wire`, the echo discard a shared line requires of any tool driving
|
||||
it. **9** is the third wire change and the largest: bit opcodes in place of
|
||||
the letters, one sealed header shape for every command, and a verdict on that
|
||||
seal before the command runs (*Session* above). It also drops the
|
||||
running-slot write guard, which the seal makes redundant and which was the
|
||||
only thing standing between a resident copy and its own slot. Identify is
|
||||
answered by both knock bytes so version discovery works before the version is
|
||||
known, which is what keeps a deployed pureboot 8 drivable and self-updatable
|
||||
to 9.
|
||||
|
||||
Every closed generation is tagged in this repo at its era's last commit — the
|
||||
commit just before the next version bump, so a tag holds everything its
|
||||
@@ -345,7 +495,7 @@ ATmega328P profiles (addresses for its 32 KiB):
|
||||
| BOOTSZ | BOOTRST | Behavior |
|
||||
|---|---|---|
|
||||
| 256 words (512 B) | programmed | *Standalone*: reset always enters the loader; **self-update impossible** (the staging slot lies outside the boot section, where SPM is disabled). |
|
||||
| 512 words (1 KB) | unprogrammed | *Self-update, app-first*: reset always boots the application, which owns all 31.5 KB and must offer its own jump to 0x7e00 to reach the loader (a virgin chip reaches it by reset across erased flash). Updates are power-fail-safe except mid-rewrite of the resident slot itself (no reset path leads to the staging copy then). |
|
||||
| 512 words (1 KB) | unprogrammed | *Self-update, app-first*: reset always boots the application, which owns all 31.5 KB and must offer its own jump to 0x7e00 to reach the loader (a virgin chip reaches it by reset across erased flash; *Entering from a running application* under Activation is how that jump is written). Updates are power-fail-safe except mid-rewrite of the resident slot itself (no reset path leads to the staging copy then). |
|
||||
| 512 words (1 KB) | programmed | *Self-update, loader-first*: reset lands at 0x7c00 — the staging slot, normally erased, so execution walks up into the loader; during an update it is the staging copy itself, so a mid-rewrite power loss recovers by reset. The loss windows move to the staging install/retire page writes instead (page-write scale). The host keeps `[0x7c00, 0x7e00)` clear of application data (`--force` overrides). |
|
||||
|
||||
Applications are flashed unmodified here — word 0 stays the application's own
|
||||
@@ -419,7 +569,7 @@ are interchangeable — as the silicon is.
|
||||
the host composes the slot's last word as a jump to the resident base, so
|
||||
even an abandoned staging copy times out into a loader. A loader already
|
||||
sitting whole in the staging slot is left as the staging copy instead —
|
||||
rewriting it would only meet its own running-slot guard.
|
||||
rewriting it in place would be a copy overwriting itself as it runs.
|
||||
3. `J` enters the staging copy, which rewrites the resident slot. Where a
|
||||
patched reset vector routes through the resident, the host first re-aims
|
||||
word 0 at the staging copy, so a power loss mid-rewrite still resets into a
|
||||
@@ -457,7 +607,7 @@ 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).
|
||||
wrong is rewritten up to three times before the run stops (see the fill above).
|
||||
`--verify-flash` only reports. Images are raw binary, or Intel HEX by
|
||||
extension. `--force` overrides the refusable safety checks — today, flashing
|
||||
application data into a mega's reset walk region.
|
||||
@@ -469,6 +619,11 @@ the loader's bit-period unit, decoded and multiplied by the session rate —
|
||||
which is the number an `OSCCAL` bake or a fixed-baud build for the part is
|
||||
held against; `--clock <hz>` states the drift against a nominal.
|
||||
|
||||
`--one-wire` marks the link as a shared line (*One-wire* above): the tool
|
||||
reads back and verifies its own echoed bytes, whatever the backend.
|
||||
It combines with everything, `--scan` included — undiscarded echoes would
|
||||
answer every rate a scan probes.
|
||||
|
||||
`--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
|
||||
±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
|
||||
window — reset the target as each probe announces itself (a board with DTR
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// reset-vector surgery, self-update) lives in the host tool. Protocol,
|
||||
// deployment and configuration: README.md next to this file.
|
||||
//
|
||||
// The image is position-independent — PC-relative control flow, wire
|
||||
// addresses in, the write guard and the info block both anchored on the
|
||||
// runtime return address — so the identical binary runs from any slot. That
|
||||
// is what makes a copy one slot below able to rewrite the resident one, and
|
||||
// every change here has to keep it (test/check_pi.py).
|
||||
// The image is position-independent — PC-relative control flow and wire
|
||||
// addresses in, no absolute address formed anywhere — so the identical binary
|
||||
// runs from any slot. That is what makes a copy one slot below able to rewrite
|
||||
// the resident one, and every change here has to keep it (test/check_pi.py).
|
||||
// It does not need to know *which* slot it is in: nothing here refuses an
|
||||
// address, so there is no running-slot comparison to anchor.
|
||||
|
||||
#include <chrono>
|
||||
|
||||
@@ -26,6 +27,37 @@ constexpr auto off = avr::irq::guard_policy::unused;
|
||||
|
||||
constexpr std::uint8_t ack = '+';
|
||||
|
||||
// The refusal, which is the ack inverted: on a link whose whole problem is
|
||||
// flipped bits, the byte saying "nothing happened" should be as far as a byte
|
||||
// can be from the one saying "it did", and the complement is all eight bits.
|
||||
// It is also the only spelling that needs no justifying — every other value
|
||||
// would be a choice.
|
||||
constexpr std::uint8_t nak = static_cast<std::uint8_t>(~ack);
|
||||
|
||||
// What a command's fields must fold to. Any non-zero constant does: zero is
|
||||
// what a run of one repeated byte folds to, and a repeated byte is the shape of
|
||||
// both a line stuck at a level and a page of erased flash arriving where a
|
||||
// header belongs.
|
||||
constexpr std::uint8_t seal = 0x5a;
|
||||
|
||||
// The opcode, as bits rather than letters. Each is a one-instruction skip,
|
||||
// where a set of arbitrary values costs a compare and a branch apiece — and
|
||||
// with the seal deciding what is a command at all, there is nothing left for a
|
||||
// readable spelling to buy. A transfer is the absence of the other three, and
|
||||
// its direction is the low bit.
|
||||
//
|
||||
// Identify is bit 5 for one reason: 'p' and 'b' both carry it, and those are
|
||||
// the knock. Two things follow that no other assignment gives. A host cannot
|
||||
// know which generation it is talking to until something has answered, so the
|
||||
// command reporting the version has to mean the same thing before the version
|
||||
// is known — 'b' still asks it. And a knock aimed at a loader that is
|
||||
// *already* in session has to stay harmless: with no opcode reserved as
|
||||
// invalid, every byte now starts a command, so a knock that meant nothing to
|
||||
// earlier generations would otherwise consume the five header bytes behind it
|
||||
// and put the stream out of step. Answering both knock bytes with the identity
|
||||
// keeps the reconnect exactly as cheap as it was.
|
||||
enum : std::uint8_t { op_write = 1, op_fill = 4, op_jump = 8, op_identify = 0x20 };
|
||||
|
||||
// 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. An autobaud
|
||||
@@ -43,8 +75,8 @@ constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
|
||||
// The loader owns the top 512 bytes; a staging copy goes in the slot below.
|
||||
// Chips without a hardware boot section — the tinies and the m48s, whose SPM
|
||||
// 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;
|
||||
// reset vector in the word under the slot. The size itself is the linker's and
|
||||
// the host's business: nothing in here needs to know where the slot ends.
|
||||
constexpr std::uint16_t page = spm::page_bytes;
|
||||
constexpr bool boot_section = avr::hw::curated::has_boot_section();
|
||||
|
||||
@@ -77,7 +109,7 @@ static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL
|
||||
|
||||
// The loader's one identity number. The protocol carries none of its own —
|
||||
// a version implies it, and the host tool holds that map (README.md).
|
||||
constexpr std::uint8_t version = 7;
|
||||
constexpr std::uint8_t version = 9;
|
||||
|
||||
// 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
|
||||
@@ -105,12 +137,18 @@ constexpr std::uint8_t stamp_identity = 2;
|
||||
// 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
|
||||
// spm_ops is the one that is not memory: naming it hands the count field 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.
|
||||
//
|
||||
// It is the one space with no direction: the opcode's write bit is not
|
||||
// consulted, because a sealed command naming this space says what it means and
|
||||
// there is nothing for the other direction to denote. Testing the bit anyway
|
||||
// would cost six bytes to catch a host contradicting itself, which is the same
|
||||
// trade the running-slot guard lost.
|
||||
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
|
||||
@@ -128,22 +166,6 @@ enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_
|
||||
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
|
||||
@@ -157,6 +179,9 @@ constexpr std::uint8_t bank_shift = 16 - slot_shift;
|
||||
#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART)
|
||||
#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART"
|
||||
#endif
|
||||
#if defined(PUREBOOT_HALF_DUPLEX) && (defined(PUREBOOT_SOFT_SERIAL) || defined(PUREBOOT_AUTOBAUD))
|
||||
#error "PUREBOOT_HALF_DUPLEX is the hardware USART's one-wire mode; a software link goes one-wire by RX == TX"
|
||||
#endif
|
||||
#if !defined(PUREBOOT_RX)
|
||||
#define PUREBOOT_RX pb0
|
||||
#endif
|
||||
@@ -169,22 +194,42 @@ constexpr int usart_unit = PUREBOOT_USART;
|
||||
constexpr int usart_unit = 0;
|
||||
#endif
|
||||
|
||||
// One-wire on the hardware USART (PUREBOOT_HALF_DUPLEX): RXD and TXD tied
|
||||
// together off-chip, exactly one direction enabled at a time — the library's
|
||||
// .half_duplex turn-around. The activation window is unchanged; only its
|
||||
// poll grows the release-line test rx_ready() carries in this mode.
|
||||
constexpr bool hw_half_duplex =
|
||||
#if defined(PUREBOOT_HALF_DUPLEX)
|
||||
true;
|
||||
#else
|
||||
false;
|
||||
#endif
|
||||
|
||||
template <avr::hertz_t C, avr::baud_t B>
|
||||
struct hardware_link {
|
||||
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
|
||||
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct, .half_duplex = hw_half_duplex}>;
|
||||
|
||||
// The compiled idle poll around the window's narrow (uint24_t) countdown:
|
||||
// the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
|
||||
// status register's home — a 2-cycle bit-skip where UCSRnA sits in
|
||||
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
|
||||
// I/O. A uint32_t countdown pays one more sbci — window_polls() adds it
|
||||
// where the count forces the wide type. Held by the pureboot.window gate.
|
||||
// The lookup rides the baud parameter so it stays dependent: the trait is
|
||||
// an incomplete type on the USART-less chips, which parse this template
|
||||
// without ever instantiating it.
|
||||
// I/O. Half-duplex polls through rx_ready()'s release-line test, which
|
||||
// -Os outlines: the rcall (3), the UCSR#B read and not-taken skip with
|
||||
// the jump over the write (I/O 3, extended 5), the ret (4) — and the
|
||||
// call in the loop body pushes the countdown into call-saved registers,
|
||||
// where the uint24_t step is ldi+sub+sbc+sbc (4) instead of sbiw+sbci
|
||||
// (3). Measured off the built loops: 18 a poll in bit-addressable I/O,
|
||||
// 22 in extended. A uint32_t countdown pays one more sbci —
|
||||
// window_polls() adds it where the count forces the wide type. Held per
|
||||
// chip by the pureboot.window gates. The lookup rides the baud parameter
|
||||
// so it stays dependent: the trait is an incomplete type on the
|
||||
// USART-less chips, which parse this template without ever instantiating
|
||||
// it.
|
||||
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
|
||||
static consteval std::uint8_t poll_cost()
|
||||
{
|
||||
if (hw_half_duplex)
|
||||
return U::ucsra::addr < 0x40 ? 18 : 22;
|
||||
return U::ucsra::addr < 0x40 ? 7 : 9;
|
||||
}
|
||||
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
|
||||
@@ -220,8 +265,11 @@ struct hardware_link {
|
||||
|
||||
template <avr::hertz_t C, avr::baud_t B>
|
||||
struct software_link {
|
||||
// RX == TX is the one-wire deployment: the transmitter becomes a guest
|
||||
// on the receiver's pull-up line, taking the pin's direction for exactly
|
||||
// one frame per byte.
|
||||
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B, avr::PUREBOOT_RX == avr::PUREBOOT_TX>;
|
||||
|
||||
// The compiled idle poll around the window's narrow (uint24_t) countdown:
|
||||
// sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
|
||||
@@ -448,37 +496,42 @@ void await_host()
|
||||
}
|
||||
|
||||
// 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)
|
||||
// page at a time through 'W' and is committed by the sealed SPM command — 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::uint16_t at, std::uint8_t value)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// The irreversible half of the protocol, and the whole of it: page erase, page
|
||||
// write and the lock bits are one SPM command each, and nothing else the loader
|
||||
// does outlasts being done again. Reached only from a sealed command (run()),
|
||||
// so both the byte handed to SPMCSR and the address it fires at are the ones
|
||||
// the host computed its seal over.
|
||||
//
|
||||
// Nothing here refuses an address. A loader that will not write its own slot
|
||||
// cannot plant anything in it either, and a resident copy able to rewrite its
|
||||
// own trailing page is what lets a 512-byte boot section — where no staging
|
||||
// copy can run SPM at all — carry an SPM primitive for an application-side
|
||||
// installer to drive. The protection that made the guard look necessary is the
|
||||
// seal: a wire fault can no longer name an address, only a host can, and a host
|
||||
// that names this one means it.
|
||||
[[gnu::always_inline]] inline void commit(std::uint8_t bank, std::uint16_t at, std::uint8_t value)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -515,13 +568,6 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
run_app();
|
||||
|
||||
link::init();
|
||||
|
||||
// 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 slot_high = avr::startup::caller_page();
|
||||
|
||||
await_host();
|
||||
|
||||
for (;;) {
|
||||
@@ -530,54 +576,99 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
ee::wait();
|
||||
tx_ack();
|
||||
const std::uint8_t command = link::rx();
|
||||
switch (command) {
|
||||
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.
|
||||
if (command & op_identify) {
|
||||
// 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. Unsealed, because it
|
||||
// takes no argument and changes nothing — and because a command
|
||||
// that cannot be got wrong is what a lost host resynchronises on.
|
||||
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
|
||||
link::tx(identity_stamp[at]);
|
||||
break;
|
||||
case 'J': // jump: sel8 (reserved), addr16 as a wire word address
|
||||
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
|
||||
case 'G': // read: sel8, addr16, n8 (0 = 256)
|
||||
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
|
||||
} else {
|
||||
// One decode, one cursor and one loop for every space, both
|
||||
// directions and the jump: a command per memory would carry a copy
|
||||
// of all three each. 'J' — the hand-over and staging transfer —
|
||||
// carries a selector it ignores so its address rides the same two
|
||||
// reads as everything else; 'W' joins the same decode rather than
|
||||
// keeping an address form of its own, so flash addressing is
|
||||
// uniform across every command that names it.
|
||||
// of all three each. The jump — the hand-over and staging transfer
|
||||
// — carries a selector it ignores so its address rides the same two
|
||||
// reads as everything else; the page fill joins the same decode
|
||||
// rather than keeping an address form of its own, so flash
|
||||
// addressing is uniform across every command that names it. Both
|
||||
// carry the count they do not use for the same reason: one header
|
||||
// shape is one decode, and one seal covers a fixed set of bytes.
|
||||
const std::uint8_t selector = link::rx();
|
||||
const std::uint8_t space = space_of(selector);
|
||||
const std::uint8_t bank = bank_of(selector);
|
||||
std::uint16_t at = rx16();
|
||||
if (command == 'J') {
|
||||
tx_ack();
|
||||
link::drain();
|
||||
jump(reinterpret_cast<void (*)()>(at));
|
||||
}
|
||||
if (command == 'W') {
|
||||
fill_page(bank, at);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
default: // unknown bytes are ignored; the loop re-acks
|
||||
break;
|
||||
const std::uint8_t sealed = link::rx();
|
||||
|
||||
// The seal: every field that decides what this command does folded
|
||||
// into one byte the host chose, tested before any of it happens.
|
||||
//
|
||||
// Checked here rather than acknowledged afterwards, which is the
|
||||
// whole point. An ack reports a command that has already run, and
|
||||
// for the one command that cannot be taken back a report is not a
|
||||
// defence. Once the running-slot guard is gone the address is as
|
||||
// fatal as the command byte — a wrong one reaches the loader's own
|
||||
// page — so the seal covers the act and the place together, and a
|
||||
// stream that lost or mangled either cannot produce it.
|
||||
//
|
||||
// Folded here, after the last read, and never accumulated across
|
||||
// the reads: every field is still live at this point because the
|
||||
// command needs it anyway, so the fold costs one xor each and no
|
||||
// register. An accumulator would have to survive four calls, and
|
||||
// paying for that in call-saved registers costs more than the whole
|
||||
// check costs in arithmetic — measured at fourteen bytes, on a
|
||||
// budget of ten.
|
||||
std::uint8_t fold = command;
|
||||
fold ^= selector;
|
||||
fold ^= static_cast<std::uint8_t>(at);
|
||||
fold ^= static_cast<std::uint8_t>(at >> 8);
|
||||
fold ^= count;
|
||||
fold ^= sealed;
|
||||
// The verdict, and it is not a courtesy. Every command whose
|
||||
// payload the host sends without waiting — a page fill, a write
|
||||
// burst — would otherwise be handed to a loader that has already
|
||||
// gone back to reading commands, so a *detected* error would
|
||||
// become the desync the seal exists to prevent: a 128-byte page
|
||||
// read as command headers is twenty-one more chances at the one in
|
||||
// two hundred and fifty-six. Answering the seal before the payload
|
||||
// is what keeps a refusal local to the command that earned it.
|
||||
//
|
||||
// An unknown opcode lands here too — every bit pattern is now some
|
||||
// command, so it is the seal, not a table of valid letters, that
|
||||
// rejects noise, and the host hears about it either way.
|
||||
if (fold != seal) {
|
||||
link::tx(nak);
|
||||
} else {
|
||||
tx_ack();
|
||||
if (command & op_jump) {
|
||||
link::drain();
|
||||
jump(reinterpret_cast<void (*)()>(at));
|
||||
} else if (command & op_fill) {
|
||||
fill_page(bank, at);
|
||||
} else if (space == sp_spm) {
|
||||
// An SPM command is the whole of what this loader can do
|
||||
// that doing again will not undo, and it is one byte — so
|
||||
// it rides the count field, inside the seal, rather than
|
||||
// arriving as data after the seal has been checked. Which
|
||||
// is also what makes a deliberate lock-bit write
|
||||
// expressible, where refusing it outright did not.
|
||||
commit(bank, at, count);
|
||||
} else {
|
||||
do {
|
||||
// Direction is one bit of the opcode, so the loop
|
||||
// picks it with a one-word skip.
|
||||
if (command & op_write) {
|
||||
store(space, at, link::rx());
|
||||
tx_ack();
|
||||
} else
|
||||
link::tx(load(space, bank, at));
|
||||
++at;
|
||||
} while (--count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,20 @@ else:
|
||||
import termios
|
||||
|
||||
PROMPT = b"+"
|
||||
VERSION = 8 # this tool's own version — free to drift from a loader's
|
||||
# The refusal, from pureboot 9: the prompt inverted, so no single flipped bit
|
||||
# turns "nothing happened" into "it did".
|
||||
NAK = bytes((~PROMPT[0] & 0xFF,))
|
||||
VERSION = 10 # this tool's own version — free to drift from a loader's
|
||||
# The loader versions this tool can drive. A pureboot version implies its wire
|
||||
# protocol, which carries no number of its own, so this window is where that
|
||||
# map lives: the tool keeps a decoder for every generation in it (1–4 speak
|
||||
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
|
||||
# builds and changes nothing on the wire), and a version it has no decoder
|
||||
# for moves the floor.
|
||||
# builds and changes nothing on the wire; 8 the one-wire deployments, whose
|
||||
# only host-side trace is the --one-wire echo discard; 9 seals every command
|
||||
# and answers each seal before acting), and a version it has no decoder for
|
||||
# moves the floor.
|
||||
OLDEST_LOADER = 1
|
||||
NEWEST_LOADER = 7
|
||||
NEWEST_LOADER = 9
|
||||
SLOT = 512 # the loader slot, on every chip
|
||||
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
|
||||
@@ -45,10 +50,37 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
# 6 marks the builds that may carry a baked OSCCAL trim, nothing on the wire;
|
||||
# 7 gives 'J' a selector byte (older loaders take the bare address — jump()
|
||||
# sends each form to the version that speaks it) and re-homes the autobaud
|
||||
# unit into the GPIOR pair where the chip has one.
|
||||
# unit into the GPIOR pair where the chip has one; 8 marks the builds whose
|
||||
# deployment may be one-wire (hardware half-duplex, or a software link folded
|
||||
# onto one pin) — nothing on the wire either, but a shared line makes the
|
||||
# host read its own bytes back, which is what --one-wire consumes.
|
||||
UNIFIED_LOADER = 5
|
||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||
|
||||
# pureboot 9 replaces the command letters with bits and seals every command.
|
||||
# The header is one shape for all of them — opcode, selector, address, count,
|
||||
# seal — and the loader answers the seal *before* it acts: PROMPT accepts, NAK
|
||||
# refuses and nothing happened. That verdict is what lets a refusal stay local
|
||||
# to its own command: the payload of a fill or a write burst only goes out
|
||||
# after the header has been accepted, so a rejected header never leaves the
|
||||
# host pushing bytes into a loader that has gone back to reading commands.
|
||||
#
|
||||
# A transfer is the absence of the other three opcodes, and its direction is
|
||||
# the low bit. Identify is bit 5 because both knock bytes carry it: 'b' has to
|
||||
# still ask the version (the host cannot know which generation it is talking to
|
||||
# until something answers), and 'p' has to stay harmless against a loader
|
||||
# already in session — pureboot 9 reserves no invalid opcode, so a knock that
|
||||
# meant nothing before would otherwise eat the five bytes behind it.
|
||||
SEALED_LOADER = 9
|
||||
OP_WRITE, OP_FILL, OP_JUMP, OP_IDENTIFY = 1, 4, 8, 0x20
|
||||
# What a sealed header's fields must fold to. Non-zero, so a run of one
|
||||
# repeated byte — a stuck line, a page of erased flash read as a header —
|
||||
# cannot satisfy it.
|
||||
SEAL = 0x5A
|
||||
# An SPM command carries its SPMCSR byte in the count field, where the seal
|
||||
# covers it. There is no data phase: a byte after the header would arrive after
|
||||
# the seal had already been checked, which is the hole the seal exists to close.
|
||||
|
||||
# An autobaud loader keeps its measured bit period readable, encoded as
|
||||
# delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
|
||||
# floored — the spin granule and per-bit overhead of libavr's software UART.
|
||||
@@ -433,6 +465,99 @@ if os.name == "nt":
|
||||
Port = WindowsPort if os.name == "nt" else PosixPort
|
||||
|
||||
|
||||
class OneWirePort:
|
||||
"""The host side of a shared line (--one-wire): an FTDI-style adapter on
|
||||
a one-wire link reads back every byte it transmits — its RX is tied to
|
||||
its own TX through the line. Consume that echo at each write and verify
|
||||
it, which doubles as a wiring check: an echo that never comes is an RX
|
||||
not on the line, and is reported as itself instead of decoding as a
|
||||
device reply.
|
||||
|
||||
The device's reply may interleave with the echo of a multi-byte write —
|
||||
a loader already in session re-prompts after the knock's first byte
|
||||
while the second is still queued behind that reply — so the echo is
|
||||
matched byte for byte and anything else arriving in between is device
|
||||
traffic, held for the next read."""
|
||||
|
||||
def __init__(self, port):
|
||||
self._port = port
|
||||
self._pending = b""
|
||||
self.lost_echoes = 0
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._port, name)
|
||||
|
||||
def write(self, data, blind=False):
|
||||
"""Put `data` on the line and consume its echo.
|
||||
|
||||
`blind` marks the protocol's one multi-byte write with no ack between
|
||||
its bytes — the knock. Aimed at a loader already in session, its first
|
||||
byte draws a prompt while the second is still going out, and on real
|
||||
wiring the device's push-pull ack **wins the line** against the host's
|
||||
1 k series resistor: that second byte is *destroyed, not delayed*, and
|
||||
its echo never comes. Measured on an ATtiny13A at 57600 — the loader
|
||||
answers a single byte perfectly and loses the knock's second every
|
||||
time. So on a blind write a missing echo is a property of the wiring
|
||||
rather than a fault in it, and the caller's retry is what deals with
|
||||
it. Every other write is ack-paced and cannot collide, so a missing
|
||||
echo there really is an RX that is not on the line.
|
||||
"""
|
||||
data = bytes(data)
|
||||
self._port.write(data)
|
||||
# The echo arrives at line rate — 10 bits a byte — plus adapter
|
||||
# latency; a generous floor keeps slow rates and USB scheduling out
|
||||
# of the error path.
|
||||
deadline = time.monotonic() + 10 * len(data) / self._port.baud + 0.5
|
||||
remaining = data
|
||||
while remaining and time.monotonic() < deadline:
|
||||
# Speculative, so it cannot be read_exact, whose contract is to
|
||||
# raise: doing that made the diagnosis below unreachable on every
|
||||
# quiet line and surfaced a bare "timeout: got 0 of 1 bytes" in
|
||||
# its place — the one message this class exists to replace.
|
||||
for byte in self._port.read_available(0.02):
|
||||
if remaining and byte == remaining[0]:
|
||||
remaining = remaining[1:]
|
||||
else:
|
||||
self._pending += bytes((byte,))
|
||||
if not remaining:
|
||||
return
|
||||
if not blind:
|
||||
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
|
||||
f"{len(data)} byte(s) — is the adapter's RX tied to the line?")
|
||||
self.lost_echoes += len(remaining)
|
||||
# Which loss this is matters, and the count says it. *Some* bytes lost is
|
||||
# the device's ack winning the line against the host's series resistor —
|
||||
# ordinary, and what the retry absorbs. *Every* byte lost is nothing
|
||||
# coming back at all, which is a line that is not free: an application
|
||||
# holding the shared pin low (this rig's LED demo ends that way), a
|
||||
# wedge, or an RX that is not on the line. Same retry either way, but
|
||||
# blaming an ack that never happened sends the reader to the wrong place.
|
||||
if len(remaining) == len(data):
|
||||
verbose(f"one-wire: none of {len(data)} byte(s) echoed — the line is not "
|
||||
f"coming back. Held low by something? (a pin driven low, a wedge, "
|
||||
f"or an RX not on the line)")
|
||||
else:
|
||||
verbose(f"one-wire: {len(remaining)} of {len(data)} knock byte(s) lost to the "
|
||||
f"device's ack; retrying")
|
||||
|
||||
def write_blind(self, data):
|
||||
self.write(data, blind=True)
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
taken, self._pending = self._pending[:count], self._pending[count:]
|
||||
if len(taken) == count:
|
||||
return taken
|
||||
return taken + self._port.read_exact(count - len(taken), timeout)
|
||||
|
||||
def read_available(self, wait):
|
||||
taken, self._pending = self._pending, b""
|
||||
return taken + self._port.read_available(0 if taken else wait)
|
||||
|
||||
def flush_input(self):
|
||||
self._pending = b""
|
||||
self._port.flush_input()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- protocol ---
|
||||
|
||||
|
||||
@@ -451,8 +576,8 @@ class Info:
|
||||
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."""
|
||||
that send a block do. Which slot a copy runs in is not something the
|
||||
identity reports, and from pureboot 9 nothing on the device cares."""
|
||||
if len(raw) != 4:
|
||||
raise Error(f"bad identity reply: {raw.hex()}")
|
||||
version, signature = raw[0], tuple(raw[1:4])
|
||||
@@ -572,6 +697,18 @@ class Loader:
|
||||
return Info(head + self.port.read_exact(8, 0.5))
|
||||
return Info.from_identity(head)
|
||||
|
||||
def identity(self):
|
||||
"""Ask a live session who it is.
|
||||
|
||||
The identity command takes no argument and changes nothing, which makes
|
||||
it the one question whose answer is known in advance — so it doubles as
|
||||
the host's check that the stream is still in step, and as the byte a
|
||||
lost host resynchronises on."""
|
||||
self.port.write(b"b")
|
||||
answer = self._read_identity()
|
||||
self._expect_prompt()
|
||||
return answer
|
||||
|
||||
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
|
||||
@@ -599,9 +736,16 @@ class Loader:
|
||||
break
|
||||
knocks = 0
|
||||
refusal = None
|
||||
# The knock is the only write in the protocol with no ack between its
|
||||
# bytes, so on a shared line it is the only one whose echo may
|
||||
# legitimately not come back — the device's ack collides with it and
|
||||
# wins (OneWirePort.write). Losing a byte here is what the retry below
|
||||
# is for; raising instead aborted the loop before it ever ran, which on
|
||||
# real wiring made every reconnect into a live session fail.
|
||||
knock_out = getattr(self.port, "write_blind", self.port.write)
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(knock)
|
||||
knock_out(knock)
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
# Settle: absorb a real loader's trailing bytes before asking
|
||||
@@ -654,6 +798,32 @@ class Loader:
|
||||
if byte != PROMPT:
|
||||
raise Error(f"expected prompt, got {byte.hex()}")
|
||||
|
||||
@property
|
||||
def sealed(self):
|
||||
"""pureboot 9 and later: bit opcodes, a sealed header, and a verdict
|
||||
on that seal before the command runs."""
|
||||
return self.info is not None and self.info.version >= SEALED_LOADER
|
||||
|
||||
def _header(self, op, space, address, count, timeout=2.0):
|
||||
"""Send a sealed header and take the loader's verdict on it.
|
||||
|
||||
Returning normally means the loader has accepted the command and not
|
||||
yet done it — which is the whole point of the verdict, and why the
|
||||
payload of a fill or a write burst is sent only after this returns."""
|
||||
head = bytes((op, selector(space, address), address & 0xFF, (address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
self.port.write(head + bytes((seal,)))
|
||||
answer = self.port.read_exact(1, timeout)
|
||||
if answer == NAK:
|
||||
raise Error(
|
||||
f"the loader refused the command (opcode {op:#04x}, {address:#06x}): the "
|
||||
f"seal did not match, so nothing was done — the link mangled the header"
|
||||
)
|
||||
if answer != PROMPT:
|
||||
raise Error(f"expected a verdict on the seal, got {answer.hex()}")
|
||||
|
||||
def _command(self, tx, reply_len=0, timeout=2.0):
|
||||
self.port.write(tx)
|
||||
reply = self.port.read_exact(reply_len, timeout) if reply_len else b""
|
||||
@@ -681,9 +851,14 @@ class Loader:
|
||||
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)
|
||||
if self.sealed:
|
||||
self._header(0, space, address, chunk, 5.0)
|
||||
data += self.port.read_exact(chunk, 5.0)
|
||||
self._expect_prompt(5.0)
|
||||
else:
|
||||
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
|
||||
@@ -695,9 +870,11 @@ class Loader:
|
||||
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)
|
||||
if self.sealed:
|
||||
self._header(OP_WRITE, space, address, len(chunk))
|
||||
else:
|
||||
self.port.write(bytes((ord("g"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, len(chunk) & 0xFF)))
|
||||
for byte in chunk:
|
||||
self.port.write(bytes((byte,)))
|
||||
self._expect_prompt()
|
||||
@@ -709,7 +886,16 @@ class Loader:
|
||||
|
||||
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."""
|
||||
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here.
|
||||
|
||||
From pureboot 9 the operation rides the header's count field instead of
|
||||
arriving as data behind it, which is what puts it inside the seal: the
|
||||
loader will not hand a byte to SPMCSR that the host did not seal, and
|
||||
the verdict says whether it did."""
|
||||
if self.sealed:
|
||||
self._header(OP_WRITE, SP_SPM, address, operation, 5.0)
|
||||
self._expect_prompt(5.0)
|
||||
return
|
||||
self._write_space(SP_SPM, address, bytes((operation,)))
|
||||
|
||||
def read_ram(self, address, count):
|
||||
@@ -747,12 +933,22 @@ class Loader:
|
||||
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)
|
||||
# The fill loads 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.
|
||||
if self.sealed:
|
||||
# The page goes out only once the header is accepted. It is the
|
||||
# protocol's one unacked burst, so a header refused after the
|
||||
# host had already started sending it would leave the page
|
||||
# being read as commands — which is exactly what the verdict
|
||||
# is placed here to prevent.
|
||||
self._header(OP_FILL, SP_FLASH, address, len(data) & 0xFF)
|
||||
self.port.write(data)
|
||||
self._expect_prompt(2.0)
|
||||
else:
|
||||
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:
|
||||
@@ -787,8 +983,13 @@ class Loader:
|
||||
|
||||
def jump(self, word_address):
|
||||
"""The device acks, then execution continues at the word address.
|
||||
From v7 'J' rides the unified decode, so it carries a selector byte
|
||||
the loader ignores; older loaders take the bare address."""
|
||||
From v7 the jump rides the unified decode, so it carries a selector
|
||||
byte the loader ignores; older loaders take the bare address. From v9
|
||||
the ack is the verdict on its seal — a jump to a mangled address is a
|
||||
jump into arbitrary code, so it is sealed like everything else."""
|
||||
if self.sealed:
|
||||
self._header(OP_JUMP, 0, word_address, 0)
|
||||
return
|
||||
if self.info.version >= 7:
|
||||
self.port.write(bytes((ord("J"), 0, word_address & 0xFF, word_address >> 8)))
|
||||
else:
|
||||
@@ -1205,7 +1406,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
|
||||
state.load_or_save(loader)
|
||||
|
||||
# A loader already sitting whole in the staging slot IS the staging copy:
|
||||
# rewriting it would only meet its own running-slot guard. Any pureboot
|
||||
# rewriting it in place would be a copy overwriting itself as it runs. Any pureboot
|
||||
# with the device's info block serves, since a staged copy only streams
|
||||
# pages. "Whole" needs both checks — the block where every image carries
|
||||
# it and matching byte for byte, and the slot unchanged since this update
|
||||
@@ -1478,12 +1679,13 @@ def scan_report(baud, pct, version, clock=None):
|
||||
return lines
|
||||
|
||||
|
||||
def op_scan(port_path, baud, wait, clock=None):
|
||||
def op_scan(port_path, baud, wait, clock=None, one_wire=False):
|
||||
"""A fixed-baud loader whose oscillator drifted still answers — at the
|
||||
drifted ratio, since its rate scales with its clock. One probe per
|
||||
activation window, and with an application resident the window opens
|
||||
exactly once per reset, so each probe announces itself and expects a
|
||||
fresh reset before knocking."""
|
||||
fresh reset before knocking. On a shared line the probes echo back like
|
||||
everything else; undiscarded they would answer every rate."""
|
||||
for pct in scan_ratios():
|
||||
rate = scan_rate(baud, pct)
|
||||
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
|
||||
@@ -1492,6 +1694,8 @@ def op_scan(port_path, baud, wait, clock=None):
|
||||
except Error as unmakeable:
|
||||
print(f"scan: {rate} Bd skipped — {unmakeable}")
|
||||
continue
|
||||
if one_wire:
|
||||
port = OneWirePort(port)
|
||||
try:
|
||||
info = Loader(port).connect(wait)
|
||||
except Error:
|
||||
@@ -1517,6 +1721,9 @@ def main():
|
||||
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
|
||||
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
|
||||
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
|
||||
parser.add_argument("--one-wire", action="store_true",
|
||||
help="the link is a shared line: read back and discard this tool's own "
|
||||
"echoed bytes (any backend of a one-wire deployment)")
|
||||
parser.add_argument("--autobaud", action="store_true",
|
||||
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
|
||||
"knock, and take geometry from the signature (no clock/baud baked in)")
|
||||
@@ -1575,11 +1782,14 @@ def main():
|
||||
if args.scan:
|
||||
if args.autobaud:
|
||||
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
|
||||
op_scan(args.port, args.baud, args.wait, args.clock)
|
||||
op_scan(args.port, args.baud, args.wait, args.clock, args.one_wire)
|
||||
return
|
||||
|
||||
port = Port(args.port, args.baud)
|
||||
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
|
||||
if args.one_wire:
|
||||
port = OneWirePort(port)
|
||||
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted"
|
||||
+ (", one-wire echo discarded" if args.one_wire else ""))
|
||||
try:
|
||||
loader = Loader(port)
|
||||
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
|
||||
|
||||
@@ -41,6 +41,9 @@ consteval avr::hertz_t clock()
|
||||
#if !defined(PUREBOOT_TX)
|
||||
#define PUREBOOT_TX pb1
|
||||
#endif
|
||||
#if !defined(PUREBOOT_RX)
|
||||
#define PUREBOOT_RX pb0
|
||||
#endif
|
||||
#if !defined(PUREBOOT_USART)
|
||||
#define PUREBOOT_USART 0
|
||||
#endif
|
||||
@@ -64,6 +67,10 @@ struct link {
|
||||
static constexpr avr::baud_t baud{115200};
|
||||
#endif
|
||||
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
|
||||
static void init()
|
||||
{
|
||||
avr::init<tx_t>();
|
||||
}
|
||||
static void tx(char c)
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
@@ -110,7 +117,21 @@ struct link<C, false> {
|
||||
#else
|
||||
static constexpr avr::baud_t baud{57600};
|
||||
#endif
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud>;
|
||||
// A shared-pin deployment (RX == TX) banners as a guest on its own line:
|
||||
// the pull-up input is the released line, the transmitter takes the pin
|
||||
// for exactly one frame per byte — the shape a real one-wire application
|
||||
// beside this loader uses.
|
||||
static constexpr bool one_wire = avr::PUREBOOT_RX == avr::PUREBOOT_TX;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud, one_wire>;
|
||||
static void init()
|
||||
{
|
||||
// The guest transmitter configures no pin; the released line — the
|
||||
// pull-up input a receiver would own — is established here.
|
||||
if constexpr (one_wire)
|
||||
avr::init<avr::io::input<avr::PUREBOOT_TX, avr::io::pull::up>, tx_t>();
|
||||
else
|
||||
avr::init<tx_t>();
|
||||
}
|
||||
static void tx(char c)
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
@@ -145,7 +166,7 @@ struct link<C, false> {
|
||||
|
||||
int main()
|
||||
{
|
||||
avr::init<typename link<dev::clock>::tx_t>();
|
||||
link<dev::clock>::init();
|
||||
#if !defined(PUREBOOT_HANDOVER)
|
||||
link<dev::clock>::tx('A');
|
||||
link<dev::clock>::tx('P');
|
||||
|
||||
@@ -6,13 +6,14 @@ the *same* loader binary, which is the property autobaud exists for: one
|
||||
clock-agnostic image that locks onto whatever rate the host sends.
|
||||
|
||||
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
|
||||
<app_bin> <app_hz> <app_baud> <tool_py> <workdir>
|
||||
<app_bin> <app_hz> <app_baud> <tool_py> <workdir> [link]
|
||||
|
||||
The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's
|
||||
default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). The
|
||||
app fixture is built for (app_hz, app_baud); the hand-over is checked at that
|
||||
point, and a second point at half the clock proves the lock is measured, not
|
||||
baked in.
|
||||
The loader is a software-serial build, driven over the GPIO⇄pty bridge; the
|
||||
optional link overrides the default -l sw:B0,B1 — RX == TX in it is the
|
||||
one-wire deployment, and every session then runs with the host's echo
|
||||
discard on. The app fixture is built for (app_hz, app_baud); the hand-over
|
||||
is checked at that point, and a second point at half the clock proves the
|
||||
lock is measured, not baked in.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -27,8 +28,12 @@ def fail(message):
|
||||
|
||||
|
||||
def main():
|
||||
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:]
|
||||
args = sys.argv[1:]
|
||||
link = args.pop() if len(args) == 11 else "sw:B0,B1"
|
||||
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = args
|
||||
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
|
||||
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
|
||||
extra = ("--one-wire",) if one_wire else ()
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
@@ -55,11 +60,11 @@ def main():
|
||||
"""One clock point: reset, calibrate + knock, program, verify against the
|
||||
simulator's own flash, and (at the app's point) hand over to the fixture."""
|
||||
dump = os.path.join(workdir, f"flash_{label}.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link="sw:B0,B1")
|
||||
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
||||
# and a single knock at `baud`; the loader locks to it.
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--info", "--clock", str(hz),
|
||||
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
@@ -80,7 +85,7 @@ def main():
|
||||
# Read both memories back over the locked link and check them.
|
||||
read_flash = os.path.join(workdir, f"rf_{label}.bin")
|
||||
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--verify-flash", app_bin,
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--verify-flash", app_bin,
|
||||
"--verify-eeprom", ee_path, "--read-flash", read_flash,
|
||||
"--read-eeprom", read_eeprom, "--stay")
|
||||
if out.count("verify:") != 2:
|
||||
@@ -99,6 +104,8 @@ def main():
|
||||
# pulse is genuinely seen and the test cannot pass vacuously.)
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
time.sleep(0.2)
|
||||
port.write(bytes((pb.CALIBRATE,)))
|
||||
@@ -117,6 +124,8 @@ def main():
|
||||
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
live = loader.connect_autobaud(15)
|
||||
@@ -160,9 +169,11 @@ def main():
|
||||
the question is only whether the loader can still measure the pulse."""
|
||||
dump = os.path.join(workdir, f"flash_{label}.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump,
|
||||
link="sw:B0,B1")
|
||||
link=link)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
live = pb.Loader(port).connect_autobaud(15)
|
||||
if live.version != pb.NEWEST_LOADER:
|
||||
|
||||
124
test/pbglitch.py
Normal file
124
test/pbglitch.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A link that damages bytes, deliberately and reproducibly, against the seal
|
||||
that exists for it.
|
||||
|
||||
The board this was written for loses and mangles bytes on its own serial path,
|
||||
and the failure that made it matter — a page-fill byte lost, the stream one
|
||||
byte out, a page-address byte arriving where an SPMCSR value belongs — is not
|
||||
reachable by asking a healthy link nicely. So the damage is injected here, at
|
||||
a named byte index rather than a probability: a failing case is a case that
|
||||
fails again.
|
||||
|
||||
Every check is a pair. The same bit flipped in the same field is applied on
|
||||
one side of the seal and then the other: *after* the host seals the header,
|
||||
which is a mangled command and must be refused, and *before*, which is a
|
||||
well-formed command for something else and must be obeyed. Only the pair
|
||||
proves anything — a test that showed the refusal alone would pass against a
|
||||
loader that had simply stopped doing SPM, and one that showed the corruption
|
||||
alone would not say what caught it.
|
||||
|
||||
Usage: pbglitch.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def frame(pb, op, space, address, count, damage=None, before_seal=False):
|
||||
"""A sealed command header, optionally with one byte damaged.
|
||||
|
||||
`damage` is (index, mask). Applied before the seal is computed it produces
|
||||
a valid command for whatever the damaged fields now say; applied after, a
|
||||
command whose seal no longer matches its own body — which is the shape a
|
||||
link fault actually has."""
|
||||
head = bytearray((op, pb.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
if damage and before_seal:
|
||||
head[damage[0]] ^= damage[1]
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
out = bytearray(head + bytes((seal,)))
|
||||
if damage and not before_seal:
|
||||
out[damage[0]] ^= damage[1]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
|
||||
base, page, baud = int(base_hex, 0), int(page), int(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)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
info = loader.connect(25)
|
||||
if info.version < pb.SEALED_LOADER:
|
||||
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
|
||||
|
||||
# A page of known bytes to watch. Everything below aims at it, so
|
||||
# "nothing happened" is a readable claim rather than an absence.
|
||||
marker = bytes((0x40 + (i & 0x3F)) for i in range(page))
|
||||
loader.write_page(0, marker)
|
||||
if loader.read_flash(0, page) != marker:
|
||||
fail("the marker page did not survive an undamaged write")
|
||||
|
||||
# Every field of the header, one bit each. A damaged seal must be
|
||||
# refused, the loader must re-prompt, and the page must be untouched —
|
||||
# and it is the erase being aimed at it, so a single escape is visible.
|
||||
for index in range(6):
|
||||
bad = frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE, damage=(index, 0x01))
|
||||
port.write(bad)
|
||||
answer = port.read_exact(1, 5.0)
|
||||
if answer != pb.NAK:
|
||||
fail(f"a header damaged in byte {index} was not refused (got {answer.hex()})")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail(f"no prompt after refusing a header damaged in byte {index}")
|
||||
if loader.read_flash(0, page) != marker:
|
||||
fail(f"damage in byte {index} reached flash — the marker page changed")
|
||||
|
||||
# The fill, whose payload is the protocol's one unacked burst: a
|
||||
# refused fill must be refused *before* the page is sent, or the host
|
||||
# is left pushing 128 bytes into a loader reading commands. Nothing is
|
||||
# sent after the verdict here, and the very next command must be
|
||||
# understood — that is the whole claim.
|
||||
bad = frame(pb, pb.OP_FILL, pb.SP_FLASH, 0, page, damage=(3, 0x80))
|
||||
port.write(bad)
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("a damaged fill header was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("no prompt after refusing a damaged fill header")
|
||||
if loader.identity().raw != info.raw:
|
||||
fail("the loader was out of step after refusing a fill")
|
||||
|
||||
# The pair's other half. The identical flip, applied before the seal:
|
||||
# a well-formed erase of the page one bit away from the one intended.
|
||||
# It must be obeyed — otherwise the refusals above prove nothing about
|
||||
# the seal and only that this loader stopped erasing.
|
||||
port.write(frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE,
|
||||
damage=(2, 0x01), before_seal=True))
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("a correctly sealed erase was refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("no prompt after a correctly sealed erase")
|
||||
if loader.read_flash(0, page) != b"\xff" * page:
|
||||
fail("the sealed erase did not reach flash — the marker page is intact")
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
print("pbglitch: damaged headers are refused, the identical damage sealed is obeyed")
|
||||
|
||||
|
||||
main()
|
||||
@@ -17,6 +17,7 @@ Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
@@ -35,6 +36,9 @@ def main():
|
||||
|
||||
if "@" not in link:
|
||||
fail(f"the link {link} names no owning USART — nothing would be under test")
|
||||
# A shared line (RX == TX) echoes the host's own bytes; discard them the
|
||||
# way the shipped --one-wire mode does.
|
||||
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1@[01]", link) is not None
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
@@ -42,6 +46,8 @@ def main():
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(25)
|
||||
resident = loader.info.version
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
canonical slot must still be a working loader, and the ordinary
|
||||
--update-loader flow must put a build into the top slot from there.
|
||||
|
||||
Two positions. Address 0, a raw .bin handed to a programmer: the staging
|
||||
install and the word-0 redirect run from copies outside page 0's slot, so the
|
||||
running-slot guard never blocks them. And the staging slot itself, where a
|
||||
loader already sitting there IS the staging copy — recognized by its embedded
|
||||
block and left in place, then streaming the new resident like any staged copy.
|
||||
Two positions. Address 0, a raw .bin handed to a programmer. And the staging
|
||||
slot itself, where a loader already sitting there IS the staging copy —
|
||||
recognized by its embedded block and left in place, then streaming the new
|
||||
resident like any staged copy.
|
||||
|
||||
Usage: pbrehome.py <device_bin> <pureboot_elf> <update_bin> <mcu> <hz>
|
||||
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
|
||||
@@ -22,7 +21,7 @@ def fail(message):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin, base, page, baud, app_bin, workdir,
|
||||
def rehome_from(pbsim, pb, device_bin, elf, place_hex, update_bin, base, page, baud, app_bin, workdir,
|
||||
mcu, hz):
|
||||
"""Place the loader at `place_hex`, heal through --update-loader, flash
|
||||
the application, expect the banner."""
|
||||
@@ -38,14 +37,6 @@ def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin,
|
||||
if info.base != base:
|
||||
fail(f"the misplaced copy reports base {info.base:#06x} — the info block must stay canonical")
|
||||
|
||||
# The accidental slot still guards itself; re-homing rides on the
|
||||
# canonical slots being writable from it.
|
||||
probe = int(guard_probe, 0)
|
||||
before = loader.read_flash(probe, info.page)
|
||||
loader.write_page(probe, bytes(info.page))
|
||||
if loader.read_flash(probe, info.page) != before:
|
||||
fail("the misplaced copy's guard let its own slot change")
|
||||
|
||||
# The ordinary update flow puts the build into the top slot.
|
||||
pb.op_update_loader(loader, 25, update_bin, state, None)
|
||||
update = open(update_bin, "rb").read()
|
||||
@@ -76,16 +67,15 @@ def main():
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
|
||||
# Address 0: the raw-.bin-to-a-programmer accident. The guard probe is
|
||||
# the copy's own page 0.
|
||||
rehome_from(pbsim, pb, device_bin, elf, "0x0", "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
|
||||
# Address 0: the raw-.bin-to-a-programmer accident.
|
||||
rehome_from(pbsim, pb, device_bin, elf, "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
|
||||
print("re-home from address 0: converged")
|
||||
|
||||
# The staging slot: erased flash with the loader sitting exactly where
|
||||
# a staging copy would — the tool must leave it in place and let it
|
||||
# stream the (different) update build into the resident slot.
|
||||
stage = base - pb.SLOT
|
||||
rehome_from(pbsim, pb, device_bin, elf, hex(stage), hex(stage), update_bin, base, page, baud, app_bin, workdir,
|
||||
rehome_from(pbsim, pb, device_bin, elf, hex(stage), update_bin, base, page, baud, app_bin, workdir,
|
||||
mcu, hz)
|
||||
print("re-home from the staging slot: converged")
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Position-independence acceptance test: the identical binary, flashed one
|
||||
slot below the resident, must serve the complete command set from there. The
|
||||
info block must come back byte-identical, the write guard must refuse the
|
||||
staged copy's own slot and permit the resident's, and the staged copy must be
|
||||
able to rewrite the resident verbatim.
|
||||
info block must come back byte-identical, and the staged copy must be able to
|
||||
rewrite the resident verbatim — which is the whole of what relocation is for.
|
||||
|
||||
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
@@ -64,14 +63,14 @@ def main():
|
||||
if loader.read_eeprom(0, len(pattern)) != pattern:
|
||||
fail("EEPROM round-trip through the staged copy")
|
||||
|
||||
# The guard, both ways: its own slot refused (drained, unchanged), the
|
||||
# resident slot writable. The refusal leaves its drained words in the
|
||||
# SPM buffer, so the write that follows may take them — and clears
|
||||
# them by writing, so the retry must not.
|
||||
before = loader.read_flash(stage, page)
|
||||
loader.write_page(stage, bytes(page))
|
||||
if loader.read_flash(stage, page) != before:
|
||||
fail("the staged copy's guard let its own slot change")
|
||||
# The resident slot, written from the copy standing beside it — the
|
||||
# whole point of relocating. pureboot 9 dropped the running-slot guard
|
||||
# that used to sit behind this, so the probe that used to accompany it
|
||||
# (aim a write at the copy's *own* slot and watch it be refused) is
|
||||
# gone with it: there is nothing to refuse now, and a copy that erases
|
||||
# the page it is executing from does not come back to report it.
|
||||
# pbselfwrite.py gates that direction on a device it is allowed to
|
||||
# destroy.
|
||||
marker = bytes((i * 3) & 0xFF for i in range(page))
|
||||
loader.write_page(base, marker)
|
||||
if loader.read_flash(base, page) != marker:
|
||||
|
||||
102
test/pbselfwrite.py
Normal file
102
test/pbselfwrite.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The seal, gated on the one command that proves it: erase the page the
|
||||
loader is executing from.
|
||||
|
||||
pureboot 9 dropped the running-slot write guard, so this command is now
|
||||
permitted — that is what lets a resident copy plant something in its own slot,
|
||||
which on a chip whose boot section *is* the loader slot is the only route a
|
||||
self-update has. Permitted means the loader must actually do it, and the only
|
||||
honest proof is the flash afterwards.
|
||||
|
||||
What stands in the guard's place is the seal, and the two halves are tested
|
||||
against each other here: the identical destructive command, refused when its
|
||||
seal is wrong and honoured when it is right. A test that only showed the
|
||||
refusal would pass just as well against a loader that ignores SPM entirely.
|
||||
|
||||
Usage: pbselfwrite.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sealed_frame(pb, op, space, address, count):
|
||||
"""A command header and its seal, built here rather than borrowed from the
|
||||
tool: this test is about what the loader accepts, and a probe that shares
|
||||
the host's frame builder cannot tell a wrong frame from a wrong loader."""
|
||||
head = bytes((op, pb.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
return head + bytes((seal,))
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
|
||||
base, page, baud = int(base_hex, 0), int(page), int(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)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
info = loader.connect(25)
|
||||
if info.version < pb.SEALED_LOADER:
|
||||
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
|
||||
|
||||
# Erase the first page of the running slot: the entry stub and the
|
||||
# command loop are both in it, so a loader that performs this does not
|
||||
# answer again. Nothing else in the protocol is as sharp a probe.
|
||||
frame = sealed_frame(pb, pb.OP_WRITE, pb.SP_SPM, base, pb.SPM_ERASE)
|
||||
|
||||
# Red: the same command with one bit wrong in its seal. Refused before
|
||||
# anything happens, and the loader is still there to say so.
|
||||
broken = bytearray(frame)
|
||||
broken[-1] ^= 0x01
|
||||
port.write(bytes(broken))
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("an unsealed erase of the running page was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("the loader did not re-prompt after refusing the erase")
|
||||
alive = loader.read_flash(base, 8)
|
||||
if alive == b"\xff" * 8:
|
||||
fail("the refused erase happened anyway — the running page reads erased")
|
||||
|
||||
# Green: the identical command, correctly sealed. Nothing is required
|
||||
# of the link from here on. The verdict is *issued* before the SPM, but
|
||||
# the erase takes the code that would have finished saying it, and how
|
||||
# much of it survives is the chip's business — an erase removes one page
|
||||
# and nothing else, so a loader whose command loop lives past the page
|
||||
# erased will prompt as usual where one with 128-byte pages goes with
|
||||
# the stub. None of that is the claim. The claim is that the erase
|
||||
# reached flash, and the dump is both the only witness for it and a
|
||||
# better one: it tells "accepted and performed" from "merely answered".
|
||||
port.write(frame)
|
||||
try:
|
||||
port.read_exact(2, 2.0)
|
||||
except pb.Error:
|
||||
pass
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
# Ground truth: the simulator's flash, not the loader's opinion of it.
|
||||
flash = open(dump, "rb").read()
|
||||
if flash[base : base + page] != b"\xff" * page:
|
||||
fail("the sealed erase did not reach flash — the running page is intact")
|
||||
print("pbselfwrite: the running slot is refused unsealed and erased sealed")
|
||||
|
||||
|
||||
main()
|
||||
@@ -11,6 +11,7 @@ loader built off the chip's natural serial default.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
@@ -70,10 +71,15 @@ def main():
|
||||
+ bytes([flags])
|
||||
)
|
||||
|
||||
# A shared-line link (RX == TX in the -l spec) makes the host read every
|
||||
# byte it sends back off the line; all sessions then discard the echo.
|
||||
one_wire = bool(link) and re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
|
||||
extra = ("--one-wire",) if one_wire else ()
|
||||
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
# Session 1: knock from reset, identify, program everything, stay.
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--info", "--fuses", "--flash", app_bin,
|
||||
"--eeprom", ee_path, "--stay")
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
@@ -83,7 +89,7 @@ def main():
|
||||
# the data space; hand over is deferred — the pty must be reopened for
|
||||
# the APP banner first.
|
||||
probe = "c0ffee"
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
|
||||
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
|
||||
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
|
||||
if out.count("verify:") != 2:
|
||||
@@ -111,6 +117,8 @@ def main():
|
||||
# land in the application, which banners on the same link.
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
live = loader.connect(15)
|
||||
@@ -123,19 +131,42 @@ def main():
|
||||
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
|
||||
# A fill 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 — 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.
|
||||
# target and the payload is erased-state bytes, so the probe can
|
||||
# disturb neither the image nor the page buffer it leaves behind: a
|
||||
# fill only loads the buffer, and nothing commits it. 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.
|
||||
# Hand-sealed too — a protocol probe that borrowed the tool's own
|
||||
# frame builder could not tell a wrong frame from a wrong loader.
|
||||
wire = base + 1
|
||||
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
|
||||
+ b"\xff" * page)
|
||||
head = bytes((pb.OP_FILL, pb.selector(pb.SP_FLASH, wire), wire & 0xFF,
|
||||
(wire >> 8) & 0xFF, page & 0xFF))
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
port.write(head + bytes((seal,)))
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("unaligned W did not return to the prompt")
|
||||
fail("the loader refused a correctly sealed fill")
|
||||
port.write(b"\xff" * page)
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("unaligned fill did not return to the prompt")
|
||||
|
||||
# And the seal itself, red: one wrong bit in the address of that
|
||||
# same frame must be refused outright. The verdict has to arrive
|
||||
# *before* the page would have been sent — that ordering is what
|
||||
# keeps a refusal from turning into a desync — so the probe sends
|
||||
# no payload at all and expects the loader straight back at the
|
||||
# command level.
|
||||
broken = bytearray(head + bytes((seal,)))
|
||||
broken[2] ^= 0x01
|
||||
port.write(bytes(broken))
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("a header with a broken seal was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("the loader did not re-prompt after refusing a broken seal")
|
||||
|
||||
loader.run_application()
|
||||
banner = port.read_exact(3, 5.0)
|
||||
|
||||
@@ -13,7 +13,7 @@ mis-counted cycle per poll shifts a window by 10 % and more.
|
||||
|
||||
Fixed-baud loaders declare their window in seconds (--seconds, the build's
|
||||
TIMEOUT). The autobaud loader's window is its calibration poll budget
|
||||
(--autobaud-polls); the seconds it amounts to are budget × 10 / f_cpu, the
|
||||
(--autobaud-polls); the seconds it amounts to are budget × 9 / f_cpu, the
|
||||
measured cost of the calibrate() wait loop this gate pins.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace {
|
||||
avr_t *avr;
|
||||
uart_pty_t uart_pty;
|
||||
bool link_software;
|
||||
avr_uart_t *hw_uart; // the pty-driven USART, for the datasheet-reset fix below
|
||||
char uart_digit = '0';
|
||||
char sw_rx_port = 'B', sw_tx_port = 'B';
|
||||
int sw_rx_bit = 0, sw_tx_bit = 1;
|
||||
@@ -85,6 +86,18 @@ void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
|
||||
window_first_tx();
|
||||
}
|
||||
|
||||
// One-wire (RX == TX in the link spec): both directions on one GPIO line
|
||||
// idling on the firmware's pull-up. The bridge then follows the pin's
|
||||
// direction the way the real wiring does: it drives only while the
|
||||
// firmware's DDR bit reads input, decodes transitions as the firmware's
|
||||
// transmit only while the firmware owns the line, ignores its own raises
|
||||
// coming back through the shared irq — and echoes every byte it drives back
|
||||
// to the pty, which is what the host-side FTDI tie does and what the host
|
||||
// tool's --one-wire mode reads back and discards.
|
||||
bool link_one_wire;
|
||||
bool mcu_owns_line;
|
||||
bool self_drive;
|
||||
|
||||
int parse_link(std::string_view spec)
|
||||
{
|
||||
if (spec == "usart0" || spec == "usart1") {
|
||||
@@ -101,6 +114,7 @@ int parse_link(std::string_view spec)
|
||||
std::sscanf(spec.data() + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner);
|
||||
if (fields == 4 || fields == 5) {
|
||||
sw_tx_owner = owner;
|
||||
link_one_wire = sw_rx_port == sw_tx_port && sw_rx_bit == sw_tx_bit;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -260,7 +274,14 @@ avr_uart_t *tx_owner;
|
||||
|
||||
bool tx_pin_taken()
|
||||
{
|
||||
return tx_owner && avr_regbit_get(avr, tx_owner->txen);
|
||||
if (!tx_owner)
|
||||
return false;
|
||||
if (avr_regbit_get(avr, tx_owner->txen))
|
||||
return true;
|
||||
// One-wire on the USART's RXD: RXEN forces the shared pin's direction to
|
||||
// input (§20.7.3), so the firmware's drive goes nowhere until the
|
||||
// release — the receive-side twin of the TXD hold.
|
||||
return link_one_wire && avr_regbit_get(avr, tx_owner->rxen);
|
||||
}
|
||||
|
||||
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
|
||||
@@ -288,6 +309,13 @@ void find_tx_owner()
|
||||
|
||||
void tx_hook(avr_irq_t *, std::uint32_t value, void *)
|
||||
{
|
||||
if (link_one_wire && (self_drive || !mcu_owns_line)) {
|
||||
// The bridge's own drive coming back through the shared irq, or a
|
||||
// transition while the line is the bridge's — either way not the
|
||||
// firmware talking: the decoder sees an idle line.
|
||||
tx_level = 1;
|
||||
return;
|
||||
}
|
||||
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
|
||||
tx_level = 1;
|
||||
return;
|
||||
@@ -308,15 +336,29 @@ std::uint8_t rx_byte;
|
||||
|
||||
void rx_start_next();
|
||||
|
||||
// Every level the bridge itself puts on the line goes through here, so the
|
||||
// shared-pin decoder can tell its own drive from the firmware's.
|
||||
void bridge_drive(int level)
|
||||
{
|
||||
self_drive = true;
|
||||
avr_raise_irq(rx_pin, static_cast<std::uint32_t>(level));
|
||||
self_drive = false;
|
||||
}
|
||||
|
||||
avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
if (rx_bit < 8) {
|
||||
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
|
||||
bridge_drive((rx_byte >> rx_bit) & 1);
|
||||
rx_bit++;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (rx_bit == 8) { // stop bit, plus one idle bit of margin
|
||||
avr_raise_irq(rx_pin, 1);
|
||||
bridge_drive(1);
|
||||
// The host-side tie: an FTDI adapter on a one-wire line reads every
|
||||
// byte it transmits — supply that echo, which the host tool's
|
||||
// --one-wire mode consumes as its wiring check.
|
||||
if (link_one_wire && write(pty_master, &rx_byte, 1) != 1)
|
||||
std::println(stderr, "device: pty echo lost a byte");
|
||||
rx_bit++;
|
||||
return when + 2 * bit_cycles;
|
||||
}
|
||||
@@ -329,14 +371,33 @@ void rx_start_next()
|
||||
{
|
||||
if (rx_active || rx_head == rx_tail)
|
||||
return;
|
||||
// The firmware is answering on the shared line: hold the byte — a real
|
||||
// host's transmission waits out the reply on the wire too. The next
|
||||
// poll_pty tick retries once the line is handed back.
|
||||
if (link_one_wire && mcu_owns_line)
|
||||
return;
|
||||
rx_byte = rx_queue[rx_head];
|
||||
rx_head = (rx_head + 1) % sizeof(rx_queue);
|
||||
rx_active = 1;
|
||||
rx_bit = 0;
|
||||
avr_raise_irq(rx_pin, 0); // start bit
|
||||
bridge_drive(0); // start bit
|
||||
avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
|
||||
}
|
||||
|
||||
// The shared pin's direction is the line's ownership: DDR-out is the
|
||||
// firmware driving a frame, DDR-in hands the line back to the bridge.
|
||||
void on_ddr(avr_irq_t *, std::uint32_t value, void *)
|
||||
{
|
||||
const bool owns = (value >> sw_rx_bit) & 1;
|
||||
if (mcu_owns_line && !owns)
|
||||
bridge_drive(1); // hand-back: a turn-based host idles here, and the cache stays truthful
|
||||
mcu_owns_line = owns;
|
||||
// A byte held back while the firmware answered starts from the next
|
||||
// poll_pty tick, never from inside the DDR write itself — the port
|
||||
// model's own pull-up re-derivation runs right after this notify and
|
||||
// would erase a start edge raised here.
|
||||
}
|
||||
|
||||
// A reset abandons whatever the bridge was mid-transfer: bytes still queued
|
||||
// for a chip that no longer has the context to receive them meaningfully,
|
||||
// and a decode in progress on a TX line the reset may have already changed.
|
||||
@@ -352,6 +413,7 @@ void bridge_reset()
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
tx_level = 1;
|
||||
mcu_owns_line = false; // avr_reset zeroed DDR: every pin reads input again
|
||||
// Re-drive the idle line through a forced transition: ioport pin irqs are
|
||||
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
|
||||
// keeps its pre-reset cached value — so a plain raise(1) against a cached
|
||||
@@ -360,8 +422,8 @@ void bridge_reset()
|
||||
// pulse and mis-locks or boots the application on the first real knock.
|
||||
// No cycles run between the two raises, so the device only ever sees the
|
||||
// final idle-high.
|
||||
avr_raise_irq(rx_pin, 0);
|
||||
avr_raise_irq(rx_pin, 1);
|
||||
bridge_drive(0);
|
||||
bridge_drive(1);
|
||||
}
|
||||
|
||||
void poll_pty()
|
||||
@@ -375,8 +437,9 @@ void poll_pty()
|
||||
rx_queue[rx_tail] = chunk[i];
|
||||
rx_tail = next;
|
||||
}
|
||||
if (got > 0)
|
||||
rx_start_next();
|
||||
// Unconditional: a byte held back while the firmware owned a shared
|
||||
// line restarts from here once the hand-back has happened.
|
||||
rx_start_next();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main ---
|
||||
@@ -518,6 +581,19 @@ int main(int argc, char *argv[])
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
|
||||
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
// simavr leaves TXEN set out of reset where silicon clears the whole
|
||||
// UCSR#B (§20.11.3). Harmless to a loader that enables TXEN itself —
|
||||
// but a half-duplex build's receiver-only init then *drops* TXEN,
|
||||
// and this uart model clears UDRE on that edge and never re-raises
|
||||
// it on a later enable: the first transmitter after the hand-over
|
||||
// waits UDRE forever, a wedge silicon does not have. Start from the
|
||||
// datasheet's zero, as the software bridge's tx-owner model does.
|
||||
for (avr_io_t *io = avr->io_port; io; io = io->next)
|
||||
if (io->kind && std::string_view{io->kind} == "uart" &&
|
||||
reinterpret_cast<avr_uart_t *>(io)->name == uart_digit)
|
||||
hw_uart = reinterpret_cast<avr_uart_t *>(io);
|
||||
if (hw_uart)
|
||||
avr_regbit_clear(avr, hw_uart->txen);
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, uart_digit);
|
||||
if (window_report)
|
||||
@@ -532,7 +608,10 @@ int main(int argc, char *argv[])
|
||||
avr_irq_register_notify(
|
||||
avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), static_cast<unsigned>(sw_tx_bit)), tx_hook,
|
||||
nullptr);
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
if (link_one_wire)
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), IOPORT_IRQ_DIRECTION_ALL),
|
||||
on_ddr, nullptr);
|
||||
bridge_drive(1); // idle line
|
||||
|
||||
int slave;
|
||||
struct termios raw;
|
||||
@@ -564,6 +643,8 @@ int main(int argc, char *argv[])
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
|
||||
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
if (hw_uart) // and simavr's bogus reset TXEN (§20.11.3: zero)
|
||||
avr_regbit_clear(avr, hw_uart->txen);
|
||||
} else {
|
||||
bridge_reset();
|
||||
reset_tx_owner();
|
||||
|
||||
182
tools/pbhw.py
182
tools/pbhw.py
@@ -53,7 +53,7 @@ class Suite:
|
||||
"""The info block, which every later check takes its bounds from."""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
self.rig.reset()
|
||||
port = module.Port(self.rig.d.port, self.rig.d.baud)
|
||||
port = self.rig.open_port() # wrapped for the echo where the line is shared
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
@@ -87,7 +87,10 @@ class Suite:
|
||||
rate = module.scan_rate(self.rig.d.baud, pct)
|
||||
self.rig.reset()
|
||||
try:
|
||||
port = module.Port(self.rig.d.port, rate)
|
||||
# Same wrap as identity(): on a shared line an undiscarded
|
||||
# echo answers every rate a scan probes, so the walk would
|
||||
# report the first one it tried.
|
||||
port = self.rig.open_port(rate)
|
||||
except module.Error as error:
|
||||
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
|
||||
return
|
||||
@@ -129,18 +132,28 @@ class Suite:
|
||||
got = erased.read_bytes() if erased.exists() else b""
|
||||
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
|
||||
|
||||
def application(self, info, app: pathlib.Path, marker: str) -> None:
|
||||
def application(self, info, app: pathlib.Path, marker: str,
|
||||
marker_wait: float = 2.5) -> None:
|
||||
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
|
||||
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
|
||||
|
||||
if marker:
|
||||
# The tool hands over as it ends its session, so the application is
|
||||
# already running; opening the port does not reset a board whose DTR
|
||||
# is unwired, so this simply listens.
|
||||
data = self.rig.capture(seconds=2.5)
|
||||
# already running — but only on a board whose DTR is unwired, where
|
||||
# opening a port simply listens. Where DTR *is* wired to reset (an
|
||||
# Arduino, most USB-serial dev boards), this open resets the part
|
||||
# and the activation window comes first, so a marker emitted once at
|
||||
# startup happens on the far side of a wait this cannot know the
|
||||
# length of: the window is a compile-time constant and nothing on
|
||||
# the wire reports it. Hence --marker-wait, and a fixture that
|
||||
# repeats its banner (PUREBOOT_HEARTBEAT) rather than saying it once.
|
||||
data = self.rig.capture(seconds=marker_wait)
|
||||
seen = marker.encode() in data
|
||||
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
|
||||
self.check(f"application runs (emits {marker!r})", seen, f"|{sample}|")
|
||||
self.check(f"application runs (emits {marker!r})", seen,
|
||||
f"|{sample}|" if seen or data else
|
||||
f"nothing in {marker_wait:g} s — if this board resets when its port "
|
||||
f"opens, that wait has to outlast the activation window")
|
||||
|
||||
back = self.work / "app-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-flash", str(back))
|
||||
@@ -148,33 +161,140 @@ class Suite:
|
||||
self.check("application flash reads back", rc == 0 and len(got) == info.base,
|
||||
f"{len(got)} B of {info.base}")
|
||||
|
||||
def erase_and_guard(self, info, loader_image: pathlib.Path | None) -> None:
|
||||
def _witness(self, info, slot_length: int):
|
||||
"""Read back the erased region and the loader slot: (erased, slot, how).
|
||||
|
||||
Prefers ISP, because an independent reader is the only one that can
|
||||
testify about a loader just asked to erase around itself. Where no
|
||||
programmer is attached the link answers instead — which is weaker for
|
||||
exactly the reason it is worth having, a destroyed loader being unable
|
||||
to report anything at all. The two are never printed under one word:
|
||||
an absent probe is a fact about the bench, a wrong byte is a verdict on
|
||||
the loader, and a check that conflates them stops being read.
|
||||
"""
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
whole = self.work / "whole.bin"
|
||||
if self.rig.read_memory("flash", whole, "r"):
|
||||
image = whole.read_bytes()
|
||||
image += b"\xff" * (info.flash_size - len(image))
|
||||
return image[0:limit], image[info.base:info.base + slot_length], "ISP"
|
||||
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
return (loader.read_flash(0, limit),
|
||||
loader.read_flash(info.base, slot_length),
|
||||
"the link, no probe attached — the loader's own account")
|
||||
except Exception as error: # noqa: BLE001 — a dead link is a result
|
||||
print(f" skip slot checks: no programmer, and the link did not "
|
||||
f"answer either ({str(error)[:60]})")
|
||||
return None, None, ""
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def erase_and_slot(self, info, loader_image: pathlib.Path | None) -> None:
|
||||
rc, out = self.rig.pureboot("--erase-flash")
|
||||
self.check("application region erases", rc == 0, self._brief(out))
|
||||
|
||||
# The slot must be untouched by an application erase, which only an
|
||||
# independent read can show — so this one goes over ISP, not the link.
|
||||
whole = self.work / "whole.bin"
|
||||
if not self.rig.read_memory("flash", whole, "r"):
|
||||
self.check("loader slot survives the erase", False, "ISP read failed")
|
||||
want = loader_image.read_bytes() if loader_image and loader_image.exists() else b""
|
||||
erased, slot, how = self._witness(info, len(want))
|
||||
if erased is None:
|
||||
return
|
||||
image = whole.read_bytes()
|
||||
image += b"\xff" * (info.flash_size - len(image))
|
||||
|
||||
# Erased application flash, up to the trampoline word the host composes
|
||||
# on a patched-vector part.
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
self.check("erased application region is 0xff",
|
||||
set(image[0:limit]) <= {0xFF}, f"0x0000..{limit:#06x}")
|
||||
set(erased) <= {0xFF}, f"0x0000..{limit:#06x} via {how}")
|
||||
|
||||
if loader_image and loader_image.exists():
|
||||
want = loader_image.read_bytes()
|
||||
got = image[info.base:info.base + len(want)]
|
||||
self.check("loader slot survives the erase", got == want,
|
||||
f"{len(want)} B at {info.base:#06x}")
|
||||
if want:
|
||||
self.check("loader slot survives the erase", slot == want,
|
||||
f"{len(want)} B at {info.base:#06x} via {how}")
|
||||
else:
|
||||
print(" skip loader slot comparison (pass --loader <image.bin>)")
|
||||
|
||||
def seal(self, info, rounds: int = 1) -> None:
|
||||
"""The seal, adversarially, over the real link.
|
||||
|
||||
pureboot 9 has no running-slot guard: what stops a mangled command from
|
||||
erasing the loader is the seal and nothing else. So this aims the worst
|
||||
command the protocol has — an SPM erase at the loader's own first page —
|
||||
and damages one header byte at a time. Every one must come back NAK with
|
||||
the slot untouched and the session still in step.
|
||||
|
||||
On a board whose link drops or mangles bytes of its own accord this is
|
||||
also the stress test: `--seal-rounds` repeats it, and a link fault
|
||||
during a round is indistinguishable to the loader from the damage being
|
||||
injected, which is the point.
|
||||
"""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
self.rig.reset()
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
if loader.info.version < module.SEALED_LOADER:
|
||||
print(f" skip seal checks (loader is pureboot {loader.info.version})")
|
||||
return
|
||||
|
||||
head_of = lambda dmg: self._sealed(module, module.OP_WRITE, module.SP_SPM,
|
||||
info.base, module.SPM_ERASE, dmg)
|
||||
refused = 0
|
||||
attempts = 0
|
||||
for _ in range(rounds):
|
||||
for index in range(6):
|
||||
attempts += 1
|
||||
port.write(head_of((index, 0x01)))
|
||||
verdict = port.read_exact(1, 5.0)
|
||||
if verdict != module.NAK:
|
||||
self.check(f"damaged byte {index} refused", False,
|
||||
f"verdict {verdict.hex()}")
|
||||
return
|
||||
if port.read_exact(1, 5.0) != module.PROMPT:
|
||||
self.check(f"re-prompt after byte {index}", False, "no prompt")
|
||||
return
|
||||
refused += 1
|
||||
self.check("damaged headers refused", refused == attempts,
|
||||
f"{refused}/{attempts}, every header byte")
|
||||
self.check("session still in step", loader.identity().raw == info.raw)
|
||||
|
||||
# And the slot itself, read back over the link: the loader is the
|
||||
# thing that would have been erased, so its own account of its
|
||||
# first bytes is a real witness — an erased page reads all 0xff.
|
||||
head = loader.read_flash(info.base, 16)
|
||||
self.check("loader slot intact", set(head) != {0xFF}, head[:8].hex())
|
||||
except Exception as error: # noqa: BLE001 — a dead link is a result
|
||||
self.check("seal checks", False, str(error)[:70])
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _sealed(module, op, space, address, count, damage=None):
|
||||
"""A sealed header, damaged after sealing — the shape a link fault has."""
|
||||
head = bytearray((op, module.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = module.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
out = bytearray(head + bytes((seal,)))
|
||||
if damage:
|
||||
out[damage[0]] ^= damage[1]
|
||||
return bytes(out)
|
||||
|
||||
def refusals(self, info) -> None:
|
||||
# One word too many: a patched-vector part spends the slot's last word
|
||||
# on the trampoline, so its application stops two bytes short.
|
||||
@@ -187,7 +307,7 @@ class Suite:
|
||||
# ------------------------------------------------------------------- run
|
||||
|
||||
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
|
||||
marker: str) -> int:
|
||||
marker: str, marker_wait: float = 2.5, seal_rounds: int = 1) -> int:
|
||||
print("identity")
|
||||
info = self.identity()
|
||||
if info is None:
|
||||
@@ -203,12 +323,15 @@ class Suite:
|
||||
|
||||
if app:
|
||||
print("\napplication")
|
||||
self.application(info, app, marker)
|
||||
self.application(info, app, marker, marker_wait)
|
||||
else:
|
||||
print("\nskip application checks (pass --app <image.hex>)")
|
||||
|
||||
print("\nerase and the write guard")
|
||||
self.erase_and_guard(info, loader_image)
|
||||
print("\nerase and the slot boundary")
|
||||
self.erase_and_slot(info, loader_image)
|
||||
|
||||
print("\nthe seal")
|
||||
self.seal(info, seal_rounds)
|
||||
|
||||
print("\nrefusals")
|
||||
self.refusals(info)
|
||||
@@ -227,8 +350,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="application image to flash (test/pbapp.cpp built for this deployment)")
|
||||
parser.add_argument("--loader", type=pathlib.Path,
|
||||
help="the resident loader's .bin, to prove the slot survives an erase")
|
||||
parser.add_argument("--seal-rounds", type=int, default=1,
|
||||
help="repeat the adversarial seal sweep N times (a lossy board's stress test)")
|
||||
parser.add_argument("--marker", default="",
|
||||
help="text the application emits when it runs, e.g. APP")
|
||||
parser.add_argument("--marker-wait", type=float, default=2.5,
|
||||
help="seconds to listen for it. On a board whose DTR is wired to "
|
||||
"reset, opening the port resets the part, so this must outlast "
|
||||
"the activation window (default 2.5)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
|
||||
@@ -236,7 +365,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
f"{' (autobaud)' if args.autobaud else ''}")
|
||||
print("this overwrites the application flash and EEPROM\n")
|
||||
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
|
||||
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker)
|
||||
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker,
|
||||
args.marker_wait, args.seal_rounds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -87,6 +87,7 @@ class Deployment:
|
||||
port: str = "" # serial device the loader speaks on
|
||||
baud: int = 57600 # host rate; for autobaud, the rate to drive
|
||||
autobaud: bool = False # send the calibration pulse instead of p+b
|
||||
one_wire: bool = False # shared line: the host discards its own echo
|
||||
programmer: str = "" # avrdude -c
|
||||
part: str = "" # avrdude -p
|
||||
avrdude: str = "avrdude"
|
||||
@@ -101,6 +102,7 @@ class Deployment:
|
||||
port=os.environ.get("PUREBOOT_PORT", ""),
|
||||
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
|
||||
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
|
||||
one_wire=os.environ.get("PUREBOOT_ONE_WIRE", "") not in ("", "0"),
|
||||
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
|
||||
part=os.environ.get("PUREBOOT_PART", ""),
|
||||
avrdude=os.environ.get("AVRDUDE", "avrdude"),
|
||||
@@ -117,6 +119,8 @@ class Deployment:
|
||||
help="host rate (for autobaud, the rate to drive)")
|
||||
parser.add_argument("--autobaud", action="store_true", default=env.autobaud,
|
||||
help="send the calibration pulse instead of the p+b knock")
|
||||
parser.add_argument("--one-wire", action="store_true", default=env.one_wire,
|
||||
help="shared line: pass the tool its echo discard")
|
||||
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
|
||||
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
|
||||
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
|
||||
@@ -128,6 +132,7 @@ class Deployment:
|
||||
@classmethod
|
||||
def from_args(cls, args: argparse.Namespace) -> "Deployment":
|
||||
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
|
||||
one_wire=args.one_wire,
|
||||
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
|
||||
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
|
||||
|
||||
@@ -267,6 +272,8 @@ class Rig:
|
||||
"--wait", str(self.d.wait)]
|
||||
if self.d.autobaud if autobaud is None else autobaud:
|
||||
command.append("--autobaud")
|
||||
if self.d.one_wire:
|
||||
command.append("--one-wire")
|
||||
command += [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
@@ -274,6 +281,22 @@ class Rig:
|
||||
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
|
||||
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
def open_port(self, baud: int | None = None):
|
||||
"""A port opened the way this deployment says to speak to the board.
|
||||
|
||||
Everything the rig runs as a *subprocess* gets its flags from
|
||||
`pureboot()` above; anything that drives the protocol in-process has
|
||||
to reach the same facts, and until this existed only the subprocess
|
||||
path could. A shared line is the one where that gap is fatal rather
|
||||
than untidy: the host reads back every byte it writes, so an
|
||||
undiscarded echo answers the knock before the device does. Open
|
||||
through here and a one-wire deployment cannot be silently driven as
|
||||
a two-wire one.
|
||||
"""
|
||||
module = load_pureboot(self.d.pureboot)
|
||||
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
|
||||
return module.OneWirePort(port) if self.d.one_wire else port
|
||||
|
||||
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
|
||||
"""Listen to whatever the board is saying, at an arbitrary rate.
|
||||
|
||||
|
||||
@@ -136,7 +136,9 @@ def cmd_max(args) -> int:
|
||||
def cmd_check_readme(args) -> int:
|
||||
"""The README's per-chip table, against the stock build and the worst
|
||||
autobaud configuration (OSCCAL baked, plus the USART-pin release where
|
||||
the chip has a USART) — the config the Autobaud column documents."""
|
||||
the chip has a USART; the one-wire fold of the same build is its twin
|
||||
and competes for the same cell) — the config the Autobaud column
|
||||
documents."""
|
||||
readme = (ROOT / "pureboot" / "README.md").read_text()
|
||||
measured = collect()
|
||||
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
|
||||
@@ -148,8 +150,15 @@ def cmd_check_readme(args) -> int:
|
||||
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
|
||||
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
||||
built = {name: text for name, text, _ in measured.get(chip, [])}
|
||||
worst = ("pureboot_autobaud_osccal_on_usart0"
|
||||
if "pureboot_autobaud_osccal_on_usart0" in built else "pureboot_autobaud_osccal")
|
||||
# The on-USART pair defines the column where the chip has a USART;
|
||||
# the default-pin pair is the whole space elsewhere. Whichever twin
|
||||
# measures larger is the number the cell must state.
|
||||
candidates = [name for name in ("pureboot_autobaud_osccal_on_usart0",
|
||||
"pureboot_1w_autobaud_osccal_on_usart0") if name in built]
|
||||
if not candidates:
|
||||
candidates = [name for name in ("pureboot_autobaud_osccal",
|
||||
"pureboot_1w_autobaud_osccal") if name in built]
|
||||
worst = max(candidates, key=lambda name: built[name], default="pureboot_autobaud_osccal")
|
||||
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
|
||||
if target not in built:
|
||||
skipped += 1
|
||||
|
||||
Reference in New Issue
Block a user