Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ea099105 | |||
| c535c4756c | |||
| 321ff8a4ee | |||
| 531ae6c8dc | |||
| b3f41caf6e | |||
| 7716e1e291 | |||
| 1b18f10f4e | |||
| 52c4cdab32 | |||
| 69f089e53a | |||
| fe0d9f8790 | |||
| 3ce817ea03 | |||
| af0dd15a77 | |||
| a54075e526 | |||
| aec430c2e1 | |||
| 9a8a5b0082 | |||
| 7702c6b700 | |||
| 77dd45aeca | |||
| 433bec3e58 | |||
| e89000f73e | |||
| b0737f7cc0 | |||
| 07f93caba8 | |||
| 45f10f843a | |||
| 34b47048ca | |||
| 392035923f | |||
| f98ed406b8 | |||
| a4da885e36 | |||
| 335e494a31 | |||
| 799709efcf | |||
| 7ae80087b3 | |||
| b477f53ca5 | |||
| 84d3f679c2 | |||
| b5020a1e20 |
250
CMakeLists.txt
250
CMakeLists.txt
@@ -2,21 +2,19 @@ cmake_minimum_required(VERSION 3.28)
|
||||
|
||||
project(tsb_libavr LANGUAGES CXX)
|
||||
|
||||
# libavr from a local checkout (LIBAVR_ROOT) or the forge; the toolchain file
|
||||
# comes from the same checkout via CMakePresets.json.
|
||||
include(FetchContent)
|
||||
# libavr rides as the pinned submodule; LIBAVR_ROOT (cache or environment)
|
||||
# overrides it for tandem development against a working tree. The toolchain
|
||||
# file comes from the submodule via CMakePresets.json either way.
|
||||
if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT})
|
||||
set(LIBAVR_ROOT $ENV{LIBAVR_ROOT})
|
||||
endif()
|
||||
if(NOT LIBAVR_ROOT)
|
||||
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr)
|
||||
endif()
|
||||
if(LIBAVR_ROOT)
|
||||
FetchContent_Declare(libavr SOURCE_DIR ${LIBAVR_ROOT})
|
||||
else()
|
||||
FetchContent_Declare(libavr GIT_REPOSITORY git@git.blackmark.me:avr/libavr.git GIT_TAG main)
|
||||
if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt)
|
||||
message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} — run: git submodule update --init libavr")
|
||||
endif()
|
||||
FetchContent_MakeAvailable(libavr)
|
||||
add_subdirectory(${LIBAVR_ROOT} libavr-build)
|
||||
|
||||
if(PROJECT_IS_TOP_LEVEL)
|
||||
add_compile_options(-Werror) # warnings are errors for the port's own code
|
||||
@@ -24,15 +22,17 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
|
||||
# The behavioral tests drive the real wire protocols over a simavr pty
|
||||
# (as the host tools do) and actually flash the device. The runners are
|
||||
# host programs built at configure time against libsimavr; if they or
|
||||
# Python are missing, only the size tests run.
|
||||
find_program(_host_cc NAMES cc gcc)
|
||||
# host programs built at configure time against libsimavr (C++23 — what
|
||||
# the distribution's compiler speaks in full); if they or Python are
|
||||
# missing, only the size tests run.
|
||||
find_program(_host_cxx NAMES c++ g++)
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(_host_cc AND Python3_FOUND)
|
||||
if(_host_cxx AND Python3_FOUND)
|
||||
set(PB_DEVICE ${CMAKE_BINARY_DIR}/pureboot_device)
|
||||
execute_process(
|
||||
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts
|
||||
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.c
|
||||
COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
|
||||
-I/usr/include/simavr -I/usr/include/simavr/parts
|
||||
-o ${PB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pureboot_device.cpp
|
||||
-lsimavr -lsimavrparts -lelf -lutil
|
||||
RESULT_VARIABLE _pbdev_res ERROR_VARIABLE _pbdev_err)
|
||||
if(NOT _pbdev_res EQUAL 0)
|
||||
@@ -42,8 +42,9 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p")
|
||||
set(TSB_DEVICE ${CMAKE_BINARY_DIR}/tsb_device)
|
||||
execute_process(
|
||||
COMMAND ${_host_cc} -O2 -I/usr/include/simavr -I/usr/include/simavr/parts
|
||||
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.c
|
||||
COMMAND ${_host_cxx} -std=c++23 -Wall -Wextra -O2
|
||||
-I/usr/include/simavr -I/usr/include/simavr/parts
|
||||
-o ${TSB_DEVICE} ${CMAKE_CURRENT_SOURCE_DIR}/test/device.cpp
|
||||
-lsimavr -lsimavrparts -lelf
|
||||
RESULT_VARIABLE _dev_res ERROR_VARIABLE _dev_err)
|
||||
if(NOT _dev_res EQUAL 0)
|
||||
@@ -68,16 +69,18 @@ function(add_image_outputs name)
|
||||
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin)
|
||||
endfunction()
|
||||
|
||||
# The TinySafeBoot protocol reimplemented on libavr in three variants that trade
|
||||
# The TinySafeBoot protocol reimplemented on libavr in variants that trade
|
||||
# clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects
|
||||
# its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled
|
||||
# loader has no use for the crt or the vector table. The naked entry sits in
|
||||
# .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section
|
||||
# size; the linker section-start and the source's boot_bytes agree. tsb_app is
|
||||
# loader has no use for the crt or the vector table. The entry sits in
|
||||
# .vectors, laid first, and runs — avr::startup::entry on the policy tier,
|
||||
# the experiment tiers' own naked stubs elsewhere, each documented in its
|
||||
# source. The boot base is FLASHEND+1 minus the section size; the linker
|
||||
# section-start and the source's boot_bytes agree. tsb_app is
|
||||
# the application's reset vector, pinned to 0 here so the loaders jump to a
|
||||
# named function; --pmem-wrap-around lets relaxation turn that absolute jump
|
||||
# into the wrapped rjmp AVR's modulo-flash PC actually executes.
|
||||
# All three implement the full oracle feature set (see oracle/README.md):
|
||||
# All four implement the full oracle feature set (see oracle/README.md):
|
||||
# watchdog bail, one-wire half-duplex, config-page activation timeout, password
|
||||
# gate, emergency erase, config/flash/EEPROM read-write. They differ only in how,
|
||||
# and the size gradient is the cost of that "how" — see dev/lessons.md.
|
||||
@@ -97,6 +100,10 @@ endfunction()
|
||||
# tsb_pure — pure idiomatic libavr, one function per command, TU-local
|
||||
# (internal linkage), streaming (no SRAM page buffer): 836 B in
|
||||
# the 1 KB section.
|
||||
# tsb_policy — the policy floor: pureboot's rules (no asm, no register
|
||||
# variables) with every pureboot lesson applied. 638 B in the
|
||||
# 1 KB section — the measured evidence that the 512 B fit is a
|
||||
# property of the mechanisms philosophy #5 bans.
|
||||
#
|
||||
# add_tsb_variant(<name> <boot-section-bytes>)
|
||||
function(add_tsb_variant name bytes)
|
||||
@@ -124,8 +131,13 @@ endfunction()
|
||||
# chips build pureboot alone.
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p")
|
||||
add_tsb_variant(tsb_asm 512)
|
||||
add_tsb_variant(tsb_policy 1024)
|
||||
add_tsb_variant(tsb_pure 1024)
|
||||
add_tsb_variant(tsb_tricks 1024)
|
||||
# The policy tier's floor is measured with the loop flags pureboot's size
|
||||
# work found (a loader's loop bodies all contain calls); the other tiers
|
||||
# keep the flag set their recorded floors were measured with — none.
|
||||
target_compile_options(tsb_policy PRIVATE -fno-move-loop-invariants -fno-tree-ter)
|
||||
endif()
|
||||
|
||||
# pureboot — the pure-constraint port (see pureboot/README.md): one source,
|
||||
@@ -153,10 +165,25 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
if(Python3_FOUND)
|
||||
add_test(NAME pureboot.pi
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py
|
||||
${CMAKE_OBJDUMP} ${CMAKE_NM} $<TARGET_FILE:pureboot> ${PUREBOOT_BASE_HEX})
|
||||
${CMAKE_OBJDUMP} ${CMAKE_OBJCOPY} ${CMAKE_CXX_COMPILER} ${LIBAVR_MCU}
|
||||
$<TARGET_FILE:pureboot>
|
||||
${CMAKE_BINARY_DIR}/CMakeFiles/pureboot.dir/pureboot/pureboot.cpp.obj
|
||||
${PUREBOOT_BASE_HEX})
|
||||
add_test(NAME pureboot.planner
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
|
||||
add_test(NAME pureboot.scan
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
|
||||
# CMakePresets.json is generated; hand edits drift the moment the
|
||||
# generator reruns, so the gate holds the pair together.
|
||||
add_test(NAME presets.generated
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/make_presets.py
|
||||
--check)
|
||||
add_test(NAME pureboot.handshake
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
|
||||
add_test(NAME pureboot.updatelink
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_update_link.py)
|
||||
endif()
|
||||
|
||||
# The protocol test flashes this fixture through the loader with the real
|
||||
@@ -235,12 +262,12 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
|
||||
# The size matrix: every configuration axis that could move the image
|
||||
# size — the serial backend (different code), the USART instance
|
||||
# (different registers), the clock (different constants), and the baud
|
||||
# through the shapes its bit timing takes — each combination must still
|
||||
# fit the chip's slot budget. Pins are size-neutral (port and bit are
|
||||
# immediate operands) and the timeout is a constant, so neither adds an
|
||||
# axis. The stock build is one point of this matrix and already has its
|
||||
# test.
|
||||
# (different registers), the clock (different constants), the baud
|
||||
# through the shapes its bit timing takes, and the pins through the one
|
||||
# thing they decide (whether a bit-banged link has to release the USART
|
||||
# that owns them) — each combination must still fit the chip's slot
|
||||
# budget. The timeout is a constant and adds no axis. The stock build is
|
||||
# one point of this matrix and already has its test.
|
||||
function(pureboot_size_variant name)
|
||||
pureboot_add_loader(${name} ${ARGN})
|
||||
add_test(NAME ${name}.size
|
||||
@@ -248,19 +275,41 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
|
||||
endfunction()
|
||||
|
||||
# The autobaud loader: one clock-agnostic image per chip, so it has no
|
||||
# clock x baud axis of its own — the matrix below sweeps those for the
|
||||
# fixed-baud builds, and this one binary has to serve all of them at run
|
||||
# time. Size-tested against the same per-chip budget as every other variant.
|
||||
pureboot_add_loader(pureboot_autobaud SERIAL autobaud)
|
||||
add_test(NAME pureboot_autobaud.size
|
||||
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
|
||||
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
|
||||
# The measured unit is the loader's only RAM object and sits at the very
|
||||
# start of SRAM — where the host reads the bit period from (--info's
|
||||
# measured clock), so the address is wire contract, not layout accident.
|
||||
add_test(NAME pureboot_autobaud.unit
|
||||
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
|
||||
-DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
|
||||
|
||||
# One point of the exhaustive matrix, named from its resolved parameters
|
||||
# so the enumeration cannot collide with itself. Unreachable rates drop
|
||||
# out here rather than aborting the configure.
|
||||
function(pureboot_matrix_point hz baud link)
|
||||
# so the enumeration cannot collide with itself. `pins` is empty for the
|
||||
# default pair, or the index of the USART whose own pins a bit-banged
|
||||
# link sits on. Unreachable rates drop out here rather than aborting the
|
||||
# configure.
|
||||
function(pureboot_matrix_point hz baud link pins)
|
||||
set(_name pbm_${hz}_${baud}_${link})
|
||||
if(link STREQUAL "software")
|
||||
pureboot_baud_feasible(${hz} ${baud} 1 _ok)
|
||||
set(_args SERIAL software)
|
||||
if(NOT pins STREQUAL "")
|
||||
list(APPEND _args RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_TX})
|
||||
set(_name ${_name}_on${pins})
|
||||
endif()
|
||||
else()
|
||||
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
|
||||
set(_args USART ${link})
|
||||
endif()
|
||||
if(_ok)
|
||||
pureboot_size_variant(pbm_${hz}_${baud}_${link} CLOCK ${hz} BAUD ${baud} ${_args})
|
||||
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args})
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
@@ -287,24 +336,25 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
# sites), the largest image the space produces and a shape the ladder
|
||||
# default — always the *fastest* rate a clock reaches — never picks.
|
||||
#
|
||||
# Bounded to one chip per size-bearing class: flash addressing (the
|
||||
# word-addressed 1284), hand-over shape (the patched vector on the tinies
|
||||
# and m48s), page size, and USART inventory. Everything else in the image
|
||||
# is chip-independent code, so a further chip buys builds and no
|
||||
# coverage; every chip outside the set carries the compact matrix.
|
||||
# Every chip runs the full cross product: the size-bearing classes (flash
|
||||
# addressing, hand-over shape, page size, USART inventory) are what make
|
||||
# the image differ, and a chip outside them is expected to match its class
|
||||
# — but "expected" is what a matrix is for, and the whole sweep is cheap
|
||||
# enough to run rather than reason about. PUREBOOT_FULL_MATRIX is what
|
||||
# selects it; the compact matrix below is the per-commit default.
|
||||
get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
|
||||
list(APPEND _full_bauds 16000 4800 2400 1200)
|
||||
set(_matrix_spot attiny13a attiny85 atmega48pa atmega8a atmega168pa
|
||||
atmega328p atmega164a atmega644a atmega1284p)
|
||||
if(DEFINED ENV{PUREBOOT_FULL_MATRIX} AND LIBAVR_MCU IN_LIST _matrix_spot)
|
||||
if(DEFINED ENV{PUREBOOT_FULL_MATRIX})
|
||||
foreach(_matrix_hz IN LISTS _full_clocks)
|
||||
foreach(_matrix_baud IN LISTS _full_bauds)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software "")
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "")
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
@@ -323,11 +373,74 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
endforeach()
|
||||
list(GET _matrix_clocks -1 _matrix_top_hz)
|
||||
pureboot_size_variant(pureboot_sw_wide CLOCK ${_matrix_top_hz} BAUD 9600 SERIAL software)
|
||||
# The pin axis at the widest software image — the slowest ladder rate
|
||||
# against the fastest clock, whose bit spin needs the 16-bit delay
|
||||
# loop — with the USART release on top of it. The exhaustive sweep
|
||||
# above carries the same axis across its whole cross product.
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_size_variant(pureboot_sw_wide_on_usart0 CLOCK ${_matrix_top_hz} BAUD 9600
|
||||
SERIAL software RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_size_variant(pureboot_sw_wide_on_usart1 CLOCK ${_matrix_top_hz} BAUD 9600
|
||||
SERIAL software RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
|
||||
endif()
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_size_variant(pureboot_usart1 USART 1)
|
||||
endif()
|
||||
|
||||
# The pin axis at its fixed points, in both matrix modes. The autobaud
|
||||
# loader carries no clock and no baud, so the sweep has nothing to vary
|
||||
# for it — yet it is the tightest image in the space, and on a USART's
|
||||
# own pins it pays the release too: that combination is the one that
|
||||
# overflowed the 1284's slot. The software build on those pins is the
|
||||
# same deployment the mute test drives.
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_size_variant(pureboot_sw_on_usart0 SERIAL software
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
|
||||
pureboot_size_variant(pureboot_autobaud_on_usart0 SERIAL autobaud
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_size_variant(pureboot_sw_on_usart1 SERIAL software
|
||||
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
|
||||
pureboot_size_variant(pureboot_autobaud_on_usart1 SERIAL autobaud
|
||||
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
|
||||
endif()
|
||||
|
||||
# The OSCCAL axis at its fixed points: the stock shape, and the tightest
|
||||
# image in the space with the trim on top — the axis adds one register
|
||||
# write, and these points hold both of its addressing encodings to every
|
||||
# chip's budget.
|
||||
pureboot_size_variant(pureboot_osccal OSCCAL 0x9c)
|
||||
pureboot_size_variant(pureboot_autobaud_osccal SERIAL autobaud OSCCAL 0x9c)
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_size_variant(pureboot_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
|
||||
endif()
|
||||
|
||||
# The trim byte, observed through the wire from the first prompt — one
|
||||
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
|
||||
# an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out —
|
||||
# Atmel-2586 §21).
|
||||
if(LIBAVR_MCU MATCHES "^(atmega328p|attiny85)$" AND DEFINED PB_DEVICE)
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p")
|
||||
set(_osccal_addr 0x66)
|
||||
else()
|
||||
set(_osccal_addr 0x51)
|
||||
endif()
|
||||
get_target_property(_osccal_hz pureboot_osccal PUREBOOT_HZ)
|
||||
get_target_property(_osccal_baud pureboot_osccal PUREBOOT_BAUD)
|
||||
add_test(NAME pureboot.osccal
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbosccal.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_osccal> ${PUREBOOT_SIM_MCU}
|
||||
${_osccal_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_osccal_baud}
|
||||
${_osccal_addr} 0x9c ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbosccal-work)
|
||||
set_tests_properties(pureboot.osccal PROPERTIES TIMEOUT 120)
|
||||
endif()
|
||||
|
||||
# One configured deployment end to end — a real board's shape rather
|
||||
# than the stock assumption: the ATmega328P on its shipped 1 MHz fuses,
|
||||
# the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder
|
||||
@@ -356,6 +469,33 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
set_tests_properties(pureboot.custom PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# Hand-over with the USART that owns the loader's pins left enabled — the
|
||||
# state an application reaches by jumping in without a reset, and the one
|
||||
# that made a bit-banged loader on PD0/PD1 (where the Uno's USB bridge
|
||||
# lands) receive and obey while answering nothing. Run where it was found
|
||||
# on silicon; the runner supplies the pin ownership simavr has no model
|
||||
# for, which is what lets this fail when the release is gone.
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p" AND DEFINED PB_DEVICE)
|
||||
get_target_property(_mute_hz pureboot_sw_on_usart0 PUREBOOT_HZ)
|
||||
get_target_property(_mute_baud pureboot_sw_on_usart0 PUREBOOT_BAUD)
|
||||
get_target_property(_mute_link pureboot_sw_on_usart0 PUREBOOT_LINK)
|
||||
add_executable(pbapp_handover test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_handover PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_handover PRIVATE PUREBOOT_CLOCK_HZ=${_mute_hz}
|
||||
PUREBOOT_BAUD=${_mute_baud} PUREBOOT_HANDOVER)
|
||||
add_custom_command(TARGET pbapp_handover POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_handover> $<TARGET_FILE:pbapp_handover>.bin)
|
||||
add_test(NAME pureboot.mute
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbmute.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_sw_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-work ${_mute_link})
|
||||
set_tests_properties(pureboot.mute PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# The second USART, driven for real on one chip: instance selection is
|
||||
# compile-checked everywhere, but only a live session proves the loader
|
||||
# initialized and polls the USART it claims to. The fixture application
|
||||
@@ -378,4 +518,28 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
${CMAKE_BINARY_DIR}/pbusart1-work usart1)
|
||||
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# The autobaud loader driven end to end over the software-UART bridge:
|
||||
# the host sends the 0xC0 calibration pulse, the loader times it, locks,
|
||||
# and programs. Run on the near-flash 328P
|
||||
# and the word-addressed 1284P — the two flash-addressing classes — and each
|
||||
# at two clocks with the one binary, which is the clock-agnostic property
|
||||
# autobaud exists for (test/pbautobaud.py). The fixture application banners
|
||||
# over the same software link at the first clock's rate.
|
||||
if(LIBAVR_MCU MATCHES "^atmega(328p|1284p)$" AND DEFINED PB_DEVICE)
|
||||
add_executable(pbapp_autobaud test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_autobaud PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_autobaud PRIVATE PUREBOOT_CLOCK_HZ=1000000
|
||||
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL PUREBOOT_TX=pb1)
|
||||
add_custom_command(TARGET pbapp_autobaud POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_autobaud> $<TARGET_FILE:pbapp_autobaud>.bin)
|
||||
add_test(NAME pureboot.autobaud
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_autobaud> ${PUREBOOT_SIM_MCU}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $<TARGET_FILE:pbapp_autobaud>.bin
|
||||
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbautobaud-work)
|
||||
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
77
ide/README.md
Normal file
77
ide/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Atmel Studio
|
||||
|
||||
`master` carries `bootloader.atsln`, so this branch does too: `ide/bootloader.atsln`
|
||||
builds the loaders from the same sources Ninja does, to a **byte-identical
|
||||
`.text`** — 400 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
|
||||
its 512-byte section. CMake remains the build system; the solution is here so the
|
||||
port opens in Studio as its predecessor did.
|
||||
|
||||
## The two projects, and why two
|
||||
|
||||
pureboot is a chip × backend × clock × baud matrix — `pureboot_add_loader()`
|
||||
resolves a deployment into compile definitions — and a `.cppproj` is one binary
|
||||
at one set of flags, so a project can only ever be one point of it. `pureboot`
|
||||
is that point: the stock 328P deployment, USART0 at 115200 on a 16 MHz crystal,
|
||||
an 8-second activation window. `tsb_asm` is the TinySafeBoot tier that occupies
|
||||
the same 512-byte section `master`'s `tsb` project targeted.
|
||||
|
||||
The other three tsb tiers (`tsb_pure`, `tsb_tricks`, `tsb_policy`) are not here.
|
||||
They differ from `tsb_asm` in their source file, their section size, and — for
|
||||
`tsb_policy` — two loop flags; nothing about that is a Studio concern, and what
|
||||
they exist to demonstrate is a size gradient only the CMake size tests measure.
|
||||
Adding one is a copy of `tsb_asm/tsb_asm.cppproj` in its own directory, with its
|
||||
name, its GUID, its source path and its `--section-start` changed (`0x7c00` for
|
||||
the 1 KiB tiers), plus four lines in the solution.
|
||||
|
||||
`avrdevice` is a project property, so each project gets its own directory:
|
||||
Studio builds into `<project dir>/<Configuration>` whatever `OutputDirectory`
|
||||
says, and two projects sharing a directory would share one object file.
|
||||
|
||||
## Debug keeps `-Os`
|
||||
|
||||
Both configurations compile at `-Os`; Debug adds only `-gdwarf-4`. The `.text`
|
||||
is therefore identical in both, which is the point — a loader's section is a
|
||||
**correctness** bound and not a budget. `-Og` builds this same source to 590 B,
|
||||
and linking it at `--section-start=.text=0x7e00` on a 32 KiB part puts 78 bytes
|
||||
past flash end **without a diagnostic**: `rcall`/`rjmp` targets there wrap
|
||||
modulo flash size, so the image dies right after activation. A debug
|
||||
configuration that silently produces that is worse than none, and DWARF costs no
|
||||
flash, so the optimisation level stays where correctness needs it.
|
||||
|
||||
## What Studio needs from the machine
|
||||
|
||||
libavr from the **submodule**, found at
|
||||
`$(MSBuildProjectDirectory)\..\..\libavr\include` — correct by construction, and
|
||||
anchored to the project because a plain relative path resolves against the
|
||||
generated makefile's directory (the configuration's output directory), not the
|
||||
project's. There is no `LIBAVR_ROOT` escape hatch: a variable exported in a
|
||||
shell is invisible to Studio launched from the Start menu, and the failure reads
|
||||
as a missing `libavr/libavr.hpp` — which is what the submodule answers.
|
||||
|
||||
A GCC 16.1 toolchain registered as flavour `avr-g++-16.1.0`, nothing older
|
||||
reaching `-std=c++26`.
|
||||
|
||||
## Generating and gating
|
||||
|
||||
One generated file is required before a project will load at all, and one command
|
||||
per project checks the flags have not drifted (both from libavr's
|
||||
`tools/atmelstudio/`):
|
||||
|
||||
```sh
|
||||
for name in pureboot tsb_asm; do
|
||||
python libavr/tools/atmelstudio/componentinfo.py \
|
||||
"ide/$name/$name.componentinfo.xml" --device ATmega328P
|
||||
python libavr/tools/atmelstudio/check-flags.py \
|
||||
--solution ide/bootloader.atsln --project "$name" --target "$name" \
|
||||
--compile-commands build/atmega328p-generated/compile_commands.json \
|
||||
--log "build/as-$name.log"
|
||||
done
|
||||
```
|
||||
|
||||
`--project` because one reference describes one binary; `--target` because
|
||||
`pureboot.cpp` is compiled by every point of the size matrix and the flags
|
||||
differ per point, so the basename alone does not name a reference. Release is
|
||||
what the gate compares — the presets define no debug build, and Debug differs
|
||||
from Release only in `-gdwarf-4`.
|
||||
|
||||
Legacy (the yazoalfa-era submodules) stays on `master`.
|
||||
28
ide/bootloader.atsln
Normal file
28
ide/bootloader.atsln
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Atmel Studio Solution File, Format Version 11.00
|
||||
VisualStudioVersion = 14.0.23107.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "pureboot", "pureboot\pureboot.cppproj", "{99067222-32D5-49E3-B4F8-5ABA0F7722B7}"
|
||||
EndProject
|
||||
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "tsb_asm", "tsb_asm\tsb_asm.cppproj", "{6618D3BE-7EB3-49A2-9113-F128E396FF06}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|AVR = Debug|AVR
|
||||
Release|AVR = Release|AVR
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Debug|AVR.ActiveCfg = Debug|AVR
|
||||
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Debug|AVR.Build.0 = Debug|AVR
|
||||
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Release|AVR.ActiveCfg = Release|AVR
|
||||
{99067222-32D5-49E3-B4F8-5ABA0F7722B7}.Release|AVR.Build.0 = Release|AVR
|
||||
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Debug|AVR.ActiveCfg = Debug|AVR
|
||||
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Debug|AVR.Build.0 = Debug|AVR
|
||||
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Release|AVR.ActiveCfg = Release|AVR
|
||||
{6618D3BE-7EB3-49A2-9113-F128E396FF06}.Release|AVR.Build.0 = Release|AVR
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
118
ide/pureboot/pureboot.cppproj
Normal file
118
ide/pureboot/pureboot.cppproj
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
<ProjectVersion>7.0</ProjectVersion>
|
||||
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
|
||||
<ProjectGuid>99067222-32d5-49e3-b4f8-5aba0f7722b7</ProjectGuid>
|
||||
<avrdevice>ATmega328P</avrdevice>
|
||||
<avrdeviceseries>none</avrdeviceseries>
|
||||
<OutputType>Executable</OutputType>
|
||||
<Language>CPP</Language>
|
||||
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
|
||||
<OutputFileExtension>.elf</OutputFileExtension>
|
||||
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
|
||||
<AssemblyName>pureboot</AssemblyName>
|
||||
<Name>pureboot</Name>
|
||||
<RootNamespace>pureboot</RootNamespace>
|
||||
<ToolchainFlavour>avr-g++-16.1.0</ToolchainFlavour>
|
||||
<KeepTimersRunning>true</KeepTimersRunning>
|
||||
<OverrideVtor>false</OverrideVtor>
|
||||
<CacheFlash>true</CacheFlash>
|
||||
<ProgFlashFromRam>true</ProgFlashFromRam>
|
||||
<RamSnippetAddress>0x20000000</RamSnippetAddress>
|
||||
<UncachedRange />
|
||||
<preserveEEPROM>true</preserveEEPROM>
|
||||
<OverrideVtorValue>exception_table</OverrideVtorValue>
|
||||
<BootSegment>2</BootSegment>
|
||||
<ResetRule>0</ResetRule>
|
||||
<eraseonlaunchrule>0</eraseonlaunchrule>
|
||||
<EraseKey />
|
||||
<AsfFrameworkConfig>
|
||||
<framework-data xmlns="">
|
||||
<options />
|
||||
<configurations />
|
||||
<files />
|
||||
<documentation help="" />
|
||||
<offline-documentation help="" />
|
||||
<dependencies>
|
||||
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.52.0" />
|
||||
</dependencies>
|
||||
</framework-data>
|
||||
</AsfFrameworkConfig>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<ToolchainSettings>
|
||||
<AvrGccCpp>
|
||||
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
|
||||
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
|
||||
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
|
||||
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
|
||||
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
|
||||
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
|
||||
<avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<ListValues>
|
||||
<Value>NDEBUG</Value>
|
||||
<Value>PUREBOOT_CLOCK_HZ=16000000</Value>
|
||||
<Value>PUREBOOT_BAUD=115200</Value>
|
||||
<Value>PUREBOOT_TIMEOUT=8</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<avrgcccpp.compiler.directories.IncludePaths>
|
||||
<ListValues>
|
||||
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.directories.IncludePaths>
|
||||
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
|
||||
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
|
||||
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
|
||||
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
|
||||
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types</avrgcccpp.compiler.miscellaneous.OtherFlags>
|
||||
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
|
||||
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=pureboot_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
|
||||
</AvrGccCpp>
|
||||
</ToolchainSettings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<ToolchainSettings>
|
||||
<AvrGccCpp>
|
||||
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
|
||||
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
|
||||
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
|
||||
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
|
||||
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
|
||||
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
|
||||
<avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<ListValues>
|
||||
<Value>DEBUG</Value>
|
||||
<Value>PUREBOOT_CLOCK_HZ=16000000</Value>
|
||||
<Value>PUREBOOT_BAUD=115200</Value>
|
||||
<Value>PUREBOOT_TIMEOUT=8</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<avrgcccpp.compiler.directories.IncludePaths>
|
||||
<ListValues>
|
||||
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.directories.IncludePaths>
|
||||
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
|
||||
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
|
||||
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
|
||||
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
|
||||
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types -gdwarf-4</avrgcccpp.compiler.miscellaneous.OtherFlags>
|
||||
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
|
||||
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=pureboot_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
|
||||
</AvrGccCpp>
|
||||
</ToolchainSettings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\pureboot\pureboot.cpp">
|
||||
<SubType>compile</SubType>
|
||||
<Link>pureboot\pureboot.cpp</Link>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="pureboot" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(AVRSTUDIO_EXE_PATH)\Vs\Compiler.targets" />
|
||||
</Project>
|
||||
112
ide/tsb_asm/tsb_asm.cppproj
Normal file
112
ide/tsb_asm/tsb_asm.cppproj
Normal file
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
<ProjectVersion>7.0</ProjectVersion>
|
||||
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
|
||||
<ProjectGuid>6618d3be-7eb3-49a2-9113-f128e396ff06</ProjectGuid>
|
||||
<avrdevice>ATmega328P</avrdevice>
|
||||
<avrdeviceseries>none</avrdeviceseries>
|
||||
<OutputType>Executable</OutputType>
|
||||
<Language>CPP</Language>
|
||||
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
|
||||
<OutputFileExtension>.elf</OutputFileExtension>
|
||||
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
|
||||
<AssemblyName>tsb_asm</AssemblyName>
|
||||
<Name>tsb_asm</Name>
|
||||
<RootNamespace>tsb_asm</RootNamespace>
|
||||
<ToolchainFlavour>avr-g++-16.1.0</ToolchainFlavour>
|
||||
<KeepTimersRunning>true</KeepTimersRunning>
|
||||
<OverrideVtor>false</OverrideVtor>
|
||||
<CacheFlash>true</CacheFlash>
|
||||
<ProgFlashFromRam>true</ProgFlashFromRam>
|
||||
<RamSnippetAddress>0x20000000</RamSnippetAddress>
|
||||
<UncachedRange />
|
||||
<preserveEEPROM>true</preserveEEPROM>
|
||||
<OverrideVtorValue>exception_table</OverrideVtorValue>
|
||||
<BootSegment>2</BootSegment>
|
||||
<ResetRule>0</ResetRule>
|
||||
<eraseonlaunchrule>0</eraseonlaunchrule>
|
||||
<EraseKey />
|
||||
<AsfFrameworkConfig>
|
||||
<framework-data xmlns="">
|
||||
<options />
|
||||
<configurations />
|
||||
<files />
|
||||
<documentation help="" />
|
||||
<offline-documentation help="" />
|
||||
<dependencies>
|
||||
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.52.0" />
|
||||
</dependencies>
|
||||
</framework-data>
|
||||
</AsfFrameworkConfig>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<ToolchainSettings>
|
||||
<AvrGccCpp>
|
||||
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
|
||||
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
|
||||
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
|
||||
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
|
||||
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
|
||||
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
|
||||
<avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<ListValues>
|
||||
<Value>NDEBUG</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<avrgcccpp.compiler.directories.IncludePaths>
|
||||
<ListValues>
|
||||
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.directories.IncludePaths>
|
||||
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
|
||||
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
|
||||
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
|
||||
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
|
||||
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics</avrgcccpp.compiler.miscellaneous.OtherFlags>
|
||||
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
|
||||
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
|
||||
</AvrGccCpp>
|
||||
</ToolchainSettings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<ToolchainSettings>
|
||||
<AvrGccCpp>
|
||||
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
|
||||
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
|
||||
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
|
||||
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
|
||||
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
|
||||
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
|
||||
<avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<ListValues>
|
||||
<Value>DEBUG</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.symbols.DefSymbols>
|
||||
<avrgcccpp.compiler.directories.IncludePaths>
|
||||
<ListValues>
|
||||
<Value>$(MSBuildProjectDirectory)\..\..\libavr\include</Value>
|
||||
</ListValues>
|
||||
</avrgcccpp.compiler.directories.IncludePaths>
|
||||
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
|
||||
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
|
||||
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
|
||||
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
|
||||
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -Werror -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -gdwarf-4</avrgcccpp.compiler.miscellaneous.OtherFlags>
|
||||
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
|
||||
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax -nostartfiles -Wl,--section-start=.text=0x7e00 -Wl,--defsym=tsb_app=0 -Wl,--pmem-wrap-around=32k</avrgcccpp.linker.miscellaneous.LinkerFlags>
|
||||
</AvrGccCpp>
|
||||
</ToolchainSettings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\tsb\tsb_asm.cpp">
|
||||
<SubType>compile</SubType>
|
||||
<Link>tsb\tsb_asm.cpp</Link>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="tsb" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(AVRSTUDIO_EXE_PATH)\Vs\Compiler.targets" />
|
||||
</Project>
|
||||
2
libavr
2
libavr
Submodule libavr updated: a8ed8c4851...911a87538f
@@ -116,6 +116,17 @@ else()
|
||||
math(EXPR _pb_limit "${_pb_slot} - 2")
|
||||
endif()
|
||||
|
||||
# The pins each USART owns. A bit-banged link deployed on them has to release
|
||||
# that USART before it can drive the line, and those instructions are the one
|
||||
# way the choice of pins moves the image — so a size matrix needs them as an
|
||||
# axis even though pins are otherwise immediate operands. Uniform across every
|
||||
# mega libavr covers: USART0 (the classics' un-numbered USART included) on
|
||||
# PD0/PD1, USART1 on PD2/PD3.
|
||||
set(_pb_usart0_rx pd0)
|
||||
set(_pb_usart0_tx pd1)
|
||||
set(_pb_usart1_rx pd2)
|
||||
set(_pb_usart1_tx pd3)
|
||||
|
||||
# simavr names its cores after the base dies; the A revisions run on them
|
||||
# (the 644PA on the 644P core).
|
||||
set(_pb_sim_mcu ${LIBAVR_MCU})
|
||||
@@ -125,6 +136,19 @@ elseif(LIBAVR_MCU STREQUAL "atmega644pa")
|
||||
set(_pb_sim_mcu atmega644p)
|
||||
endif()
|
||||
|
||||
# Where SRAM begins: the classic megas keep it right after the plain I/O
|
||||
# registers, the x8/x4 generations push it past their extended I/O file, and
|
||||
# the tinies match the classics. An autobaud loader's measured unit lives at
|
||||
# exactly this address (the host reads it there — pureboot.py), and the
|
||||
# unit-position test holds the layout to it.
|
||||
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
|
||||
set(_pb_ram 0x60)
|
||||
elseif(LIBAVR_MCU MATCHES "^atmega")
|
||||
set(_pb_ram 0x100)
|
||||
else()
|
||||
set(_pb_ram 0x60)
|
||||
endif()
|
||||
|
||||
# The function runs in its caller's scope, so everything it needs crosses
|
||||
# scopes as global properties.
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
|
||||
@@ -133,6 +157,8 @@ 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_TX ${_pb_usart0_tx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART1_TX ${_pb_usart1_tx})
|
||||
|
||||
# The port's own build (tests, the size matrix) reads the geometry from the
|
||||
# parent scope; a downstream consumer gets the same variables for free.
|
||||
@@ -142,9 +168,14 @@ set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE)
|
||||
set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
|
||||
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
|
||||
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
|
||||
set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
|
||||
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
|
||||
set(PUREBOOT_USART0_RX ${_pb_usart0_rx} PARENT_SCOPE)
|
||||
set(PUREBOOT_USART0_TX ${_pb_usart0_tx} PARENT_SCOPE)
|
||||
set(PUREBOOT_USART1_RX ${_pb_usart1_rx} PARENT_SCOPE)
|
||||
set(PUREBOOT_USART1_TX ${_pb_usart1_tx} PARENT_SCOPE)
|
||||
|
||||
# The rates a default may pick, fastest first.
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
|
||||
@@ -194,15 +225,29 @@ function(pureboot_default_baud clock software outvar)
|
||||
endfunction()
|
||||
|
||||
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
|
||||
# [SERIAL auto|hardware|software] [USART <n>]
|
||||
# [RX <pin>] [TX <pin>] [TIMEOUT <s>])
|
||||
# [SERIAL auto|hardware|software|autobaud] [USART <n>]
|
||||
# [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
|
||||
#
|
||||
# The loader target plus its flashable images (<name>.hex for a programmer,
|
||||
# <name>.bin for --update-loader). The resolved deployment is stamped on the
|
||||
# target as PUREBOOT_HZ / PUREBOOT_BAUD / PUREBOOT_LINK (the link spelled
|
||||
# usart0, usart1 or sw:<RX>,<TX>) — what a test harness speaks to it with.
|
||||
# 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.
|
||||
#
|
||||
# SERIAL autobaud measures the host's bit timing at run time, so the image
|
||||
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
|
||||
# and one binary per chip serves every F_CPU and every rate. The stamped
|
||||
# PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
|
||||
# not what it was built for.
|
||||
#
|
||||
# OSCCAL bakes a measured oscillator trim into the loader (README.md: the
|
||||
# RC-oscillator deployment answer): the byte is written at the top of run(),
|
||||
# so every reset path — the watchdog hand-over included — runs on the
|
||||
# corrected clock. Orthogonal to the backend: an autobaud build may carry it
|
||||
# purely for the application's benefit, its own link being clock-free. No
|
||||
# value, no code.
|
||||
function(pureboot_add_loader name)
|
||||
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN})
|
||||
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
|
||||
if(PB_UNPARSED_ARGUMENTS)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
|
||||
endif()
|
||||
@@ -222,8 +267,8 @@ function(pureboot_add_loader name)
|
||||
if(NOT PB_SERIAL)
|
||||
set(PB_SERIAL auto)
|
||||
endif()
|
||||
if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software")
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software")
|
||||
if(DEFINED PB_USART AND NOT PB_SERIAL MATCHES "^(auto|hardware)$")
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL ${PB_SERIAL}")
|
||||
endif()
|
||||
if(DEFINED PB_USART)
|
||||
set(PB_SERIAL hardware)
|
||||
@@ -252,7 +297,7 @@ function(pureboot_add_loader name)
|
||||
set(PB_SERIAL software)
|
||||
endif()
|
||||
endif()
|
||||
if(PB_SERIAL STREQUAL "software")
|
||||
if(PB_SERIAL MATCHES "^(software|autobaud)$")
|
||||
if(NOT PB_RX)
|
||||
set(PB_RX pb0)
|
||||
endif()
|
||||
@@ -264,12 +309,26 @@ function(pureboot_add_loader name)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1")
|
||||
endif()
|
||||
endforeach()
|
||||
if(PB_SERIAL STREQUAL "autobaud")
|
||||
set(_serial_defines PUREBOOT_AUTOBAUD PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
|
||||
else()
|
||||
set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX})
|
||||
# sw:<RX>,<TX> as port letter and bit, upcased.
|
||||
endif()
|
||||
# sw:<RX>,<TX> as port letter and bit, upcased — with @<n> where
|
||||
# the TX pin is a USART's own TXD, since a harness driving that
|
||||
# link has to know the USART owns the pin until the loader
|
||||
# releases it.
|
||||
string(SUBSTRING ${PB_RX} 1 2 _rx_pin)
|
||||
string(SUBSTRING ${PB_TX} 1 2 _tx_pin)
|
||||
string(TOUPPER "sw:${_rx_pin},${_tx_pin}" _link)
|
||||
string(REPLACE "SW" "sw" _link ${_link})
|
||||
get_property(_tx0 GLOBAL PROPERTY PUREBOOT_USART0_TX)
|
||||
get_property(_tx1 GLOBAL PROPERTY PUREBOOT_USART1_TX)
|
||||
if(_usart AND PB_TX STREQUAL _tx0)
|
||||
set(_link "${_link}@0")
|
||||
elseif(_usart1 AND PB_TX STREQUAL _tx1)
|
||||
set(_link "${_link}@1")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(NOT PB_BAUD)
|
||||
@@ -280,23 +339,36 @@ function(pureboot_add_loader name)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(PB_SERIAL STREQUAL "autobaud")
|
||||
# No clock and no baud reach the image; the window is a poll budget.
|
||||
set(_defines ${_serial_defines})
|
||||
else()
|
||||
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
|
||||
${_serial_defines})
|
||||
endif()
|
||||
if(DEFINED PB_OSCCAL)
|
||||
math(EXPR _osccal "${PB_OSCCAL}" OUTPUT_FORMAT DECIMAL)
|
||||
if(_osccal LESS 0 OR _osccal GREATER 255)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): OSCCAL ${PB_OSCCAL} is not one byte")
|
||||
endif()
|
||||
list(APPEND _defines PUREBOOT_OSCCAL=${_osccal})
|
||||
endif()
|
||||
|
||||
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
|
||||
target_link_libraries(${name} PRIVATE libavr)
|
||||
target_compile_definitions(${name} PRIVATE ${_defines})
|
||||
# Codegen shaping for the loader TU only, worth 14–36 B depending on the
|
||||
# chip. At -Os GCC otherwise rewrites the byte-stream loops' counters into
|
||||
# end-pointer forms that cost registers (-fno-ivopts,
|
||||
# -fno-split-wide-types), leaves register pressure on the table with the
|
||||
# default allocator (-fira-algorithm=priority), and keeps loop-invariant
|
||||
# immediates and expression temporaries in registers
|
||||
# (-fno-move-loop-invariants, -fno-tree-ter) — but every loop body here
|
||||
# contains a call, so a register held across it costs more than the
|
||||
# load-immediate it saves.
|
||||
# Codegen shaping for the loader TU only. At -Os GCC otherwise rewrites the
|
||||
# byte-stream loops' counters into end-pointer forms that cost registers
|
||||
# (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the
|
||||
# table with the default allocator (-fira-algorithm=priority), and keeps
|
||||
# expression temporaries in registers (-fno-tree-ter) — but every loop body
|
||||
# here contains a call, so a register held across it costs more than the
|
||||
# load-immediate it saves. The set is fitted to the loader's body and has to
|
||||
# be re-measured when that body changes: -fno-move-loop-invariants belonged
|
||||
# here while the command loop carried four transfer bodies and costs bytes
|
||||
# now that it carries one.
|
||||
target_compile_options(${name} PRIVATE
|
||||
-fno-ivopts -fira-algorithm=priority -fno-move-loop-invariants -fno-tree-ter -fno-split-wide-types)
|
||||
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
|
||||
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
|
||||
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
|
||||
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
|
||||
@@ -310,3 +382,4 @@ function(pureboot_add_loader name)
|
||||
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
|
||||
PUREBOOT_LINK ${_link})
|
||||
endfunction()
|
||||
|
||||
|
||||
@@ -8,46 +8,54 @@ erase, reset-vector surgery, updating the loader itself — lives in the host
|
||||
tool (`pureboot.py`).
|
||||
|
||||
The image is **position-independent**: control flow is PC-relative, the
|
||||
read/write paths take wire addresses, the write guard protects the slot the
|
||||
code is *running* in (from the runtime return address), the info block is
|
||||
addressed from that same anchor, and the application jump is an indirect call
|
||||
transfer paths take wire addresses, the write guard protects the slot the code
|
||||
is *running* in (from the runtime return address), nothing else is
|
||||
flash-resident to address at all, and the application jump is an indirect call
|
||||
to an absolute entry. The identical binary therefore runs from any slot with
|
||||
every command intact, which makes pureboot **its own staging loader**: the
|
||||
host installs the same binary one slot below the resident, jumps into it, and
|
||||
lets it rewrite the resident.
|
||||
every command intact, which makes pureboot **its own staging loader**: the host
|
||||
installs the same binary one slot below the resident, jumps into it, and lets
|
||||
it rewrite the resident. The lint holds it to that literally — the image must
|
||||
come out byte-identical linked at a different base.
|
||||
|
||||
## Chips
|
||||
|
||||
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a
|
||||
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on
|
||||
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves
|
||||
per build — see *Configuration*; the largest image any of them produces is a
|
||||
software UART at a slow baud, which on the 1284s is 494 B, the tightest fit in
|
||||
the whole matrix at 18 B spare.
|
||||
per build — see *Configuration*. The autobaud column is the clock-free build,
|
||||
which is the largest the space produces and the tightest fit in the matrix;
|
||||
it carries the calibration machinery and no clock at all.
|
||||
|
||||
| Chip | Flash | Loader at | Link | Size |
|
||||
|---|---|---|---|---|
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 416 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 420 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 424 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 424 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 396 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 400 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 400 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 414 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 434 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 438 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 438 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 438 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 438 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 432 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 478 B |
|
||||
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|
||||
|---|---|---|---|---|---|
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 390 B | 460 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 394 B | 464 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 398 B | 468 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 398 B | 468 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 360 B | 474 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 362 B | 480 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 362 B | 480 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 388 B | 464 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 398 B | 474 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 394 B | 474 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 420 B | 500 B |
|
||||
|
||||
† No hardware boot section: the host patches the reset vector, and the budget
|
||||
is 510 bytes, since the slot's last word is the trampoline.
|
||||
|
||||
The 1284s are the heaviest because they alone carry the far-flash machinery —
|
||||
ELPM reads, RAMPZ page commands, a word-addressed wire.
|
||||
The tightest fit in the whole space is the 1284s' autobaud build deployed on a
|
||||
USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone
|
||||
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud
|
||||
alone carries the calibration loop, a bit-banged link on a USART's pins alone
|
||||
has to release it (below), and the trim adds its one register write. Without
|
||||
the trim that build is 504; on the default pins, 500. The flash bank riding
|
||||
in a transfer's selector byte keeps even those chips' addressing the same
|
||||
16-bit form every other chip uses, which is why they are no longer the
|
||||
outlier they were.
|
||||
|
||||
The software UART enables the RX pull-up; TX idles high. All multi-byte wire
|
||||
quantities are little-endian.
|
||||
@@ -62,10 +70,11 @@ repo's build and by a downstream project alike:
|
||||
|---|---|---|
|
||||
| `CLOCK <hz>` | the clock the board runs | 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s |
|
||||
| `BAUD <bd>` | the wire rate | the ladder below |
|
||||
| `SERIAL auto\|hardware\|software` | the link backend | `auto`: the hardware USART where the chip has one |
|
||||
| `SERIAL auto\|hardware\|software\|autobaud` | the link backend | `auto`: the hardware USART where the chip has one |
|
||||
| `USART <n>` | the USART instance (x4 megas carry two) | 0 |
|
||||
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
|
||||
| `TIMEOUT <s>` | the activation window | 8 |
|
||||
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
|
||||
|
||||
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock
|
||||
reaches within 2.5 % — the same U2X-included divisor search libavr's baud
|
||||
@@ -74,15 +83,55 @@ receiver's 100-cycles-a-bit floor. Whatever is picked or overridden is
|
||||
re-checked in the compile: an infeasible combination, or a USART the chip does
|
||||
not have, fails with a named static assert.
|
||||
|
||||
Putting a bit-banged link on a USART's own pins is a supported deployment, and
|
||||
the usual one where a board's USB bridge is wired to RXD/TXD: the link's `init`
|
||||
clears that USART's `UCSRnB` first, because while its `TXEN` is set the USART —
|
||||
not the port register — owns the TX pin, and a loader entered from an
|
||||
application that left it enabled would receive and obey while answering nothing
|
||||
(§20.2). It costs four bytes, and only on those pins.
|
||||
|
||||
`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing
|
||||
at run time, so `CLOCK` and `BAUD` are not build parameters there and one
|
||||
binary per chip serves every clock and every rate. It is for the deployments
|
||||
whose clock is not known at build time and does not hold still — the internal
|
||||
RC oscillator, ±10 % from the factory and moving with supply and temperature —
|
||||
where a fixed-baud software build has to be rebuilt per clock and still drifts
|
||||
out of tolerance. The cost is that it is software-serial only (a hardware USART
|
||||
needs its divisor programmed) and that activation counts poll iterations rather
|
||||
than seconds, since there is no clock to convert them against
|
||||
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000).
|
||||
|
||||
**Pick the rate by cycles a bit, and leave the oscillator room.** What the
|
||||
calibration can measure is bounded by how many clock cycles one bit lasts, so a
|
||||
rate is only ever sensible relative to the clock. Two different floors matter:
|
||||
|
||||
| | cycles a bit |
|
||||
|---|---|
|
||||
| the logic's floor — exact clock, simulated | solid to ~36, fails outright by ~31 (`pureboot.autobaud` gates a point here) |
|
||||
| a factory-trimmed internal RC, measured on an ATtiny13A | reliable at ~118; already locking 1 attempt in 5 by ~59 |
|
||||
|
||||
The gap is the oscillator's own jitter, and no exact-clock simulation shows it.
|
||||
So on an RC part, **budget about 100 cycles a bit** — the same order as the
|
||||
fixed-baud software receiver's floor — rather than the logic's ~36. Measured
|
||||
envelope on that ATtiny13A, with an application resident: 9.6 and 4.8 MHz reach
|
||||
115200, 1.2 MHz reaches 9600, 600 kHz reaches 4800, 128 kHz reaches 2400.
|
||||
|
||||
One trap in testing this: on a patched-vector chip an **erased** application
|
||||
region walks straight back up into the loader, so every expired window opens
|
||||
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.
|
||||
|
||||
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
|
||||
software UART on hand-picked pins, say:
|
||||
software UART on hand-picked pins, say. A submodule pins the loader version
|
||||
(the tags name them; this repo pins its own libavr the same way), where
|
||||
FetchContent tracks whatever `main` is:
|
||||
|
||||
```cmake
|
||||
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main)
|
||||
FetchContent_MakeAvailable(bootloader)
|
||||
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)
|
||||
# git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent
|
||||
add_subdirectory(bootloader/pureboot pureboot)
|
||||
|
||||
pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
|
||||
```
|
||||
@@ -98,17 +147,35 @@ speak to the build. This exact deployment runs the full protocol suite in CI
|
||||
|
||||
Reset enters the loader (BOOTRST on the boot-sectioned megas, the patched
|
||||
reset vector elsewhere) — except a watchdog reset, which hands straight to the
|
||||
application, since the application owns its watchdog and must clear WDRF
|
||||
itself.
|
||||
application with no activation window, since the application owns its watchdog.
|
||||
This is deliberate: it lets an application reboot itself instantly rather than
|
||||
sit through the window. The application must clear WDRF itself (libavr's
|
||||
`watchdog::disable()` does). **Gotcha:** WDRF is sticky (cleared only by
|
||||
software, not by a later reset), so an application that watchdog-resets and
|
||||
never clears it diverts *every* subsequent reset — external ones included —
|
||||
past the window too, and the loader becomes reachable only through an external
|
||||
programmer until the flag is cleared. A serial recovery path therefore assumes
|
||||
the application clears WDRF on its own reset path.
|
||||
|
||||
The host then knocks `p` then `b`, each awaited byte under a fresh activation
|
||||
window; any other byte is discarded and awaited again, so line noise can delay
|
||||
the loader but never lock it. A window expiring on an idle line boots the
|
||||
application.
|
||||
|
||||
An autobaud build opens differently, because it has to learn the rate before it
|
||||
can read a byte at all: the host sends the **calibration byte 0xC0** — a start
|
||||
bit plus six zero data bits form one low pulse of seven bit-times — and the
|
||||
loader times that pulse into its bit period. A single `p` then activates; the
|
||||
pulse has already proven a host is present, which the two-byte knock exists to
|
||||
establish elsewhere. Both waits are bounded, so a stray low pulse with no host
|
||||
behind it costs one window and then boots the application rather than holding
|
||||
the loader.
|
||||
|
||||
The window is a compile-time constant (`TIMEOUT`, 8 s by default), so the whole
|
||||
EEPROM belongs to the application — pureboot keeps no state of its own.
|
||||
Re-timing a deployed loader is a self-update with a re-timed build.
|
||||
Re-timing a deployed loader is a self-update with a re-timed build. An autobaud
|
||||
build counts poll iterations instead (`PUREBOOT_AUTOBAUD_POLLS`), there being
|
||||
no clock to turn into seconds.
|
||||
|
||||
## Session
|
||||
|
||||
@@ -118,68 +185,126 @@ write and sends the prompt `+` (0x2b), which is therefore also the previous
|
||||
command's completion ack. A session is: await `+`, send a command, read its
|
||||
reply, repeat.
|
||||
|
||||
On chips whose flash exceeds 64 KiB (the 1284s — info-block flag bit 1) the
|
||||
`R`/`W` flash addresses are **word** addresses; everywhere else they are byte
|
||||
addresses (the 644s' 64 KiB is exactly the 16-bit byte space). EEPROM
|
||||
addresses and all counts are bytes.
|
||||
Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in
|
||||
the command's selector byte, so no command has to speak word addresses. `J` is
|
||||
the exception: it takes a word address, because that is what the hardware's own
|
||||
jump takes. EEPROM and data-space addresses and all counts are bytes.
|
||||
|
||||
The loader trusts the host to keep addresses in range: it does not bound them
|
||||
against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps —
|
||||
EEAR is only as wide as the array, so an address past the end truncates onto
|
||||
low EEPROM and the write silently overwrites it. Keeping transfers within the
|
||||
real sizes is the host's job (the shipped tool does); the flash budget is
|
||||
better spent on features than on re-checking a bound the host already holds.
|
||||
|
||||
| Cmd | Arguments | Reply |
|
||||
|---|---|---|
|
||||
| `b` | — | the 12-byte info block |
|
||||
| `R` | addr16, n8 | n flash bytes (n = 0 means 256) |
|
||||
| `W` | addr16 (any address in the page), then one page of data | — (completion = next prompt) |
|
||||
| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) |
|
||||
| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
|
||||
| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse |
|
||||
| `b` | — | 4 bytes: the pureboot version, then the three signature bytes |
|
||||
| `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) |
|
||||
| `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun |
|
||||
| `W` | sel8, addr16, then one page of data | — (completion = next prompt) |
|
||||
| `J` | word address (16-bit) | `+`, then execution continues there |
|
||||
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
|
||||
|
||||
`W` streams exactly one SPM page (size from the info block) into the buffer,
|
||||
then erases and programs — except pages inside the 512-byte slot
|
||||
the loader is *running* in, which are drained and left alone, so a broken host
|
||||
cannot brick the running copy and a staged copy may rewrite the resident.
|
||||
`G` and `g` are one letter in two cases, which is the whole command set for
|
||||
every memory: the **selector** byte's low nibble names the space and its high
|
||||
nibble carries the flash bank.
|
||||
|
||||
| Space | | |
|
||||
|---|---|---|
|
||||
| 0 | flash | read-only here; it is written through `W` and the SPM space |
|
||||
| 1 | EEPROM | |
|
||||
| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR |
|
||||
| 3 | fuse and lock | index 0..3 in the hardware's own Z order: low, lock, extended, high |
|
||||
| 4 | SPM | write-only: the byte goes to SPMCSR and fires the instruction at the address |
|
||||
|
||||
The data space is worth more than it looks. pureboot keeps **zero static RAM**
|
||||
and pushes no register, so at loader entry an application's SRAM is still
|
||||
whatever the application left there, bar the handful of bytes of return-address
|
||||
stack — which makes `G` over space 2 a post-mortem of a running application,
|
||||
not just a poke hole. The same address space carries the register file and the
|
||||
I/O registers, so peripheral state is readable too; reading some of those has
|
||||
side effects (reading UDR clears its flags), which is the host's business to
|
||||
know.
|
||||
|
||||
Programming a page is therefore `W` to fill the buffer, then a `g` to the SPM
|
||||
space for the erase, another for the write, and on a boot-sectioned chip a
|
||||
third to re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR
|
||||
encodings every part pureboot targets shares. The loader carries no page-commit
|
||||
logic of its own, and the same primitive reaches every other SPM operation,
|
||||
lock bits included.
|
||||
|
||||
The SPM store and the SPM instruction must issue within four cycles of each
|
||||
other (§26.2), which no host can hit across a serial link — so this one
|
||||
primitive is *fused* rather than being a poke of SPMCSR followed by a poke of
|
||||
something else. That four-cycle window is the floor on how low-level a
|
||||
bootloader's primitives can go; it is not a byte-count decision.
|
||||
|
||||
An SPM command aimed at the 512-byte slot the loader is **running in** is
|
||||
dropped, so a broken host cannot brick the running copy, while a staged copy
|
||||
one slot lower may rewrite the resident — which is what a self-update is.
|
||||
|
||||
The loader never clears the SPM buffer before a fill, so **one `W` may program
|
||||
the wrong bytes, and the host is what fixes it**. The buffer is write-once per
|
||||
word until cleared, and two things leave words in it: a refused page, and —
|
||||
where SPM runs from anywhere, the tinies and the m48s — an application that
|
||||
self-programmed before entering. The next `W` takes those stale words and
|
||||
clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on the
|
||||
tinies), so repeating it programs correctly. The host therefore verifies every
|
||||
page it writes and rewrites what comes back wrong (three retries, then it
|
||||
self-programmed before entering. The next page write takes those stale words
|
||||
and clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on
|
||||
the tinies), so repeating it programs correctly. The host therefore verifies
|
||||
every page it writes and rewrites what comes back wrong (three retries, then it
|
||||
stops).
|
||||
|
||||
`w` is host-paced: send the next byte only after the previous byte's `+`. `F`
|
||||
returns the bytes in the hardware's Z order; on a chip without an extended
|
||||
fuse byte that slot carries no meaning. Fuse *writing* does not exist — SPM
|
||||
reaches flash and boot lock bits only.
|
||||
`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse
|
||||
*writing* does not exist — SPM reaches flash and boot lock bits only.
|
||||
|
||||
`J` is the one control-transfer primitive: it runs the application (word 0 or
|
||||
the trampoline word, both known from the info block) and moves between loader
|
||||
the trampoline word, both derived from the chip) and moves between loader
|
||||
copies during a self-update. A jump to a slot's base re-enters that copy's own
|
||||
startup, which must then be knocked afresh.
|
||||
|
||||
The info block (`b`):
|
||||
`b` answers with the loader's identity — its version and the chip's signature —
|
||||
and nothing else. Everything else the host needs (page size, loader base,
|
||||
EEPROM size, whether the reset vector must be patched, how many flash banks)
|
||||
follows from the signature, and the host holds that table; the loader derived
|
||||
the same facts from its own chip database at build time, so nothing is guessed,
|
||||
it is simply not sent twice.
|
||||
|
||||
| Offset | Content |
|
||||
|---|---|
|
||||
| 0–2 | `'P'`, `'B'`, pureboot version (3) |
|
||||
| 3–5 | device signature |
|
||||
| 6 | SPM page size in bytes (0 means 256) |
|
||||
| 7–8 | loader base — application flash ends here (a word address when bit 1 is set) |
|
||||
| 9–10 | EEPROM size |
|
||||
| 11 | bit 0: host must patch the reset vector (no hardware boot section); bit 1: flash wire addresses are word addresses |
|
||||
An update image, though, is a bare 512-byte slot with no device to ask, and
|
||||
installing one built for another chip bricks the target. Every loader image
|
||||
therefore carries a six-byte **stamp** — `'P'`, `'B'`, the version, the three
|
||||
signature bytes — which the loader itself never reads and the host tool refuses
|
||||
to install a mismatch against.
|
||||
|
||||
## Version
|
||||
|
||||
The info block's third byte is the **pureboot version** — the loader's one
|
||||
identity number, and the only way to tell what a deployed loader is. Nothing
|
||||
else is numbered: the wire protocol has no version, a pureboot version implies
|
||||
it, and the host tool holds that map. The tool states the window of loader
|
||||
versions it speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a
|
||||
version that changes the protocol becomes the new floor there. None has so
|
||||
far: 1 through 3 speak the identical session. A loader newer than the tool is
|
||||
refused by name rather than decoded on the assumption that nothing moved.
|
||||
`b`'s first byte is the **pureboot version** — the loader's one identity
|
||||
number, and the only way to tell what a deployed loader is. Nothing else is
|
||||
numbered: the wire protocol has no version, a pureboot version implies it, and
|
||||
the host tool holds that map. The tool states the window of loader versions it
|
||||
speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a version that
|
||||
changes the protocol becomes the new floor there. A loader newer than the tool
|
||||
is refused by name rather than decoded on the assumption that nothing moved.
|
||||
|
||||
Two generations exist. **1 through 4** speak one session — a 12-byte info block
|
||||
from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses).
|
||||
**5** replaced those with the single `G`/`g` pair over selector-named spaces
|
||||
above; the shipped tool speaks both, choosing on the version it reads, so a
|
||||
deployed pureboot 4 stays drivable and self-updatable to 5. **6** changes
|
||||
nothing on the wire: it marks the builds that may carry a baked `OSCCAL` trim
|
||||
(Configuration), so a tool driving an update knows such images exist.
|
||||
|
||||
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
|
||||
version ever gained — and each tag carries the `libavr/` submodule pinned to
|
||||
the libavr that loader was built against, as the whole libavr era does commit
|
||||
by commit. `git checkout v3 && git submodule update --init libavr` followed by
|
||||
the usual preset build therefore reproduces the v3 loader exactly; the open
|
||||
generation is `main`.
|
||||
|
||||
Collapsing four command bodies into one transfer loop is what paid for the
|
||||
version: the data space, the host-issued SPM operations and the fuses now share
|
||||
the loop, the cursor and the argument decode that `R`/`r`/`w` each carried a
|
||||
copy of. The loader shrank while gaining all three.
|
||||
|
||||
The tool carries its own version, free to drift; `--version` prints it and the
|
||||
window.
|
||||
@@ -231,6 +356,18 @@ mega (SPM only executes from the boot section — reflash the .hex), but *runs*
|
||||
on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it
|
||||
into the top slot from there (`pureboot.rehome`).
|
||||
|
||||
**Fixed-baud on an internal RC oscillator is a deployment risk the build
|
||||
cannot see.** The factory trim is ±10 % where an 8N1 frame survives about
|
||||
±4: a part at the edge answers nothing at the built rate, and the symptom —
|
||||
silence — reads as a wiring fault (a real ATtiny13A measured −5.5 %, outside
|
||||
every standard rate at its own documented default). The **autobaud build is
|
||||
the deployment-proof backend**: it has no rate to miss. Where fixed-baud on
|
||||
RC is wanted anyway, measure first and bake the trim: an autobaud session's
|
||||
`--info` prints the part's true clock from the loader's own measured bit
|
||||
period, OSCCAL moves the oscillator about 1 % per step, and `OSCCAL <byte>`
|
||||
builds the correction in — one build–measure iteration converges. A loader
|
||||
already deployed and silent is diagnosed with `--scan` (Host tool).
|
||||
|
||||
## Updating the loader
|
||||
|
||||
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
|
||||
@@ -238,11 +375,32 @@ with any pureboot build — a re-timed window, a newer version — using the
|
||||
loader itself as its own staging loader. The image is the loader's own 512
|
||||
bytes as a raw binary, or the Intel HEX the build emits beside it.
|
||||
|
||||
The preflight refuses an image built for another chip: the info block embedded
|
||||
in every pureboot binary (signature, page size, loader base, EEPROM size,
|
||||
flags) must match the device's own, and the error names both. Die revisions
|
||||
share their base signature and geometry, so their images are interchangeable —
|
||||
as the silicon is.
|
||||
One thing the image cannot tell the host: **which link it speaks.** The update
|
||||
works by entering copies of the *new* image (steps 3 and 4 below), so a build
|
||||
made for another baud or another backend answers on that one and not on the
|
||||
session's — and 512 bytes of position-independent code carry no header to read
|
||||
it from. Where the new image's link differs, name it:
|
||||
|
||||
```sh
|
||||
# a 57600 fixed-baud resident, replaced by an autobaud build
|
||||
pureboot.py --port … --baud 57600 --update-loader ab.bin --staged-autobaud
|
||||
# …or by a 38400 build of the same backend
|
||||
pureboot.py --port … --baud 57600 --update-loader sw38400.bin --staged-baud 38400
|
||||
```
|
||||
|
||||
The host retunes on the open port, so no DTR pulse resets the copy it is talking
|
||||
to. Omit them against a changed link and the update stops after installing the
|
||||
staging copy, saying so and naming this as the cause.
|
||||
|
||||
An `OSCCAL`-baked image is a link change in effect even at an unchanged rate
|
||||
on paper: the staging copy shifts the physical clock the moment its `run()`
|
||||
starts, and from then on speaks exactly what it was built for. Declare it
|
||||
like any other link change — `--staged-baud` with the new build's rate.
|
||||
|
||||
The preflight refuses an image built for another chip: the stamp every pureboot
|
||||
binary carries must resolve to the device's own geometry, and the error names
|
||||
both. Die revisions share their base signature and geometry, so their images
|
||||
are interchangeable — as the silicon is.
|
||||
|
||||
1. The staging slot `[base−512, base)` is saved to a host-side state file (on
|
||||
the 1 KB tiny13s that is the whole application, vectors included).
|
||||
@@ -259,10 +417,15 @@ as the silicon is.
|
||||
content, and the state file is discarded.
|
||||
|
||||
Every phase is idempotent and keyed off the actual flash state, so re-running
|
||||
the same command after any interruption resumes and completes. The state file
|
||||
carries the only bytes not recoverable from the device; losing it mid-update
|
||||
still completes the update, and the staging region comes back by reflashing
|
||||
the application. A boot-sectioned mega needs its fuses for the preflight — read
|
||||
the same command after any interruption resumes and completes — with one
|
||||
qualification, which is the link again: from step 2 on, the copy the re-run has
|
||||
to reach is the *new* image, so a resumed run needs the same `--staged-*` as the
|
||||
first one. On a patched-vector part step 3 also re-aims word 0 at the staging
|
||||
copy, so after that point a reset reaches the new image's link and **only** that
|
||||
one; a re-run on the resident's link finds nothing at all. The state file carries
|
||||
the only bytes not recoverable from the device; losing it mid-update still
|
||||
completes the update, and the staging region comes back by reflashing the
|
||||
application. A boot-sectioned mega needs its fuses for the preflight — read
|
||||
from the device, or supplied with `--assume-fuses` where reading is impossible
|
||||
(simulators).
|
||||
|
||||
@@ -279,8 +442,8 @@ to reset gets its reset pulse and opens the activation window by itself.
|
||||
--info --fuses --flash app.hex
|
||||
|
||||
Operations run in a fixed order within one session: info, fuses, loader
|
||||
update, flash (erase / program / read / verify), EEPROM (the same) — then the
|
||||
loader hands over to the application. `--stay` keeps the session alive
|
||||
update, flash (erase / program / read / verify), EEPROM (the same), then
|
||||
`--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive
|
||||
instead, and a later invocation reconnects into it. `--flash` and `--eeprom`
|
||||
verify by read-back unless `--no-verify`, and a flash page that reads back
|
||||
wrong is rewritten up to three times before the run stops (see `W` above).
|
||||
@@ -288,16 +451,55 @@ wrong is rewritten up to three times before the run stops (see `W` above).
|
||||
extension. `--force` overrides the refusable safety checks — today, flashing
|
||||
application data into a mega's reset walk region.
|
||||
|
||||
Readouts come one fact per line: `--info` decodes the info block field by
|
||||
field, `--fuses` each fuse byte plus, on a boot-sectioned mega, its decoded
|
||||
meaning. Transfers that take wire time draw a transient progress bar on stderr
|
||||
`--autobaud` opens with the calibration pulse instead of the plain knock, for a
|
||||
loader built `SERIAL autobaud`; the rest of the session is identical, at
|
||||
whatever `--baud` the host chose. Its `--info` adds the **measured clock** —
|
||||
the loader's bit-period unit, decoded and multiplied by the session rate —
|
||||
which is the number an `OSCCAL` bake or a fixed-baud build for the part is
|
||||
held against; `--clock <hz>` states the drift against a nominal.
|
||||
|
||||
`--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
|
||||
±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
|
||||
window — reset the target as each probe announces itself (a board with DTR
|
||||
wired to reset is pulsed by the probe's own port-open). A loader
|
||||
off-frequency answers at its oscillator's ratio, and the report gives the
|
||||
found rate as the session workaround, the offset, the OSCCAL correction's
|
||||
direction at ~1 % per step, and the autobaud way out. Standalone — no other
|
||||
operation combines with it.
|
||||
|
||||
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) —
|
||||
SRAM, and through the same address space the register file and every I/O
|
||||
register. Reading an I/O register can have side effects (reading UDR clears its
|
||||
flags), which is the caller's business to know.
|
||||
|
||||
Reads are safe anywhere; **two small regions cannot be written without ending the
|
||||
session,** because they are what the loader is standing on:
|
||||
|
||||
- the **top of SRAM**, where its stack lives — a handful of bytes below RAMEND;
|
||||
- on an **autobaud** build, the **two bytes at RAMSTART**: the measured bit
|
||||
period, in `.noinit`, which is the whole of that loader's static RAM. Overwrite
|
||||
it and its next reply is timed against garbage. On an ATtiny13A that is
|
||||
`0x60..0x61`, and the symptom is a mangled prompt byte rather than any error —
|
||||
the loader is fine, it simply is no longer speaking the agreed rate.
|
||||
|
||||
Both are self-inflicted rather than defects, and a reset clears them. Note also
|
||||
that `--poke` can write OSCCAL, which does take effect — but a session can only
|
||||
survive a step or two of it before the clock walks the link out of the rate
|
||||
autobaud locked to, and OSCCAL reverts on reset regardless.
|
||||
|
||||
Readouts come one fact per line: `--info` prints the device's version and
|
||||
signature and the geometry that follows from them, `--fuses` each fuse byte
|
||||
plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr
|
||||
when it is a tty. `-v`/`--verbose` adds the decisions as they happen: knock
|
||||
counts, the programming plan, update state handling and per-phase page counts.
|
||||
|
||||
## Tests
|
||||
|
||||
`tools/check.sh` runs every chip's workflow (`--full` adds the reflect-mode
|
||||
builds of libavr's spot set; `tools/make_presets.py` regenerates the presets).
|
||||
libavr rides as the `libavr/` submodule (`git submodule update --init libavr`);
|
||||
`LIBAVR_ROOT` (cache or environment) overrides it for tandem development
|
||||
against a working tree. `tools/check.sh` runs every chip's workflow (`--full`
|
||||
adds the reflect-mode builds of libavr's spot set; `tools/make_presets.py`
|
||||
regenerates the presets).
|
||||
Per chip preset, `ctest` runs:
|
||||
|
||||
- `pureboot.size` — the 510-byte (patched-vector) / 512-byte budget;
|
||||
@@ -307,23 +509,49 @@ Per chip preset, `ctest` runs:
|
||||
the fastest clock — where a software UART's per-bit spin outgrows its
|
||||
one-register delay loop and takes the 16-bit one. That is the largest image
|
||||
the configuration space produces, and a shape the ladder default (always the
|
||||
*fastest* rate a clock reaches) never picks. Pins are immediate operands and
|
||||
the timeout is a constant: neither is an axis;
|
||||
- `pbm_*.size` — under `--full`, the exhaustive cross product replacing that
|
||||
compact matrix: every plausible oscillator (the internal ones, the CKDIV8
|
||||
floor, the plain and the UART crystals) × every rate reachable from it ×
|
||||
every backend, unreachable combinations dropping out rather than aborting
|
||||
the configure. Bounded to one chip per size-bearing class — flash
|
||||
addressing, hand-over shape, page size, USART inventory — since everything
|
||||
else in the image is chip-independent code;
|
||||
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, the
|
||||
info block within the image's first 256 bytes;
|
||||
*fastest* rate a clock reaches) never picks. Pins are an axis for one reason
|
||||
only, and it is enough: a bit-banged link on a USART's own pins has to
|
||||
release that USART, so `pureboot_{sw,autobaud}_on_usart{0,1}` build there
|
||||
too. The timeout is a constant and is no axis;
|
||||
- `pureboot_autobaud.size` — the clock-free build, which has no clock or baud
|
||||
axis of its own: one binary per chip has to serve every point the matrix
|
||||
below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock
|
||||
shape and on the tightest image in the space (autobaud on a USART's own
|
||||
pins), holding both of the trim write's addressing encodings to the budget;
|
||||
- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
|
||||
object and sits exactly at ram_start, where `--info` reads it: wire
|
||||
contract, not layout accident;
|
||||
- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
|
||||
replacing that compact matrix, on **every** chip: every plausible oscillator
|
||||
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×
|
||||
every rate reachable from it × every backend, unreachable combinations
|
||||
dropping out rather than aborting the configure. Thousands of points per
|
||||
chip, and cheap enough to run rather than reason about;
|
||||
- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, no
|
||||
flash-resident section but `.text`, and the image byte-identical when linked
|
||||
at a different base — which is position independence itself rather than a
|
||||
proxy for it;
|
||||
- `pureboot.handshake` — the host tool's activation must not hang on a target
|
||||
that never falls quiet: the drain after a prompt is bounded by the handshake
|
||||
deadline, and a well-behaved loader still connects;
|
||||
- `pureboot.updatelink` — an update whose image changes the baud or the backend
|
||||
must follow the staging copy onto *its* link, since that copy is the new image;
|
||||
and where nothing was declared, the failure must name the link rather than
|
||||
report a bare activation timeout, because by then the staging slot is written
|
||||
and on a 1 KiB tiny that was the application;
|
||||
- `pureboot.planner` — the host tool's pure logic: programming orders and their
|
||||
recovery properties, the surgery, the staging composition, the boot-fuse
|
||||
decode, the update preflight over synthetic fuse bytes, and the repairing
|
||||
verify against a fake device;
|
||||
- `pureboot.scan` — `--scan`'s walk and report logic: the probe order, the
|
||||
rate arithmetic, and the trim advice's direction. A pty carries bytes at
|
||||
any termios rate, so the rate physics itself belongs to the hardware
|
||||
harness, and what the wire would arbitrate is pinned as logic;
|
||||
- `presets.generated` — CMakePresets.json matches its generator
|
||||
(`tools/make_presets.py --check`), so a hand edit or a generator change
|
||||
cannot drift the pair apart;
|
||||
- `pureboot.protocol` — end to end against a simavr device
|
||||
(`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed
|
||||
(`test/pureboot_device.cpp`: a hardware USART as a pty, or a cycle-timed
|
||||
GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's
|
||||
tiny cores lack) driven by the real host tool through knock-from-reset,
|
||||
program + verify of both memories, session reconnect, an external reset
|
||||
@@ -340,6 +568,12 @@ Per chip preset, `ctest` runs:
|
||||
- `pureboot.usart1` (644A) — the same suite over the second hardware USART:
|
||||
instance selection is compile-checked everywhere, but only a live session
|
||||
proves the loader polls the USART it claims;
|
||||
- `pureboot.mute` (328P) — a software link on USART0's own pins, entered from an
|
||||
application that handed over with that USART still enabled: the loader must
|
||||
still answer, which it does only because it releases it. The pin ownership is
|
||||
the runner's, not simavr's — simavr wires a USART through IRQs and never takes
|
||||
the pin from the port, so without that model the state under test could not
|
||||
arise at all;
|
||||
- `pureboot.dirty` (328P) — entering the loader from a running application over
|
||||
an SPM buffer it deliberately dirtied, the case the loader declines to guard:
|
||||
a bare verify must see the corruption and the repairing verify must fix it in
|
||||
@@ -347,7 +581,59 @@ Per chip preset, `ctest` runs:
|
||||
anywhere, which is what makes the path constructible;
|
||||
- `pureboot.update` — the full `--update-loader` flow, then every power-fail
|
||||
phase: the device is killed mid-write, restarted from its flash dump, and a
|
||||
re-run must complete the update with the application intact.
|
||||
re-run must complete the update with the application intact;
|
||||
- `pureboot.osccal` (328P, t85) — a loader built with the `OSCCAL` axis holds
|
||||
the trim register at the built byte from its first prompt, observed through
|
||||
the wire on one chip per addressing encoding (`sts` and low-I/O `out`);
|
||||
- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
|
||||
bridge: the calibration handshake, a flash + EEPROM + fuse round trip against
|
||||
the simulator's own memory, a data-space round trip, the hand-over — then the
|
||||
same binary again at double the clock, which is the property the backend
|
||||
exists for. The measured clock `--info` prints is asserted against the
|
||||
simulator's exact clock, inside the unit encoding's own envelope, at both
|
||||
points. A lone calibration pulse with no knock behind it must still let
|
||||
the application boot, so no wait in activation can be unbounded.
|
||||
|
||||
`size`, `pi` and `planner` are host logic and run anywhere; the
|
||||
simulator-driven targets need simavr and a pty, so they are POSIX-only.
|
||||
`size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run
|
||||
anywhere; the simulator-driven targets need simavr and a pty, so they are
|
||||
POSIX-only.
|
||||
|
||||
## Hardware
|
||||
|
||||
The suite above proves the protocol on every chip; it cannot prove a *board*.
|
||||
Two things live only on silicon: an RC oscillator that is not on its nominal, and
|
||||
a reset edge that has to come from somewhere. `tools/pbrig.py` and
|
||||
`tools/pbhw.py` cover that, and know nothing per-board — every deployment fact
|
||||
is a flag or a `PUREBOOT_*` environment variable.
|
||||
|
||||
```sh
|
||||
export PUREBOOT_PROGRAMMER=atmelice_isp PUREBOOT_PART=t13 PUREBOOT_PORT=COM6
|
||||
tools/pbrig.py backup rig-backup/ # verified, before anything is written
|
||||
tools/pbhw.py --autobaud --loader build/ab.bin --app build/pbapp.hex --marker APP
|
||||
```
|
||||
|
||||
`pbrig.py` is the primitives — `signature`, `reset`, `flash`, `fuses`, `backup`,
|
||||
`rate` — and the module `pbhw.py` builds on. Two rig facts are encoded in it
|
||||
because neither is guessable: an **ISP access is the reset edge** (the part runs
|
||||
the moment the programmer releases it, which is the only edge available when the
|
||||
adapter's DTR is not wired to reset, so a session begins with an ISP touch and
|
||||
knocks immediately after), and **avrdude splits `-U` on colons**, so a Windows
|
||||
path's drive letter breaks the spec and every file is passed as a bare name with
|
||||
avrdude run in its own directory.
|
||||
|
||||
`pbrig.py rate` is the one that turns "the loader is silent, so the wiring must
|
||||
be wrong" into a number. Against a fixture built with `PUREBOOT_HEARTBEAT` — a
|
||||
*fixed* cycles-per-bit transmitter — it sweeps the host rate, and the band where
|
||||
the marker still decodes brackets the part's true bit rate; with the clock the
|
||||
image was built for, that is the clock the part is really running at. No
|
||||
instrument beyond the adapter already attached. An ATtiny13A measured this way
|
||||
came out at 9.072 MHz against its 9.6 MHz nominal, −5.5 % — inside the
|
||||
datasheet's ±10 % and outside what an 8N1 frame survives, which is the whole
|
||||
case for the autobaud backend on such a part.
|
||||
|
||||
`pbhw.py` takes its bounds from the info block the loader reports, so one run
|
||||
covers a 1 KiB tiny and a 128 KiB mega alike: identity, the EEPROM round trip
|
||||
and erase, an application flashed and verified and then *seen running*, the
|
||||
application region read and erased, the loader slot proven intact across that
|
||||
erase by an independent ISP read, and an oversized image refused. It overwrites
|
||||
the application flash and EEPROM, which is why `backup` comes first.
|
||||
|
||||
@@ -26,70 +26,135 @@ constexpr std::uint8_t ack = '+';
|
||||
|
||||
// Deployment parameters come from the build (pureboot_add_loader()). The
|
||||
// signature is not one of them: the chip database is the only universal
|
||||
// source — a tiny13A cannot read its own signature row from code.
|
||||
#if !defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)
|
||||
// source — a tiny13A cannot read its own signature row from code. An autobaud
|
||||
// build carries no clock and no baud at all; it measures both.
|
||||
#if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD))
|
||||
#error \
|
||||
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader() (README.md)"
|
||||
"PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)"
|
||||
#endif
|
||||
|
||||
#if !defined(PUREBOOT_AUTOBAUD)
|
||||
using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>;
|
||||
constexpr avr::baud_t wire_baud{PUREBOOT_BAUD};
|
||||
|
||||
// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR.
|
||||
consteval std::int16_t wdrf_field()
|
||||
{
|
||||
auto reg = std::string_view{avr::hw::db.regs[static_cast<std::size_t>(avr::power::detail::reset_reg())].name};
|
||||
return avr::hw::db.field_index(reg, "WDRF");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 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;
|
||||
constexpr std::uint32_t base = spm::flash_bytes - slot_bytes;
|
||||
constexpr std::uint16_t page = spm::page_bytes;
|
||||
constexpr bool boot_section = avr::hw::curated::has_boot_section();
|
||||
|
||||
// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash
|
||||
// addresses there are word addresses ('J' always was one). A slot is 256 of
|
||||
// those — one value of a wire address's high byte, where 512 bytes span two.
|
||||
constexpr bool word_flash = spm::flash_bytes > 65536;
|
||||
constexpr std::uint16_t wire_base =
|
||||
word_flash ? static_cast<std::uint16_t>(base / 2) : static_cast<std::uint16_t>(base);
|
||||
// Past 64 KiB one bank of flash does not cover the chip, so a transfer's
|
||||
// selector byte carries the bank and the wire address stays a byte address
|
||||
// within it. 'J' is the exception: it is a word address everywhere, because
|
||||
// that is what the hardware's own jump takes.
|
||||
constexpr bool banked_flash = spm::flash_bytes > 65536;
|
||||
|
||||
// A compile-time window, so the whole EEPROM belongs to the application;
|
||||
// re-timing a deployed loader is a self-update with a re-timed build.
|
||||
// re-timing a deployed loader is a self-update with a re-timed build. An
|
||||
// autobaud build has no clock to convert seconds against and counts polls.
|
||||
#if !defined(PUREBOOT_TIMEOUT)
|
||||
#define PUREBOOT_TIMEOUT 8
|
||||
#endif
|
||||
constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
|
||||
|
||||
#if !defined(PUREBOOT_AUTOBAUD_POLLS)
|
||||
#define PUREBOOT_AUTOBAUD_POLLS 4000000
|
||||
#endif
|
||||
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
|
||||
|
||||
// A build may bake a measured oscillator trim (README.md: the RC-oscillator
|
||||
// deployment answer); the byte is applied at the top of run(). Orthogonal to
|
||||
// the serial backend — an autobaud build may carry it for the application's
|
||||
// benefit alone.
|
||||
#if defined(PUREBOOT_OSCCAL)
|
||||
static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
|
||||
#endif
|
||||
|
||||
// The loader's one identity number. The protocol carries none of its own —
|
||||
// a version implies it, and the host tool holds that map (README.md).
|
||||
constexpr std::uint8_t version = 3;
|
||||
constexpr std::uint8_t version = 6;
|
||||
|
||||
// The 'b' reply, byte for byte (layout: README.md). Flash-resident because
|
||||
// no crt copies a .data image — and flash_table's storage carries the word
|
||||
// alignment 'b' needs to halve the address on the large chips.
|
||||
inline constexpr avr::flash_table<std::array<std::uint8_t, 12>{
|
||||
'P', 'B', version, avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
|
||||
static_cast<std::uint8_t>(page), // 0 means 256
|
||||
wire_base & 0xff, wire_base >> 8, avr::hw::db.mem.eeprom_size & 0xff, avr::hw::db.mem.eeprom_size >> 8,
|
||||
static_cast<std::uint8_t>((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), // patch-vector, word-addressed
|
||||
}>
|
||||
info_data;
|
||||
// The image's identity stamp, for the host tool rather than for the wire: an
|
||||
// update image is a bare 512-byte slot, and without this nothing in it says
|
||||
// which chip it was built for. The tool refuses to install an image whose
|
||||
// stamp does not match the device — flashing a foreign loader bricks the
|
||||
// target, and the loader itself cannot check what has already replaced it.
|
||||
//
|
||||
// Never read from flash by the loader — 'b' answers out of this array, but at
|
||||
// constant indices, so those fold to immediates and no runtime address of it
|
||||
// is ever formed. `used` keeps the compiler from dropping the copy the host
|
||||
// needs and `retain` keeps --gc-sections from collecting it.
|
||||
// clang-format off
|
||||
[[gnu::used, gnu::retain, gnu::section(".text.stamp")]]
|
||||
inline constexpr std::uint8_t identity_stamp[]{
|
||||
'P', 'B', // the magic the host scans an image for
|
||||
version, // and from here on, exactly what 'b' answers
|
||||
avr::hw::db.signature[0],
|
||||
avr::hw::db.signature[1],
|
||||
avr::hw::db.signature[2],
|
||||
};
|
||||
// clang-format on
|
||||
// Where the identity proper starts: past the magic the host scans for.
|
||||
constexpr std::uint8_t stamp_identity = 2;
|
||||
|
||||
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL,
|
||||
// defaulting to the chip's USART0 where it has one. The software receiver is
|
||||
// the polled one: the vector table belongs to the application. Templates on
|
||||
// the clock, so only the selected backend instantiates. pending() is the
|
||||
// cheap line test the activation window polls; drain() holds until the last
|
||||
// frame is off the wire, so a hand-over cannot let the target's re-init clip
|
||||
// the ack.
|
||||
// The address spaces a transfer can name, in a selector byte's low nibble.
|
||||
// Flash is 0 so it is the cheapest to select.
|
||||
//
|
||||
// spm_ops is the one that is not memory: a write there hands its byte to
|
||||
// SPMCSR and fires the instruction at the transfer's address, which is how
|
||||
// page erase, page write and RWW re-enable reach the wire without the loader
|
||||
// carrying a command for each. The hardware's four-cycle store-to-SPM window
|
||||
// is why this is one fused primitive and not a poke of SPMCSR — no host can
|
||||
// hit that window across a serial link.
|
||||
enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 };
|
||||
|
||||
// A selector's high nibble is the flash bank — the address bits above the
|
||||
// 16-bit wire address, RAMPZ on the chips that have one. Keeping it here
|
||||
// rather than widening the wire address is what lets one 16-bit cursor serve
|
||||
// every space: a 24-bit cursor would pay its extra byte on EEPROM and data
|
||||
// reads that can never need it.
|
||||
[[gnu::always_inline]] inline std::uint8_t space_of(std::uint8_t selector)
|
||||
{
|
||||
return selector & 0x0f;
|
||||
}
|
||||
|
||||
[[gnu::always_inline]] inline std::uint8_t bank_of(std::uint8_t selector)
|
||||
{
|
||||
return static_cast<std::uint8_t>(selector >> 4);
|
||||
}
|
||||
|
||||
// The slot a flash address falls in, as one byte. A slot is half as many words
|
||||
// as bytes, so the word address's high byte is exactly this index — which is
|
||||
// what lets the write guard compare a single byte, and what the running copy's
|
||||
// own return address yields for free.
|
||||
constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes);
|
||||
constexpr std::uint8_t bank_shift = 16 - slot_shift;
|
||||
|
||||
[[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
|
||||
{
|
||||
const auto within = static_cast<std::uint8_t>(at >> slot_shift);
|
||||
if constexpr (banked_flash)
|
||||
return static_cast<std::uint8_t>((bank << bank_shift) | within);
|
||||
else
|
||||
return within;
|
||||
}
|
||||
|
||||
// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL /
|
||||
// PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The
|
||||
// software receiver is the polled one: the vector table belongs to the
|
||||
// application. Templates on the clock, so only the selected backend
|
||||
// instantiates. pending() is the cheap line test the activation window polls;
|
||||
// drain() holds until the last frame is off the wire, so a hand-over cannot
|
||||
// let the target's re-init clip the ack.
|
||||
#if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART)
|
||||
#error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends"
|
||||
#endif
|
||||
#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART)
|
||||
#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART"
|
||||
#endif
|
||||
#if !defined(PUREBOOT_RX)
|
||||
#define PUREBOOT_RX pb0
|
||||
#endif
|
||||
@@ -102,9 +167,9 @@ constexpr char usart_digit = '0' + PUREBOOT_USART;
|
||||
constexpr char usart_digit = '0';
|
||||
#endif
|
||||
|
||||
template <avr::hertz_t C>
|
||||
template <avr::hertz_t C, avr::baud_t B>
|
||||
struct hardware_link {
|
||||
using uart = avr::uart::usart<usart_digit, C, {.baud = wire_baud, .max_baud_error = 2.5_pct}>;
|
||||
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
|
||||
|
||||
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
|
||||
// sbiw + sbci + sbci + brne (6).
|
||||
@@ -136,10 +201,10 @@ struct hardware_link {
|
||||
}
|
||||
};
|
||||
|
||||
template <avr::hertz_t C>
|
||||
template <avr::hertz_t C, avr::baud_t B>
|
||||
struct software_link {
|
||||
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, wire_baud>;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, wire_baud>;
|
||||
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
|
||||
|
||||
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
|
||||
// sbci + brne (6).
|
||||
@@ -171,14 +236,44 @@ struct software_link {
|
||||
}
|
||||
};
|
||||
|
||||
#if defined(PUREBOOT_USART)
|
||||
// The clock-free link: the bit period is measured from the host's calibration
|
||||
// pulse instead of derived from a clock, so one image serves every F_CPU and
|
||||
// every rate. Activation differs in kind from the other two — there is no
|
||||
// clock to time a window against — so this backend brings its own, below.
|
||||
struct autobaud_link {
|
||||
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX>;
|
||||
|
||||
static void init()
|
||||
{
|
||||
avr::init<uart>();
|
||||
}
|
||||
|
||||
static std::uint8_t rx()
|
||||
{
|
||||
return uart::template read<off>();
|
||||
}
|
||||
|
||||
static void tx(std::uint8_t byte)
|
||||
{
|
||||
uart::template write<off>(byte);
|
||||
}
|
||||
|
||||
static void drain()
|
||||
{
|
||||
uart::drain();
|
||||
}
|
||||
};
|
||||
|
||||
#if defined(PUREBOOT_AUTOBAUD)
|
||||
using link = autobaud_link;
|
||||
#elif defined(PUREBOOT_USART)
|
||||
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
|
||||
using link = hardware_link<dev::clock>;
|
||||
using link = hardware_link<dev::clock, wire_baud>;
|
||||
#elif defined(PUREBOOT_SOFT_SERIAL)
|
||||
using link = software_link<dev::clock>;
|
||||
using link = software_link<dev::clock, wire_baud>;
|
||||
#else
|
||||
using link =
|
||||
std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock>, software_link<dev::clock>>;
|
||||
using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
|
||||
software_link<dev::clock, wire_baud>>;
|
||||
#endif
|
||||
|
||||
// The application's entry, pinned by the linker (--defsym): word 0 on a
|
||||
@@ -198,6 +293,27 @@ extern "C" [[noreturn]] void pureboot_app();
|
||||
jump(pureboot_app);
|
||||
}
|
||||
|
||||
// Activation: a bounded wait for the host, then the knock. Both forms boot the
|
||||
// application when the window closes on an idle line, and both bound *every*
|
||||
// wait — a knock awaited without a deadline would let one stray edge hold an
|
||||
// unattended device in the loader forever.
|
||||
#if defined(PUREBOOT_AUTOBAUD)
|
||||
// The window is a fixed poll budget: with no clock, whole seconds cannot be
|
||||
// timed. A uint24_t holds it — a fourth byte would cost two words at every
|
||||
// countdown step for range never used.
|
||||
void await_host()
|
||||
{
|
||||
for (;;) {
|
||||
if (!link::uart::calibrate(autobaud_budget))
|
||||
run_app();
|
||||
// The calibration pulse has already proven a host is there, so one
|
||||
// byte activates. A knock that never arrives falls back to calibrate(),
|
||||
// whose own budget then boots the application.
|
||||
if (link::uart::template read<off>(autobaud_budget) == 'p')
|
||||
return;
|
||||
}
|
||||
}
|
||||
#else
|
||||
// The window as one 32-bit countdown, divided by the backend's counted
|
||||
// poll-loop cycles. Whole seconds is all it promises.
|
||||
consteval std::uint32_t window_polls()
|
||||
@@ -205,9 +321,14 @@ consteval std::uint32_t window_polls()
|
||||
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles);
|
||||
}
|
||||
|
||||
// The countdown in the narrowest type that holds it: a fourth byte would
|
||||
// cost a wider decrement chain at every poll for range most windows never
|
||||
// use (the autobaud budget makes the same choice).
|
||||
using window_t = std::conditional_t<window_polls() <= 0xffffff, avr::uint24_t, std::uint32_t>;
|
||||
|
||||
bool pending_before_deadline()
|
||||
{
|
||||
std::uint32_t polls = window_polls();
|
||||
window_t polls = window_polls();
|
||||
do {
|
||||
if (link::pending())
|
||||
return true;
|
||||
@@ -224,6 +345,14 @@ std::uint8_t rx_deadline()
|
||||
return link::rx();
|
||||
}
|
||||
|
||||
void await_host()
|
||||
{
|
||||
// 'p' then 'b', each under a fresh window; anything else is line noise.
|
||||
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Inlined: read across a call, the first byte strands in a call-saved
|
||||
// register the caller has to push and pop.
|
||||
[[gnu::always_inline]] inline std::uint16_t rx16()
|
||||
@@ -241,157 +370,116 @@ std::uint8_t rx_deadline()
|
||||
return std::bit_cast<std::uint16_t>(pair);
|
||||
}
|
||||
|
||||
// Counts arrive in the wire's 8-bit form: 0 means 256. Both streamers fold
|
||||
// into the one command that reads flash, which is what lets the far one's
|
||||
// 24-bit cursor sit in the command loop's own call-saved registers.
|
||||
[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count)
|
||||
{
|
||||
do
|
||||
link::tx(avr::flash_load(reinterpret_cast<const std::uint8_t *>(address++)));
|
||||
while (--count);
|
||||
}
|
||||
|
||||
// The 24-bit cursor as the machine holds it — the RAMPZ byte and a 16-bit Z,
|
||||
// carried apart; the reassembled address folds away inside the far load.
|
||||
[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count)
|
||||
{
|
||||
std::uint8_t rampz = static_cast<std::uint8_t>(address >> 15);
|
||||
std::uint16_t z = static_cast<std::uint16_t>(address << 1);
|
||||
do {
|
||||
link::tx(avr::flash_load_far<std::uint8_t>((static_cast<std::uint32_t>(rampz) << 16) | z));
|
||||
// Carrying the wrap is smaller than the flat 32-bit cursor GCC
|
||||
// builds without it.
|
||||
if (++z == 0)
|
||||
++rampz;
|
||||
} while (--count);
|
||||
}
|
||||
|
||||
[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count)
|
||||
{
|
||||
if constexpr (word_flash)
|
||||
send_flash_far(address, count);
|
||||
else
|
||||
send_flash_near(address, count);
|
||||
}
|
||||
|
||||
// Out of line: three sites send it, and a call is shorter than three
|
||||
// load-immediates.
|
||||
// Out of line: several sites send it, and a call is shorter than a
|
||||
// load-immediate at each.
|
||||
[[gnu::noinline]] void tx_ack()
|
||||
{
|
||||
link::tx(ack);
|
||||
}
|
||||
|
||||
void send_eeprom(std::uint16_t address, std::uint8_t count)
|
||||
// A wire address and its selector's bank as the flash address they name.
|
||||
[[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at)
|
||||
{
|
||||
do
|
||||
link::tx(ee::read(address++));
|
||||
while (--count);
|
||||
if constexpr (banked_flash)
|
||||
return (static_cast<spm::flash_address_t>(bank) << 16) | at;
|
||||
else
|
||||
return at;
|
||||
}
|
||||
|
||||
// One byte out of any space. Every accessor shares the transfer's cursor, its
|
||||
// loop and its call site, so a space costs only its own instruction rather
|
||||
// than a body, a loop and a dispatch arm of its own.
|
||||
[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank,
|
||||
std::uint16_t at)
|
||||
{
|
||||
if (space == sp_eeprom)
|
||||
return ee::read(at);
|
||||
if (space == sp_data)
|
||||
return *reinterpret_cast<volatile std::uint8_t *>(at);
|
||||
if (space == sp_fuse)
|
||||
return spm::read_fuse<off>(static_cast<spm::fuse>(at));
|
||||
if constexpr (banked_flash)
|
||||
return avr::flash_load_far<std::uint8_t>(flash_address(bank, at));
|
||||
else
|
||||
return avr::flash_load(reinterpret_cast<const std::uint8_t *>(at));
|
||||
}
|
||||
|
||||
// One byte into a writable space. Flash is not one of them — it arrives a
|
||||
// page at a time through 'W' and is committed through sp_spm — and the fuses
|
||||
// are not writable at all: SPM reaches flash and boot lock bits only.
|
||||
[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value,
|
||||
std::uint8_t slot_high)
|
||||
{
|
||||
if (space == sp_data) {
|
||||
*reinterpret_cast<volatile std::uint8_t *>(at) = value;
|
||||
return;
|
||||
}
|
||||
if (space == sp_spm) {
|
||||
// The running-slot write guard. An SPM command aimed at the slot this
|
||||
// code executes from is dropped, so a broken host cannot brick the
|
||||
// running loader — while a copy one slot lower may still rewrite the
|
||||
// resident one, which is what a self-update is. Guarding the commit
|
||||
// rather than the page fill covers erase and write both, and leaves a
|
||||
// refused page's words in the buffer: harmless, since the next page
|
||||
// write auto-erases it (§26.2.1).
|
||||
if (slot_of(bank, at) != slot_high)
|
||||
spm::command<off>(value, flash_address(bank, at));
|
||||
// Only a boot-sectioned mega runs on while its RWW section programs;
|
||||
// everywhere else the CPU halts through erase and write, so the wait
|
||||
// is already over by the time it returns.
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
return;
|
||||
}
|
||||
// Host-paced: the ack goes out once the write has begun, so the next byte
|
||||
// arrives while it completes and nothing is missed without a buffer.
|
||||
void store_eeprom(std::uint16_t address, std::uint8_t count)
|
||||
{
|
||||
do {
|
||||
ee::write<off>(address++, link::rx());
|
||||
tx_ack();
|
||||
} while (--count);
|
||||
ee::write<off>(at, value);
|
||||
}
|
||||
|
||||
// One page into the SPM buffer, then erase and program — except the slot
|
||||
// this code is running in (`slot_high`, from run()), which is drained and
|
||||
// left alone. A broken host therefore cannot brick the running loader, and a
|
||||
// copy one slot lower may rewrite the resident one.
|
||||
// One page into the SPM buffer, and only that: the erase and the write that
|
||||
// commit it are host-issued sp_spm stores, which reach the same fused
|
||||
// store-and-SPM pair through the transfer path's own address and data.
|
||||
//
|
||||
// Nothing discards the buffer first: it is write-once per word (§26.2.1), so
|
||||
// filling over a refused page or an application's leavings programs stale
|
||||
// words — but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so
|
||||
// that write clears the condition and the host's read-back rewrites the page.
|
||||
void program_flash(std::uint16_t wire_address, std::uint8_t slot_high)
|
||||
void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
{
|
||||
// The address names a page, so its in-page bits are dropped and the walk
|
||||
// starts at the page base — one induction either way: a byte-addressed
|
||||
// wire address walks the page itself (the offset bits wrap back to zero),
|
||||
// while a word one becomes a byte cursor once. The slot index is the wire
|
||||
// address's high byte — on byte-addressed chips the byte address's, with
|
||||
// the low bit dropped, since a slot is two of those.
|
||||
spm::flash_address_t address;
|
||||
std::uint8_t page_high;
|
||||
if constexpr (word_flash) {
|
||||
// A page is aligned, so it never crosses 64 KiB: RAMPZ is a per-page
|
||||
// constant and the 16-bit Z's low byte is the whole in-page offset.
|
||||
const std::uint8_t rampz = static_cast<std::uint8_t>(wire_address >> 15);
|
||||
const std::uint16_t z0 =
|
||||
static_cast<std::uint16_t>(wire_address << 1) & ~static_cast<std::uint16_t>(page - 1);
|
||||
std::uint16_t z = z0;
|
||||
// starts at the page base; the low byte of the cursor is the whole in-page
|
||||
// offset, since a page is aligned and never crosses a bank.
|
||||
std::uint16_t z = at & ~static_cast<std::uint16_t>(page - 1);
|
||||
do {
|
||||
std::uint8_t low = link::rx();
|
||||
std::uint8_t high = link::rx();
|
||||
spm::fill<off>((static_cast<spm::flash_address_t>(rampz) << 16) | z, word_of({low, high}));
|
||||
spm::fill<off>(flash_address(bank, z), word_of({low, high}));
|
||||
z += 2;
|
||||
} while (static_cast<std::uint8_t>(z));
|
||||
address = (static_cast<spm::flash_address_t>(rampz) << 16) | z0;
|
||||
page_high = static_cast<std::uint8_t>(wire_address >> 8);
|
||||
} else {
|
||||
address = static_cast<spm::flash_address_t>(wire_address & ~static_cast<std::uint16_t>(page - 1));
|
||||
do {
|
||||
std::uint8_t low = link::rx();
|
||||
std::uint8_t high = link::rx();
|
||||
spm::fill<off>(address, word_of({low, high}));
|
||||
address += 2;
|
||||
} while (static_cast<std::uint8_t>(address) & (page - 1));
|
||||
address -= 2; // back inside the page — erase and write ignore the word bits
|
||||
page_high = static_cast<std::uint8_t>(address >> 8) & 0xfe;
|
||||
}
|
||||
if (page_high != slot_high) {
|
||||
// Only a boot-sectioned mega runs on while its RWW section programs;
|
||||
// everywhere else the CPU halts through erase and write.
|
||||
spm::erase_page<off>(address);
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
spm::write_page<off>(address);
|
||||
if constexpr (boot_section)
|
||||
spm::wait();
|
||||
}
|
||||
// Programming leaves the RWW section disabled; reads need it back on. The
|
||||
// same store discards the buffer (§26.2.2), so a boot-sectioned mega never
|
||||
// meets the stale-word case above.
|
||||
if constexpr (boot_section)
|
||||
spm::rww_enable<off>();
|
||||
}
|
||||
|
||||
// The four fuse and lock bytes in the hardware's own Z order: low, lock,
|
||||
// extended, high.
|
||||
void send_fuses()
|
||||
{
|
||||
std::uint8_t which = 0;
|
||||
do
|
||||
link::tx(spm::read_fuse<off>(static_cast<spm::fuse>(which)));
|
||||
while (++which != 4);
|
||||
} while (static_cast<std::uint8_t>(z) & (page - 1));
|
||||
}
|
||||
|
||||
[[noreturn]] void run()
|
||||
{
|
||||
#if defined(PUREBOOT_OSCCAL)
|
||||
// The build's oscillator trim, ahead of everything — the WDRF bail
|
||||
// included — so every path out of reset, the watchdog hand-over to the
|
||||
// application first among them, runs on the corrected clock.
|
||||
avr::clock::calibrate(PUREBOOT_OSCCAL);
|
||||
#endif
|
||||
// A watchdog reset belongs to the application, whose watchdog stays forced
|
||||
// on until it clears WDRF — no activation window in its way.
|
||||
if (avr::hw::field_impl<wdrf_field()>::test())
|
||||
if (avr::power::peek_reset_cause().watchdog)
|
||||
run_app();
|
||||
|
||||
link::init();
|
||||
|
||||
// The high byte of the slot this copy runs at, which the write guard and
|
||||
// the info block both follow: the return address is a word address, so its
|
||||
// high byte is the 256-word slot index, doubled back into byte terms where
|
||||
// the wire counts bytes. Taken as byteswap's low byte — the builtin already
|
||||
// swaps the two stacked bytes, and the double swap folds away, where `>> 8`
|
||||
// would leave the swap materialized.
|
||||
const std::uint16_t ra_words = reinterpret_cast<std::uint16_t>(__builtin_return_address(0));
|
||||
const std::uint8_t ra_high = static_cast<std::uint8_t>(std::byteswap(ra_words));
|
||||
const std::uint8_t slot_high = word_flash ? ra_high : static_cast<std::uint8_t>(ra_high << 1);
|
||||
// The slot this copy runs in, which the write guard follows: the return
|
||||
// address is a word address and a slot is half as many words as bytes, so
|
||||
// its high byte is the slot index outright. No absolute address is ever
|
||||
// formed, so the image stays position-independent.
|
||||
const auto slot_high = avr::startup::caller_page();
|
||||
|
||||
// 'p' then 'b', each under a fresh window; anything else is line noise.
|
||||
while (rx_deadline() != 'p' || rx_deadline() != 'b') {
|
||||
}
|
||||
await_host();
|
||||
|
||||
for (;;) {
|
||||
// No prompt while an EEPROM write runs: it blocks SPM and fuse reads
|
||||
@@ -406,47 +494,43 @@ void send_fuses()
|
||||
link::drain();
|
||||
jump(target);
|
||||
}
|
||||
case 'b': // info block, read relative to the running slot
|
||||
case 'R': // read flash: addr16, n8 (0 = 256)
|
||||
case 'r': // read EEPROM: addr16, n8
|
||||
case 'w': { // write EEPROM: addr16, n8, then n bytes each acked
|
||||
// One address-and-count path for all four: 'b' is a flash read
|
||||
// whose arguments the loader already knows, so it joins the
|
||||
// wire-argument three rather than streaming from a call site of its
|
||||
// own. That leaves one flash streamer in the image, and lets its
|
||||
// cursor live in this never-returning loop's own call-saved
|
||||
// registers instead of being saved and restored around a call.
|
||||
std::uint16_t address;
|
||||
std::uint8_t count;
|
||||
if (command == 'b') {
|
||||
// The block sits in the image's first 256 bytes (check_pi.py
|
||||
// asserts it) and slots are 512-aligned, so the low byte of its
|
||||
// link address is its offset in any slot — halved where wire
|
||||
// units are words. The high byte is runtime data, so no
|
||||
// absolute address is ever materialized.
|
||||
const auto link_byte =
|
||||
static_cast<std::uint8_t>(reinterpret_cast<std::uint16_t>(info_data.storage.data()));
|
||||
const std::uint8_t low = word_flash ? static_cast<std::uint8_t>(link_byte >> 1) : link_byte;
|
||||
address = static_cast<std::uint16_t>(low | (slot_high << 8));
|
||||
count = static_cast<std::uint8_t>(info_data.size());
|
||||
} else {
|
||||
address = rx16();
|
||||
count = link::rx();
|
||||
}
|
||||
if (command == 'r')
|
||||
send_eeprom(address, count);
|
||||
else if (command == 'w')
|
||||
store_eeprom(address, count);
|
||||
else
|
||||
send_flash(address, count);
|
||||
case 'b': // identity: the version, then the three signature bytes
|
||||
// Straight out of the stamp, so the wire and the image can never
|
||||
// disagree about what this loader is. The indices are constant and
|
||||
// the array is constexpr, so these are immediates, not flash reads:
|
||||
// nothing here needs the stamp's runtime address.
|
||||
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
|
||||
link::tx(identity_stamp[at]);
|
||||
break;
|
||||
case 'W': // fill one flash page buffer: sel8, addr16, then page bytes
|
||||
case 'G': // read: sel8, addr16, n8 (0 = 256)
|
||||
case 'g': { // write: sel8, addr16, n8, then n bytes, each acked
|
||||
// One decode, one cursor and one loop for every space and both
|
||||
// directions: a command per memory would carry a copy of all three
|
||||
// each. 'W' joins the same decode rather than keeping an address
|
||||
// form of its own, so flash addressing is uniform across every
|
||||
// command that names it.
|
||||
const std::uint8_t selector = link::rx();
|
||||
const std::uint8_t space = space_of(selector);
|
||||
const std::uint8_t bank = bank_of(selector);
|
||||
std::uint16_t at = rx16();
|
||||
if (command == 'W') {
|
||||
fill_page(bank, at);
|
||||
break;
|
||||
}
|
||||
case 'W': // program one flash page: addr16, page bytes
|
||||
program_flash(rx16(), slot_high);
|
||||
break;
|
||||
case 'F': // fuse and lock bytes
|
||||
send_fuses();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,20 +20,95 @@ if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
else:
|
||||
import array
|
||||
import fcntl
|
||||
import select
|
||||
import termios
|
||||
|
||||
PROMPT = b"+"
|
||||
VERSION = 2 # this tool's own version — free to drift from a loader's
|
||||
# The loader versions this tool speaks. A pureboot version implies its wire
|
||||
VERSION = 6 # 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: every version so far speaks the same protocol, and one that
|
||||
# changes it becomes the new floor here.
|
||||
# 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.
|
||||
OLDEST_LOADER = 1
|
||||
NEWEST_LOADER = 3
|
||||
NEWEST_LOADER = 6
|
||||
SLOT = 512 # the loader slot, on every chip
|
||||
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
|
||||
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
|
||||
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
|
||||
# the spaces below. The loader carries one transfer loop instead of four bodies
|
||||
# — which is what buys the data space and the host-issued SPM operations.
|
||||
UNIFIED_LOADER = 5
|
||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||
|
||||
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
|
||||
# as delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
|
||||
# floored — the spin granule and per-bit overhead of libavr's software UART.
|
||||
# --info undoes the encoding to report the true clock, which therefore sits
|
||||
# within one granule below it.
|
||||
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
|
||||
|
||||
# A selector's high nibble is the flash bank — the address bits above the 16-bit
|
||||
# wire address — so a transfer names a byte address within one 64 KiB bank and
|
||||
# no command has to speak word addresses. No single transfer may cross a bank
|
||||
# boundary; the host chunks to keep that true.
|
||||
def selector(space, address):
|
||||
return space | ((address >> 16) << 4)
|
||||
|
||||
|
||||
# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its
|
||||
# byte to SPMCSR and fires the instruction at the selected flash address. Every
|
||||
# part pureboot targets agrees on these encodings.
|
||||
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
|
||||
|
||||
# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero
|
||||
# data bits — one low pulse of seven bit-times, which the loader times into its
|
||||
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
|
||||
CALIBRATE = 0xC0
|
||||
|
||||
# pureboot 5 answers 'b' with its version and the chip signature; the host
|
||||
# derives the rest of the geometry from the signature rather than reading a
|
||||
# table off the device. flash, page, eeprom, patch-vector per distinct
|
||||
# signature, over every chip pureboot targets (the loader computes the same
|
||||
# from its chip database at build time). Die revisions that share a signature
|
||||
# share this row, as they share the silicon.
|
||||
CHIP_GEOMETRY = {
|
||||
# signature : (flash, page, eeprom, patch_vector, ram_start)
|
||||
# ram_start is where SRAM begins in data space: the classic megas and the
|
||||
# tinies keep it right after the plain I/O registers (0x60), the x8/x4
|
||||
# generations past their extended I/O file (0x100). An autobaud loader's
|
||||
# measured bit period lives at exactly ram_start (its only RAM object;
|
||||
# the loader's own build pins the layout), which is what --info reads.
|
||||
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
|
||||
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
|
||||
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
|
||||
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
|
||||
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
|
||||
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
|
||||
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
|
||||
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
|
||||
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
|
||||
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
|
||||
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
|
||||
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
|
||||
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
|
||||
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
|
||||
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
|
||||
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
|
||||
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
|
||||
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
|
||||
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA
|
||||
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
|
||||
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
|
||||
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
|
||||
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
|
||||
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
|
||||
}
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
|
||||
@@ -85,23 +160,60 @@ class Progress:
|
||||
|
||||
|
||||
class PosixPort:
|
||||
"""A raw serial port with deadline-based reads, over termios."""
|
||||
"""A raw serial port with deadline-based reads, over termios. A rate with
|
||||
no B-constant — the off-nominal probes `--scan` walks — goes through
|
||||
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
|
||||
by name."""
|
||||
|
||||
# The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed
|
||||
# word offsets: four flag words, then a line-discipline byte and 19
|
||||
# control chars padded to word 9 (include/uapi/asm-generic/termbits.h).
|
||||
_TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B
|
||||
_BOTHER, _CBAUD = 0o010000, 0o010017
|
||||
_ISPEED, _OSPEED = 9, 10
|
||||
|
||||
@staticmethod
|
||||
def _speed(baud):
|
||||
return getattr(termios, f"B{baud}", None)
|
||||
|
||||
def _set_arbitrary(self, baud):
|
||||
buf = array.array("i", [0] * (self._OSPEED + 1))
|
||||
try:
|
||||
fcntl.ioctl(self.fd, self._TCGETS2, buf, True)
|
||||
buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER
|
||||
buf[self._ISPEED] = buf[self._OSPEED] = baud
|
||||
fcntl.ioctl(self.fd, self._TCSETS2, buf)
|
||||
except OSError:
|
||||
raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None
|
||||
|
||||
def _apply_baud(self, attrs, baud):
|
||||
speed = self._speed(baud)
|
||||
attrs[4] = attrs[5] = speed if speed is not None else termios.B38400
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
|
||||
if speed is None:
|
||||
self._set_arbitrary(baud)
|
||||
self.baud = baud
|
||||
|
||||
def __init__(self, path, baud):
|
||||
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
|
||||
try:
|
||||
attrs = termios.tcgetattr(self.fd)
|
||||
attrs[0] = 0 # iflag
|
||||
attrs[1] = 0 # oflag
|
||||
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
|
||||
attrs[3] = 0 # lflag
|
||||
try:
|
||||
speed = getattr(termios, f"B{baud}")
|
||||
except AttributeError:
|
||||
raise Error(f"unsupported baud rate {baud}") from None
|
||||
attrs[4] = attrs[5] = speed
|
||||
attrs[6][termios.VMIN] = 0
|
||||
attrs[6][termios.VTIME] = 0
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
|
||||
self._apply_baud(attrs, baud)
|
||||
except BaseException:
|
||||
os.close(self.fd)
|
||||
raise
|
||||
|
||||
def set_baud(self, baud):
|
||||
"""Retune the port without closing it — the fd stays open, so no DTR
|
||||
pulse and no reset. That matters: the only caller is mid-session with a
|
||||
loader copy that a reset would throw away."""
|
||||
self._apply_baud(termios.tcgetattr(self.fd), baud)
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
@@ -230,6 +342,7 @@ if os.name == "nt":
|
||||
# timeout would otherwise stay at the driver's default — which
|
||||
# may be "wait forever" — until the first read.
|
||||
self._deadline(_GAP_MS, 1000)
|
||||
self.baud = baud
|
||||
except Error:
|
||||
# An open port outlives the exception otherwise, and a COM
|
||||
# handle is exclusive: the next attempt would meet its own
|
||||
@@ -237,6 +350,21 @@ if os.name == "nt":
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def set_baud(self, baud):
|
||||
"""Retune the port on its live handle — SetCommState only, so the
|
||||
handle is never reopened and DTR never drops. That matters: the only
|
||||
caller is mid-session with a loader copy a reset would throw away."""
|
||||
if baud < 50:
|
||||
raise Error(f"unsupported baud rate {baud}")
|
||||
dcb = _DCB()
|
||||
dcb.DCBlength = ctypes.sizeof(_DCB)
|
||||
if not _k32.GetCommState(self.handle, ctypes.byref(dcb)):
|
||||
_fail("cannot read the port state")
|
||||
dcb.BaudRate = baud
|
||||
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
|
||||
_fail(f"cannot retune the port to {baud} baud")
|
||||
self.baud = baud
|
||||
|
||||
def close(self):
|
||||
_k32.CloseHandle(self.handle)
|
||||
|
||||
@@ -301,6 +429,36 @@ Port = WindowsPort if os.name == "nt" else PosixPort
|
||||
class Info:
|
||||
"""The 12-byte info block."""
|
||||
|
||||
@classmethod
|
||||
def from_identity(cls, raw):
|
||||
"""pureboot 5's reply: the version and the chip signature. The rest of
|
||||
the geometry is looked up from the signature — the loader derived the
|
||||
same facts from its chip database at build time, so nothing is guessed,
|
||||
it is simply not sent. Reconstructs a block in the older layout, so
|
||||
every derived attribute below is shared with the loaders that do send
|
||||
one.
|
||||
|
||||
The base is where application flash ends, which is a property of the
|
||||
chip and not of the copy answering: a loader staged one slot lower
|
||||
reports the same geometry the resident one does, exactly as the loaders
|
||||
that send a block do. Which slot a copy runs in matters only to its own
|
||||
write guard, which is the loader's business."""
|
||||
if len(raw) != 4:
|
||||
raise Error(f"bad identity reply: {raw.hex()}")
|
||||
version, signature = raw[0], tuple(raw[1:4])
|
||||
geometry = CHIP_GEOMETRY.get(signature)
|
||||
if geometry is None:
|
||||
sig = " ".join(f"{b:02x}" for b in signature)
|
||||
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
|
||||
flash, page, eeprom, patch, _ = geometry
|
||||
base = flash - SLOT
|
||||
word_flash = flash > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
raw12 = bytes((ord("P"), ord("B"), version, *signature, page & 0xFF,
|
||||
wire_base & 0xFF, wire_base >> 8, eeprom & 0xFF, eeprom >> 8, flags))
|
||||
return cls(raw12)
|
||||
|
||||
def __init__(self, raw):
|
||||
if len(raw) != 12 or raw[0:2] != b"PB":
|
||||
raise Error(f"bad info block: {raw.hex()}")
|
||||
@@ -314,8 +472,11 @@ class Info:
|
||||
self.signature = raw[3:6]
|
||||
self.page = raw[6] or 256 # the wire count convention: 0 means 256
|
||||
self.patch_vector = bool(raw[11] & 1)
|
||||
# Bit 1: flash addresses are words on the wire. Every address here
|
||||
# stays a byte address and converts at the wire.
|
||||
# Bit 1: the flash runs past what one 16-bit address covers. Through
|
||||
# pureboot 4 that made flash addresses words on the wire; pureboot 5
|
||||
# keeps them bytes and carries the bank in the selector instead. Every
|
||||
# address in this tool stays a byte address either way and converts at
|
||||
# the wire.
|
||||
self.word_flash = bool(raw[11] & 2)
|
||||
scale = 2 if self.word_flash else 1
|
||||
self.base = (raw[7] | (raw[8] << 8)) * scale
|
||||
@@ -325,6 +486,11 @@ class Info:
|
||||
# The hand-over target as 'J' takes it: the trampoline below the
|
||||
# loader, or word 0 where BOOTRST re-vectors reset in hardware.
|
||||
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
|
||||
# Where SRAM begins, from the signature — None only for a chip this
|
||||
# tool has no geometry row for, which the wire-block path (v1–4)
|
||||
# permits where from_identity refuses.
|
||||
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
|
||||
self.ram = geometry[4] if geometry else None
|
||||
|
||||
def describe(self):
|
||||
sig = " ".join(f"{b:02x}" for b in self.signature)
|
||||
@@ -345,7 +511,7 @@ class Info:
|
||||
f"version pureboot {self.version}",
|
||||
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
|
||||
f"flash {self.flash_size} B, {self.page} B pages"
|
||||
+ (", word-addressed wire" if self.word_flash else ""),
|
||||
+ (", past one 16-bit bank" if self.word_flash else ""),
|
||||
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
|
||||
f"loader {self.base:#06x} ({SLOT} B slot)",
|
||||
f"staging {self.stage:#06x}",
|
||||
@@ -362,40 +528,86 @@ class Loader:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.info = None
|
||||
# Set once a session is established over an autobaud link, so a
|
||||
# re-entry after 'J' repeats the handshake that worked.
|
||||
self.autobaud = False
|
||||
# The link this session is speaking. It moves when the host follows a
|
||||
# staging copy built for another one (enter_copy).
|
||||
self.baud = getattr(port, "baud", None)
|
||||
self._link_declared = False
|
||||
|
||||
def connect(self, wait):
|
||||
"""Knock until the info block comes back. The block is what proves the
|
||||
loader is listening — a prompt byte alone does not, since one left over
|
||||
from a previous session can still be in the pipeline while the port
|
||||
opening resets the device into a fresh activation window, where a
|
||||
command without its knock is discarded. Each attempt is therefore the
|
||||
whole handshake, retried until it produces the block or the window
|
||||
closes. Also converges into a live session: the knock bytes are ignored
|
||||
there and the drain absorbs whatever they produced."""
|
||||
def _read_identity(self):
|
||||
"""The 'b' reply, in either of the two layouts a loader may send.
|
||||
pureboot 5 answers with its version and the signature; older loaders
|
||||
answer with a 12-byte block. The version byte cannot be mistaken for
|
||||
the older block's 'P', so four bytes are enough to tell them apart."""
|
||||
head = self.port.read_exact(4, 2.0)
|
||||
if head[0:2] == b"PB":
|
||||
return Info(head + self.port.read_exact(8, 2.0))
|
||||
return Info.from_identity(head)
|
||||
|
||||
def _handshake(self, wait, knock, what):
|
||||
"""One activation, retried until the loader answers or the window
|
||||
closes. The identity reply is what proves the loader is listening — a
|
||||
prompt byte alone does not, since one left over from a previous session
|
||||
can still be in the pipeline while the port opening resets the device
|
||||
into a fresh window, where a command without its knock is discarded.
|
||||
Each attempt is therefore the whole handshake. This also converges into
|
||||
an already-live session: the knock bytes are ignored there and the
|
||||
drain absorbs whatever they produced."""
|
||||
deadline = time.monotonic() + wait
|
||||
knocks = 0
|
||||
refusal = None
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(b"pb")
|
||||
self.port.write(knock)
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
# Settle: absorb a real loader's trailing bytes before asking
|
||||
# for the identity. Bounded by the deadline so a target that
|
||||
# never falls quiet — a board stuck in a reset loop, whose
|
||||
# garbage carries a stray prompt — cannot spin here forever.
|
||||
while self.port.read_available(0.3):
|
||||
pass
|
||||
if time.monotonic() > deadline:
|
||||
break
|
||||
self.port.write(b"b")
|
||||
try:
|
||||
block = self.port.read_exact(12, 2.0)
|
||||
except Error:
|
||||
block = b""
|
||||
# A version the tool cannot speak is the loader's own answer,
|
||||
# not a failed knock: Info reports it rather than retrying.
|
||||
if block[0:2] == b"PB":
|
||||
self.info = Info(block)
|
||||
# A version the tool cannot speak is the loader's own
|
||||
# answer, not a failed knock: Info reports it rather than
|
||||
# sending the tool round the loop again.
|
||||
self.info = self._read_identity()
|
||||
except Error as failed:
|
||||
if "pureboot" in str(failed):
|
||||
raise
|
||||
# A malformed or unknown identity is retried as noise, but
|
||||
# it was an answer: if nothing better ever arrives, naming
|
||||
# it beats reporting silence.
|
||||
refusal = failed
|
||||
self.info = None
|
||||
if self.info is not None:
|
||||
self._expect_prompt()
|
||||
verbose(f"loader answered knock {knocks}; info block read")
|
||||
verbose(f"loader answered {what} {knocks}; identity read")
|
||||
return self.info
|
||||
if time.monotonic() > deadline:
|
||||
if refusal is not None:
|
||||
raise Error(f"no usable answer — the last identity reply failed: {refusal}")
|
||||
raise Error("no answer — reset the device within its activation window")
|
||||
|
||||
def connect(self, wait):
|
||||
"""Knock 'p' then 'b' and read the identity."""
|
||||
return self._handshake(wait, b"pb", "knock")
|
||||
|
||||
def connect_autobaud(self, wait):
|
||||
"""The autobaud handshake. In place of the p+b knock the host sends the
|
||||
calibration pulse — one seven-bit-time low pulse at the host's chosen
|
||||
baud, which the loader times into its per-bit unit — then a single 'p'
|
||||
the loader decodes at the rate it just measured. A lost pulse, or a
|
||||
knock landing while the loader is mid-frame, simply fails to answer and
|
||||
leaves the measurement loop waiting for the next pulse, so the retry in
|
||||
_handshake covers it."""
|
||||
self.autobaud = True
|
||||
return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration")
|
||||
|
||||
def _expect_prompt(self, timeout=2.0):
|
||||
byte = self.port.read_exact(1, timeout)
|
||||
if byte != PROMPT:
|
||||
@@ -418,7 +630,58 @@ class Loader:
|
||||
count -= chunk
|
||||
return data
|
||||
|
||||
@property
|
||||
def unified(self):
|
||||
"""pureboot 5 and later: one 'G'/'g' pair over selector-named spaces."""
|
||||
return self.info is not None and self.info.version >= UNIFIED_LOADER
|
||||
|
||||
def _read_space(self, space, address, count):
|
||||
"""A run out of any space, chunked to 256 bytes and to bank bounds."""
|
||||
data = b""
|
||||
while count:
|
||||
chunk = min(count, 256, 0x10000 - (address & 0xFFFF))
|
||||
head = bytes((ord("G"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, chunk & 0xFF))
|
||||
data += self._command(head, chunk, 5.0)
|
||||
address += chunk
|
||||
count -= chunk
|
||||
return data
|
||||
|
||||
def _write_space(self, space, address, data, progress=None):
|
||||
"""A run into any space. Each byte is acked as its write begins — an
|
||||
EEPROM cell and an SPM operation both need that pacing, and the ack is
|
||||
what the loader sends in place of a completion status."""
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))]
|
||||
head = bytes((ord("g"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, len(chunk) & 0xFF))
|
||||
self.port.write(head)
|
||||
for byte in chunk:
|
||||
self.port.write(bytes((byte,)))
|
||||
self._expect_prompt()
|
||||
if progress:
|
||||
progress.step()
|
||||
self._expect_prompt() # the next command prompt
|
||||
address += len(chunk)
|
||||
offset += len(chunk)
|
||||
|
||||
def spm(self, operation, address):
|
||||
"""One SPM operation at a flash address — the erase, write and RWW
|
||||
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here."""
|
||||
self._write_space(SP_SPM, address, bytes((operation,)))
|
||||
|
||||
def read_ram(self, address, count):
|
||||
"""Data space: SRAM, and with it the register file and every I/O
|
||||
register, which share the address space on AVR. New in pureboot 5."""
|
||||
return self._read_space(SP_RAM, address, count)
|
||||
|
||||
def write_ram(self, address, data):
|
||||
self._write_space(SP_RAM, address, data)
|
||||
|
||||
def read_flash(self, address, count):
|
||||
if self.unified:
|
||||
return self._read_space(SP_FLASH, address, count)
|
||||
if not self.info.word_flash:
|
||||
return self._stream_read("R", address, count)
|
||||
# Word-addressed wire: widen to even bounds and never let one read
|
||||
@@ -436,15 +699,32 @@ class Loader:
|
||||
return data[address - start : address - start + count]
|
||||
|
||||
def read_eeprom(self, address, count):
|
||||
if self.unified:
|
||||
return self._read_space(SP_EEPROM, address, count)
|
||||
return self._stream_read("r", address, count)
|
||||
|
||||
def write_page(self, address, data):
|
||||
assert len(data) == self.info.page and address % self.info.page == 0
|
||||
if self.unified:
|
||||
# 'W' fills the page buffer and stops there; the erase and the write
|
||||
# are host-issued SPM operations. Only a chip with a boot section
|
||||
# has RWW to re-enable — on the others bit 4 of SPMCSR means
|
||||
# something else entirely, so it must not be sent.
|
||||
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
|
||||
self._command(head + data, 0, 2.0)
|
||||
self.spm(SPM_ERASE, address)
|
||||
self.spm(SPM_WRITE, address)
|
||||
if not self.info.patch_vector:
|
||||
self.spm(SPM_RWWSRE, address)
|
||||
return
|
||||
wire = address // (2 if self.info.word_flash else 1)
|
||||
head = bytes((ord("W"), wire & 0xFF, wire >> 8))
|
||||
self._command(head + data, 0, 2.0)
|
||||
|
||||
def write_eeprom(self, address, data, progress=None):
|
||||
if self.unified:
|
||||
self._write_space(SP_EEPROM, address, data, progress)
|
||||
return
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset : offset + 256]
|
||||
@@ -460,6 +740,8 @@ class Loader:
|
||||
offset += len(chunk)
|
||||
|
||||
def read_fuses(self):
|
||||
if self.unified:
|
||||
return self._read_space(SP_FUSE, 0, 4)
|
||||
return self._command(b"F", 4, 2.0)
|
||||
|
||||
def jump(self, word_address):
|
||||
@@ -467,11 +749,41 @@ class Loader:
|
||||
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
|
||||
self._expect_prompt()
|
||||
|
||||
def enter_copy(self, byte_address, wait):
|
||||
def enter_copy(self, byte_address, wait, link=None):
|
||||
"""Jump into the loader copy at `byte_address` and knock it — a slot
|
||||
base is that copy's entry stub, so it can only land there."""
|
||||
base is that copy's entry stub, so it can only land there.
|
||||
|
||||
`link` is that copy's own `(baud, autobaud)`, for when it is not this
|
||||
session's. A staging copy *is* the new image, so it speaks the rate and
|
||||
backend it was built for; the host has to be told which, because 512
|
||||
bytes of position-independent code carry no header to read it from.
|
||||
Retuning goes through the open port, so no DTR pulse resets the copy that
|
||||
is now running — and the session keeps the new link afterwards, since
|
||||
every later jump lands in the same image.
|
||||
"""
|
||||
baud, autobaud = link if link is not None else (self.baud, self.autobaud)
|
||||
if link is not None:
|
||||
self._link_declared = True
|
||||
self.jump(byte_address // 2)
|
||||
return self.connect(wait)
|
||||
if baud is not None and baud != self.baud:
|
||||
self.port.set_baud(baud)
|
||||
self.baud = baud
|
||||
self.autobaud = autobaud
|
||||
try:
|
||||
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
|
||||
except Error as unheard:
|
||||
if self._link_declared:
|
||||
raise
|
||||
# The bare activation timeout sends the operator to look at wiring,
|
||||
# while on a patched-vector part the application region is already
|
||||
# gone. Name the one cause that fits: the copy answers on its own
|
||||
# link, not the resident's.
|
||||
raise Error(
|
||||
f"the copy at {byte_address:#06x} did not answer on this session's "
|
||||
f"link ({baud} Bd, {'autobaud' if autobaud else 'fixed baud'}). An "
|
||||
f"image built for another baud or backend speaks that one instead — "
|
||||
f"say which with --staged-baud / --staged-autobaud"
|
||||
) from unheard
|
||||
|
||||
def run_application(self):
|
||||
self.jump(self.info.app_entry_word)
|
||||
@@ -638,12 +950,26 @@ def mega_boot(info, fuse_bytes):
|
||||
|
||||
|
||||
def image_info(image):
|
||||
"""The info block embedded in a pureboot binary, or None. Searched once
|
||||
per known version, so the magic stays three selective bytes rather than
|
||||
two that code could carry by chance."""
|
||||
"""What a pureboot binary says about itself, or None.
|
||||
|
||||
An update image is a bare slot: nothing about it names the chip it was
|
||||
built for, and installing a foreign one bricks the target — so every
|
||||
loader carries a stamp for this. Through pureboot 4 the stamp is the
|
||||
12-byte info block the device also serves; pureboot 5 serves its identity
|
||||
from immediates and carries a 6-byte stamp (magic, version, signature)
|
||||
that only this exists for, from which the geometry is looked up exactly as
|
||||
it is for a live device.
|
||||
|
||||
Searched once per known version, so the magic stays three selective bytes
|
||||
rather than two that code could carry by chance."""
|
||||
for version in range(OLDEST_LOADER, NEWEST_LOADER + 1):
|
||||
at = image.find(b"PB" + bytes((version,)))
|
||||
if 0 <= at <= len(image) - 12:
|
||||
if at < 0:
|
||||
continue
|
||||
if version >= UNIFIED_LOADER:
|
||||
if at <= len(image) - 6:
|
||||
return Info.from_identity(image[at + 2 : at + 6])
|
||||
elif at <= len(image) - 12:
|
||||
return Info(image[at : at + 12])
|
||||
return None
|
||||
|
||||
@@ -805,10 +1131,16 @@ def patch_word0(loader, page0, target_base):
|
||||
return bytes(patched)
|
||||
|
||||
|
||||
def op_update_loader(loader, wait, path, state_path, fuse_bytes):
|
||||
def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=None):
|
||||
"""Replace the resident loader with `path`, using the loader as its own
|
||||
staging loader. Every phase is idempotent and keyed off the flash state,
|
||||
so a re-run resumes; the state file carries what the staging slot held."""
|
||||
so a re-run resumes; the state file carries what the staging slot held.
|
||||
|
||||
`staged_link` is the new image's own `(baud, autobaud)` where it differs from
|
||||
this session's — the copies the host enters *are* that image, so they answer
|
||||
on its link and not the resident's. Note what this does to the idempotence
|
||||
above: once the staging copy is installed, the resumable state is only
|
||||
reachable on the new link, so a re-run has to name it too."""
|
||||
info = loader.info
|
||||
image = loader_image(path)
|
||||
for warning in update_preflight(image, info, fuse_bytes):
|
||||
@@ -833,7 +1165,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
|
||||
# it and matching byte for byte, and the slot unchanged since this update
|
||||
# began, so a half-written install takes the path below instead.
|
||||
current = loader.read_flash(info.stage, SLOT)
|
||||
staged_loader = image_info(current[:268])
|
||||
# The whole slot is searched: a loader's stamp sits wherever its image put
|
||||
# it, which is the end of the code on pureboot 5 and the front of it
|
||||
# before that.
|
||||
staged_loader = image_info(current)
|
||||
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
|
||||
print("staging slot already holds a loader — left in place")
|
||||
else:
|
||||
@@ -850,7 +1185,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
|
||||
# routes through the resident, word 0 is re-aimed at the staging copy for
|
||||
# the rewrite, so a power loss mid-rewrite still resets into a loader.
|
||||
verbose(f"entering the staging copy at {info.stage:#06x}")
|
||||
loader.enter_copy(info.stage, wait)
|
||||
loader.enter_copy(info.stage, wait, link=staged_link)
|
||||
redirect = info.patch_vector and info.stage != 0
|
||||
if redirect:
|
||||
verbose("word 0 re-aimed at the staging copy for the rewrite")
|
||||
@@ -1016,6 +1351,37 @@ def op_read_eeprom(loader, path):
|
||||
print(f"read EEPROM: {len(data)} B -> {path}")
|
||||
|
||||
|
||||
def _require_unified(loader, what):
|
||||
if not loader.unified:
|
||||
raise Error(f"{what} needs pureboot {UNIFIED_LOADER} or later; this loader is {loader.info.version}")
|
||||
|
||||
|
||||
def _peek_spec(spec):
|
||||
"""ADDR[:N] — addresses and counts in any Python integer base."""
|
||||
address, _, count = spec.partition(":")
|
||||
return int(address, 0), int(count, 0) if count else 1
|
||||
|
||||
|
||||
def op_peek(loader, spec):
|
||||
_require_unified(loader, "--peek")
|
||||
address, count = _peek_spec(spec)
|
||||
data = loader.read_ram(address, count)
|
||||
for offset in range(0, len(data), 16):
|
||||
row = data[offset : offset + 16]
|
||||
text = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
|
||||
print(f"{address + offset:#06x} {row.hex(' '):<47} {text}")
|
||||
|
||||
|
||||
def op_poke(loader, spec):
|
||||
_require_unified(loader, "--poke")
|
||||
address, _, payload = spec.partition(":")
|
||||
if not payload:
|
||||
raise Error("--poke needs ADDR:HEX, for example 0x200:deadbeef")
|
||||
data = bytes.fromhex(payload.replace(" ", ""))
|
||||
loader.write_ram(int(address, 0), data)
|
||||
print(f"poke: {len(data)} B at {int(address, 0):#06x}")
|
||||
|
||||
|
||||
def op_fuses(loader):
|
||||
low, lock, extended, high = loader.read_fuses()
|
||||
print("fuses:")
|
||||
@@ -1037,6 +1403,62 @@ def op_fuses(loader):
|
||||
return fuse_bytes
|
||||
|
||||
|
||||
def scan_ratios():
|
||||
"""The probe walk, in percent of the built rate: the built rate itself
|
||||
first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near
|
||||
its trim is the common case, and each probe costs a reset."""
|
||||
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
|
||||
|
||||
|
||||
def scan_rate(baud, pct):
|
||||
return round(baud * (100 + pct) / 100)
|
||||
|
||||
|
||||
def scan_report(baud, pct, version, clock=None):
|
||||
"""The findings, one per line: the found rate is the session workaround,
|
||||
its ratio to the built rate is the oscillator's offset, and the fixes are
|
||||
the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build."""
|
||||
rate = scan_rate(baud, pct)
|
||||
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}",
|
||||
f" session --baud {rate}"]
|
||||
if clock:
|
||||
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
|
||||
if pct:
|
||||
direction = "lower" if pct > 0 else "higher"
|
||||
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
|
||||
"or the autobaud build")
|
||||
else:
|
||||
lines.append(" fix none — the built rate answers; check the earlier wiring instead")
|
||||
return lines
|
||||
|
||||
|
||||
def op_scan(port_path, baud, wait, clock=None):
|
||||
"""A fixed-baud loader whose oscillator drifted still answers — at the
|
||||
drifted ratio, since its rate scales with its clock. One probe per
|
||||
activation window, and with an application resident the window opens
|
||||
exactly once per reset, so each probe announces itself and expects a
|
||||
fresh reset before knocking."""
|
||||
for pct in scan_ratios():
|
||||
rate = scan_rate(baud, pct)
|
||||
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
|
||||
try:
|
||||
port = Port(port_path, rate)
|
||||
except Error as unmakeable:
|
||||
print(f"scan: {rate} Bd skipped — {unmakeable}")
|
||||
continue
|
||||
try:
|
||||
info = Loader(port).connect(wait)
|
||||
except Error:
|
||||
continue
|
||||
finally:
|
||||
port.close()
|
||||
for line in scan_report(baud, pct, info.version, clock):
|
||||
print(line)
|
||||
return
|
||||
raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the "
|
||||
"autobaud build, which has no rate to miss (README.md)")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- cli ---
|
||||
|
||||
|
||||
@@ -1049,10 +1471,28 @@ def main():
|
||||
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
|
||||
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
|
||||
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
|
||||
parser.add_argument("--autobaud", action="store_true",
|
||||
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
|
||||
"knock, and take geometry from the signature (no clock/baud baked in)")
|
||||
parser.add_argument("--scan", action="store_true",
|
||||
help="walk ±10%% around --baud for a fixed-baud loader gone silent — one "
|
||||
"reset per probe, standalone (README.md: deployment)")
|
||||
parser.add_argument("--clock", type=int, metavar="HZ",
|
||||
help="the clock the loader was built for — lets --scan and an autobaud "
|
||||
"--info state drift in absolute terms")
|
||||
parser.add_argument("--info", action="store_true", help="print the device info block")
|
||||
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
|
||||
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
|
||||
parser.add_argument("--state", metavar="FILE", help="update state file (default: FILE.pbstate)")
|
||||
# The update enters the staging copy, which is the new image and so speaks
|
||||
# the link *it* was built for. Nothing in the image says which, so where it
|
||||
# differs from this session's these name it and the host follows.
|
||||
parser.add_argument("--staged-baud", metavar="BD", type=int,
|
||||
help="the baud the --update-loader image was built for, where it "
|
||||
"differs from --baud")
|
||||
parser.add_argument("--staged-autobaud", action=argparse.BooleanOptionalAction, default=None,
|
||||
help="whether that image is an autobaud build, where it differs "
|
||||
"from --autobaud")
|
||||
parser.add_argument("--assume-fuses", metavar="HEX8", help="fuse bytes low,lock,ext,high as 8 hex digits "
|
||||
"(overrides reading them — e.g. under a simulator that cannot)")
|
||||
parser.add_argument("--erase-flash", action="store_true", help="0xff over the application flash")
|
||||
@@ -1064,6 +1504,10 @@ def main():
|
||||
parser.add_argument("--eeprom", metavar="FILE", help="program the EEPROM (bin or ihex)")
|
||||
parser.add_argument("--read-eeprom", metavar="FILE", help="dump the EEPROM")
|
||||
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
|
||||
parser.add_argument("--peek", metavar="ADDR[:N]", help="read N bytes of data space (SRAM, registers, "
|
||||
"I/O) — pureboot 5 and later")
|
||||
parser.add_argument("--poke", metavar="ADDR:HEX", help="write hex bytes into data space — "
|
||||
"pureboot 5 and later")
|
||||
parser.add_argument("--force", action="store_true", help="override refusable safety checks")
|
||||
parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
@@ -1082,15 +1526,33 @@ def main():
|
||||
except (ValueError, AssertionError):
|
||||
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
|
||||
|
||||
if args.scan:
|
||||
if args.autobaud:
|
||||
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
|
||||
op_scan(args.port, args.baud, args.wait, args.clock)
|
||||
return
|
||||
|
||||
port = Port(args.port, args.baud)
|
||||
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
|
||||
try:
|
||||
loader = Loader(port)
|
||||
info = loader.connect(args.wait)
|
||||
info = loader.connect_autobaud(args.wait) if args.autobaud else loader.connect(args.wait)
|
||||
if args.info:
|
||||
print("device:")
|
||||
for line in info.lines():
|
||||
print(f" {line}")
|
||||
if args.autobaud and info.ram is not None:
|
||||
# The whole of the loader's RAM is the measured bit period at
|
||||
# ram_start; decoded and times the rate this session drives,
|
||||
# that is the true clock — the number to hold an OSCCAL bake
|
||||
# or a fixed-baud build against (README.md: deployment). The
|
||||
# autobaud identity path refuses unknown signatures, so ram is
|
||||
# always known here; the guard states that dependency.
|
||||
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
|
||||
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
|
||||
clock = cycles * args.baud
|
||||
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""
|
||||
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
|
||||
fuse_bytes = fuse_override
|
||||
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
|
||||
read = op_fuses(loader)
|
||||
@@ -1098,7 +1560,14 @@ def main():
|
||||
fuse_bytes = read
|
||||
if args.update_loader:
|
||||
state = args.state or args.update_loader + ".pbstate"
|
||||
op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes)
|
||||
staged_link = None
|
||||
if args.staged_baud is not None or args.staged_autobaud is not None:
|
||||
staged_link = (
|
||||
args.staged_baud if args.staged_baud is not None else args.baud,
|
||||
args.staged_autobaud if args.staged_autobaud is not None else args.autobaud,
|
||||
)
|
||||
op_update_loader(loader, args.wait, args.update_loader, state, fuse_bytes,
|
||||
staged_link)
|
||||
if args.flash:
|
||||
op_flash(loader, args.flash, args.erase_flash, not args.no_verify, fuse_bytes, args.force)
|
||||
elif args.erase_flash:
|
||||
@@ -1115,6 +1584,10 @@ def main():
|
||||
op_read_eeprom(loader, args.read_eeprom)
|
||||
if args.verify_eeprom:
|
||||
op_verify_eeprom(loader, args.verify_eeprom)
|
||||
if args.poke:
|
||||
op_poke(loader, args.poke)
|
||||
if args.peek:
|
||||
op_peek(loader, args.peek)
|
||||
if args.stay:
|
||||
print("loader stays in its session (reset to leave)")
|
||||
else:
|
||||
@@ -1127,7 +1600,7 @@ def main():
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Error as error:
|
||||
except (Error, OSError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Position-independence lint: the two link-time facts that let the identical
|
||||
image run from any slot, asserted from the built ELF.
|
||||
"""Position-independence lint: the property that lets the identical image run
|
||||
from any slot, asserted from the built ELF and its object.
|
||||
|
||||
1. No absolute jmp/call — -mrelax normally guarantees it, but a branch that
|
||||
grows out of relaxation range would break it silently.
|
||||
2. The info block within the image's first 256 bytes: 'b' rebuilds its
|
||||
address as (running slot high byte : link address low byte).
|
||||
2. Nothing flash-resident to address: the image is .text alone, so there is
|
||||
no table whose runtime address has to be reconstructed.
|
||||
3. The image is byte-identical when linked at a different base. This is
|
||||
position independence itself rather than a proxy for it — an absolute
|
||||
address anywhere in the image would move with the link and show up as a
|
||||
differing byte.
|
||||
|
||||
Usage: check_pi.py <objdump> <nm> <elf> <text_start_hex>
|
||||
Usage: check_pi.py <objdump> <objcopy> <cxx> <mcu> <elf> <object> <text_start_hex>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
objdump, nm, elf, text_start = sys.argv[1:]
|
||||
objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:]
|
||||
text_start = int(text_start, 0)
|
||||
|
||||
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
|
||||
absolute = [
|
||||
line
|
||||
for line in listing.splitlines()
|
||||
if re.search(r"\t(jmp|call)\t", line)
|
||||
]
|
||||
absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)]
|
||||
if absolute:
|
||||
print("FAIL: absolute control flow in the image:")
|
||||
print("\n".join(absolute))
|
||||
sys.exit(1)
|
||||
fail("absolute control flow in the image:\n" + "\n".join(absolute))
|
||||
|
||||
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
|
||||
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
|
||||
if len(info) != 1:
|
||||
print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
|
||||
sys.exit(1)
|
||||
address = int(info[0].split()[0], 16)
|
||||
offset = address - text_start
|
||||
if not 0 <= offset < 256:
|
||||
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
|
||||
sys.exit(1)
|
||||
# Allocated flash beyond .text would be data the running copy has to find.
|
||||
# Only ALLOC sections reach the device at all; .comment and the debug
|
||||
# sections ride along in the ELF container and are never flashed. objdump
|
||||
# prints each section's flags on the line following its header.
|
||||
headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines()
|
||||
for index, line in enumerate(headers):
|
||||
fields = line.split()
|
||||
if len(fields) < 6 or not fields[0].isdigit():
|
||||
continue
|
||||
name, size = fields[1], int(fields[2], 16)
|
||||
flags = headers[index + 1] if index + 1 < len(headers) else ""
|
||||
if "ALLOC" not in flags or not size:
|
||||
continue
|
||||
if name not in (".text", ".noinit", ".bss"):
|
||||
fail(f"flash-resident section {name} ({size} bytes): the image must be .text alone")
|
||||
|
||||
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}")
|
||||
# Relink at a different base and compare the bytes.
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
elsewhere = text_start - 0x200 if text_start >= 0x200 else text_start + 0x200
|
||||
images = []
|
||||
for base, tag in ((text_start, "here"), (elsewhere, "there")):
|
||||
relinked = os.path.join(work, f"{tag}.elf")
|
||||
binary = os.path.join(work, f"{tag}.bin")
|
||||
subprocess.run(
|
||||
[cxx, f"-mmcu={mcu}", "-nostartfiles", f"-Wl,--section-start=.text={base:#x}",
|
||||
"-Wl,--defsym=pureboot_app=0", "-mrelax", obj, "-o", relinked],
|
||||
check=True, capture_output=True)
|
||||
subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True)
|
||||
images.append(open(binary, "rb").read())
|
||||
if images[0] != images[1]:
|
||||
differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b]
|
||||
fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: "
|
||||
f"{len(differing)} byte(s) differ, first at offset {differing[0]:#x}")
|
||||
|
||||
print(f"PI lint: control flow PC-relative, .text only, identical linked at {text_start:#x} and {elsewhere:#x}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
23
test/check_unit.cmake
Normal file
23
test/check_unit.cmake
Normal file
@@ -0,0 +1,23 @@
|
||||
# Asserts the autobaud loader's measured unit is the first RAM object: the
|
||||
# host tool reads the bit period from ram_start (--info's measured clock), so
|
||||
# the unit's address is wire contract. Run as
|
||||
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> -P check_unit.cmake
|
||||
|
||||
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
|
||||
if(NOT _res EQUAL 0)
|
||||
message(FATAL_ERROR "${OBJDUMP} -t ${ELF} failed")
|
||||
endif()
|
||||
|
||||
# The symbol line: "00800100 l O .noinit 00000002 <mangled>unit_E".
|
||||
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_[^ \t\n]*\n" _line "${_syms}")
|
||||
if(NOT _line)
|
||||
message(FATAL_ERROR "no unit_ symbol in ${ELF} — is this the autobaud loader?")
|
||||
endif()
|
||||
|
||||
# AVR data-space symbols carry the 0x800000 VMA offset.
|
||||
math(EXPR _want "0x800000 + ${RAM_START}" OUTPUT_FORMAT HEXADECIMAL)
|
||||
math(EXPR _have "0x${CMAKE_MATCH_1}" OUTPUT_FORMAT HEXADECIMAL)
|
||||
if(NOT _have STREQUAL _want)
|
||||
message(FATAL_ERROR "unit_ sits at ${_have}, ram_start is ${_want} — the host peeks ram_start")
|
||||
endif()
|
||||
message(STATUS "unit_ at ${_have} == ram_start")
|
||||
@@ -7,77 +7,95 @@
|
||||
// SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM)
|
||||
// we dump the flash image to a file for a ground-truth cross-check against
|
||||
// what the client read back through the bootloader.
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <print>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
|
||||
// unlike simavr's core headers — the block covers both harmlessly.
|
||||
extern "C" {
|
||||
#include "avr_uart.h"
|
||||
#include "sim_avr.h"
|
||||
#include "sim_elf.h"
|
||||
#include "uart_pty.h"
|
||||
}
|
||||
|
||||
static avr_t *avr;
|
||||
static uart_pty_t uart_pty;
|
||||
static const char *dump_path;
|
||||
namespace {
|
||||
|
||||
static void finish(int sig)
|
||||
avr_t *avr;
|
||||
uart_pty_t uart_pty;
|
||||
const char *dump_path;
|
||||
|
||||
[[noreturn]] void finish(int)
|
||||
{
|
||||
(void)sig;
|
||||
if (dump_path) {
|
||||
FILE *f = fopen(dump_path, "wb");
|
||||
std::FILE *f = std::fopen(dump_path, "wb");
|
||||
if (f) {
|
||||
fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
fclose(f);
|
||||
std::fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
}
|
||||
uart_pty_stop(&uart_pty);
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: %s <tsb.elf> <boot_base_hex> [flash_dump.bin]\n", argv[0]);
|
||||
std::println(stderr, "usage: {} <tsb.elf> <boot_base_hex> [flash_dump.bin]", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
uint32_t boot_base = (uint32_t)strtoul(argv[2], NULL, 0);
|
||||
dump_path = argc >= 4 ? argv[3] : NULL;
|
||||
auto boot_base = static_cast<std::uint32_t>(std::strtoul(argv[2], nullptr, 0));
|
||||
dump_path = argc >= 4 ? argv[3] : nullptr;
|
||||
|
||||
avr = avr_make_mcu_by_name("atmega328p");
|
||||
if (!avr) {
|
||||
fprintf(stderr, "device: no ATmega328P core\n");
|
||||
std::println(stderr, "device: no ATmega328P core");
|
||||
return 1;
|
||||
}
|
||||
avr_init(avr);
|
||||
avr->frequency = 16000000;
|
||||
// Real flash powers up erased (0xff); the app region must look erased
|
||||
// before the bootloader programs it.
|
||||
memset(avr->flash, 0xff, avr->flashend + 1);
|
||||
std::memset(avr->flash, 0xff, avr->flashend + 1);
|
||||
|
||||
// simavr's ELF loader flattens the flash base to 0 (it expects an app at
|
||||
// 0x0), but it hands back the boot code in fw.flash; place it at the boot
|
||||
// section base ourselves and enter there (BOOTRST is not modelled).
|
||||
elf_firmware_t fw = {0};
|
||||
elf_firmware_t fw{};
|
||||
if (elf_read_firmware(argv[1], &fw) != 0) {
|
||||
fprintf(stderr, "device: cannot read %s\n", argv[1]);
|
||||
std::println(stderr, "device: cannot read {}", argv[1]);
|
||||
return 1;
|
||||
}
|
||||
memcpy(avr->flash + boot_base, fw.flash, fw.flashsize);
|
||||
// An image that runs past flash end cannot execute on hardware, and a
|
||||
// naive copy of it would smash the heap beyond avr->flash — after which
|
||||
// the simulation misbehaves in ways that point everywhere but here.
|
||||
// Refuse it loudly instead.
|
||||
if (boot_base + fw.flashsize > avr->flashend + 1) {
|
||||
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
|
||||
fw.flashsize, boot_base, avr->flashend);
|
||||
return 1;
|
||||
}
|
||||
std::memcpy(avr->flash + boot_base, fw.flash, fw.flashsize);
|
||||
avr->pc = boot_base;
|
||||
avr->codeend = avr->flashend;
|
||||
|
||||
// Optional: seed the config page (one page below the boot section) with a
|
||||
// hex byte string, so the password gate and emergency erase can be tested.
|
||||
// Layout: [appjump lo][appjump hi][timeout][password...][0xff].
|
||||
const char *cfg = getenv("TSB_CONFIG");
|
||||
const char *cfg = std::getenv("TSB_CONFIG");
|
||||
if (cfg) {
|
||||
uint32_t app_end = boot_base - 128; // config page sits directly below the boot code
|
||||
std::uint32_t app_end = boot_base - 128; // config page sits directly below the boot code
|
||||
for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) {
|
||||
char b[3] = {cfg[i], cfg[i + 1], 0};
|
||||
avr->flash[app_end + i / 2] = (uint8_t)strtoul(b, NULL, 16);
|
||||
avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(b, nullptr, 16));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +104,18 @@ int main(int argc, char *argv[])
|
||||
// tight-polling loader (one that releases TX between bytes, as one-wire does)
|
||||
// in real time, distorting protocol timing. Clear it so the loader runs at
|
||||
// true cycle speed.
|
||||
uint32_t uflags = 0;
|
||||
std::uint32_t uflags = 0;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS('0'), &uflags);
|
||||
uflags &= ~AVR_UART_FLAG_POLL_SLEEP;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS('0'), &uflags);
|
||||
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, '0');
|
||||
printf("TSB_PTY %s\n", uart_pty.pty.slavename);
|
||||
fflush(stdout);
|
||||
std::println("TSB_PTY {}", uart_pty.pty.slavename);
|
||||
std::fflush(stdout);
|
||||
|
||||
signal(SIGTERM, finish);
|
||||
signal(SIGINT, finish);
|
||||
std::signal(SIGTERM, finish);
|
||||
std::signal(SIGINT, finish);
|
||||
|
||||
for (;;) {
|
||||
int state = avr_run(avr);
|
||||
@@ -105,5 +123,4 @@ int main(int argc, char *argv[])
|
||||
break;
|
||||
}
|
||||
finish(0);
|
||||
return 0;
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
// reset reaches those loaders through the patched vector (or the runner
|
||||
// models BOOTRST), so the application owes them nothing.
|
||||
//
|
||||
// PUREBOOT_HANDOVER drops the listening and jumps straight in, leaving the
|
||||
// USART enabled behind it — the hand-over state a loader bit-banging on that
|
||||
// USART's own pins has to survive.
|
||||
//
|
||||
// The fixture speaks the deployment its loader was built for: the same
|
||||
// PUREBOOT_* defines configure it, and without them it assumes the stock
|
||||
// deployment (the crystal/RC clock table below, the chip's natural link).
|
||||
@@ -64,16 +68,29 @@ struct link {
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
}
|
||||
// The loader sits in the top slot — 512 bytes on every chip. The jump
|
||||
// takes a word address, which is what makes the >64 KiB chips' entry
|
||||
// reachable through a 16-bit pointer at all.
|
||||
static void enter_loader()
|
||||
{
|
||||
constexpr std::uint32_t slot = 512;
|
||||
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
|
||||
}
|
||||
|
||||
[[noreturn]] static void idle()
|
||||
{
|
||||
// 'L' hands back to the loader in the top slot — 512 bytes on every
|
||||
// chip. The jump takes a word address, which is what makes the
|
||||
// >64 KiB chips' entry reachable through a 16-bit pointer at all.
|
||||
constexpr std::uint32_t slot = 512;
|
||||
#if defined(PUREBOOT_HANDOVER)
|
||||
// Hand back at once, with this USART still enabled — the state that
|
||||
// leaves a bit-banged loader on its pins mute unless the loader
|
||||
// releases it. Unconditional because there is no command wire to
|
||||
// wait on: that loader's link is the pins, not this peripheral.
|
||||
enter_loader();
|
||||
__builtin_unreachable();
|
||||
#else
|
||||
for (;;) {
|
||||
auto command = tx_t::read_blocking();
|
||||
if (command == 'L')
|
||||
reinterpret_cast<void (*)()>(static_cast<std::uint16_t>((avr::hw::db.mem.flash_size - slot) / 2))();
|
||||
enter_loader();
|
||||
// 'D' leaves every word of the SPM page buffer dirty, so that a
|
||||
// following 'L' enters the loader with the buffer it never clears.
|
||||
if (command == 'D') {
|
||||
@@ -82,6 +99,7 @@ struct link {
|
||||
tx('D');
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +117,27 @@ struct link<C, false> {
|
||||
}
|
||||
[[noreturn]] static void idle()
|
||||
{
|
||||
#if defined(PUREBOOT_HEARTBEAT)
|
||||
// Repeat the banner forever, which turns the fixture into a fixed
|
||||
// cycles-per-bit transmitter: `tools/pbrig.py rate` sweeps the host rate
|
||||
// against it to find the part's true bit rate, and from that the clock
|
||||
// its RC oscillator is really running at. Only the *bit* timing carries
|
||||
// the measurement — the delay merely spaces the lines out, so its own
|
||||
// error does not matter. Software link only: the hardware-link idle owes
|
||||
// the self-update tests a command loop, and a crystal deployment has
|
||||
// nothing to measure.
|
||||
while (true) {
|
||||
tx('A');
|
||||
tx('P');
|
||||
tx('P');
|
||||
tx('\r');
|
||||
tx('\n');
|
||||
dev::delay<50_ms>();
|
||||
}
|
||||
#else
|
||||
while (true) {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,8 +146,13 @@ struct link<C, false> {
|
||||
int main()
|
||||
{
|
||||
avr::init<typename link<dev::clock>::tx_t>();
|
||||
#if !defined(PUREBOOT_HANDOVER)
|
||||
link<dev::clock>::tx('A');
|
||||
link<dev::clock>::tx('P');
|
||||
link<dev::clock>::tx('P');
|
||||
#endif
|
||||
// The hand-over fixture stays silent: nothing is listening on the USART it
|
||||
// brings up — the loader it hands to speaks those pins directly — so its
|
||||
// banner would be a write into a peer that does not exist.
|
||||
link<dev::clock>::idle();
|
||||
}
|
||||
|
||||
199
test/pbautobaud.py
Normal file
199
test/pbautobaud.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end autobaud test: drive an autobaud loader in simavr through the
|
||||
calibration handshake and a flash + EEPROM + fuse round-trip, cross-checked
|
||||
against the simulator's ground-truth memory — then repeat at a second F_CPU with
|
||||
the *same* loader binary, which is the property autobaud exists for: one
|
||||
clock-agnostic image that locks onto whatever rate the host sends.
|
||||
|
||||
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
|
||||
<app_bin> <app_hz> <app_baud> <tool_py> <workdir>
|
||||
|
||||
The loader is a software-serial build on PB0/PB1 (pureboot_add_autobaud's
|
||||
default), so the runner drives it over the GPIO⇄pty bridge (-l sw:B0,B1). The
|
||||
app fixture is built for (app_hz, app_baud); the hand-over is checked at that
|
||||
point, and a second point at half the clock proves the lock is measured, not
|
||||
baked in.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = sys.argv[1:]
|
||||
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
import pureboot as pb
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
ee_image = bytes(range(0xA0, 0xB0))
|
||||
ee_path = os.path.join(workdir, "ee.bin")
|
||||
open(ee_path, "wb").write(ee_image)
|
||||
|
||||
# The geometry the surgery planner needs, from the chip class the runner is
|
||||
# told — the same derivation pbtest.py makes: the boot-sectioned megas need
|
||||
# no vector surgery, the tinies and the boot-section-less m48s do, and the
|
||||
# large chips speak word addresses.
|
||||
mega = mcu.startswith("atmega")
|
||||
patch = not mega or mcu.startswith("atmega48")
|
||||
word_flash = base + pb.SLOT > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
ground_truth = pb.Info(bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF,
|
||||
wire_base & 0xFF, wire_base >> 8, 0, 0, flags]))
|
||||
|
||||
def round_trip(hz, baud, label, hand_over):
|
||||
"""One clock point: reset, calibrate + knock, program, verify against the
|
||||
simulator's own flash, and (at the app's point) hand over to the fixture."""
|
||||
dump = os.path.join(workdir, f"flash_{label}.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump, link="sw:B0,B1")
|
||||
try:
|
||||
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
||||
# and a single knock at `baud`; the loader locks to it.
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
|
||||
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
||||
# The measured clock, decoded from the unit at ram_start. The
|
||||
# runner's clock is exact, so the figure must land inside the
|
||||
# encoding's own envelope: the loader floors the bit period to
|
||||
# 4-cycle spin granules after an 8-cycle discount, and the edge
|
||||
# poll can shave a few cycles more — one granule of slack below
|
||||
# the true clock, none above (in cycles per bit, times the rate).
|
||||
measured = re.search(r"measured\s+(\d+) Hz", out)
|
||||
if not measured:
|
||||
fail(f"{label}: --info lacks the measured clock\n{out}")
|
||||
measured = int(measured.group(1))
|
||||
if not hz - 19 * baud <= measured <= hz + 4 * baud:
|
||||
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
|
||||
# Read both memories back over the locked link and check them.
|
||||
read_flash = os.path.join(workdir, f"rf_{label}.bin")
|
||||
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--verify-flash", app_bin,
|
||||
"--verify-eeprom", ee_path, "--read-flash", read_flash,
|
||||
"--read-eeprom", read_eeprom, "--stay")
|
||||
if out.count("verify:") != 2:
|
||||
fail(f"{label}: did not verify both memories\n{out}")
|
||||
if open(read_eeprom, "rb").read()[: len(ee_image)] != ee_image:
|
||||
fail(f"{label}: EEPROM read-back mismatch")
|
||||
|
||||
if hand_over:
|
||||
# Regression: a calibration pulse with no knock behind it must
|
||||
# not wedge the loader. The knock's edge wait used to be
|
||||
# unbudgeted, so one stray low pulse — EMI, or a host that opens
|
||||
# the port and never knocks — held the loader forever and the
|
||||
# application never ran. The whole activation is bounded now, so
|
||||
# the window closes and the app boots; the banner is the proof.
|
||||
# (The pause lets the loader reach its measurement loop, so the
|
||||
# pulse is genuinely seen and the test cannot pass vacuously.)
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
try:
|
||||
time.sleep(0.2)
|
||||
port.write(bytes((pb.CALIBRATE,)))
|
||||
# Accumulate rather than match exactly: the reset leaves the
|
||||
# idle line a framing artefact ahead of the banner, which is
|
||||
# noise here — the question is only whether the app ran.
|
||||
seen = b""
|
||||
deadline = time.monotonic() + 180.0
|
||||
while b"APP" not in seen and time.monotonic() < deadline:
|
||||
seen += port.read_available(1.0)
|
||||
if b"APP" not in seen:
|
||||
fail(f"{label}: lone calibration pulse wedged the loader — app never bannered, saw {seen!r}")
|
||||
print(f" {label}: lone calibration pulse does not wedge the loader")
|
||||
finally:
|
||||
port.close()
|
||||
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
live = loader.connect_autobaud(15)
|
||||
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
|
||||
fail(f"{label}: loader reports pureboot {live.version}")
|
||||
if loader.unified:
|
||||
# pureboot 5's data space. 0x0200 is clear of the
|
||||
# loader's own .noinit unit at the bottom of SRAM and of
|
||||
# the stack at the top. Reading it back over the same
|
||||
# locked link proves both directions of the new space.
|
||||
probe = bytes(range(0x30, 0x40))
|
||||
loader.write_ram(0x0200, probe)
|
||||
if loader.read_ram(0x0200, len(probe)) != probe:
|
||||
fail(f"{label}: RAM round-trip mismatch")
|
||||
# The register file and the I/O space share the data
|
||||
# address space on AVR, so the same command reaches a
|
||||
# peripheral register. SPMCSR reads back as idle here.
|
||||
verbose_ram = loader.read_ram(0x0200, 4)
|
||||
print(f" {label}: RAM read/write ok ({verbose_ram.hex()})")
|
||||
loader.run_application()
|
||||
banner = port.read_exact(3, 5.0)
|
||||
if banner != b"APP":
|
||||
fail(f"{label}: application banner was {banner!r}")
|
||||
finally:
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
# Ground truth (read after the runner exits and writes its dump): what
|
||||
# the tool programmed must be what the simulator actually holds.
|
||||
pages = pb.plan_flash(open(app_bin, "rb").read(), ground_truth)
|
||||
flash_true = open(dump, "rb").read()
|
||||
for address, data in pages.items():
|
||||
if flash_true[address : address + page] != data:
|
||||
fail(f"{label}: simulator flash differs from the programmed image at {address:#06x}")
|
||||
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
|
||||
+ (", hand-over ok" if hand_over else ""))
|
||||
|
||||
def must_lock(hz, baud, label):
|
||||
"""The calibration alone, at a tight bit period. Nothing is programmed —
|
||||
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")
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
try:
|
||||
live = pb.Loader(port).connect_autobaud(15)
|
||||
if live.version != pb.NEWEST_LOADER:
|
||||
fail(f"{label}: loader reports pureboot {live.version}")
|
||||
finally:
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
print(f" {label}: locked at {hz} Hz / {baud} Bd ({hz / baud:.0f} cycles a bit)")
|
||||
|
||||
# The app fixture is built for one clock; the hand-over banners there. A
|
||||
# second point at double that clock, same loader binary, proves the lock is
|
||||
# measured, not baked in — the whole point of autobaud. (Doubling keeps the
|
||||
# bit period healthy; halving would drop it below the software UART's floor.)
|
||||
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
|
||||
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
|
||||
|
||||
# Both points above sit near 100 cycles a bit, which is comfortable. The
|
||||
# calibration's real floor is far tighter, and it is worth a gate: measured
|
||||
# here, the lock is solid down to ~36 cycles a bit and fails outright by ~31
|
||||
# — a sharp edge, not a fraying one. This pins the tightest standard rate the
|
||||
# fixture's clock reaches, so a change that raises the floor is caught.
|
||||
#
|
||||
# It does *not* bound what a real deployment can use. On silicon the
|
||||
# oscillator's own jitter costs roughly a factor of two: an ATtiny13A on its
|
||||
# factory RC trim was reliable at ~118 cycles a bit and already locking only
|
||||
# 1 attempt in 5 by ~59, which no exact-clock simulation can show. The
|
||||
# deployable envelope is a README matter; this is the logic's floor.
|
||||
must_lock(app_hz, app_baud * 2, "tight-bit")
|
||||
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks, "
|
||||
"and the tight bit period still locks")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
test/pbmute.py
Normal file
73
test/pbmute.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hand-over with a USART left enabled on the loader's own pins.
|
||||
|
||||
A software or autobaud link deployed on a USART's TxD is mute if an
|
||||
application hands over with that USART still enabled: TXEN keeps the USART
|
||||
owning the pin, so the bit-banged transmitter's port writes go nowhere and the
|
||||
loader receives and obeys while answering nothing. The link's init releases it.
|
||||
|
||||
The state is reached the way silicon reaches it — an application that sets up
|
||||
its USART and jumps in with no reset between, so nothing clears UCSRnB for it.
|
||||
The pin ownership itself is modelled by the device runner: simavr wires a
|
||||
USART through IRQs alone and never takes the pin from the port, so without
|
||||
that the mute could not happen here at all (test/pureboot_device.cpp).
|
||||
|
||||
Usage: pbmute.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <app_bin> <tool_py> <workdir> <link>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir, link = sys.argv[1:]
|
||||
page, baud = 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
|
||||
|
||||
if "@" not in link:
|
||||
fail(f"the link {link} names no owning USART — nothing would be under test")
|
||||
|
||||
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, link=link)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(25)
|
||||
resident = loader.info.version
|
||||
|
||||
# Install the fixture and let it take over. It brings up the USART
|
||||
# that owns these pins and jumps straight back in.
|
||||
pb.op_flash(loader, app_bin, erase=False, verify=True)
|
||||
loader.run_application()
|
||||
|
||||
# The loader is running again with that USART enabled behind it. Only
|
||||
# the release makes it audible; without it the connect times out.
|
||||
loader = pb.Loader(port)
|
||||
try:
|
||||
loader.connect(25)
|
||||
except pb.Error as error:
|
||||
fail(f"the loader never answered after the hand-over — the USART still owns its TX pin ({error})")
|
||||
if loader.info.version != resident:
|
||||
fail(f"identity changed across the hand-over: {resident} then {loader.info.version}")
|
||||
|
||||
# Answering is not enough: it has to still be a working loader.
|
||||
pb.verify_pages(loader, pb.plan_flash(open(app_bin, "rb").read(), loader.info))
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
print("pbmute: a loader on a USART's own pins answers after a hand-over that left it enabled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
test/pbosccal.py
Normal file
46
test/pbosccal.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The build-time OSCCAL trim, observed through the wire: a loader built with
|
||||
the OSCCAL axis holds the trim register at the built byte from its first
|
||||
prompt on — the write sits at the top of run(), ahead of the WDRF bail, so
|
||||
every path out of reset runs on the corrected clock. simavr's clock does not
|
||||
follow OSCCAL, which is what makes the value assertable at all: the register
|
||||
is plain state there, and the peek must return exactly what the build
|
||||
declared rather than whatever the oscillator needed.
|
||||
|
||||
Usage: pbosccal.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <osccal_addr> <osccal_value> <tool_py> <workdir>
|
||||
[link]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
link = args.pop() if len(args) == 12 else None
|
||||
(device_bin, elf, mcu, hz, base_hex, page, baud, addr, value, tool, workdir) = args
|
||||
addr, value, baud = int(addr, 0), int(value, 0), int(baud)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "flash_dump.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--peek", f"{addr:#x}:1")
|
||||
want = f"{addr:#06x} {value:02x}"
|
||||
if want not in out:
|
||||
fail(f"OSCCAL at {addr:#x} did not read back {value:#04x}:\n{out}")
|
||||
finally:
|
||||
device.stop()
|
||||
print("OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -55,6 +55,12 @@ def main():
|
||||
# the page byte is the wire's 0-means-256.
|
||||
mega = mcu.startswith("atmega")
|
||||
patch = not mega or mcu.startswith("atmega48")
|
||||
# Where SRAM begins: the x8 and x4 megas push it past their extended I/O
|
||||
# space, everything else starts right after the plain I/O registers. The
|
||||
# loader keeps no statics and its stack sits at RAMEND, so the first SRAM
|
||||
# byte is free for the data-space probe below.
|
||||
classic = mcu in ("atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a")
|
||||
ram_base = 0x0100 if mega and not classic else 0x0060
|
||||
word_flash = base + pb.SLOT > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
@@ -73,12 +79,20 @@ def main():
|
||||
if needed not in out:
|
||||
fail(f"session 1 output lacks {needed!r}")
|
||||
|
||||
# Session 2: reconnect into the live session, verify, dump, hand over
|
||||
# is deferred — the pty must be reopened for the APP banner first.
|
||||
# Session 2: reconnect into the live session, verify, dump, exercise
|
||||
# the data space; hand over is deferred — the pty must be reopened for
|
||||
# the APP banner first.
|
||||
probe = "c0ffee"
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
|
||||
"--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay")
|
||||
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
|
||||
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
|
||||
if out.count("verify:") != 2:
|
||||
fail("session 2 did not verify both memories")
|
||||
# What went into SRAM must come back out of it: the data space is one
|
||||
# more selector on the same transfer as flash and EEPROM, so a wrong
|
||||
# selector decode would show up here and nowhere else.
|
||||
if probe not in out.replace(" ", ""):
|
||||
fail(f"data-space round trip at {ram_base:#x} did not read back {probe}\n{out}")
|
||||
|
||||
eeprom_back = open(read_eeprom, "rb").read()
|
||||
if eeprom_back[: len(ee_image)] != ee_image:
|
||||
@@ -100,19 +114,26 @@ def main():
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
live = loader.connect(15)
|
||||
# The loader built from this tree and the tool beside it must
|
||||
# agree on where the version numbering stands: a bump the tool
|
||||
# was never told about is a loader it would refuse to speak to.
|
||||
if live.version != pb.NEWEST_LOADER:
|
||||
fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}")
|
||||
# The loader built from this tree must report a version the tool
|
||||
# beside it speaks — a bump the tool was never told about is a
|
||||
# loader it would refuse to talk to. Not equality with the newest:
|
||||
# the tool now spans two loader generations, the fixed-baud one
|
||||
# here and the unified autobaud loader that follows it.
|
||||
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
|
||||
fail(f"loader reports pureboot {live.version}, the tool speaks "
|
||||
f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}")
|
||||
|
||||
# A W addressed inside a page rather than at its base must still
|
||||
# consume exactly one page and prompt. The loader's own slot is
|
||||
# the target — it is drained and never programmed — and the
|
||||
# payload is erased-state bytes, so the probe can disturb neither
|
||||
# the image nor the page buffer it leaves behind.
|
||||
wire = wire_base + 1
|
||||
port.write(bytes((ord("W"), wire & 0xFF, wire >> 8)) + b"\xff" * page)
|
||||
# consume exactly one page and prompt. The loader's own slot is the
|
||||
# target — the guard refuses to commit it — and the payload is
|
||||
# erased-state bytes, so the probe can disturb neither the image nor
|
||||
# the page buffer it leaves behind. Hand-built rather than through
|
||||
# write_page(), which would follow the fill with its erase and
|
||||
# write; the point here is that the fill alone consumes exactly one
|
||||
# page whatever the address's low bits say.
|
||||
wire = base + 1
|
||||
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
|
||||
+ b"\xff" * page)
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("unaligned W did not return to the prompt")
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
//
|
||||
// The link follows the chip's natural default (USART0 on the megas, the
|
||||
// software UART on PB0/PB1 elsewhere) unless -l overrides it: `-l usart1`
|
||||
// for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins.
|
||||
// for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins,
|
||||
// and `-l sw:D0,D1@0` where those pins are a USART's own — see the pin
|
||||
// ownership the bridge models below.
|
||||
//
|
||||
// simavr's tiny cores decode the SPM opcode but attach no NVM module — SPM
|
||||
// is a silent no-op (the mega's boot section has one, avr_flash). The
|
||||
@@ -21,16 +23,22 @@
|
||||
//
|
||||
// On exit (or SIGTERM) the flash and EEPROM are dumped to files for a
|
||||
// ground-truth cross-check against what the host read back.
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <print>
|
||||
#include <string_view>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <pty.h>
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
|
||||
// unlike simavr's core headers — the block covers both harmlessly.
|
||||
extern "C" {
|
||||
#include "avr_eeprom.h"
|
||||
#include "avr_flash.h"
|
||||
#include "avr_ioport.h"
|
||||
@@ -39,31 +47,40 @@
|
||||
#include "sim_elf.h"
|
||||
#include "sim_io.h"
|
||||
#include "uart_pty.h"
|
||||
}
|
||||
|
||||
static avr_t *avr;
|
||||
static uart_pty_t uart_pty;
|
||||
static int link_software;
|
||||
static char uart_digit = '0';
|
||||
static char sw_rx_port = 'B', sw_tx_port = 'B';
|
||||
static int sw_rx_bit = 0, sw_tx_bit = 1;
|
||||
static const char *dump_path;
|
||||
static uint32_t reset_pc;
|
||||
static volatile sig_atomic_t reset_requested;
|
||||
namespace {
|
||||
|
||||
static int parse_link(const char *spec)
|
||||
avr_t *avr;
|
||||
uart_pty_t uart_pty;
|
||||
bool link_software;
|
||||
char uart_digit = '0';
|
||||
char sw_rx_port = 'B', sw_tx_port = 'B';
|
||||
int sw_rx_bit = 0, sw_tx_bit = 1;
|
||||
char sw_tx_owner = 0; // the USART whose TXD the software link sits on
|
||||
const char *dump_path;
|
||||
std::uint32_t reset_pc;
|
||||
volatile std::sig_atomic_t reset_requested;
|
||||
|
||||
int parse_link(std::string_view spec)
|
||||
{
|
||||
if (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) {
|
||||
link_software = 0;
|
||||
if (spec == "usart0" || spec == "usart1") {
|
||||
link_software = false;
|
||||
uart_digit = spec[5];
|
||||
return 0;
|
||||
}
|
||||
if (strncmp(spec, "sw", 2) == 0) {
|
||||
link_software = 1;
|
||||
if (spec[2] == '\0')
|
||||
if (spec.starts_with("sw")) {
|
||||
link_software = true;
|
||||
if (spec.size() == 2)
|
||||
return 0;
|
||||
if (sscanf(spec + 2, ":%c%d,%c%d", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit) == 4)
|
||||
char owner = 0;
|
||||
int fields =
|
||||
std::sscanf(spec.data() + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner);
|
||||
if (fields == 4 || fields == 5) {
|
||||
sw_tx_owner = owner;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -80,19 +97,19 @@ static int parse_link(const char *spec)
|
||||
// core — so the discard store falls through into the buffer-fill branch and
|
||||
// plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard
|
||||
// here instead.
|
||||
static avr_flash_t *mega_flash;
|
||||
static int (*mega_flash_ioctl)(avr_io_t *io, uint32_t ctl, void *param);
|
||||
avr_flash_t *mega_flash;
|
||||
int (*mega_flash_ioctl)(avr_io_t *io, std::uint32_t ctl, void *param);
|
||||
|
||||
static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param)
|
||||
int fixed_flash_ioctl(avr_io_t *io, std::uint32_t ctl, void *param)
|
||||
{
|
||||
if (ctl == AVR_IOCTL_FLASH_SPM && avr_regbit_get(io->avr, mega_flash->pgers)) {
|
||||
uint16_t z = (uint16_t)(io->avr->data[30] | (io->avr->data[31] << 8));
|
||||
uint16_t masked = (uint16_t)(z & ~(mega_flash->spm_pagesize - 1));
|
||||
io->avr->data[30] = (uint8_t)masked;
|
||||
io->avr->data[31] = (uint8_t)(masked >> 8);
|
||||
auto z = static_cast<std::uint16_t>(io->avr->data[30] | (io->avr->data[31] << 8));
|
||||
auto masked = static_cast<std::uint16_t>(z & ~(mega_flash->spm_pagesize - 1));
|
||||
io->avr->data[30] = static_cast<std::uint8_t>(masked);
|
||||
io->avr->data[31] = static_cast<std::uint8_t>(masked >> 8);
|
||||
int result = mega_flash_ioctl(io, ctl, param);
|
||||
io->avr->data[30] = (uint8_t)z;
|
||||
io->avr->data[31] = (uint8_t)(z >> 8);
|
||||
io->avr->data[30] = static_cast<std::uint8_t>(z);
|
||||
io->avr->data[31] = static_cast<std::uint8_t>(z >> 8);
|
||||
return result;
|
||||
}
|
||||
if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) &&
|
||||
@@ -107,46 +124,44 @@ static int fixed_flash_ioctl(avr_io_t *io, uint32_t ctl, void *param)
|
||||
return mega_flash_ioctl(io, ctl, param);
|
||||
}
|
||||
|
||||
static void fix_mega_flash_erase(void)
|
||||
void fix_mega_flash_erase()
|
||||
{
|
||||
for (avr_io_t *io = avr->io_port; io; io = io->next) {
|
||||
if (io->kind && strcmp(io->kind, "flash") == 0) {
|
||||
mega_flash = (avr_flash_t *)io;
|
||||
if (io->kind && std::string_view{io->kind} == "flash") {
|
||||
mega_flash = reinterpret_cast<avr_flash_t *>(io);
|
||||
mega_flash_ioctl = io->ioctl;
|
||||
io->ioctl = fixed_flash_ioctl;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fprintf(stderr, "device: no flash module to fix — SPM page erases may misalign\n");
|
||||
std::println(stderr, "device: no flash module to fix — SPM page erases may misalign");
|
||||
}
|
||||
|
||||
static void request_reset(int sig)
|
||||
void request_reset(int)
|
||||
{
|
||||
(void)sig;
|
||||
reset_requested = 1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tiny NVM ---
|
||||
|
||||
typedef struct {
|
||||
struct tiny_nvm_t {
|
||||
avr_io_t io;
|
||||
uint8_t buffer[128];
|
||||
uint8_t used[128]; // a buffer word loads once until erased — like silicon
|
||||
std::uint8_t buffer[128];
|
||||
std::uint8_t used[128]; // a buffer word loads once until erased — like silicon
|
||||
unsigned page;
|
||||
} tiny_nvm_t;
|
||||
};
|
||||
|
||||
static tiny_nvm_t nvm;
|
||||
tiny_nvm_t nvm;
|
||||
|
||||
static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
|
||||
int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
|
||||
{
|
||||
(void)param;
|
||||
if (ctl != AVR_IOCTL_FLASH_SPM)
|
||||
return -1;
|
||||
tiny_nvm_t *n = (tiny_nvm_t *)io;
|
||||
auto *n = reinterpret_cast<tiny_nvm_t *>(io);
|
||||
avr_t *mcu = io->avr;
|
||||
uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
|
||||
uint16_t z = (uint16_t)(mcu->data[30] | (mcu->data[31] << 8));
|
||||
uint32_t page_base = (uint32_t)(z & ~(n->page - 1)) % (mcu->flashend + 1);
|
||||
std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
|
||||
auto z = static_cast<std::uint16_t>(mcu->data[30] | (mcu->data[31] << 8));
|
||||
std::uint32_t page_base = static_cast<std::uint32_t>(z & ~(n->page - 1)) % (mcu->flashend + 1);
|
||||
if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0
|
||||
unsigned offset = z & (n->page - 1) & ~1u;
|
||||
if (!n->used[offset]) { // first write wins until the buffer clears
|
||||
@@ -155,66 +170,109 @@ static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
|
||||
n->used[offset] = 1;
|
||||
}
|
||||
} else if (command == 0x03) { // PGERS
|
||||
memset(mcu->flash + page_base, 0xff, n->page);
|
||||
std::memset(mcu->flash + page_base, 0xff, n->page);
|
||||
} else if (command == 0x05) { // PGWRT: programming only clears bits
|
||||
for (unsigned i = 0; i < n->page; i++)
|
||||
mcu->flash[page_base + i] &= n->buffer[i];
|
||||
memset(n->buffer, 0xff, n->page);
|
||||
memset(n->used, 0, n->page);
|
||||
std::memset(n->buffer, 0xff, n->page);
|
||||
std::memset(n->used, 0, n->page);
|
||||
} else if (command == 0x11) { // CTPB
|
||||
memset(n->buffer, 0xff, n->page);
|
||||
memset(n->used, 0, n->page);
|
||||
std::memset(n->buffer, 0xff, n->page);
|
||||
std::memset(n->used, 0, n->page);
|
||||
}
|
||||
mcu->data[0x57] &= (uint8_t)~0x1f; // the operation completes instantly
|
||||
mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- GPIO bridge ---
|
||||
|
||||
static int pty_master = -1;
|
||||
static avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
|
||||
static avr_cycle_count_t bit_cycles;
|
||||
int pty_master = -1;
|
||||
avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
|
||||
avr_cycle_count_t bit_cycles;
|
||||
|
||||
static int tx_level = 1, tx_active, tx_bit;
|
||||
static uint8_t tx_shift;
|
||||
int tx_level = 1, tx_active, tx_bit;
|
||||
std::uint8_t tx_shift;
|
||||
|
||||
static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param)
|
||||
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
(void)mcu;
|
||||
(void)param;
|
||||
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (tx_bit < 8) {
|
||||
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
return when + bit_cycles;
|
||||
// The byte is delivered at the stop bit's sampling point (9.5 bit
|
||||
// times), where a hardware receiver raises its RXC — not sooner: a
|
||||
// host answering before the stop bit would put its start bit on the
|
||||
// wire while the device is still driving, which the device,
|
||||
// transmitting, is not watching for.
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (write(pty_master, &tx_shift, 1) != 1)
|
||||
fprintf(stderr, "device: pty write lost a byte\n");
|
||||
std::println(stderr, "device: pty write lost a byte");
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void tx_hook(avr_irq_t *irq, uint32_t value, void *param)
|
||||
// A USART owns its TxD pin whenever its transmitter is enabled, and the port
|
||||
// register cannot drive it (§20.2 / Atmel-8271 §19.2) — which is why a
|
||||
// bit-banged link deployed on those pins is mute until it clears UCSRnB.
|
||||
// simavr wires a USART entirely through IRQs and never touches the port pin
|
||||
// model, so the ownership does not exist there and the mute cannot happen:
|
||||
// supply it, or the very state this models is untestable. The link spec's
|
||||
// trailing @n names the USART; without one the pins are nobody's.
|
||||
avr_uart_t *tx_owner;
|
||||
|
||||
bool tx_pin_taken()
|
||||
{
|
||||
(void)irq;
|
||||
(void)param;
|
||||
return tx_owner && avr_regbit_get(avr, tx_owner->txen);
|
||||
}
|
||||
|
||||
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
|
||||
// whole register (§20.11.3) — which would hand the pin to a USART no code has
|
||||
// enabled, making a freshly reset chip mute for reasons hardware does not
|
||||
// have. Reset it the way the datasheet does, so the ownership starts from
|
||||
// nobody's and only an application that really enables the USART takes it.
|
||||
void reset_tx_owner()
|
||||
{
|
||||
if (tx_owner)
|
||||
avr_regbit_clear(avr, tx_owner->txen);
|
||||
}
|
||||
|
||||
void find_tx_owner()
|
||||
{
|
||||
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 == sw_tx_owner) {
|
||||
tx_owner = reinterpret_cast<avr_uart_t *>(io);
|
||||
reset_tx_owner();
|
||||
return;
|
||||
}
|
||||
std::println(stderr, "device: no USART{} to own the software link's TX pin", sw_tx_owner);
|
||||
}
|
||||
|
||||
void tx_hook(avr_irq_t *, std::uint32_t value, void *)
|
||||
{
|
||||
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
|
||||
tx_level = 1;
|
||||
return;
|
||||
}
|
||||
int level = value & 1;
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge
|
||||
tx_active = 1;
|
||||
tx_bit = 0;
|
||||
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, NULL);
|
||||
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr);
|
||||
}
|
||||
tx_level = level;
|
||||
}
|
||||
|
||||
static uint8_t rx_queue[8192];
|
||||
static unsigned rx_head, rx_tail; // ring: head = next to send
|
||||
static int rx_active, rx_bit;
|
||||
static uint8_t rx_byte;
|
||||
std::uint8_t rx_queue[8192];
|
||||
unsigned rx_head, rx_tail; // ring: head = next to send
|
||||
int rx_active, rx_bit;
|
||||
std::uint8_t rx_byte;
|
||||
|
||||
static void rx_start_next(void);
|
||||
void rx_start_next();
|
||||
|
||||
static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param)
|
||||
avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
(void)mcu;
|
||||
(void)param;
|
||||
if (rx_bit < 8) {
|
||||
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
|
||||
rx_bit++;
|
||||
@@ -230,7 +288,7 @@ static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void rx_start_next(void)
|
||||
void rx_start_next()
|
||||
{
|
||||
if (rx_active || rx_head == rx_tail)
|
||||
return;
|
||||
@@ -239,7 +297,7 @@ static void rx_start_next(void)
|
||||
rx_active = 1;
|
||||
rx_bit = 0;
|
||||
avr_raise_irq(rx_pin, 0); // start bit
|
||||
avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL);
|
||||
avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
|
||||
}
|
||||
|
||||
// A reset abandons whatever the bridge was mid-transfer: bytes still queued
|
||||
@@ -249,10 +307,10 @@ static void rx_start_next(void)
|
||||
// output latch, whose falling edge starts a spurious decode before this
|
||||
// runs, and a stale tx_sample would then interleave with the loader's first
|
||||
// real answer through the shared shift state, corrupting it.
|
||||
static void bridge_reset(void)
|
||||
void bridge_reset()
|
||||
{
|
||||
avr_cycle_timer_cancel(avr, tx_sample, NULL);
|
||||
avr_cycle_timer_cancel(avr, rx_step, NULL);
|
||||
avr_cycle_timer_cancel(avr, tx_sample, nullptr);
|
||||
avr_cycle_timer_cancel(avr, rx_step, nullptr);
|
||||
rx_head = rx_tail = 0;
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
@@ -260,9 +318,9 @@ static void bridge_reset(void)
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
}
|
||||
|
||||
static void poll_pty(void)
|
||||
void poll_pty()
|
||||
{
|
||||
uint8_t chunk[256];
|
||||
std::uint8_t chunk[256];
|
||||
ssize_t got = read(pty_master, chunk, sizeof(chunk));
|
||||
for (ssize_t i = 0; i < got; i++) {
|
||||
unsigned next = (rx_tail + 1) % sizeof(rx_queue);
|
||||
@@ -277,23 +335,22 @@ static void poll_pty(void)
|
||||
|
||||
// ------------------------------------------------------------------ main ---
|
||||
|
||||
static void finish(int sig)
|
||||
[[noreturn]] void finish(int)
|
||||
{
|
||||
(void)sig;
|
||||
if (dump_path) {
|
||||
FILE *f = fopen(dump_path, "wb");
|
||||
std::FILE *f = std::fopen(dump_path, "wb");
|
||||
if (f) {
|
||||
fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
fclose(f);
|
||||
std::fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
avr_eeprom_desc_t ee = {.ee = NULL, .offset = 0, .size = 0};
|
||||
avr_eeprom_desc_t ee = {.ee = nullptr, .offset = 0, .size = 0};
|
||||
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) {
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%s.eeprom", dump_path);
|
||||
f = fopen(path, "wb");
|
||||
std::snprintf(path, sizeof(path), "%s.eeprom", dump_path);
|
||||
f = std::fopen(path, "wb");
|
||||
if (f) {
|
||||
fwrite(ee.ee, 1, ee.size, f);
|
||||
fclose(f);
|
||||
std::fwrite(ee.ee, 1, ee.size, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,22 +359,25 @@ static void finish(int sig)
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int link_given = 0;
|
||||
bool link_given = false;
|
||||
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) {
|
||||
if (opt != 'l' || parse_link(optarg) != 0) {
|
||||
fprintf(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)\n");
|
||||
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
|
||||
return 2;
|
||||
}
|
||||
link_given = 1;
|
||||
link_given = true;
|
||||
}
|
||||
int args = argc - optind;
|
||||
if (args < 7 || args > 9) {
|
||||
fprintf(stderr,
|
||||
"usage: %s [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
std::print(stderr,
|
||||
"usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
" [reset_hex] [resume_flash]\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1] (RX,TX); default: the chip's own\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
|
||||
" them); default: the chip's own\n"
|
||||
" reset_hex: reset vector (default: base with a boot section, else 0)\n"
|
||||
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
|
||||
" run's dump, for power-fail resume tests\n",
|
||||
@@ -325,53 +385,61 @@ int main(int argc, char *argv[])
|
||||
return 2;
|
||||
}
|
||||
argv += optind - 1; // argv[1] is the ELF again, whatever was parsed
|
||||
const char *mcu_name = argv[2];
|
||||
uint32_t base = (uint32_t)strtoul(argv[4], NULL, 0);
|
||||
unsigned page = (unsigned)atoi(argv[5]);
|
||||
unsigned baud = (unsigned)atoi(argv[6]);
|
||||
const std::string_view mcu_name = argv[2];
|
||||
auto base = static_cast<std::uint32_t>(std::strtoul(argv[4], nullptr, 0));
|
||||
auto page = static_cast<unsigned>(std::atoi(argv[5]));
|
||||
auto baud = static_cast<unsigned>(std::atoi(argv[6]));
|
||||
dump_path = argv[7];
|
||||
int is_mega = strncmp(mcu_name, "atmega", 6) == 0;
|
||||
const bool is_mega = mcu_name.starts_with("atmega");
|
||||
if (!link_given)
|
||||
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
|
||||
|
||||
avr = avr_make_mcu_by_name(mcu_name);
|
||||
avr = avr_make_mcu_by_name(mcu_name.data());
|
||||
if (!avr) {
|
||||
fprintf(stderr, "device: no %s core\n", mcu_name);
|
||||
std::println(stderr, "device: no {} core", mcu_name);
|
||||
return 1;
|
||||
}
|
||||
avr_init(avr);
|
||||
avr->frequency = (uint32_t)strtoul(argv[3], NULL, 0);
|
||||
memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
|
||||
avr->frequency = static_cast<std::uint32_t>(std::strtoul(argv[3], nullptr, 0));
|
||||
std::memset(avr->flash, 0xff, avr->flashend + 1); // real flash powers up erased
|
||||
|
||||
if (args > 8) {
|
||||
// Resume: the full flash image of an interrupted prior run.
|
||||
FILE *f = fopen(argv[9], "rb");
|
||||
if (!f || fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
|
||||
fprintf(stderr, "device: cannot read %s\n", argv[9]);
|
||||
std::FILE *f = std::fopen(argv[9], "rb");
|
||||
if (!f || std::fread(avr->flash, 1, avr->flashend + 1, f) == 0) {
|
||||
std::println(stderr, "device: cannot read {}", argv[9]);
|
||||
return 1;
|
||||
}
|
||||
fclose(f);
|
||||
std::fclose(f);
|
||||
} else {
|
||||
elf_firmware_t fw = {0};
|
||||
elf_firmware_t fw{};
|
||||
if (elf_read_firmware(argv[1], &fw) != 0) {
|
||||
fprintf(stderr, "device: cannot read %s\n", argv[1]);
|
||||
std::println(stderr, "device: cannot read {}", argv[1]);
|
||||
return 1;
|
||||
}
|
||||
memcpy(avr->flash + base, fw.flash, fw.flashsize);
|
||||
// An image past flash end would smash the simulator's heap and turn
|
||||
// into phantom peripheral behavior (lessons: believe the size gate
|
||||
// first) — refuse it loudly instead.
|
||||
if (base + fw.flashsize > avr->flashend + 1) {
|
||||
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} — image does not fit its slot",
|
||||
fw.flashsize, base, avr->flashend);
|
||||
return 1;
|
||||
}
|
||||
std::memcpy(avr->flash + base, fw.flash, fw.flashsize);
|
||||
}
|
||||
// The boot-sectioned megas enter the loader in hardware (BOOTRST, not
|
||||
// modeled — the argument picks the modeled fuse's target); the tinies
|
||||
// and the boot-section-less m48s reset to word 0 like silicon — erased
|
||||
// flash walks up into the loader, and after the host's surgery the
|
||||
// patched vector routes there.
|
||||
int boot_section = is_mega && strncmp(mcu_name, "atmega48", 8) != 0;
|
||||
reset_pc = args > 7 ? (uint32_t)strtoul(argv[8], NULL, 0) : (boot_section ? base : 0);
|
||||
const bool boot_section = is_mega && !mcu_name.starts_with("atmega48");
|
||||
reset_pc = args > 7 ? static_cast<std::uint32_t>(std::strtoul(argv[8], nullptr, 0)) : (boot_section ? base : 0);
|
||||
avr->pc = reset_pc;
|
||||
avr->codeend = avr->flashend;
|
||||
|
||||
// Erased EEPROM, as hardware powers up (simavr zeroes it).
|
||||
uint8_t blank[1024];
|
||||
memset(blank, 0xff, sizeof(blank));
|
||||
std::uint8_t blank[1024];
|
||||
std::memset(blank, 0xff, sizeof(blank));
|
||||
avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0};
|
||||
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) {
|
||||
seed.ee = blank;
|
||||
@@ -385,7 +453,7 @@ int main(int argc, char *argv[])
|
||||
fix_mega_flash_erase();
|
||||
} else {
|
||||
nvm.page = page;
|
||||
memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
|
||||
std::memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
|
||||
nvm.io.kind = "tiny_nvm";
|
||||
nvm.io.ioctl = nvm_ioctl;
|
||||
avr_register_io(avr, &nvm.io);
|
||||
@@ -394,35 +462,38 @@ int main(int argc, char *argv[])
|
||||
if (!link_software) {
|
||||
// POLL_SLEEP paces an idle-polling loader in host real time (a
|
||||
// no-hardware CPU-saving hack); clear it so cycles run free.
|
||||
uint32_t flags = 0;
|
||||
std::uint32_t flags = 0;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
|
||||
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, uart_digit);
|
||||
printf("PB_PTY %s\n", uart_pty.pty.slavename);
|
||||
std::println("PB_PTY {}", uart_pty.pty.slavename);
|
||||
} else {
|
||||
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
|
||||
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), (unsigned)sw_rx_bit);
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), (unsigned)sw_tx_bit), tx_hook,
|
||||
NULL);
|
||||
if (sw_tx_owner)
|
||||
find_tx_owner();
|
||||
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), static_cast<unsigned>(sw_rx_bit));
|
||||
avr_irq_register_notify(
|
||||
avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), static_cast<unsigned>(sw_tx_bit)), tx_hook,
|
||||
nullptr);
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
|
||||
int slave;
|
||||
struct termios raw;
|
||||
cfmakeraw(&raw);
|
||||
if (openpty(&pty_master, &slave, NULL, &raw, NULL) != 0) {
|
||||
fprintf(stderr, "device: openpty failed\n");
|
||||
if (openpty(&pty_master, &slave, nullptr, &raw, nullptr) != 0) {
|
||||
std::println(stderr, "device: openpty failed");
|
||||
return 1;
|
||||
}
|
||||
fcntl(pty_master, F_SETFL, O_NONBLOCK);
|
||||
printf("PB_PTY %s\n", ttyname(slave));
|
||||
std::println("PB_PTY {}", ttyname(slave));
|
||||
}
|
||||
fflush(stdout);
|
||||
std::fflush(stdout);
|
||||
|
||||
signal(SIGTERM, finish);
|
||||
signal(SIGINT, finish);
|
||||
signal(SIGUSR1, request_reset); // an external reset line, for the tests
|
||||
std::signal(SIGTERM, finish);
|
||||
std::signal(SIGINT, finish);
|
||||
std::signal(SIGUSR1, request_reset); // an external reset line, for the tests
|
||||
|
||||
long since_poll = 0;
|
||||
for (;;) {
|
||||
@@ -434,12 +505,13 @@ int main(int argc, char *argv[])
|
||||
avr_reset(avr);
|
||||
avr->pc = reset_pc;
|
||||
if (!link_software) { // reset restores the pacing hack; re-clear it
|
||||
uint32_t flags = 0;
|
||||
std::uint32_t flags = 0;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
|
||||
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
} else {
|
||||
bridge_reset();
|
||||
reset_tx_owner();
|
||||
}
|
||||
}
|
||||
if (link_software && ++since_poll >= 2000) {
|
||||
@@ -457,5 +529,4 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
}
|
||||
finish(0);
|
||||
return 0;
|
||||
}
|
||||
105
test/test_handshake.py
Normal file
105
test/test_handshake.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-tool activation handshake: it must not hang on a flooding target.
|
||||
|
||||
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
|
||||
trailing bytes before it asks for the identity. That drain must be bounded: a
|
||||
target that never falls quiet — a board stuck in a reset loop presents exactly
|
||||
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
|
||||
prompt — otherwise spins the tool forever. Regression for that hang, plus a
|
||||
control that a well-behaved loader still connects.
|
||||
|
||||
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
|
||||
beside pureboot.planner.
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
|
||||
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
|
||||
_spec = importlib.util.spec_from_file_location("pureboot", PB)
|
||||
pb = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(pb)
|
||||
|
||||
P = F = 0
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
global P, F
|
||||
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
|
||||
|
||||
|
||||
class FloodPort:
|
||||
"""A line that never falls quiet: read_available always returns bytes, and
|
||||
they contain a prompt. No identity ever completes."""
|
||||
|
||||
def flush_input(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
pass
|
||||
|
||||
def read_available(self, wait):
|
||||
time.sleep(0.01) # a real read waits; keep the busy loop off a core
|
||||
return b"+\x00\xff"
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
raise pb.Error("no identity")
|
||||
|
||||
|
||||
class LoaderPort:
|
||||
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the
|
||||
slim identity (version 5 + m328p signature) and a closing prompt."""
|
||||
|
||||
def __init__(self):
|
||||
self.reads = self.exacts = 0
|
||||
|
||||
def flush_input(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
pass
|
||||
|
||||
def read_available(self, wait):
|
||||
self.reads += 1
|
||||
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
self.exacts += 1
|
||||
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
|
||||
|
||||
|
||||
def terminates(port, wait, budget):
|
||||
"""Run connect_autobaud in a thread; True if it returns/raises within
|
||||
`budget` seconds rather than hanging."""
|
||||
done = threading.Event()
|
||||
|
||||
def run():
|
||||
try:
|
||||
pb.Loader(port).connect_autobaud(wait)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return done.wait(budget)
|
||||
|
||||
|
||||
def main():
|
||||
# the hang: a flooding target must not spin the drain forever. With wait=0.5
|
||||
# the whole handshake has to give up well inside a few seconds.
|
||||
check("flooding target: handshake terminates, drain is bounded",
|
||||
terminates(FloodPort(), wait=0.5, budget=4.0))
|
||||
|
||||
# the control: a real loader still connects and reads identity.
|
||||
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
|
||||
check("well-behaved loader still connects (version 5)", info.version == 5)
|
||||
|
||||
print(f"\n {P} passed, {F} failed")
|
||||
return 1 if F else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -30,8 +30,14 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla
|
||||
scale = 2 if word_flash else 1
|
||||
wire_base = base // scale
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
# The EEPROM size comes from the signature, as it must: pureboot 5 derives
|
||||
# the whole geometry from the signature rather than sending it, so a
|
||||
# synthetic block that disagreed with its own signature would describe a
|
||||
# chip that cannot exist.
|
||||
eeprom = pb.CHIP_GEOMETRY[signature][2]
|
||||
raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version,
|
||||
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags))
|
||||
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
|
||||
eeprom & 0xFF, eeprom >> 8, flags))
|
||||
info = pb.Info(raw)
|
||||
if info.flash_size != flash:
|
||||
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
|
||||
@@ -162,11 +168,16 @@ def main():
|
||||
fail("mega staging content should be the bare image")
|
||||
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
|
||||
|
||||
# The embedded info block: found in a synthetic binary, absent in noise.
|
||||
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10
|
||||
# The image stamp: found in a synthetic binary, absent in noise. pureboot
|
||||
# 5 stamps the magic, its version and the signature, and the geometry is
|
||||
# looked up from there — so what comes back must equal what a live device
|
||||
# of the same chip reports.
|
||||
stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature)
|
||||
binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10
|
||||
found = pb.image_info(binary)
|
||||
if found is None or found.raw != tiny.raw:
|
||||
fail("image_info misses the embedded block")
|
||||
fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: "
|
||||
f"{found.raw.hex() if found else None} != {tiny.raw.hex()}")
|
||||
if pb.image_info(bytes((0xAA,)) * 40) is not None:
|
||||
fail("image_info invents a block")
|
||||
# An older loader's image stays readable, so a deployed build can be
|
||||
|
||||
71
test/test_scan.py
Normal file
71
test/test_scan.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""--scan's walk and report logic, no simulator: the probe order, the rate
|
||||
arithmetic, and the advice's direction. The rate physics itself is not
|
||||
sim-testable — a pty carries bytes at any termios rate — so what the wire
|
||||
would arbitrate is pinned here as logic instead.
|
||||
|
||||
Usage: test_scan.py <tool_py>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
|
||||
import pureboot as pb
|
||||
|
||||
walk = pb.scan_ratios()
|
||||
if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]:
|
||||
fail(f"probe walk is not built-rate-first, nearest-out: {walk}")
|
||||
|
||||
if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216:
|
||||
fail("probe rate arithmetic")
|
||||
if pb.scan_rate(115200, 0) != 115200:
|
||||
fail("the built rate must probe unchanged")
|
||||
|
||||
# A loader answering fast means a fast oscillator: the trim goes down.
|
||||
report = "\n".join(pb.scan_report(9600, 4, 6))
|
||||
for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"):
|
||||
if needle not in report:
|
||||
fail(f"+4 % report lacks {needle!r}:\n{report}")
|
||||
report = "\n".join(pb.scan_report(9600, -6, 6))
|
||||
if "6 steps higher" not in report:
|
||||
fail(f"-6 % report advises the wrong direction:\n{report}")
|
||||
|
||||
report = "\n".join(pb.scan_report(9600, 0, 6))
|
||||
if "none" not in report or "steps" in report:
|
||||
fail(f"an on-rate answer must advise no trim:\n{report}")
|
||||
|
||||
report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000))
|
||||
if "9984000" not in report:
|
||||
fail(f"the absolute clock must scale with the found ratio:\n{report}")
|
||||
|
||||
# The walk's rates mostly have no termios B-constant, so the POSIX port
|
||||
# must set them through termios2 — probed on a pty, which accepts the
|
||||
# ioctl without caring about the speed. Without this every off-nominal
|
||||
# probe would abort the walk on the platform --scan matters most on.
|
||||
if os.name == "posix":
|
||||
import pty
|
||||
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
port = pb.Port(os.ttyname(slave), pb.scan_rate(9600, 4))
|
||||
port.set_baud(pb.scan_rate(9600, -4))
|
||||
port.close()
|
||||
except pb.Error as error:
|
||||
fail(f"PosixPort refused an off-nominal probe rate: {error}")
|
||||
finally:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
print("OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
167
test/test_update_link.py
Executable file
167
test/test_update_link.py
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-update across a link change: the host must follow the staging copy.
|
||||
|
||||
`--update-loader` installs the new image in the staging slot and then *enters
|
||||
it* to have it rewrite the resident. That copy is the new image, so it speaks the
|
||||
new image's baud and backend — but the host was talking to the *resident*. Where
|
||||
the two differ, the host kept knocking at the old rate in the old mode, the
|
||||
staging copy never answered, and the update stranded: staging installed, resident
|
||||
untouched, and on a 1 KiB tiny the application region (which *is* the staging
|
||||
slot there) already gone.
|
||||
|
||||
The wire cannot be probed for this — 512 bytes of position-independent code carry
|
||||
no header saying what rate they were built for — so the operator declares it, and
|
||||
a mismatch with nothing declared has to say so instead of reporting a bare
|
||||
timeout.
|
||||
|
||||
Stdlib only, no device: host-tool logic, so it runs on every chip's preset beside
|
||||
pureboot.planner.
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
|
||||
_spec = importlib.util.spec_from_file_location("pureboot", PB)
|
||||
pb = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(pb)
|
||||
|
||||
IDENTITY = b"\x05\x1e\x95\x0f" # pureboot 5 + m328p signature
|
||||
P = F = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global P, F
|
||||
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||
|
||||
|
||||
class TwoLinkPort:
|
||||
"""A board whose resident and staging copy answer on different links.
|
||||
|
||||
Only the rate currently set decides who can be heard, which is the physical
|
||||
truth: a loader's bit timing is a cycle count, so a copy built for another
|
||||
rate is unreadable until the host retunes. The knock bytes carry the mode, so
|
||||
a backend mismatch is caught the same way.
|
||||
"""
|
||||
|
||||
def __init__(self, resident=(57600, False), staged=(38400, False)):
|
||||
self.resident, self.staged = resident, staged
|
||||
self.baud = resident[0]
|
||||
self.entered = False # a 'J' has handed control to the staging copy
|
||||
self.switches = [] # every retune the host asked for
|
||||
self.pending = bytearray() # what the device has queued to send
|
||||
|
||||
# --- the part under test needs this to exist at all
|
||||
def set_baud(self, baud):
|
||||
self.baud = baud
|
||||
self.switches.append(baud)
|
||||
|
||||
def flush_input(self):
|
||||
self.pending.clear()
|
||||
|
||||
def _audible(self, knock=None):
|
||||
baud, autobaud = self.staged if self.entered else self.resident
|
||||
if self.baud != baud:
|
||||
return False
|
||||
if knock is None:
|
||||
return True
|
||||
return knock == (bytes((pb.CALIBRATE, ord("p"))) if autobaud else b"pb")
|
||||
|
||||
def write(self, data):
|
||||
data = bytes(data)
|
||||
if data[:1] == b"J" and len(data) == 3:
|
||||
# The resident acks the jump, then control moves to the copy.
|
||||
if self._audible():
|
||||
self.pending += pb.PROMPT
|
||||
self.entered = True
|
||||
elif data in (b"pb", bytes((pb.CALIBRATE, ord("p")))):
|
||||
if self._audible(data):
|
||||
self.pending += pb.PROMPT
|
||||
elif data == b"b":
|
||||
if self._audible():
|
||||
self.pending += IDENTITY + pb.PROMPT
|
||||
|
||||
def read_available(self, wait):
|
||||
out, self.pending = bytes(self.pending), bytearray()
|
||||
return out
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
if len(self.pending) < count:
|
||||
raise pb.Error(f"timeout: got {len(self.pending)} of {count} bytes")
|
||||
out, self.pending = bytes(self.pending[:count]), self.pending[count:]
|
||||
return out
|
||||
|
||||
|
||||
def connected(port):
|
||||
"""A Loader already in session with the resident."""
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(2.0)
|
||||
return loader
|
||||
|
||||
|
||||
def main():
|
||||
# The control first: where the staged image keeps the resident's link, the
|
||||
# flow works and needs no retune. This is the case that always passed, and
|
||||
# it is what made the bug look like "self-update is broken" rather than
|
||||
# "self-update cannot change the link".
|
||||
port = TwoLinkPort(resident=(57600, False), staged=(57600, False))
|
||||
loader = connected(port)
|
||||
try:
|
||||
loader.enter_copy(0x7C00, 2.0)
|
||||
check("same link: staging copy entered", True)
|
||||
except pb.Error as error:
|
||||
check("same link: staging copy entered", False, str(error))
|
||||
|
||||
# A baud change, declared. The host must retune before knocking.
|
||||
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
|
||||
loader = connected(port)
|
||||
try:
|
||||
loader.enter_copy(0x7C00, 2.0, link=(38400, False))
|
||||
check("baud change declared: entered after retuning", 38400 in port.switches,
|
||||
f"switches={port.switches}")
|
||||
except (pb.Error, TypeError) as error:
|
||||
check("baud change declared: entered after retuning", False, repr(error))
|
||||
|
||||
# A backend change, declared: the knock itself has to become the calibration
|
||||
# pulse, or an autobaud staging copy never hears a thing.
|
||||
port = TwoLinkPort(resident=(57600, False), staged=(57600, True))
|
||||
loader = connected(port)
|
||||
try:
|
||||
loader.enter_copy(0x7C00, 2.0, link=(57600, True))
|
||||
check("backend change declared: entered as autobaud", True)
|
||||
except (pb.Error, TypeError) as error:
|
||||
check("backend change declared: entered as autobaud", False, repr(error))
|
||||
|
||||
# Nothing declared against a changed link: it still cannot work, but the
|
||||
# error has to name the cause. A bare "no answer" sent the operator looking
|
||||
# at the wiring while the application region sat erased.
|
||||
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
|
||||
loader = connected(port)
|
||||
try:
|
||||
loader.enter_copy(0x7C00, 0.3)
|
||||
check("undeclared mismatch: reported", False, "unexpectedly succeeded")
|
||||
except pb.Error as error:
|
||||
text = str(error).lower()
|
||||
check("undeclared mismatch: error names the link, not just a timeout",
|
||||
"link" in text or "baud" in text or "backend" in text, str(error))
|
||||
except TypeError as error:
|
||||
check("undeclared mismatch: error names the link, not just a timeout",
|
||||
False, repr(error))
|
||||
|
||||
# The resident's own link must be restored for the caller: a declared
|
||||
# staging link is for the copy, and the tool talks to the new resident after.
|
||||
port = TwoLinkPort(resident=(57600, False), staged=(38400, False))
|
||||
loader = connected(port)
|
||||
try:
|
||||
loader.enter_copy(0x7C00, 2.0, link=(38400, False))
|
||||
check("session records the link it is now speaking", loader.baud == 38400,
|
||||
f"loader.baud={getattr(loader, 'baud', None)}")
|
||||
except (pb.Error, TypeError, AttributeError) as error:
|
||||
check("session records the link it is now speaking", False, repr(error))
|
||||
|
||||
print(f"\n {P} passed, {F} failed")
|
||||
return 1 if F else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -3,8 +3,8 @@
|
||||
# the simulator-driven protocol suites. --full adds the reflect-spot builds
|
||||
# (libavr's rule: reflect compiles are bounded to its spot set, never the
|
||||
# full matrix) and swaps the compact size matrix for the exhaustive
|
||||
# clock × baud × backend cross product. LIBAVR_ROOT must point at the libavr
|
||||
# checkout.
|
||||
# clock × baud × backend cross product. libavr resolves from the `libavr/`
|
||||
# submodule; LIBAVR_ROOT overrides it for a working tree.
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
@@ -36,4 +36,10 @@ if ((full)); then
|
||||
done
|
||||
fi
|
||||
|
||||
# Every tree is freshly built now — the one moment the README's size table
|
||||
# can be held to what the images measure (a per-preset ctest sees only its
|
||||
# own chip; the table needs all of them, and ungated it drifts: a
|
||||
# common-code shave moves every row at once with nothing over budget).
|
||||
python3 tools/sizes.py check-readme
|
||||
|
||||
echo "check: every chip green"
|
||||
|
||||
@@ -7,11 +7,14 @@ port's TUs compile identically; the sims prove nothing new there) exist for
|
||||
libavr's reflect spot set only, mirroring its rule: the full reflect matrix
|
||||
is never built, one chip per hardware class and pack vintage is.
|
||||
|
||||
Run from the repo root: tools/make_presets.py
|
||||
Run from the repo root: tools/make_presets.py — or with --check, which
|
||||
verifies the committed file matches this generator and edits nothing (the
|
||||
ctest entry `presets.generated` runs that, so drift reds the gate).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
CHIPS = [
|
||||
"attiny13", "attiny13a", "attiny25", "attiny45", "attiny85",
|
||||
@@ -41,7 +44,7 @@ def main():
|
||||
"hidden": True,
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||
"toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake",
|
||||
"toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
|
||||
@@ -72,6 +75,9 @@ def main():
|
||||
for chip in REFLECT_SPOT:
|
||||
add(chip, "reflect")
|
||||
|
||||
# CMake rejects unknown fields in the presets root, $comment included, so
|
||||
# the file cannot carry a generated-file marker; the --check ctest is the
|
||||
# whole of rule 10's guard here.
|
||||
presets = {
|
||||
"version": 8,
|
||||
"configurePresets": configure,
|
||||
@@ -79,12 +85,19 @@ def main():
|
||||
"testPresets": test,
|
||||
"workflowPresets": workflows,
|
||||
}
|
||||
rendered = json.dumps(presets, indent=1) + "\n"
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json")
|
||||
if "--check" in sys.argv[1:]:
|
||||
current = open(path).read() if os.path.exists(path) else ""
|
||||
if current != rendered:
|
||||
print("CMakePresets.json does not match its generator — run tools/make_presets.py")
|
||||
return 1
|
||||
return 0
|
||||
with open(path, "w") as f:
|
||||
json.dump(presets, f, indent=1)
|
||||
f.write("\n")
|
||||
f.write(rendered)
|
||||
print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
247
tools/pbhw.py
Executable file
247
tools/pbhw.py
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardware acceptance suite for a pureboot deployment.
|
||||
|
||||
`tools/check.sh` proves the protocol under simavr on every chip. This proves one
|
||||
*board*: that the loader actually installed on it answers, that the memories
|
||||
round-trip over the real link, that the application it flashes runs afterwards,
|
||||
and that the refusals which keep a 512-byte slot alive still fire. Run it once
|
||||
when a board is brought up, and again whenever the deployment moves — a new
|
||||
clock, a new backend, new pins.
|
||||
|
||||
Every check derives its bounds from the info block the loader itself reports, so
|
||||
nothing here is per-chip: the same run covers a 1 KiB tiny whose application
|
||||
region is 510 usable bytes and a 128 KiB mega whose flash needs a bank in the
|
||||
selector.
|
||||
|
||||
**This overwrites the board's application flash and EEPROM.** Capture them first
|
||||
with `pbrig.py backup`, which verifies what it captured.
|
||||
|
||||
tools/pbhw.py --programmer atmelice_isp --part t13 --port COM6 \
|
||||
--autobaud --loader build/ab.bin --app build/pbapp.hex \
|
||||
--marker APP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
import pbrig # noqa: E402
|
||||
|
||||
|
||||
class Suite:
|
||||
def __init__(self, rig: pbrig.Rig, work: pathlib.Path):
|
||||
self.rig = rig
|
||||
self.work = work
|
||||
self.results: list[tuple[str, bool, str]] = []
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> bool:
|
||||
self.results.append((name, ok, detail))
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" {detail}" if detail else ""))
|
||||
return ok
|
||||
|
||||
@staticmethod
|
||||
def _brief(text: str, limit: int = 78) -> str:
|
||||
return " | ".join(l.strip() for l in text.splitlines() if l.strip())[:limit]
|
||||
|
||||
# ----------------------------------------------------------------- checks
|
||||
|
||||
def identity(self) -> object | None:
|
||||
"""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)
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
info = loader.info
|
||||
self.check("identity read", True, info.describe())
|
||||
return info
|
||||
except Exception as error: # noqa: BLE001 — a dead link is a result
|
||||
self.check("identity read", False, str(error)[:70])
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def scan(self) -> None:
|
||||
"""The --scan walk against real termios and a real oscillator: every
|
||||
probe rate must open a port (the off-nominal rates exist only through
|
||||
termios2), and one probe must answer — the nominal on a healthy board,
|
||||
a neighbor on a drifted one. The rig injects the one reset per probe
|
||||
the operator supplies in the field; this is the rate physics the
|
||||
simulator cannot arbitrate (a pty carries bytes at any rate), pinned
|
||||
on silicon."""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
found = None
|
||||
try:
|
||||
for pct in module.scan_ratios():
|
||||
rate = module.scan_rate(self.rig.d.baud, pct)
|
||||
self.rig.reset()
|
||||
try:
|
||||
port = module.Port(self.rig.d.port, rate)
|
||||
except module.Error as error:
|
||||
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
|
||||
return
|
||||
try:
|
||||
module.Loader(port).connect(min(self.rig.d.wait, 6.0))
|
||||
found = pct
|
||||
break
|
||||
except module.Error:
|
||||
continue
|
||||
finally:
|
||||
port.close()
|
||||
except Exception as error: # noqa: BLE001 — a rig hiccup is a result
|
||||
self.check("scan walks the probe ladder", False, str(error)[:70])
|
||||
return
|
||||
self.check("scan finds the board's rate", found is not None,
|
||||
"no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd")
|
||||
|
||||
def eeprom(self, info) -> None:
|
||||
size = info.eeprom_size
|
||||
if not size:
|
||||
print(" skip EEPROM (this part has none)")
|
||||
return
|
||||
# A pattern no erase or partial write could produce by accident.
|
||||
pattern = bytes((i * 7 + 3) & 0xFF for i in range(size))
|
||||
image = self.work / "ee.bin"
|
||||
image.write_bytes(pattern)
|
||||
|
||||
rc, out = self.rig.pureboot("--eeprom", str(image), "--verify-eeprom", str(image))
|
||||
self.check(f"EEPROM write + verify ({size} B)", rc == 0, self._brief(out))
|
||||
|
||||
back = self.work / "ee-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-eeprom", str(back))
|
||||
got = back.read_bytes() if back.exists() else b""
|
||||
self.check("EEPROM reads back what was written", got == pattern, f"{len(got)} B")
|
||||
|
||||
self.rig.pureboot("--erase-eeprom")
|
||||
erased = self.work / "ee-erased.bin"
|
||||
self.rig.pureboot("--read-eeprom", str(erased))
|
||||
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:
|
||||
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)
|
||||
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}|")
|
||||
|
||||
back = self.work / "app-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-flash", str(back))
|
||||
got = back.read_bytes() if back.exists() else b""
|
||||
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:
|
||||
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")
|
||||
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}")
|
||||
|
||||
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}")
|
||||
else:
|
||||
print(" skip loader slot comparison (pass --loader <image.bin>)")
|
||||
|
||||
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.
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
oversized = self.work / "oversized.bin"
|
||||
oversized.write_bytes(bytes(limit + 2))
|
||||
rc, out = self.rig.pureboot("--flash", str(oversized))
|
||||
self.check(f"image over {limit} B refused", rc != 0, self._brief(out))
|
||||
|
||||
# ------------------------------------------------------------------- run
|
||||
|
||||
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
|
||||
marker: str) -> int:
|
||||
print("identity")
|
||||
info = self.identity()
|
||||
if info is None:
|
||||
print("\nthe loader never answered; nothing below can be trusted")
|
||||
return 1
|
||||
|
||||
if not self.rig.d.autobaud:
|
||||
print("\nscan")
|
||||
self.scan()
|
||||
|
||||
print("\nEEPROM")
|
||||
self.eeprom(info)
|
||||
|
||||
if app:
|
||||
print("\napplication")
|
||||
self.application(info, app, marker)
|
||||
else:
|
||||
print("\nskip application checks (pass --app <image.hex>)")
|
||||
|
||||
print("\nerase and the write guard")
|
||||
self.erase_and_guard(info, loader_image)
|
||||
|
||||
print("\nrefusals")
|
||||
self.refusals(info)
|
||||
|
||||
passed = sum(1 for _, ok, _ in self.results if ok)
|
||||
print(f"\n{passed}/{len(self.results)} passed")
|
||||
return 0 if passed == len(self.results) else 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="hardware acceptance suite for one pureboot deployment",
|
||||
epilog="overwrites the board's application flash and EEPROM — back them up first")
|
||||
pbrig.Deployment.add_arguments(parser)
|
||||
parser.add_argument("--app", type=pathlib.Path,
|
||||
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("--marker", default="",
|
||||
help="text the application emits when it runs, e.g. APP")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
|
||||
print(f"rig: {args.part} on {args.programmer}, link {args.port} at {args.baud} Bd"
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except pbrig.Error as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
427
tools/pbrig.py
Executable file
427
tools/pbrig.py
Executable file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardware rig driver for pureboot: an ISP programmer beside a serial link.
|
||||
|
||||
The simulated suites (`test/pb*.py`) prove the protocol; this drives the same
|
||||
loader on real silicon, where the things a cycle-exact simulator cannot model
|
||||
live — an RC oscillator off its nominal, a reset edge that has to come from
|
||||
somewhere, a serial bridge with its own idea of what a baud is.
|
||||
|
||||
Nothing here knows a port name, a part or a programmer. Every deployment fact
|
||||
arrives from the command line or the environment, so the same script serves any
|
||||
board: see `Deployment`. As a module it is the reset/flash/talk primitives that
|
||||
`pbhw.py` builds its acceptance suite from; as a command it is the handful of
|
||||
one-shot operations worth having on a rig — most importantly `backup`, which is
|
||||
the only thing standing between a fuse experiment and an unrecoverable part.
|
||||
|
||||
Two rig facts are encoded here because they are not guessable and cost a
|
||||
session each to learn:
|
||||
|
||||
* **An ISP access resets the part**, and it runs again the moment the programmer
|
||||
releases it. That is the only reset edge available when the serial adapter's
|
||||
DTR is not wired to reset — so a loader session begins with an ISP touch and
|
||||
knocks immediately after, which is what `Rig.pureboot()` does.
|
||||
* **avrdude splits `-U memory:op:file:format` on colons**, so a Windows path's
|
||||
drive letter breaks the spec. Every file argument is therefore passed as a
|
||||
bare filename with avrdude run in that file's own directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
DEFAULT_PUREBOOT = HERE.parent / "pureboot" / "pureboot.py"
|
||||
|
||||
# Memories worth capturing before an experiment, and the format each is read in.
|
||||
# Fuses and lock are per-part: a part without an extended fuse simply fails that
|
||||
# one read, which `backup` reports and steps over rather than aborting on.
|
||||
BACKUP_MEMORIES = (
|
||||
("flash", "i", "hex"),
|
||||
("flash", "r", "bin"),
|
||||
("eeprom", "i", "hex"),
|
||||
("eeprom", "r", "bin"),
|
||||
("lfuse", "h", "hex"),
|
||||
("hfuse", "h", "hex"),
|
||||
("efuse", "h", "hex"),
|
||||
("lock", "h", "hex"),
|
||||
("calibration", "h", "hex"),
|
||||
)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def bitclock_for(hz: int) -> str:
|
||||
"""A safe ISP bitclock for a part *currently running* at `hz`.
|
||||
|
||||
SCK must stay under a quarter of the target clock, so the bitclock follows
|
||||
the clock in force — not the one about to be fused in. Halving that ceiling
|
||||
again costs nothing on a link that moves a few hundred bytes and buys margin
|
||||
against an oscillator that is already known to be off its nominal.
|
||||
"""
|
||||
ceiling = hz // 8
|
||||
for candidate in (1000, 4000, 8000, 32000, 125000, 400000):
|
||||
if candidate <= ceiling:
|
||||
best = candidate
|
||||
else:
|
||||
break
|
||||
else:
|
||||
best = 400000
|
||||
if ceiling < 1000:
|
||||
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
|
||||
return f"{best // 1000}kHz"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Deployment:
|
||||
"""Everything about one board. No default names a real device."""
|
||||
|
||||
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
|
||||
programmer: str = "" # avrdude -c
|
||||
part: str = "" # avrdude -p
|
||||
avrdude: str = "avrdude"
|
||||
bitclock: str = "125kHz" # see bitclock_for()
|
||||
pureboot: pathlib.Path = DEFAULT_PUREBOOT
|
||||
wait: int = 12 # seconds the host keeps knocking
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Deployment":
|
||||
"""Environment defaults, so a rig's facts live in one place per machine."""
|
||||
return cls(
|
||||
port=os.environ.get("PUREBOOT_PORT", ""),
|
||||
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
|
||||
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
|
||||
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
|
||||
part=os.environ.get("PUREBOOT_PART", ""),
|
||||
avrdude=os.environ.get("AVRDUDE", "avrdude"),
|
||||
bitclock=os.environ.get("PUREBOOT_BITCLOCK", "125kHz"),
|
||||
pureboot=pathlib.Path(os.environ.get("PUREBOOT_TOOL", str(DEFAULT_PUREBOOT))),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Deployment flags, shared by this tool and pbhw.py."""
|
||||
env = Deployment.from_env()
|
||||
parser.add_argument("--port", default=env.port, help="serial device the loader speaks on")
|
||||
parser.add_argument("--baud", type=int, default=env.baud,
|
||||
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("--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")
|
||||
parser.add_argument("--bitclock", default=env.bitclock, help="ISP bitclock, e.g. 125kHz or 8kHz")
|
||||
parser.add_argument("--pureboot", type=pathlib.Path, default=env.pureboot,
|
||||
help="path to pureboot.py")
|
||||
parser.add_argument("--wait", type=int, default=env.wait, help="seconds to keep knocking")
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, args: argparse.Namespace) -> "Deployment":
|
||||
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
|
||||
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
|
||||
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
|
||||
|
||||
|
||||
def load_pureboot(path: pathlib.Path = DEFAULT_PUREBOOT):
|
||||
"""The host tool as a module — its Port and Loader, not a subprocess.
|
||||
|
||||
Used where a subprocess cannot express what is needed: a poke followed by a
|
||||
peek in the *same* session, or a raw read at an arbitrary baud.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("pureboot", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise Error(f"cannot load the host tool from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class Rig:
|
||||
"""One board: its programmer on one side, its serial link on the other."""
|
||||
|
||||
def __init__(self, deployment: Deployment):
|
||||
self.d = deployment
|
||||
if not deployment.programmer or not deployment.part:
|
||||
raise Error("a rig needs --programmer and --part")
|
||||
|
||||
# ------------------------------------------------------------- programmer
|
||||
|
||||
def avrdude(self, *args: str, cwd: pathlib.Path | None = None,
|
||||
bitclock: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
|
||||
command = [self.d.avrdude, "-c", self.d.programmer, "-p", self.d.part,
|
||||
"-B", bitclock or self.d.bitclock, *args]
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
cwd=None if cwd is None else str(cwd), timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _ok(result: subprocess.CompletedProcess) -> bool:
|
||||
return result.returncode == 0
|
||||
|
||||
def reset(self, bitclock: str | None = None) -> None:
|
||||
"""An ISP access, which resets the part; it runs when avrdude exits."""
|
||||
self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
|
||||
|
||||
def signature(self, bitclock: str | None = None) -> str:
|
||||
result = self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
|
||||
for line in reversed(result.stdout.splitlines()):
|
||||
if line.strip().startswith("0x"):
|
||||
return line.strip()
|
||||
raise Error(f"no signature read: {(result.stderr or result.stdout).strip()[:200]}")
|
||||
|
||||
def read_memory(self, memory: str, destination: pathlib.Path, fmt: str = "r",
|
||||
bitclock: str | None = None) -> bool:
|
||||
"""Read `memory` into `destination`, whose directory avrdude runs in."""
|
||||
destination = pathlib.Path(destination).resolve()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = self.avrdude("-U", f"{memory}:r:{destination.name}:{fmt}",
|
||||
cwd=destination.parent, bitclock=bitclock)
|
||||
# A memory the part does not have (a tiny's extended fuse) leaves avrdude
|
||||
# happy and the file empty. An empty capture is a miss, not a backup.
|
||||
return self._ok(result) and destination.exists() and destination.stat().st_size > 0
|
||||
|
||||
def write_memory(self, memory: str, source: pathlib.Path, fmt: str = "i",
|
||||
erase: bool = False, bitclock: str | None = None) -> bool:
|
||||
source = pathlib.Path(source).resolve()
|
||||
args = ["-U", f"{memory}:w:{source.name}:{fmt}"]
|
||||
if erase:
|
||||
args.insert(0, "-e")
|
||||
result = self.avrdude(*args, cwd=source.parent, bitclock=bitclock)
|
||||
return "verified" in (result.stdout + result.stderr)
|
||||
|
||||
def flash_hex(self, image: pathlib.Path, erase: bool = True,
|
||||
bitclock: str | None = None) -> bool:
|
||||
return self.write_memory("flash", image, "i", erase=erase, bitclock=bitclock)
|
||||
|
||||
def read_fuses(self, bitclock: str | None = None) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for fuse in ("lfuse", "hfuse", "efuse", "lock"):
|
||||
result = self.avrdude("-U", f"{fuse}:r:-:h", bitclock=bitclock)
|
||||
values = [l.strip() for l in result.stdout.splitlines() if l.strip().startswith("0x")]
|
||||
if values:
|
||||
out[fuse] = values[-1]
|
||||
return out
|
||||
|
||||
def write_fuses(self, bitclock: str | None = None, **fuses: str) -> bool:
|
||||
"""Write named fuses. A fuse change moves the clock the *next* access is
|
||||
timed against, so pass a bitclock safe for both sides of the change."""
|
||||
args: list[str] = []
|
||||
for name, value in fuses.items():
|
||||
args += ["-U", f"{name}:w:{value}:m"]
|
||||
if not args:
|
||||
return True
|
||||
result = self.avrdude(*args, bitclock=bitclock)
|
||||
text = result.stdout + result.stderr
|
||||
return "verified" in text or "written" in text
|
||||
|
||||
# ------------------------------------------------------------ backup
|
||||
|
||||
def backup(self, directory: pathlib.Path, prefix: str = "") -> dict[str, bool]:
|
||||
"""Capture every memory worth keeping, then prove it by a second read.
|
||||
|
||||
A backup nobody verified is a guess. Each memory is read twice and the
|
||||
two reads compared; a mismatch is reported rather than quietly stored.
|
||||
"""
|
||||
directory = pathlib.Path(directory).resolve()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
stem = prefix or self.d.part
|
||||
status: dict[str, bool] = {}
|
||||
for memory, fmt, extension in BACKUP_MEMORIES:
|
||||
name = f"{stem}-{memory}.{extension}"
|
||||
if not self.read_memory(memory, directory / name, fmt):
|
||||
status[f"{memory}.{extension}"] = False
|
||||
continue
|
||||
if extension == "bin": # only the raw form is worth comparing byte-wise
|
||||
again = directory / f".{name}.again"
|
||||
self.read_memory(memory, again, fmt)
|
||||
same = again.exists() and again.read_bytes() == (directory / name).read_bytes()
|
||||
again.unlink(missing_ok=True)
|
||||
status[f"{memory}.{extension}"] = same
|
||||
else:
|
||||
status[f"{memory}.{extension}"] = True
|
||||
return status
|
||||
|
||||
# ------------------------------------------------------------ serial link
|
||||
|
||||
def pureboot(self, *args: str, reset_first: bool = True, baud: int | None = None,
|
||||
autobaud: bool | None = None, timeout: int = 300,
|
||||
bitclock: str | None = None) -> tuple[int, str]:
|
||||
"""Reset, then knock immediately — see the module docstring.
|
||||
|
||||
Returns the host tool's exit status and its combined output, so a caller
|
||||
can assert on what it printed as well as on whether it succeeded.
|
||||
"""
|
||||
if reset_first:
|
||||
self.reset(bitclock=bitclock)
|
||||
command = [sys.executable, str(self.d.pureboot), "--port", self.d.port,
|
||||
"--baud", str(self.d.baud if baud is None else baud),
|
||||
"--wait", str(self.d.wait)]
|
||||
if self.d.autobaud if autobaud is None else autobaud:
|
||||
command.append("--autobaud")
|
||||
command += [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired as expired:
|
||||
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
|
||||
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
|
||||
"""Listen to whatever the board is saying, at an arbitrary rate.
|
||||
|
||||
Opening the port does not reset a board whose DTR is unwired, so this can
|
||||
sample a running application repeatedly without disturbing it — which is
|
||||
what makes the rate sweep below possible.
|
||||
"""
|
||||
module = load_pureboot(self.d.pureboot)
|
||||
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
|
||||
try:
|
||||
data = b""
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
chunk = port.read_available(0.2)
|
||||
if chunk:
|
||||
data += chunk
|
||||
return data
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def measure_rate(rig: Rig, marker: bytes, built_baud: int, nominal_hz: int | None = None,
|
||||
span_percent: float = 12.0, step_percent: float = 0.5,
|
||||
seconds: float = 0.75) -> dict:
|
||||
"""Find a transmitting board's true bit rate, using only the serial port.
|
||||
|
||||
The board must be emitting something recognisable at a *fixed* cycles-per-bit
|
||||
— `test/pbapp.cpp` built with PUREBOOT_HEARTBEAT does. Since its bit timing is
|
||||
a cycle count, its wire rate scales with its actual clock, so the host rates
|
||||
at which `marker` still decodes bracket that rate; the centre of the band is
|
||||
the answer, and with the clock the image was built for it gives the real one.
|
||||
|
||||
This is the measurement that turns "the loader is silent, so the wiring must
|
||||
be wrong" into a number, and it needs no instrument beyond the adapter
|
||||
already attached.
|
||||
"""
|
||||
steps = int(span_percent / step_percent)
|
||||
clean: list[int] = []
|
||||
samples: list[tuple[int, int, bool]] = []
|
||||
for index in range(-steps, steps + 1):
|
||||
baud = int(round(built_baud * (1 + index * step_percent / 100.0)))
|
||||
if baud <= 0:
|
||||
continue
|
||||
data = rig.capture(seconds=seconds, baud=baud)
|
||||
hit = marker in data
|
||||
samples.append((baud, len(data), hit))
|
||||
if hit:
|
||||
clean.append(baud)
|
||||
result: dict = {"samples": samples, "clean": clean, "built_baud": built_baud}
|
||||
if clean:
|
||||
low, high = min(clean), max(clean)
|
||||
centre = (low + high) / 2.0
|
||||
result |= {"low": low, "high": high, "centre": centre,
|
||||
"half_width_percent": (high - low) / 2.0 / centre * 100.0,
|
||||
"error_percent": (centre / built_baud - 1.0) * 100.0}
|
||||
if nominal_hz:
|
||||
result["measured_hz"] = nominal_hz * centre / built_baud
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- command
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="pureboot hardware rig: ISP reset/flash beside the serial link")
|
||||
Deployment.add_arguments(parser)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("signature", help="read the part signature over ISP")
|
||||
sub.add_parser("reset", help="reset the part (an ISP access) and let it run")
|
||||
sub.add_parser("fuses", help="read the fuse and lock bytes")
|
||||
|
||||
p = sub.add_parser("flash", help="program a hex image over ISP")
|
||||
p.add_argument("image", type=pathlib.Path)
|
||||
p.add_argument("--no-erase", action="store_true", help="do not chip-erase first")
|
||||
|
||||
p = sub.add_parser("backup", help="capture and verify every memory")
|
||||
p.add_argument("directory", type=pathlib.Path)
|
||||
p.add_argument("--prefix", default="", help="filename stem (default: the part name)")
|
||||
|
||||
p = sub.add_parser("rate", help="measure the board's true bit rate and clock")
|
||||
p.add_argument("--marker", default="APP", help="text the board emits (default: APP)")
|
||||
p.add_argument("--built-baud", type=int, required=True,
|
||||
help="the baud the running image was built for")
|
||||
p.add_argument("--nominal-hz", type=int, default=0,
|
||||
help="the clock the image was built for, to report the real one")
|
||||
p.add_argument("--span", type=float, default=12.0, help="sweep +-this many percent")
|
||||
p.add_argument("--step", type=float, default=0.5, help="sweep step in percent")
|
||||
p.add_argument("--verbose", action="store_true", help="print every step")
|
||||
|
||||
p = sub.add_parser("bitclock", help="a safe ISP bitclock for a clock in force")
|
||||
p.add_argument("hz", type=int)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "bitclock":
|
||||
print(bitclock_for(args.hz))
|
||||
return 0
|
||||
|
||||
rig = Rig(Deployment.from_args(args))
|
||||
|
||||
if args.command == "signature":
|
||||
print(rig.signature())
|
||||
elif args.command == "reset":
|
||||
rig.reset()
|
||||
print("reset")
|
||||
elif args.command == "fuses":
|
||||
for name, value in rig.read_fuses().items():
|
||||
print(f"{name:<6} {value}")
|
||||
elif args.command == "flash":
|
||||
ok = rig.flash_hex(args.image, erase=not args.no_erase)
|
||||
print(f"{args.image.name}: {'verified' if ok else 'FAILED'}")
|
||||
return 0 if ok else 1
|
||||
elif args.command == "backup":
|
||||
status = rig.backup(args.directory, args.prefix)
|
||||
for name, ok in status.items():
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
missing = [n for n, ok in status.items() if not ok]
|
||||
# Fuses a part does not have are expected misses, not failures.
|
||||
fatal = [n for n in missing if not n.startswith(("efuse", "calibration"))]
|
||||
print(f"\n{len(status) - len(missing)}/{len(status)} captured into {args.directory}")
|
||||
return 1 if fatal else 0
|
||||
elif args.command == "rate":
|
||||
result = measure_rate(rig, args.marker.encode(), args.built_baud,
|
||||
args.nominal_hz or None, args.span, args.step)
|
||||
if args.verbose:
|
||||
for baud, size, hit in result["samples"]:
|
||||
print(f" {baud:7d} Bd {size:5d} B {'MARKER' if hit else ''}")
|
||||
if not result["clean"]:
|
||||
print(f"no capture contained {args.marker!r} at any rate — is the board "
|
||||
f"transmitting, and on the pin this port is wired to?")
|
||||
return 1
|
||||
print(f"clean band {result['low']}..{result['high']} Bd")
|
||||
print(f"centre {result['centre']:.0f} Bd "
|
||||
f"(+-{result['half_width_percent']:.1f} %)")
|
||||
print(f"vs built {result['built_baud']} Bd ({result['error_percent']:+.1f} %)")
|
||||
if "measured_hz" in result:
|
||||
print(f"true clock {result['measured_hz'] / 1e6:.3f} MHz")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Error as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
160
tools/sizes.py
Executable file
160
tools/sizes.py
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What the loader images actually measure, and whether the README still agrees.
|
||||
|
||||
The size matrix asserts every image fits its slot; it says nothing about the
|
||||
numbers the README prints, and those drift. Every row of that table was eight
|
||||
bytes stale once `startup::caller_page()` landed — common code, so every build
|
||||
moved at once and no test noticed, because none of them was over budget.
|
||||
|
||||
Two questions, both answered from built trees:
|
||||
|
||||
sizes.py max the largest image per chip, and anything over budget
|
||||
sizes.py check-readme the README's per-chip table against what is built
|
||||
|
||||
Nothing here knows a chip's geometry. The (image, budget) pairs come from each
|
||||
build's own `CTestTestfile.cmake` — the same values the gate checks — so the
|
||||
slot rules stay where they belong, in `pureboot/CMakeLists.txt`, and a chip
|
||||
added or a budget changed needs no edit here. Only trees a configure preset
|
||||
still owns are read: a stale directory keeps its last build, and a loader built
|
||||
before a slot changed will happily report a size that was true once
|
||||
(`tools/prune-build-trees.sh` in libavr removes them).
|
||||
|
||||
Sizes come from `avr-size`, and a target is only as current as its last build —
|
||||
run the gate first if you want the table checked against today's source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
# add_test(<name>.size ... -DELF=<path> ... -DLIMIT=<n> ...) — the gate's own
|
||||
# pairing of an image with the budget it must fit.
|
||||
# ctest writes the name as a bracket argument ([=[name.size]=]) and quotes the
|
||||
# rest, so the name starts after the bracket and the path ends at the quote.
|
||||
SIZE_TEST = re.compile(r'add_test\(\s*\[=\[(?P<name>[^\]]+?)\.size\]=\][^\n]*?'
|
||||
r'-DELF=(?P<elf>[^"\s]+)[^\n]*?-DLIMIT=(?P<limit>\d+)')
|
||||
|
||||
|
||||
def avr_size() -> str:
|
||||
for env in (ROOT / "../../toolchain").resolve().glob("avr-gcc-*/bin/avr-size"):
|
||||
if env.is_file():
|
||||
return str(env)
|
||||
found = shutil.which("avr-size")
|
||||
if not found:
|
||||
sys.exit("no avr-size found (build the toolchain, or put it on PATH)")
|
||||
return found
|
||||
|
||||
|
||||
def preset_dirs() -> list[pathlib.Path]:
|
||||
"""Build trees a configure preset still owns, newest-listed first."""
|
||||
listing = subprocess.run(["cmake", "--list-presets"], cwd=ROOT, capture_output=True, text=True)
|
||||
names = re.findall(r'^\s*"(.+)"$', listing.stdout, re.MULTILINE)
|
||||
if not names:
|
||||
sys.exit("cmake --list-presets returned nothing — run from a configured checkout")
|
||||
return [d for d in (ROOT / "build" / n for n in names) if (d / "CTestTestfile.cmake").is_file()]
|
||||
|
||||
|
||||
def measure(paths: list[str], tool: str) -> dict[str, int]:
|
||||
""".text per ELF, in one avr-size call per batch."""
|
||||
sizes: dict[str, int] = {}
|
||||
for start in range(0, len(paths), 400):
|
||||
batch = [p for p in paths[start:start + 400] if pathlib.Path(p).is_file()]
|
||||
if not batch:
|
||||
continue
|
||||
out = subprocess.run([tool, *batch], capture_output=True, text=True).stdout
|
||||
for line in out.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) >= 6 and fields[0].isdigit():
|
||||
sizes[fields[5]] = int(fields[0])
|
||||
return sizes
|
||||
|
||||
|
||||
def collect() -> dict[str, list[tuple[str, int, int]]]:
|
||||
"""chip -> [(target, text, limit)], from every owned build tree."""
|
||||
tool = avr_size()
|
||||
found: dict[str, list[tuple[str, str, int]]] = {}
|
||||
for tree in preset_dirs():
|
||||
chip = tree.name.split("-")[0]
|
||||
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
|
||||
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
|
||||
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
|
||||
measured = {
|
||||
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
|
||||
key=lambda row: -row[1])
|
||||
for chip, rows in sorted(found.items())
|
||||
}
|
||||
# A configured-but-unbuilt preset registers its tests with no images behind
|
||||
# them; it is not a chip with nothing to say, it is a chip not built yet.
|
||||
return {chip: rows for chip, rows in measured.items() if rows}
|
||||
|
||||
|
||||
def cmd_max(args) -> int:
|
||||
measured = collect()
|
||||
if not measured:
|
||||
sys.exit("nothing built — configure and build a preset first")
|
||||
over = []
|
||||
print(f"{'chip':<13} {'largest image':<34} {'.text':>6} {'budget':>7} headroom")
|
||||
for chip, rows in measured.items():
|
||||
name, text, limit = rows[0]
|
||||
flag = "OVER" if text > limit else f"{limit - text:>5} B"
|
||||
print(f"{chip:<13} {name:<34} {text:>6} {limit:>7} {flag}")
|
||||
over += [(chip, n, t, l) for n, t, l in rows if t > l]
|
||||
total = sum(len(rows) for rows in measured.values())
|
||||
print(f"\n{total} images across {len(measured)} chips")
|
||||
if over:
|
||||
print("\nOVER BUDGET:")
|
||||
for chip, name, text, limit in over:
|
||||
print(f" {chip} {name}: {text} > {limit}")
|
||||
return 1
|
||||
tightest = min(((chip, n, t, l) for chip, rows in measured.items() for n, t, l in rows),
|
||||
key=lambda row: row[3] - row[2])
|
||||
chip, name, text, limit = tightest
|
||||
print(f"tightest fit: {chip} {name} — {text} of {limit}, {limit - text} B spare")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check_readme(args) -> int:
|
||||
"""The README's per-chip table, against the stock and autobaud builds."""
|
||||
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*\|$",
|
||||
readme, re.MULTILINE)
|
||||
if not rows:
|
||||
sys.exit("no size table found in pureboot/README.md")
|
||||
bad = skipped = 0
|
||||
for chips, stock_doc, auto_doc in rows:
|
||||
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
|
||||
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
||||
built = {name: text for name, text, _ in measured.get(chip, [])}
|
||||
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)):
|
||||
if target not in built:
|
||||
skipped += 1
|
||||
continue
|
||||
if built[target] != int(documented):
|
||||
print(f" {chip:<12} {target:<18} README says {documented} B, built is {built[target]} B")
|
||||
bad += 1
|
||||
if bad:
|
||||
print(f"\n{bad} row(s) stale — update pureboot/README.md")
|
||||
return 1
|
||||
print(f"README size table matches every built image ({len(rows)} rows"
|
||||
+ (f", {skipped} not built" if skipped else "") + ")")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
subs = parser.add_subparsers(dest="cmd", required=True)
|
||||
subs.add_parser("max", help="largest image per chip, and anything over budget")
|
||||
subs.add_parser("check-readme", help="the README's size table against what is built")
|
||||
args = parser.parse_args()
|
||||
return {"max": cmd_max, "check-readme": cmd_check_readme}[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
|
||||
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
|
||||
|
||||
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
|
||||
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd);
|
||||
constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
|
||||
|
||||
// The 16-byte device-info block, streamed out on activation.
|
||||
// clang-format off
|
||||
|
||||
302
tsb/tsb_policy.cpp
Normal file
302
tsb/tsb_policy.cpp
Normal file
@@ -0,0 +1,302 @@
|
||||
// TinySafeBoot on libavr — the policy floor: pureboot's rules, measured.
|
||||
//
|
||||
// The full TinySafeBoot feature set — watchdog bail, one-wire half-duplex,
|
||||
// config-page activation timeout, password gate, emergency erase, and
|
||||
// config/flash/EEPROM read-write — under philosophy #5 exactly as pureboot
|
||||
// obeys it: no assembly, no register variables; code, attributes, and flags
|
||||
// only. Every lesson pureboot's development produced is applied — the
|
||||
// library's half-duplex serial and startup entry, lean bring-up from reset
|
||||
// state, one merged send loop over both memories, oracle-shaped loop bounds,
|
||||
// locals threaded through noinline primitives, pureboot's codegen flags —
|
||||
// and the result is 638 bytes: 198 below the idiomatic tier, and 126 above
|
||||
// the 512 B boot section the tricks/asm tiers reach with the banned
|
||||
// mechanisms (526/510). This tier exists to keep that number an artifact
|
||||
// rather than a claim: the gap to 512 is the rent of policy-clean C++ —
|
||||
// helpers that hold a cursor across rx()/tx() pay push/pop and argument
|
||||
// threading where a global-register protocol pays nothing, and both
|
||||
// control-flow merges tried (a parametrized paged session, a merged store
|
||||
// loop) measured larger than the split cases they replaced. TSB's wire fixes
|
||||
// the per-command loop shapes on the device, so pureboot 5's one-transfer-
|
||||
// loop collapse has no purchase here.
|
||||
//
|
||||
// The wire protocol is strict request/response, which is what makes the
|
||||
// shared line safe: the device drives it only between a received command and
|
||||
// its reply, and releases it (the library's half-duplex choreography)
|
||||
// whenever it waits.
|
||||
|
||||
#include <libavr/libavr.hpp>
|
||||
|
||||
using namespace avr::literals;
|
||||
namespace spm = avr::spm;
|
||||
namespace ee = avr::eeprom;
|
||||
|
||||
using dev = avr::device<{.clock = 16_MHz}>;
|
||||
// One-wire: RX and TX share the line, exactly as the native-UART TSB expects.
|
||||
using serial_t = dev::uart0<{.baud = 115200_Bd, .max_baud_error = 3_pct, .half_duplex = true}>;
|
||||
inline constexpr serial_t serial{};
|
||||
|
||||
namespace tsb {
|
||||
namespace {
|
||||
|
||||
// The loader is purely polled — it never enables interrupts — so every SPM and
|
||||
// EEPROM lock folds to nothing under this posture.
|
||||
constexpr auto off = avr::irq::guard_policy::unused;
|
||||
|
||||
// The handshake bytes, identical across every TSB host.
|
||||
constexpr std::uint8_t confirm = '!';
|
||||
constexpr std::uint8_t request = '?';
|
||||
constexpr std::uint8_t knock = '@';
|
||||
|
||||
// Boot geometry for the 1 KB boot section (BOOTSZ=10); the page size and the
|
||||
// flash/EEPROM extents are the chip database's to know. app_end is the config
|
||||
// page (TSB's LASTPAGE), one page below the boot section.
|
||||
constexpr std::uint16_t page = spm::page_bytes;
|
||||
constexpr std::uint16_t boot_bytes = 1024;
|
||||
constexpr std::uint16_t app_end = spm::flash_bytes - boot_bytes - page;
|
||||
constexpr std::uint16_t eeprom_end = avr::hw::db.mem.eeprom_size - 1;
|
||||
|
||||
// Lockout-proof floor for the activation window (the oracle's F_CPU/1MHz).
|
||||
constexpr std::uint8_t act_min = 16;
|
||||
// Post-activation window: the host gets seconds, not milliseconds, mid-session.
|
||||
constexpr std::uint8_t comm_window = 200;
|
||||
|
||||
// Firmware version stamp: YY*512 + MM*32 + DD, the encoding the host decodes.
|
||||
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 27;
|
||||
|
||||
// The 16-byte device-info block, streamed out on activation.
|
||||
// clang-format off
|
||||
[[gnu::progmem]] constexpr std::uint8_t info[16] = {
|
||||
'T', 'S', 'B',
|
||||
build_date & 0xFF, build_date >> 8,
|
||||
0xF3, // status: native-UART fixed-baud lineage
|
||||
avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2],
|
||||
page / 2, // page size in words
|
||||
(app_end / 2) & 0xFF, (app_end / 2) >> 8, // app-flash boundary, words
|
||||
eeprom_end & 0xFF, eeprom_end >> 8,
|
||||
0xAA, 0xAA, // ATmega processor-type marker (bytes 14 == 15)
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
// The receive window, pre-floored where it is set. In .noinit: there is no
|
||||
// crt to clear a .bss image, and run() stores it before the first receive.
|
||||
[[gnu::section(".noinit")]] std::uint8_t window;
|
||||
|
||||
const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
{
|
||||
return reinterpret_cast<const std::uint8_t *>(addr);
|
||||
}
|
||||
|
||||
// Bounded byte receive: poll under nested countdowns, 0 on silence. The 0
|
||||
// then falls through every compare — not a knock, not a confirm, not a
|
||||
// command — so a silent host unwinds the loader to the application from
|
||||
// anywhere, and a mid-session cable pull cannot wedge it. The line release on
|
||||
// a direction change is the serial backend's.
|
||||
[[gnu::noinline]] std::uint8_t rx()
|
||||
{
|
||||
std::uint16_t outer = static_cast<std::uint16_t>(window) << 8;
|
||||
do {
|
||||
std::uint8_t fine = 0;
|
||||
do {
|
||||
if (auto byte = serial.read())
|
||||
return *byte;
|
||||
} while (--fine);
|
||||
} while (--outer);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One-wire transmit: the backend takes the line with a turn-around guard and
|
||||
// holds it until the whole frame is out.
|
||||
[[gnu::noinline]] void tx(std::uint8_t byte)
|
||||
{
|
||||
serial.write(byte);
|
||||
}
|
||||
|
||||
// '?', then hand back the host's reply for the callers' one-byte compare.
|
||||
[[gnu::noinline]] std::uint8_t rcnf()
|
||||
{
|
||||
tx(request);
|
||||
return rx();
|
||||
}
|
||||
|
||||
// The one send loop: the info block, the config page, application flash and
|
||||
// EEPROM pages all stream through here.
|
||||
[[gnu::noinline]] void send_block(bool eep, std::uint16_t at, std::uint8_t count)
|
||||
{
|
||||
do {
|
||||
tx(eep ? ee::read(at) : avr::flash_load(flash_ptr(at)));
|
||||
++at;
|
||||
} while (--count);
|
||||
}
|
||||
|
||||
// One EEPROM byte in — shared by the emergency wipe and the 'E' stream.
|
||||
[[gnu::noinline]] void eeput(std::uint16_t at, std::uint8_t value)
|
||||
{
|
||||
ee::write<off>(at, value);
|
||||
}
|
||||
|
||||
// Wait out a running SPM op, then re-open the RWW section — after every page
|
||||
// op and before handing over, as the oracle does.
|
||||
[[gnu::noinline]] void settle()
|
||||
{
|
||||
spm::wait();
|
||||
spm::rww_enable<off>();
|
||||
}
|
||||
|
||||
// One host page straight into the erased flash page at `at` — through the SPM
|
||||
// word buffer (low byte then high), no SRAM staging — then committed. `at`
|
||||
// names a page base, so the cursor's low byte reaching the boundary ends the
|
||||
// walk.
|
||||
[[gnu::noinline]] void store_flash_page(std::uint16_t at)
|
||||
{
|
||||
do {
|
||||
std::uint8_t low = rx();
|
||||
std::uint8_t high = rx();
|
||||
spm::fill<off>(at, std::bit_cast<std::uint16_t>(std::array{low, high}));
|
||||
at += 2;
|
||||
} while (static_cast<std::uint8_t>(at) & (page - 1));
|
||||
spm::write_page<off>(at - page);
|
||||
settle();
|
||||
}
|
||||
|
||||
extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --defsym=tsb_app=0
|
||||
|
||||
[[noreturn]] void appjump()
|
||||
{
|
||||
settle();
|
||||
tsb_app();
|
||||
}
|
||||
|
||||
// Step one page down and erase it — the erase shared by the whole-app walk,
|
||||
// the config rewrite and the emergency wipe; hands the stepped address back.
|
||||
[[gnu::noinline]] std::uint16_t erase_below(std::uint16_t at)
|
||||
{
|
||||
at -= page;
|
||||
spm::erase_page<off>(at);
|
||||
settle();
|
||||
return at;
|
||||
}
|
||||
|
||||
// Erase the whole application, top-down like the oracle: the loop bound is a
|
||||
// compare with zero, and the returned 0 is the address every caller wants
|
||||
// next.
|
||||
[[gnu::noinline]] std::uint16_t erase_application()
|
||||
{
|
||||
std::uint16_t at = app_end;
|
||||
do {
|
||||
at = erase_below(at);
|
||||
} while (at != 0);
|
||||
return at;
|
||||
}
|
||||
|
||||
[[noreturn]] void run()
|
||||
{
|
||||
// A watchdog reset hands straight back to the application, as the
|
||||
// reference loader does, rather than re-entering the bootloader.
|
||||
if (avr::hw::mcusr::wdrf.test())
|
||||
appjump();
|
||||
|
||||
// Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads
|
||||
// 0, and the half-duplex write()/read() raise TXEN0/RXEN0 on first use —
|
||||
// only the divisor low byte and U2X0 need a store. The solver still does
|
||||
// the datasheet work; the asserts pin the reset-state assumptions.
|
||||
{
|
||||
constexpr auto sol = avr::uart::solve_baud(dev::clock, 115200_Bd);
|
||||
static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
avr::hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(sol.ubrr));
|
||||
avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
|
||||
}
|
||||
|
||||
// Activation: 3×'@', each inside the config page's timeout window
|
||||
// (floored so a corrupt page cannot lock the loader out); anything else —
|
||||
// including silence — hands over.
|
||||
window = avr::flash_load(flash_ptr(app_end + 2)) | act_min;
|
||||
for (std::uint8_t k = 3; k; --k)
|
||||
if (rx() != knock)
|
||||
appjump();
|
||||
window = comm_window;
|
||||
|
||||
// Password gate (config page from app_end+3, 0xff-terminated; a blank
|
||||
// page is no password). A wrong byte blanks the comparison and drains the
|
||||
// line forever, so a wrong password can never fall through; a 0 requests
|
||||
// emergency erase behind two confirms. On pass the info block goes out;
|
||||
// the emergency path skips it and drops into the command loop.
|
||||
std::uint16_t at = app_end + 3;
|
||||
std::uint8_t mask = 0xff;
|
||||
for (;;) {
|
||||
std::uint8_t expected = avr::flash_load(flash_ptr(at)) & mask;
|
||||
++at;
|
||||
if (expected == 0xff) {
|
||||
send_block(false, reinterpret_cast<std::uint16_t>(&info[0]), sizeof info);
|
||||
break;
|
||||
}
|
||||
std::uint8_t got = rx();
|
||||
if (got == 0) {
|
||||
if (mask == 0)
|
||||
continue;
|
||||
if (rcnf() != confirm || rcnf() != confirm)
|
||||
appjump();
|
||||
std::uint16_t a = erase_application();
|
||||
do {
|
||||
eeput(a, 0xff);
|
||||
} while (++a <= eeprom_end);
|
||||
erase_below(app_end + page);
|
||||
break;
|
||||
}
|
||||
if (got != expected)
|
||||
mask = 0;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
tx(confirm); // Mainloop ready
|
||||
const std::uint8_t command = rx();
|
||||
switch (command) {
|
||||
case 'f': // read application flash, one page per host '!'
|
||||
for (std::uint16_t a = 0; a < app_end; a += page) {
|
||||
if (rx() != confirm)
|
||||
break;
|
||||
send_block(false, a, page);
|
||||
}
|
||||
break;
|
||||
case 'e': // read EEPROM, one page per host '!', until the host stops
|
||||
for (std::uint16_t a = 0;; a += page) {
|
||||
if (rx() != confirm)
|
||||
break;
|
||||
send_block(true, a, page);
|
||||
}
|
||||
break;
|
||||
case 'F': { // erase the application, then take pages behind '?'
|
||||
std::uint16_t a = erase_application();
|
||||
for (; rcnf() == confirm; a += page)
|
||||
store_flash_page(a);
|
||||
break;
|
||||
}
|
||||
case 'E': // take EEPROM pages behind '?', each write host-paced
|
||||
for (std::uint16_t a = 0; rcnf() == confirm;) {
|
||||
std::uint8_t count = page;
|
||||
do {
|
||||
eeput(a, rx());
|
||||
++a;
|
||||
} while (--count);
|
||||
}
|
||||
break;
|
||||
case 'c': // read the config page
|
||||
read_config:
|
||||
send_block(false, app_end, page);
|
||||
break;
|
||||
case 'C': // replace the config page, then echo it back to verify
|
||||
if (rcnf() != confirm)
|
||||
break;
|
||||
store_flash_page(erase_below(app_end + page));
|
||||
goto read_config;
|
||||
default: // 'q' or any other byte runs the application
|
||||
appjump();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace tsb
|
||||
|
||||
// Reset lands at the boot section base (BOOTRST): the entry stub in .vectors
|
||||
// is laid first and does the one line of crt a crt-less image needs.
|
||||
template struct avr::startup::entry<tsb::run>;
|
||||
@@ -65,7 +65,7 @@ constexpr std::uint8_t comm_window = 200;
|
||||
constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
|
||||
|
||||
// Fixed 115200 8N1; the library solves UBRR + U2X from clock and baud.
|
||||
constexpr auto baud = avr::uart::detail::solve_baud(16_MHz, 115200_Bd);
|
||||
constexpr auto baud = avr::uart::solve_baud(16_MHz, 115200_Bd);
|
||||
|
||||
// The 16-byte device-info block, streamed out on activation.
|
||||
// clang-format off
|
||||
|
||||
Reference in New Issue
Block a user