Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb3c58300 | |||
| c4b7769f84 | |||
| 735ffab7dc | |||
| be78f38f3f | |||
| d3d8d07905 | |||
| b4243a89a3 | |||
| e4390d2ba8 | |||
| 0cb83ff36f | |||
| 8aa1721709 | |||
| 2655d058f4 | |||
| e9c3897d4f | |||
| 546b1589a3 | |||
| eb213e1025 | |||
| 3e4bfbaf48 | |||
| 579ca81b27 | |||
| 5d520a1ff9 | |||
| f71d76a815 | |||
| 47419400f6 | |||
| bad8b6b43e | |||
| 5d1b4497d4 | |||
| a743ea64a3 | |||
| aa66cfccff | |||
| 459d463283 | |||
| 8e7cc86fb3 | |||
| c8ac61779e | |||
| 4362886c39 | |||
| 0513d07e87 | |||
| d4ab28aa17 | |||
| b60f182105 | |||
| 5bc35a9733 | |||
| 8f6319c068 | |||
| 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 | |||
| 1dbf0089d6 | |||
| 39dfe40dbf | |||
| 8470ce3da0 | |||
| d634741015 | |||
| ab842c8d99 |
@@ -7,8 +7,9 @@ TabWidth: 4
|
||||
UseTab: ForIndentation
|
||||
AlignEscapedNewlines: DontAlign
|
||||
AllowShortFunctionsOnASingleLine: Empty
|
||||
AlwaysBreakTemplateDeclarations: true
|
||||
BreakTemplateDeclarations: Yes
|
||||
BreakBeforeBraces: Custom
|
||||
BraceWrapping:
|
||||
AfterFunction: true
|
||||
InsertBraces: true
|
||||
...
|
||||
|
||||
38
.clangd
Normal file
38
.clangd
Normal file
@@ -0,0 +1,38 @@
|
||||
# Editor accommodations for the second frontend. No compilation database is
|
||||
# named here: this repo rides as a submodule in its consumers, and this file
|
||||
# travels with it — a consumer's own database then covers these sources, with
|
||||
# that project's loader flags. The checkout that is opened as a folder names
|
||||
# its build tree in .vscode/settings.json instead.
|
||||
CompileFlags:
|
||||
Add:
|
||||
# clang has no 24-bit integer and GCC's are keywords, not macros, so the
|
||||
# editor needs a stand-in for avr::uint24_t. The next width up is the only
|
||||
# one available — clang rejects _BitInt(24) on this target.
|
||||
- -D__uint24=unsigned long
|
||||
- -D__int24=long
|
||||
# clangd forwards the driver's system includes but not its own header
|
||||
# directory, so <stdint.h> resolves to avr-libc's, which still gates the
|
||||
# limit and constant macros on the C++98 opt-in.
|
||||
- -D__STDC_LIMIT_MACROS
|
||||
- -D__STDC_CONSTANT_MACROS
|
||||
# isr::emit spells a vector number into [[gnu::signal(N)]], which clang
|
||||
# rejects rather than ignores — enough of them in one TU to reach the
|
||||
# default limit of 19 inside the headers and truncate the parse.
|
||||
- -ferror-limit=0
|
||||
Remove:
|
||||
# Codegen shaping the loader TUs carry and clang has no spelling for.
|
||||
- -fira-algorithm=*
|
||||
- -fno-split-wide-types
|
||||
- -fno-tree-ter
|
||||
- -fno-ivopts
|
||||
- -fno-move-loop-invariants
|
||||
# The build promotes warnings for the compiler that has to be right about
|
||||
# them; in the editor the flag paints a second frontend's opinions in the
|
||||
# colour reserved for things that do not compile.
|
||||
- -Werror
|
||||
Diagnostics:
|
||||
Suppress:
|
||||
# clang's AVR `signal` attribute takes no arguments and it knows none of
|
||||
# progmem, naked or OS_main. A misspelling is what the build is for.
|
||||
- attribute_wrong_number_arguments
|
||||
- unknown-attributes
|
||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"llvm-vs-code-extensions.vscode-clangd",
|
||||
"ms-vscode.cmake-tools"
|
||||
]
|
||||
}
|
||||
37
.vscode/settings.json
vendored
Normal file
37
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
// clangd is the language server; the cpptools engine would parse every file
|
||||
// a second time and disagree, since nothing tells it about a cross
|
||||
// compiler.
|
||||
"C_Cpp.intelliSenseEngine": "disabled",
|
||||
|
||||
// --query-driver lets clangd ask the cross compiler for its own system
|
||||
// includes and target. The database is named here rather than in .clangd
|
||||
// because that file travels with the driver into a consumer's submodule,
|
||||
// where a build tree of this repo's own need not exist.
|
||||
"clangd.arguments": [
|
||||
"--compile-commands-dir=${workspaceFolder}/build/atmega328p-generated",
|
||||
"--query-driver=**avr-g++*",
|
||||
"--header-insertion=never"
|
||||
],
|
||||
|
||||
// The presets are the build interface; the toolchain file inside the
|
||||
// libavr submodule is the one place the compiler is chosen. The submodule
|
||||
// carries no local/toolchain for it to discover, so the prefix is named
|
||||
// here, for the window that opens this folder.
|
||||
"cmake.useCMakePresets": "always",
|
||||
"cmake.environment": {
|
||||
"LIBAVR_TOOLCHAIN": "D:/dev/libavr/local/toolchain/avr-gcc-16.1.0-mingw"
|
||||
},
|
||||
"cmake.configureOnOpen": true,
|
||||
"cmake.options.statusBarVisibility": "compact",
|
||||
|
||||
"files.watcherExclude": {
|
||||
"**/build/**": true,
|
||||
"**/libavr/**": true
|
||||
},
|
||||
|
||||
"files.associations": {
|
||||
".clangd": "yaml",
|
||||
".clang-format": "yaml"
|
||||
}
|
||||
}
|
||||
531
CMakeLists.txt
531
CMakeLists.txt
@@ -2,64 +2,73 @@ 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)
|
||||
|
||||
include(${LIBAVR_ROOT}/cmake/checks.cmake)
|
||||
|
||||
if(PROJECT_IS_TOP_LEVEL)
|
||||
add_compile_options(-Werror) # warnings are errors for the port's own code
|
||||
enable_testing()
|
||||
|
||||
# Rules 11 and 33 over this repo's own sources. The oracle's assembly needs
|
||||
# no exclusion: it is neither formatted nor ASCII-checked, being in neither
|
||||
# glob, which is the right answer for a vendored reference whose text is
|
||||
# the artifact.
|
||||
libavr_format_test()
|
||||
|
||||
# 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)
|
||||
message(STATUS "pureboot_device not built (${_pbdev_err}) — protocol tests skipped")
|
||||
message(STATUS "pureboot_device not built (${_pbdev_err}) - protocol tests skipped")
|
||||
unset(PB_DEVICE)
|
||||
endif()
|
||||
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)
|
||||
message(STATUS "tsb_device not built (${_dev_err}) — protocol tests skipped")
|
||||
message(STATUS "tsb_device not built (${_dev_err}) - protocol tests skipped")
|
||||
unset(TSB_DEVICE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# The ELF is only a container (symbols, section headers) and is never flashed —
|
||||
# The ELF is only a container (symbols, section headers) and is never flashed -
|
||||
# and the host tool's load_image() dispatches on extension, so handing it one
|
||||
# would silently program the header bytes. Every loader image therefore gets
|
||||
# both flashable forms beside it at link time: .hex for avrdude, and .bin for
|
||||
# the host tool's raw path (which is what the reloc and update tests convert to
|
||||
# on the fly). .eeprom is dropped — EEPROM content is its own update.
|
||||
# on the fly). .eeprom is dropped - EEPROM content is its own update.
|
||||
function(add_image_outputs name)
|
||||
add_custom_command(TARGET ${name} POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
|
||||
@@ -68,35 +77,39 @@ 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
|
||||
# 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 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.
|
||||
# tsb_asm — the tricks tier's C++ with exactly two routines in asm (the
|
||||
# bounded rx and the page-store loop — the two whose remaining
|
||||
# cost is the C ABI itself): 510 B in the 512 B section the
|
||||
# hand-written 500 B oracle occupies. Everything else, from
|
||||
# bring-up to dispatch, is C++ on libavr.
|
||||
# tsb_tricks — no asm at all: the whole-loader register allocation lives in
|
||||
# and the size gradient is the cost of that "how".
|
||||
# tsb_asm - the tricks tier's C++ with exactly two routines in asm: the
|
||||
# bounded rx and the page-store loop, the two whose remaining
|
||||
# cost is the C ABI itself. Everything else, bring-up to
|
||||
# dispatch, is C++ on libavr.
|
||||
# tsb_tricks - no asm at all: the whole-loader register allocation lives in
|
||||
# global register variables (Y walks the page pointer), every
|
||||
# helper is a tiny noinline primitive placed by the
|
||||
# global-register store rules, pages stream straight to
|
||||
# SPM/EEPROM, and the bring-up is the two reset-non-default
|
||||
# registers only. 526 B in the 1 KB section (BOOTSZ=10) — 14
|
||||
# over the oracle's section, from 168 over at this tier's first
|
||||
# floor.
|
||||
# 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.
|
||||
# SPM/EEPROM.
|
||||
# tsb_pure - pure idiomatic libavr, one function per command, TU-local
|
||||
# (internal linkage), streaming (no SRAM page buffer).
|
||||
# tsb_policy - the policy floor: pureboot's rules (no asm, no register
|
||||
# variables) with every pureboot lesson applied, and the
|
||||
# measured evidence that the 512 B fit is a property of the
|
||||
# mechanisms philosophy #5 bans.
|
||||
#
|
||||
# What each measures is oracle/README.md's table, which is the one place the
|
||||
# four numbers and the hand-written loader's own are compared.
|
||||
#
|
||||
# add_tsb_variant(<name> <boot-section-bytes>)
|
||||
function(add_tsb_variant name bytes)
|
||||
@@ -124,14 +137,19 @@ 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,
|
||||
# pureboot - the pure-constraint port (see pureboot/README.md): one source,
|
||||
# no inline assembly, no global register variables, every libavr chip,
|
||||
# fitting each chip's smallest boot sector. The geometry and the
|
||||
# pureboot_add_loader() deployment function live in pureboot/CMakeLists.txt —
|
||||
# pureboot_add_loader() deployment function live in pureboot/CMakeLists.txt -
|
||||
# the unit a downstream project consumes; everything below is this port's
|
||||
# own build: the stock loaders, their tests, and the size matrix. The
|
||||
# distinct binary dir keeps the `pureboot` target's output name free.
|
||||
@@ -139,7 +157,7 @@ add_subdirectory(pureboot pureboot-cmake)
|
||||
|
||||
# The stock loader: the family-default deployment (crystal/RC clock, the
|
||||
# chip's natural link, default pins). The activation window stays a cache
|
||||
# variable — re-timing a deployed loader is a self-update with a re-timed
|
||||
# variable - re-timing a deployed loader is a self-update with a re-timed
|
||||
# build. pureboot9 is that re-timed build, and what the update test installs.
|
||||
set(PUREBOOT_TIMEOUT 8 CACHE STRING "pureboot activation window, seconds")
|
||||
pureboot_add_loader(pureboot TIMEOUT ${PUREBOOT_TIMEOUT})
|
||||
@@ -153,10 +171,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
|
||||
@@ -175,6 +208,38 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
${CMAKE_BINARY_DIR}/pbtest-work)
|
||||
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The activation window as a measured duration: application installed,
|
||||
# line idle, the first transmit is the application's banner - its
|
||||
# cycle is the window the source declares, held to +/-2 % (one
|
||||
# mis-counted cycle per poll is a 10 % shift).
|
||||
add_test(NAME pureboot.window
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
|
||||
--seconds ${PUREBOOT_TIMEOUT}
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
|
||||
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
|
||||
|
||||
# The half-duplex loader's window, same gate: its poll runs through
|
||||
# readable()'s release-line test, whose outlined call re-shapes the
|
||||
# whole loop - a per-class cycle count (poll_cost() in pureboot.cpp)
|
||||
# that only the built image can prove, chip by chip.
|
||||
if(PUREBOOT_HAS_USART)
|
||||
add_test(NAME pureboot.window.halfduplex
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_hd>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
|
||||
--seconds ${PUREBOOT_TIMEOUT}
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-hd-work)
|
||||
set_tests_properties(pureboot.window.halfduplex PROPERTIES TIMEOUT 300)
|
||||
endif()
|
||||
|
||||
# The position-independence acceptance test: the identical image,
|
||||
# installed one slot lower, must serve the full command set.
|
||||
add_test(NAME pureboot.reloc
|
||||
@@ -186,8 +251,30 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
set_tests_properties(pureboot.reloc PROPERTIES TIMEOUT 180
|
||||
ENVIRONMENT "PB_OBJCOPY=${CMAKE_OBJCOPY}")
|
||||
|
||||
# The seal against the one command that proves it: erasing the page the
|
||||
# loader runs from, refused unsealed and honoured sealed. Destroys the
|
||||
# loader by design, so it gets a device of its own.
|
||||
add_test(NAME pureboot.selfwrite
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbselfwrite.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbselfwrite-work)
|
||||
set_tests_properties(pureboot.selfwrite PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The seal against a link that damages bytes on purpose: every header
|
||||
# field flipped after sealing must be refused, and the identical flip
|
||||
# applied before sealing must be obeyed.
|
||||
add_test(NAME pureboot.glitch
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbglitch.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbglitch-work)
|
||||
set_tests_properties(pureboot.glitch PROPERTIES TIMEOUT 180)
|
||||
|
||||
# Entering the loader from a running application with no reset
|
||||
# between, over a page buffer the application dirtied — the case the
|
||||
# between, over a page buffer the application dirtied - the case the
|
||||
# loader declines to guard and the host repairs. Hardware forbids the
|
||||
# state here (SPM runs only from the boot section); simavr does not,
|
||||
# which is what makes it constructible.
|
||||
@@ -218,7 +305,7 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
endif()
|
||||
|
||||
# The self-update end-to-end: the re-timed build (same source, only
|
||||
# the timeout differs — a byte-different image) replaces the resident
|
||||
# the timeout differs - a byte-different image) replaces the resident
|
||||
# through --update-loader, with every power-fail phase rehearsed from
|
||||
# the runner's flash dumps.
|
||||
pureboot_add_loader(pureboot9 TIMEOUT 9)
|
||||
@@ -234,12 +321,13 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
endif()
|
||||
|
||||
# The size matrix: every configuration axis that could move the image
|
||||
# size — the serial backend (different code), the clock and its ladder
|
||||
# baud (different constants and divisor shapes), the USART instance
|
||||
# (different register class) — 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.
|
||||
# size - the serial backend (different code), the USART instance
|
||||
# (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
|
||||
@@ -247,14 +335,112 @@ 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's home is wire contract, not layout accident: the
|
||||
# host reads the bit period from it (--info's measured clock). In the
|
||||
# GPIOR home the image must carry no RAM copy at all; in the RAM home it
|
||||
# is the loader's only RAM object, at the very start of SRAM.
|
||||
add_test(NAME pureboot_autobaud.unit
|
||||
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
|
||||
-DRAM_START=${PUREBOOT_RAM_START} -DGPIOR=${PUREBOOT_UNIT_GPIOR}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
|
||||
|
||||
# One point of the exhaustive matrix, named from its resolved parameters
|
||||
# so the enumeration cannot collide with itself. `pins` is empty for the
|
||||
# default pair, or the index of the USART whose own pins a bit-banged
|
||||
# link sits on. Unreachable rates drop out here rather than aborting the
|
||||
# configure.
|
||||
# The optional trailing argument is the one-wire shape of the same link:
|
||||
# ONE_WIRE folds a software point onto its RX pin (the default, or the
|
||||
# named USART's RXD), HALF_DUPLEX is the hardware USART's turn-around.
|
||||
function(pureboot_matrix_point hz baud link pins)
|
||||
set(_name pbm_${hz}_${baud}_${link})
|
||||
if(link STREQUAL "software")
|
||||
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()
|
||||
if(ARGC GREATER 4 AND ARGV4 STREQUAL "ONE_WIRE")
|
||||
if(NOT pins STREQUAL "")
|
||||
set(_args SERIAL software RX ${PUREBOOT_USART${pins}_RX} TX ${PUREBOOT_USART${pins}_RX})
|
||||
else()
|
||||
list(APPEND _args RX pb0 TX pb0)
|
||||
endif()
|
||||
set(_name ${_name}_1w)
|
||||
endif()
|
||||
else()
|
||||
pureboot_baud_feasible(${hz} ${baud} 0 _ok)
|
||||
set(_args USART ${link})
|
||||
if(ARGC GREATER 4 AND ARGV4 STREQUAL "HALF_DUPLEX")
|
||||
list(APPEND _args HALF_DUPLEX)
|
||||
set(_name ${_name}_hd)
|
||||
endif()
|
||||
endif()
|
||||
if(_ok)
|
||||
pureboot_size_variant(${_name} CLOCK ${hz} BAUD ${baud} ${_args})
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# Clock points: the shipped-fuse floor (CKDIV8), the calibrated RC, and
|
||||
# the crystal the stock build assumes (the tiny13's ladder is its own RC
|
||||
# menu — it has no crystal option).
|
||||
# menu - it has no crystal option).
|
||||
if(LIBAVR_MCU MATCHES "^attiny13")
|
||||
set(_matrix_clocks 1200000 4800000 9600000)
|
||||
set(_full_clocks 128000 600000 1200000 4800000 9600000)
|
||||
else()
|
||||
set(_matrix_clocks 1000000 8000000 16000000)
|
||||
set(_full_clocks 128000 1000000 1843200 2000000 3686400 4000000 7372800 8000000
|
||||
11059200 12000000 14745600 16000000 18432000 20000000)
|
||||
endif()
|
||||
|
||||
# The exhaustive cross product: every clock a deployment plausibly runs
|
||||
# - the internal oscillators, the shipped CKDIV8 floor, the plain
|
||||
# crystals and the UART crystals - against every rate, against every
|
||||
# backend. Beyond the ladder the list carries the slow rates a
|
||||
# sub-megahertz oscillator is left with, which no ladder rate reaches
|
||||
# (16000 Bd is the only rate the 128 kHz oscillator holds exactly); at
|
||||
# the fast clocks those same rates also select the software UART's
|
||||
# 16-bit _delay_loop_2 bit spin (two words more setup at each of its five
|
||||
# sites), the largest image the space produces and a shape the ladder
|
||||
# default - always the *fastest* rate a clock reaches - never picks.
|
||||
#
|
||||
# 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)
|
||||
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 "" ONE_WIRE)
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 0 ONE_WIRE)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "")
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 0 "" HALF_DUPLEX)
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software 1 ONE_WIRE)
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "")
|
||||
pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} 1 "" HALF_DUPLEX)
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
else()
|
||||
foreach(_matrix_hz IN LISTS _matrix_clocks)
|
||||
math(EXPR _matrix_khz "${_matrix_hz} / 1000")
|
||||
if(PUREBOOT_HAS_USART OR NOT _matrix_hz EQUAL _pb_stock_hz)
|
||||
@@ -263,16 +449,109 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
if(PUREBOOT_HAS_USART AND NOT _matrix_hz EQUAL _pb_stock_hz)
|
||||
pureboot_size_variant(pureboot_hw_${_matrix_khz}k CLOCK ${_matrix_hz} SERIAL hardware)
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1 AND NOT _matrix_hz EQUAL _pb_stock_hz)
|
||||
pureboot_size_variant(pureboot_usart1_${_matrix_khz}k CLOCK ${_matrix_hz} USART 1)
|
||||
endif()
|
||||
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()
|
||||
|
||||
# One configured deployment end to end — a real board's shape rather
|
||||
# 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 one-wire axis at its fixed points, in both matrix modes (the
|
||||
# exhaustive sweep carries the same shapes across its cross product):
|
||||
# the software link folded onto one pin, the tightest autobaud image
|
||||
# likewise - on the default pin and on the USART's own RXD, whose
|
||||
# release the now-driven shared pin needs where a receive-only link
|
||||
# would not - and the hardware USART's half-duplex turn-around, stock
|
||||
# and at the widest fixed-baud shape.
|
||||
# The two spellings deliberately split across the two points: HALF_DUPLEX
|
||||
# folds TX onto RX, RX == TX states the same thing directly.
|
||||
pureboot_size_variant(pureboot_1w SERIAL software RX pb0 HALF_DUPLEX)
|
||||
pureboot_size_variant(pureboot_1w_autobaud_osccal SERIAL autobaud OSCCAL 0x9c RX pb0 TX pb0)
|
||||
if(PUREBOOT_HAS_USART)
|
||||
pureboot_size_variant(pureboot_1w_on_usart0 SERIAL software
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
|
||||
pureboot_size_variant(pureboot_1w_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
|
||||
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_RX})
|
||||
pureboot_size_variant(pureboot_hd HALF_DUPLEX)
|
||||
list(GET _matrix_clocks -1 _hd_top_hz)
|
||||
pureboot_size_variant(pureboot_hd_wide CLOCK ${_hd_top_hz} BAUD 9600 HALF_DUPLEX)
|
||||
endif()
|
||||
if(PUREBOOT_HAS_USART1)
|
||||
pureboot_size_variant(pureboot_usart1_hd USART 1 HALF_DUPLEX)
|
||||
endif()
|
||||
|
||||
# The trim byte, observed through the wire from the first prompt - one
|
||||
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
|
||||
# an sts - DS40002061B section 36), plain I/O on the 85 (data 0x51, an out -
|
||||
# Atmel-2586 section 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
|
||||
# baud (9600). The full protocol suite runs against it, fixture
|
||||
# application included, over the runner's GPIO bridge — proving the
|
||||
# application included, over the runner's GPIO bridge - proving the
|
||||
# configuration plumbing produces a working loader, not just one that
|
||||
# fits.
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p" AND DEFINED PB_DEVICE)
|
||||
@@ -296,6 +575,85 @@ 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)
|
||||
|
||||
# The same hand-over against the one-wire deployment on that USART's
|
||||
# RXD: RXEN forces the shared pin's direction, so a loader that only
|
||||
# released the transmit-side hold would read the wire and answer into
|
||||
# a pin it cannot drive. The host runs with the --one-wire echo
|
||||
# discard, which the bridge's shared-line model feeds for real.
|
||||
get_target_property(_mute1w_link pureboot_1w_on_usart0 PUREBOOT_LINK)
|
||||
add_test(NAME pureboot.mute.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbmute.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_on_usart0> ${PUREBOOT_SIM_MCU} ${_mute_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_mute_baud}
|
||||
$<TARGET_FILE:pbapp_handover>.bin
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbmute-1w-work ${_mute1w_link})
|
||||
set_tests_properties(pureboot.mute.onewire PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The full protocol suite over one shared pin: the loader folded onto
|
||||
# PB0, the bridge following the pin's direction, the fixture
|
||||
# bannering as a guest on the same line, and the host discarding its
|
||||
# own echo throughout.
|
||||
get_target_property(_1w_hz pureboot_1w PUREBOOT_HZ)
|
||||
get_target_property(_1w_baud pureboot_1w PUREBOOT_BAUD)
|
||||
get_target_property(_1w_link pureboot_1w PUREBOOT_LINK)
|
||||
add_executable(pbapp_1w test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_1w PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_1w PRIVATE PUREBOOT_CLOCK_HZ=${_1w_hz}
|
||||
PUREBOOT_BAUD=${_1w_baud} PUREBOOT_SOFT_SERIAL
|
||||
PUREBOOT_RX=pb0 PUREBOOT_TX=pb0)
|
||||
add_custom_command(TARGET pbapp_1w POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_1w> $<TARGET_FILE:pbapp_1w>.bin)
|
||||
add_test(NAME pureboot.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w> ${PUREBOOT_SIM_MCU} ${_1w_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_1w_baud} ${PUREBOOT_EEPROM}
|
||||
$<TARGET_FILE:pbapp_1w>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pb1w-work ${_1w_link})
|
||||
set_tests_properties(pureboot.onewire PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The hardware USART's half-duplex turn-around, end to end: every
|
||||
# reply byte runs drive-line, TXC-hold, release - against simavr's
|
||||
# RXEN-gated receiver, which drops input to a disabled receiver the
|
||||
# way silicon does. The pty is a two-wire transport, so the host
|
||||
# needs no echo discard here; the off-chip tie itself is the
|
||||
# hardware bench's item.
|
||||
add_test(NAME pureboot.halfduplex
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbtest.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_hd> ${PUREBOOT_SIM_MCU} ${_pb_stock_hz}
|
||||
${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} ${PUREBOOT_EEPROM}
|
||||
$<TARGET_FILE:pbapp>.bin ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbhd-work)
|
||||
set_tests_properties(pureboot.halfduplex PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
|
||||
# The second USART, driven for real on one chip: instance selection is
|
||||
# compile-checked everywhere, but only a live session proves the loader
|
||||
# initialized and polls the USART it claims to. The fixture application
|
||||
@@ -318,4 +676,67 @@ 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)
|
||||
|
||||
# The tightest deployment in the space, end to end: the autobaud
|
||||
# loader folded onto the USART's own RXD with the OSCCAL trim baked
|
||||
# - one-wire calibration, the receive-side release, and the host's
|
||||
# echo discard, over the same two-clock sweep. One chip carries it;
|
||||
# the shape is chip-independent.
|
||||
if(LIBAVR_MCU STREQUAL "atmega328p")
|
||||
get_target_property(_ab1w_link pureboot_1w_autobaud_osccal_on_usart0 PUREBOOT_LINK)
|
||||
add_executable(pbapp_autobaud_1w test/pbapp.cpp)
|
||||
target_link_libraries(pbapp_autobaud_1w PRIVATE libavr)
|
||||
target_compile_definitions(pbapp_autobaud_1w PRIVATE PUREBOOT_CLOCK_HZ=1000000
|
||||
PUREBOOT_BAUD=9600 PUREBOOT_SOFT_SERIAL
|
||||
PUREBOOT_RX=pd0 PUREBOOT_TX=pd0)
|
||||
add_custom_command(TARGET pbapp_autobaud_1w POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary
|
||||
$<TARGET_FILE:pbapp_autobaud_1w> $<TARGET_FILE:pbapp_autobaud_1w>.bin)
|
||||
add_test(NAME pureboot.autobaud.onewire
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py
|
||||
${PB_DEVICE} $<TARGET_FILE:pureboot_1w_autobaud_osccal_on_usart0>
|
||||
${PUREBOOT_SIM_MCU} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE}
|
||||
$<TARGET_FILE:pbapp_autobaud_1w>.bin
|
||||
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbautobaud-1w-work ${_ab1w_link})
|
||||
set_tests_properties(pureboot.autobaud.onewire PROPERTIES TIMEOUT 240)
|
||||
endif()
|
||||
|
||||
# The autobaud window: the calibration poll budget, at the measured
|
||||
# 10 cycles a poll (pbwindow.py pins the constant the README's
|
||||
# seconds arithmetic uses; the budget itself is the clock-free knob).
|
||||
add_test(NAME pureboot.window.autobaud
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_autobaud>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz 1000000
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud 9600 --app $<TARGET_FILE:pbapp_autobaud>.bin
|
||||
--autobaud-polls 4000000 --link sw
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-autobaud-work)
|
||||
set_tests_properties(pureboot.window.autobaud PROPERTIES TIMEOUT 300)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
78
ide/README.md
Normal file
78
ide/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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`** — the stock 328P pureboot deployment and the `tsb_asm` tier in its
|
||||
512-byte section (`check-flags.py` below is what holds the flag sets equal, so
|
||||
the sizes are Ninja's own). 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: e81dad0131...1412b7173b
@@ -38,11 +38,23 @@ avra -I /usr/share/avra tsb-fixedbaud.asm # after uncommenting .include "m328P
|
||||
```
|
||||
|
||||
**500 bytes with every feature** — the proof that ≤512 B and full feature parity
|
||||
are simultaneously reachable. The port's `tsb_asm` tier meets the same bar at
|
||||
510 B in the same 512 B section, written in C++ on libavr except the two
|
||||
routines whose remaining cost is the calling convention itself (the bounded rx
|
||||
and the page-store loop); `tsb_tricks` needs no assembly at all at 526 B, and
|
||||
`tsb_pure` stays fully idiomatic at 836 B, both in the 1 KB section.
|
||||
are simultaneously reachable. The port's four tiers reach it from the other
|
||||
side, and the gradient between them is the cost of the mechanisms each is
|
||||
allowed:
|
||||
|
||||
| tier | bytes | section | what it is allowed |
|
||||
|---|---|---|---|
|
||||
| oracle | 500 | 512 B | hand-written assembly, the reference |
|
||||
| `tsb_asm` | 512 | 512 B | C++ on libavr, two routines in asm |
|
||||
| `tsb_tricks` | 528 | 1 KB | no asm; global register variables |
|
||||
| `tsb_policy` | 630 | 1 KB | pureboot's rules: no asm, no register variables |
|
||||
| `tsb_pure` | 776 | 1 KB | idiomatic libavr throughout |
|
||||
|
||||
The two routines `tsb_asm` keeps are the ones whose remaining cost is the
|
||||
calling convention itself: the bounded rx and the page-store loop. It fills
|
||||
its section exactly, with the same one-bit-time turn-around guard the oracle
|
||||
spends six bytes on - every tier implements the whole feature set, which is
|
||||
what makes the column a gradient rather than four different loaders.
|
||||
|
||||
The oracle targets 20 MHz / 33333 baud; the port targets 16 MHz / 115200 baud
|
||||
(what the simavr protocol test drives). Baud and geometry differ, code size and
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
# pureboot as a consumable CMake unit: the per-chip geometry, the default
|
||||
# baud ladder, and pureboot_add_loader() — the one way a loader target is
|
||||
# created, both by this port's own build and by a downstream project. A
|
||||
# downstream project brings its usual libavr setup (the `libavr` target and
|
||||
# pureboot as a consumable CMake unit: the per-chip geometry, the default baud
|
||||
# ladder, and pureboot_add_loader() - the one way a loader target is created.
|
||||
# A downstream project brings its usual libavr setup (the `libavr` target and
|
||||
# the LIBAVR_MCU toolchain preset), adds this directory, and states its
|
||||
# deployment:
|
||||
# deployment; every argument is optional (README.md):
|
||||
#
|
||||
# add_subdirectory(bootloader/pureboot)
|
||||
# pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
|
||||
#
|
||||
# Every argument is optional — CLOCK defaults to the family assumption
|
||||
# below, BAUD to the fastest standard rate the clock reaches within 2.5 %
|
||||
# (the ladder), SERIAL to the chip's hardware USART where it has one
|
||||
# (`hardware`/`software` force a backend, USART 1 picks the second
|
||||
# instance), RX/TX to pb0/pb1 for the software UART, TIMEOUT to 8 s.
|
||||
# Infeasible picks fail the build by name: libavr's baud-error and
|
||||
# software-UART cycle-floor static asserts re-check whatever is passed.
|
||||
|
||||
# Per-family geometry: flash/page/EEPROM sizes and the linker wrap the PC
|
||||
# modulo needs, the loader slot (each chip's smallest boot sector — 1 KiB on
|
||||
# the word-addressed 1284s), and the deployment defaults (crystal assumption
|
||||
# on the megas, calibrated RC on the tinies). The USART flags mirror the
|
||||
# hardware inventory the loader's own static asserts check (the plain 644 is
|
||||
# the x4 family's one single-USART die, Atmel-2593).
|
||||
# Per-family geometry, deployment defaults, and the linker wrap the PC modulo
|
||||
# needs. The slot is 512 bytes on every chip. The USART flags mirror the
|
||||
# hardware inventory the loader's own static asserts check - the plain 644 is
|
||||
# the x4 family's one single-USART die (Atmel-2593).
|
||||
set(_pb_has_usart 1)
|
||||
set(_pb_has_usart1 0)
|
||||
if(LIBAVR_MCU MATCHES "^attiny13a?$")
|
||||
@@ -91,10 +80,9 @@ elseif(LIBAVR_MCU MATCHES "^atmega324(a|p|pa)$")
|
||||
set(_pb_eeprom 1024)
|
||||
set(_pb_has_usart1 1)
|
||||
elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
|
||||
# 64 KiB is exactly the 16-bit byte space: plain LPM reaches everything,
|
||||
# and the smallest boot section (1 KiB) holds the loader and its staging
|
||||
# slot together (see README.md). The plain 644 is the family's one
|
||||
# single-USART die.
|
||||
# 64 KiB is exactly the 16-bit byte space, so plain LPM still reaches
|
||||
# everything and the wire stays byte-addressed. The plain 644 is the
|
||||
# family's one single-USART die.
|
||||
set(_pb_flash 65536)
|
||||
set(_pb_wrap -Wl,--pmem-wrap-around=64k)
|
||||
set(_pb_page 256)
|
||||
@@ -104,38 +92,41 @@ elseif(LIBAVR_MCU MATCHES "^atmega644(a|p|pa)?$")
|
||||
set(_pb_has_usart1 1)
|
||||
endif()
|
||||
elseif(LIBAVR_MCU MATCHES "^atmega1284p?$")
|
||||
# 128 KiB: wire flash addresses are word addresses, reads go through
|
||||
# ELPM, and the PC's modulo wrap exceeds what --pmem-wrap-around models.
|
||||
# The slot is 1 KiB — this chip's own smallest boot sector; the far
|
||||
# machinery cannot fit 512 B (see README.md).
|
||||
# 128 KiB: wire addresses are words, reads go through ELPM, and the PC's
|
||||
# modulo wrap exceeds what --pmem-wrap-around models.
|
||||
set(_pb_flash 131072)
|
||||
set(_pb_wrap "")
|
||||
set(_pb_page 256)
|
||||
set(_pb_hz 16000000)
|
||||
set(_pb_eeprom 4096)
|
||||
set(_pb_slot 1024)
|
||||
set(_pb_limit 1024)
|
||||
set(_pb_has_usart1 1)
|
||||
else()
|
||||
message(FATAL_ERROR "pureboot: no geometry for ${LIBAVR_MCU}")
|
||||
endif()
|
||||
if(NOT DEFINED _pb_slot)
|
||||
set(_pb_slot 512)
|
||||
endif()
|
||||
math(EXPR _pb_base "${_pb_flash} - ${_pb_slot}")
|
||||
math(EXPR _pb_base_hex "${_pb_base}" OUTPUT_FORMAT HEXADECIMAL)
|
||||
# Patched-vector chips hand over through the trampoline word below the slot,
|
||||
# which is also the slot's own last word — their budget is slot − 2.
|
||||
# which is also the slot's own last word - their budget is slot - 2.
|
||||
if(LIBAVR_MCU MATCHES "^atmega" AND NOT LIBAVR_MCU MATCHES "^atmega48")
|
||||
set(_pb_app 0)
|
||||
if(NOT DEFINED _pb_limit)
|
||||
set(_pb_limit ${_pb_slot})
|
||||
endif()
|
||||
else()
|
||||
math(EXPR _pb_app "${_pb_base} - 2")
|
||||
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})
|
||||
@@ -145,6 +136,28 @@ 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 keeps its measured unit
|
||||
# in GPIOR2:GPIOR1 wherever the chip has the pair (data 0x32 on the
|
||||
# t25/45/85, 0x4A from the x8 generation on) and as the first RAM object at
|
||||
# SRAM start where it does not (the t13s and classic megas). The host reads
|
||||
# whichever home applies (pureboot.py's geometry), and the unit-position
|
||||
# test holds the image to the same split.
|
||||
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior "")
|
||||
elseif(LIBAVR_MCU MATCHES "^atmega")
|
||||
set(_pb_ram 0x100)
|
||||
set(_pb_unit_gpior 0x4A)
|
||||
elseif(LIBAVR_MCU MATCHES "^attiny13")
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior "")
|
||||
else()
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior 0x32)
|
||||
endif()
|
||||
|
||||
# The function runs in its caller's scope, so everything it needs crosses
|
||||
# scopes as global properties.
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
|
||||
@@ -153,6 +166,10 @@ set_property(GLOBAL PROPERTY PUREBOOT_WRAP "${_pb_wrap}")
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_DEFAULT_HZ ${_pb_hz})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART ${_pb_has_usart})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_HAS_USART1 ${_pb_has_usart1})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART0_RX ${_pb_usart0_rx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART0_TX ${_pb_usart0_tx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART1_RX ${_pb_usart1_rx})
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_USART1_TX ${_pb_usart1_tx})
|
||||
|
||||
# The port's own build (tests, the size matrix) reads the geometry from the
|
||||
# parent scope; a downstream consumer gets the same variables for free.
|
||||
@@ -162,21 +179,29 @@ 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_UNIT_GPIOR "${_pb_unit_gpior}" PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
|
||||
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
|
||||
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 fastest standard rate the clock reaches within 2.5 % — the same
|
||||
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a
|
||||
# default never trips the compile-time error it is checked against. A
|
||||
# software build additionally requires the polled receiver's 100-cycles-a-bit
|
||||
# floor (its own static assert): at low clocks the U2X divisor still reaches
|
||||
# rates the bit-banged sampler cannot, so the backend gates the ladder.
|
||||
function(pureboot_default_baud clock software outvar)
|
||||
foreach(baud 115200 57600 38400 19200 9600)
|
||||
# The rates a default may pick, fastest first.
|
||||
set_property(GLOBAL PROPERTY PUREBOOT_BAUD_LADDER 115200 57600 38400 19200 9600)
|
||||
|
||||
# Whether <baud> is reachable from <clock> within 2.5 %, by the same
|
||||
# best-of-U2X-and-plain divisor search libavr's solve_baud runs, so a build
|
||||
# never trips the compile-time error it is checked against. A software build
|
||||
# also needs the polled receiver's 100-cycles-a-bit floor: at low clocks the
|
||||
# U2X divisor reaches rates the bit-banged sampler cannot.
|
||||
function(pureboot_baud_feasible clock baud software outvar)
|
||||
set(${outvar} 0 PARENT_SCOPE)
|
||||
math(EXPR _cycles "${clock} / ${baud}")
|
||||
if(software AND _cycles LESS 100)
|
||||
continue()
|
||||
return()
|
||||
endif()
|
||||
foreach(divisor 8 16)
|
||||
math(EXPR _step "${divisor} * ${baud}")
|
||||
@@ -191,25 +216,56 @@ function(pureboot_default_baud clock software outvar)
|
||||
endif()
|
||||
math(EXPR _error_bp "${_delta} * 10000 / ${baud}")
|
||||
if(_error_bp LESS_EQUAL 250)
|
||||
set(${outvar} 1 PARENT_SCOPE)
|
||||
return()
|
||||
endif()
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
# The fastest ladder rate the clock reaches.
|
||||
function(pureboot_default_baud clock software outvar)
|
||||
get_property(_ladder GLOBAL PROPERTY PUREBOOT_BAUD_LADDER)
|
||||
foreach(baud ${_ladder})
|
||||
pureboot_baud_feasible(${clock} ${baud} ${software} _ok)
|
||||
if(_ok)
|
||||
set(${outvar} ${baud} PARENT_SCOPE)
|
||||
return()
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 %")
|
||||
message(FATAL_ERROR "pureboot: no standard baud rate fits a ${clock} Hz clock within 2.5 % "
|
||||
" - pass BAUD <rate> to deploy a non-standard one")
|
||||
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>]
|
||||
# [HALF_DUPLEX])
|
||||
#
|
||||
# Creates the loader target plus its flashable images (<name>.hex for a
|
||||
# programmer, <name>.bin for --update-loader) and stamps the resolved
|
||||
# deployment on the target: the PUREBOOT_HZ, PUREBOOT_BAUD and PUREBOOT_LINK
|
||||
# properties (the link as usart0/usart1/sw:<RX>,<TX> — what a test harness
|
||||
# needs to speak to the build).
|
||||
# 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> with a trailing @<n> where those pins are a
|
||||
# USART's own) - what a test harness speaks to it with.
|
||||
#
|
||||
# HALF_DUPLEX is the one-wire deployment, per backend: on the hardware USART
|
||||
# it enables the library's .half_duplex turn-around (RXD and TXD tied
|
||||
# together off-chip); on a software or autobaud link it puts both directions
|
||||
# on the RX pin - the same thing RX == TX spells directly.
|
||||
#
|
||||
# SERIAL autobaud measures the host's bit timing at run time, so the image
|
||||
# carries no clock and no baud: CLOCK and BAUD are not build parameters there,
|
||||
# and one binary per chip serves every F_CPU and every rate. The stamped
|
||||
# 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 "HALF_DUPLEX" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
|
||||
if(PB_UNPARSED_ARGUMENTS)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
|
||||
endif()
|
||||
@@ -229,8 +285,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)
|
||||
@@ -246,23 +302,38 @@ function(pureboot_add_loader name)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): ${LIBAVR_MCU} has no hardware USART")
|
||||
endif()
|
||||
set(_serial_defines PUREBOOT_USART=${PB_USART})
|
||||
if(PB_HALF_DUPLEX)
|
||||
list(APPEND _serial_defines PUREBOOT_HALF_DUPLEX)
|
||||
endif()
|
||||
set(_link usart${PB_USART})
|
||||
else()
|
||||
if(PB_SERIAL STREQUAL "auto")
|
||||
if(_usart AND (PB_RX OR PB_TX))
|
||||
message(WARNING "pureboot_add_loader(${name}): RX/TX apply to the software UART, "
|
||||
"which auto does not pick on ${LIBAVR_MCU} — SERIAL software to force it")
|
||||
"which auto does not pick on ${LIBAVR_MCU} - SERIAL software to force it")
|
||||
endif()
|
||||
if(_usart)
|
||||
set(_link usart0)
|
||||
if(PB_HALF_DUPLEX)
|
||||
set(_serial_defines PUREBOOT_HALF_DUPLEX)
|
||||
endif()
|
||||
else()
|
||||
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()
|
||||
if(PB_HALF_DUPLEX)
|
||||
# One-wire: both directions on the RX pin. RX == TX spells
|
||||
# the same deployment directly.
|
||||
if(PB_TX AND NOT PB_TX STREQUAL PB_RX)
|
||||
message(FATAL_ERROR "pureboot_add_loader(${name}): HALF_DUPLEX puts both "
|
||||
"directions on RX (${PB_RX}); TX ${PB_TX} contradicts it")
|
||||
endif()
|
||||
set(PB_TX ${PB_RX})
|
||||
endif()
|
||||
if(NOT PB_TX)
|
||||
set(PB_TX pb1)
|
||||
endif()
|
||||
@@ -271,13 +342,35 @@ 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})
|
||||
# The link spec a test harness drives a GPIO bridge with: sw:<RX>,<TX>
|
||||
# as the port letter and bit, the loader's own pin naming 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)
|
||||
get_property(_rx0 GLOBAL PROPERTY PUREBOOT_USART0_RX)
|
||||
get_property(_rx1 GLOBAL PROPERTY PUREBOOT_USART1_RX)
|
||||
if(_usart AND PB_TX STREQUAL _tx0)
|
||||
set(_link "${_link}@0")
|
||||
elseif(_usart1 AND PB_TX STREQUAL _tx1)
|
||||
set(_link "${_link}@1")
|
||||
elseif(PB_TX STREQUAL PB_RX AND _usart AND PB_RX STREQUAL _rx0)
|
||||
# One-wire on a USART's RXD: RXEN forces that pin's direction
|
||||
# (section 20.7.3), so the driven shared pin is held exactly like a
|
||||
# TXD - the harness models the hold either way.
|
||||
set(_link "${_link}@0")
|
||||
elseif(PB_TX STREQUAL PB_RX AND _usart1 AND PB_RX STREQUAL _rx1)
|
||||
set(_link "${_link}@1")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(NOT PB_BAUD)
|
||||
@@ -288,27 +381,50 @@ 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 ~40 B on every chip and
|
||||
# what carries the far-flash 1284 build under 512. 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 spends bytes on rewrites a
|
||||
# straight-line loader gains nothing from.
|
||||
# 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, and -fno-ivopts is fitted per backend - an
|
||||
# autobaud body needs ivopts to keep the calibration countdown a single
|
||||
# induction variable (without it the counter is duplicated and the
|
||||
# measurement loop runs 9 cycles instead of its contracted 7), while the
|
||||
# fixed-baud bodies still measure smaller with it off.
|
||||
if(PB_SERIAL STREQUAL "autobaud")
|
||||
target_compile_options(${name} PRIVATE
|
||||
-fno-ivopts -fira-algorithm=priority -fno-expensive-optimizations -fno-split-wide-types)
|
||||
-fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
|
||||
else()
|
||||
target_compile_options(${name} PRIVATE
|
||||
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
|
||||
endif()
|
||||
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
|
||||
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
|
||||
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
|
||||
# The ELF is a container (symbols, section headers), never flashed; the
|
||||
# flashable forms sit beside it: .hex for a programmer, .bin (the slot's
|
||||
# bare bytes) for the host tool's raw path and --update-loader.
|
||||
# The ELF is a container, never flashed: .hex for a programmer, .bin (the
|
||||
# slot's bare bytes) for --update-loader.
|
||||
add_custom_command(TARGET ${name} POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
|
||||
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.hex
|
||||
@@ -317,3 +433,4 @@ function(pureboot_add_loader name)
|
||||
set_target_properties(${name} PROPERTIES PUREBOOT_HZ ${PB_CLOCK} PUREBOOT_BAUD ${PB_BAUD}
|
||||
PUREBOOT_LINK ${_link})
|
||||
endfunction()
|
||||
|
||||
|
||||
1013
pureboot/README.md
1013
pureboot/README.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1098
pureboot/pureboot.py
1098
pureboot/pureboot.py
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Position-independence lint for the pureboot image.
|
||||
"""Position-independence lint: the property that lets the identical image run
|
||||
from any slot, asserted from the built ELF and its object.
|
||||
|
||||
The self-staging design lets the identical binary run from any 512-byte
|
||||
slot, which holds only if nothing in the image addresses itself absolutely.
|
||||
Two link-time facts guarantee it, both asserted here from the built ELF:
|
||||
1. No absolute jmp/call - -mrelax normally guarantees it, but a branch that
|
||||
grows out of relaxation range would break it silently.
|
||||
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.
|
||||
|
||||
1. No absolute jmp/call opcodes — all control flow is PC-relative
|
||||
(rjmp/rcall/ijmp/icall). -mrelax normally guarantees this; a code
|
||||
change that grows a branch out of relaxation range would break it
|
||||
silently.
|
||||
2. The info block sits within the image's first 256 bytes: the 'b'
|
||||
command rebuilds its address as (running slot high byte : low byte of
|
||||
the link address), which needs the offset to fit that low 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 len(images[0]) != len(images[1]):
|
||||
fail(f"the image is {len(images[0])} B linked at {text_start:#x} and "
|
||||
f"{len(images[1])} B at {elsewhere:#x} - relaxation followed the address")
|
||||
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__":
|
||||
|
||||
@@ -4,6 +4,9 @@ if(NOT _res EQUAL 0)
|
||||
endif()
|
||||
# avr-size line 2 is "<text> <data> <bss> <dec> <hex> <file>".
|
||||
string(REGEX MATCH "\n[ \t]*([0-9]+)" _m "${_out}")
|
||||
if(NOT _m)
|
||||
message(FATAL_ERROR "could not read a .text size out of ${SIZE_TOOL}'s output for ${ELF}:\n${_out}")
|
||||
endif()
|
||||
set(_text ${CMAKE_MATCH_1})
|
||||
if(_text GREATER LIMIT)
|
||||
message(FATAL_ERROR ".text is ${_text} bytes, over the ${LIMIT}-byte boot section")
|
||||
|
||||
36
test/check_unit.cmake
Normal file
36
test/check_unit.cmake
Normal file
@@ -0,0 +1,36 @@
|
||||
# Asserts the autobaud loader's measured unit sits where the host will read
|
||||
# it (--info's measured clock - the address is wire contract). Two homes: on
|
||||
# a chip with the GPIOR pair the unit lives there and the image must carry no
|
||||
# RAM word for it at all; elsewhere it is the first RAM object at SRAM start.
|
||||
# Run as
|
||||
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> [-DGPIOR=<data address>]
|
||||
# -P check_unit.cmake
|
||||
|
||||
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
|
||||
if(NOT _res EQUAL 0)
|
||||
message(FATAL_ERROR "${OBJDUMP} -t ${ELF} failed")
|
||||
endif()
|
||||
|
||||
# The symbol line: "00800100 l O .noinit 00000002 <mangled>6m_unitE".
|
||||
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*6m_unitE\n" _line "${_syms}")
|
||||
|
||||
if(GPIOR)
|
||||
if(_line)
|
||||
message(FATAL_ERROR "m_unit RAM symbol present although the unit's home is GPIOR ${GPIOR} - "
|
||||
"the host peeks the pair, and a RAM copy would be dead weight")
|
||||
endif()
|
||||
message(STATUS "no m_unit RAM object - the unit lives in the GPIOR pair at ${GPIOR}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT _line)
|
||||
message(FATAL_ERROR "no m_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 "m_unit sits at ${_have}, ram_start is ${_want} - the host peeks ram_start")
|
||||
endif()
|
||||
message(STATUS "m_unit at ${_have} == ram_start")
|
||||
@@ -7,103 +7,122 @@
|
||||
// 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 <array>
|
||||
#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);
|
||||
const std::array pair{cfg[i], cfg[i + 1], '\0'};
|
||||
avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(pair.data(), nullptr, 16));
|
||||
}
|
||||
}
|
||||
|
||||
// POLL_SLEEP makes simavr usleep(1) on every status-register read while the
|
||||
// UART is idle — a host-CPU-saving hack that models no hardware and paces a
|
||||
// UART is idle - a host-CPU-saving hack that models no hardware and paces a
|
||||
// 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);
|
||||
if (state == cpu_Done || state == cpu_Crashed)
|
||||
if (state == cpu_Done || state == cpu_Crashed) {
|
||||
break;
|
||||
}
|
||||
finish(0);
|
||||
return 0;
|
||||
}
|
||||
finish(0);
|
||||
}
|
||||
107
test/pbapp.cpp
107
test/pbapp.cpp
@@ -1,16 +1,20 @@
|
||||
// Test-fixture application for the pureboot protocol tests: prints "APP" on
|
||||
// the chip's serial link (the same link the loader uses) — the proof that
|
||||
// the chip's serial link (the same link the loader uses) - the proof that
|
||||
// the loader's hand-over, and on the tinies the host's reset-vector
|
||||
// surgery, actually launched it. Linked normally (crt, vectors at 0); on
|
||||
// the tinies its reset vector is the rjmp the host re-homes.
|
||||
//
|
||||
// On the hardware-USART link it then listens, and an 'L' makes it jump into
|
||||
// the resident loader — the application-owned loader entry a
|
||||
// the resident loader - the application-owned loader entry a
|
||||
// BOOTRST-unprogrammed mega relies on (reset always boots the application
|
||||
// there), exercised by the self-update tests. The software link idles:
|
||||
// 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).
|
||||
@@ -26,10 +30,12 @@ consteval avr::hertz_t clock()
|
||||
return avr::hertz_t{PUREBOOT_CLOCK_HZ};
|
||||
#else
|
||||
auto name = std::string_view{avr::hw::db.name};
|
||||
if (name.starts_with("ATtiny13"))
|
||||
if (name.starts_with("ATtiny13")) {
|
||||
return 9.6_MHz;
|
||||
if (name.starts_with("ATtiny"))
|
||||
}
|
||||
if (name.starts_with("ATtiny")) {
|
||||
return 8_MHz;
|
||||
}
|
||||
return 16_MHz;
|
||||
#endif
|
||||
}
|
||||
@@ -37,6 +43,9 @@ consteval avr::hertz_t clock()
|
||||
#if !defined(PUREBOOT_TX)
|
||||
#define PUREBOOT_TX pb1
|
||||
#endif
|
||||
#if !defined(PUREBOOT_RX)
|
||||
#define PUREBOOT_RX pb0
|
||||
#endif
|
||||
#if !defined(PUREBOOT_USART)
|
||||
#define PUREBOOT_USART 0
|
||||
#endif
|
||||
@@ -46,7 +55,7 @@ consteval bool use_hardware()
|
||||
#if defined(PUREBOOT_SOFT_SERIAL)
|
||||
return false;
|
||||
#else
|
||||
return avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART");
|
||||
return avr::uart::has_usart<0>();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -59,28 +68,57 @@ struct link {
|
||||
#else
|
||||
static constexpr avr::baud_t baud{115200};
|
||||
#endif
|
||||
using tx_t = avr::uart::usart<'0' + PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
|
||||
// The fixture speaks whatever rate the loader was built for, stock
|
||||
// 115200 at 16 MHz included, which sits past the receiver-tolerance
|
||||
// table's bound - the same deployment envelope the loader itself states.
|
||||
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .allow_baud_error = true}>;
|
||||
static void init()
|
||||
{
|
||||
avr::init<tx_t>();
|
||||
}
|
||||
static void tx(char c)
|
||||
{
|
||||
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 at the top slot — 512 bytes, or the
|
||||
// 1 KiB the >64 KiB chips use.
|
||||
constexpr std::uint32_t slot = avr::hw::db.mem.flash_size > 65536 ? 1024 : 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))();
|
||||
if (command == 'L') {
|
||||
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.
|
||||
// Hardware refuses application-section SPM on a boot-sectioned
|
||||
// part; simavr dispatches it anyway, which is the whole reason the
|
||||
// state is constructible - the stated section is the compilable
|
||||
// fiction that matches what the simulator runs.
|
||||
if (command == 'D') {
|
||||
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2)
|
||||
avr::spm::fill(at, 0xdead);
|
||||
const auto open = avr::spm::page::begin<avr::spm::from::boot_section>(0);
|
||||
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2) {
|
||||
avr::spm::fill(open, at, 0xdead);
|
||||
}
|
||||
tx('D');
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,15 +129,49 @@ struct link<C, false> {
|
||||
#else
|
||||
static constexpr avr::baud_t baud{57600};
|
||||
#endif
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud>;
|
||||
// A shared-pin deployment (RX == TX) banners as a guest on its own line:
|
||||
// the pull-up input is the released line, the transmitter takes the pin
|
||||
// for exactly one frame per byte - the shape a real one-wire application
|
||||
// beside this loader uses.
|
||||
static constexpr bool one_wire = avr::PUREBOOT_RX == avr::PUREBOOT_TX;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud, one_wire>;
|
||||
static void init()
|
||||
{
|
||||
// The guest transmitter configures no pin; the released line - the
|
||||
// pull-up input a receiver would own - is established here.
|
||||
if constexpr (one_wire) {
|
||||
avr::init<avr::io::input<avr::PUREBOOT_TX, avr::io::pull::up>, tx_t>();
|
||||
} else {
|
||||
avr::init<tx_t>();
|
||||
}
|
||||
}
|
||||
static void tx(char c)
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
}
|
||||
[[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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,9 +179,14 @@ struct link<C, false> {
|
||||
|
||||
int main()
|
||||
{
|
||||
avr::init<typename link<dev::clock>::tx_t>();
|
||||
link<dev::clock>::init();
|
||||
#if !defined(PUREBOOT_HANDOVER)
|
||||
link<dev::clock>::tx('A');
|
||||
link<dev::clock>::tx('P');
|
||||
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();
|
||||
}
|
||||
|
||||
210
test/pbautobaud.py
Normal file
210
test/pbautobaud.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/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> [link]
|
||||
|
||||
The loader is a software-serial build, driven over the GPIO<->pty bridge; the
|
||||
optional link overrides the default -l sw:B0,B1 - RX == TX in it is the
|
||||
one-wire deployment, and every session then runs with the host's echo
|
||||
discard on. The app fixture is built for (app_hz, app_baud); the hand-over
|
||||
is checked at that point, and a second point at half the clock proves the
|
||||
lock is measured, not baked in.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
link = args.pop() if len(args) == 11 else "sw:B0,B1"
|
||||
(device_bin, elf, mcu, base_hex, page, app_bin, app_hz, app_baud, tool, workdir) = args
|
||||
base, page, app_hz, app_baud = int(base_hex, 0), int(page), int(app_hz), int(app_baud)
|
||||
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
|
||||
extra = ("--one-wire",) if one_wire else ()
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
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=link)
|
||||
try:
|
||||
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
||||
# and a single knock at `baud`; the loader locks to it.
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--autobaud", "--info", "--clock", str(hz),
|
||||
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
||||
# The measured clock, decoded from the unit at whichever home this
|
||||
# version keeps it in. The runner's clock is exact, so the figure
|
||||
# must land inside the
|
||||
# encoding's own envelope: the loader floors the bit period to
|
||||
# 4-cycle spin granules after an 8-cycle discount, and the edge
|
||||
# poll can shave a few cycles more - one granule of slack below
|
||||
# the true clock, none above (in cycles per bit, times the rate).
|
||||
measured = re.search(r"measured\s+(\d+) Hz", out)
|
||||
if not measured:
|
||||
fail(f"{label}: --info lacks the measured clock\n{out}")
|
||||
measured = int(measured.group(1))
|
||||
if not hz - 19 * baud <= measured <= hz + 4 * baud:
|
||||
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
|
||||
# Read both memories back over the locked link and check them.
|
||||
read_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, *extra, "--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:
|
||||
# A calibration pulse with no knock behind it must not wedge
|
||||
# the loader: the whole activation is bounded, including the
|
||||
# knock's edge wait, so one stray low pulse - EMI, or a host
|
||||
# that opens the port and never knocks - closes the window and
|
||||
# boots the application. 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)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
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)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
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_data(0x0200, probe)
|
||||
if loader.read_data(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_data(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=link)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
live = pb.Loader(port).connect_autobaud(15)
|
||||
if live.version != pb.NEWEST_LOADER:
|
||||
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()
|
||||
@@ -1,15 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dirty-page-buffer acceptance test: the loader carries no buffer discard,
|
||||
so a page filled over words an earlier writer left behind programs those
|
||||
instead. This asserts the whole contract — the corruption is real and a bare
|
||||
verify sees it, the repairing verify fixes it in one rewrite (the write that
|
||||
took the stale words auto-erased the buffer), and it stays fixed.
|
||||
"""Dirty-page-buffer acceptance test: with no discard in the loader, a page
|
||||
filled over words an earlier writer left takes those instead. The whole
|
||||
contract is asserted - a bare verify sees the corruption, the repairing
|
||||
verify fixes it in one rewrite, and it stays fixed.
|
||||
|
||||
The state is reached the way the loader cannot prevent: an application
|
||||
dirties the buffer and jumps in with no reset between. Real boot-sectioned
|
||||
megas forbid that outright — SPM executes only from the boot section
|
||||
(Atmel-8271 §26.2) — but simavr dispatches SPM from anywhere, which is what
|
||||
makes the path constructible at all.
|
||||
The state is reached the one way the loader cannot prevent: an application
|
||||
dirties the buffer and jumps in with no reset between. Boot-sectioned megas
|
||||
forbid that outright (SPM runs only from the boot section, Atmel-8271 section 26.2),
|
||||
but simavr dispatches SPM from anywhere, which is what makes it constructible.
|
||||
|
||||
Usage: pbdirty.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <app_bin> <tool_py> <workdir>
|
||||
@@ -68,7 +66,7 @@ def main():
|
||||
fail(f"the read-back failed, but not at verify: {error}")
|
||||
else:
|
||||
# Either the fixture no longer dirties the buffer, or the loader
|
||||
# clears it again — in which case this test's premise is gone.
|
||||
# clears it again - in which case this test's premise is gone.
|
||||
fail("programming over a dirty page buffer came back clean")
|
||||
|
||||
# What the programming path uses: one rewrite settles it, and it stays
|
||||
|
||||
124
test/pbglitch.py
Normal file
124
test/pbglitch.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A link that damages bytes, deliberately and reproducibly, against the seal
|
||||
that exists for it.
|
||||
|
||||
The board this was written for loses and mangles bytes on its own serial path,
|
||||
and the failure that made it matter - a page-fill byte lost, the stream one
|
||||
byte out, a page-address byte arriving where an SPMCSR value belongs - is not
|
||||
reachable by asking a healthy link nicely. So the damage is injected here, at
|
||||
a named byte index rather than a probability: a failing case is a case that
|
||||
fails again.
|
||||
|
||||
Every check is a pair. The same bit flipped in the same field is applied on
|
||||
one side of the seal and then the other: *after* the host seals the header,
|
||||
which is a mangled command and must be refused, and *before*, which is a
|
||||
well-formed command for something else and must be obeyed. Only the pair
|
||||
proves anything - a test that showed the refusal alone would pass against a
|
||||
loader that had simply stopped doing SPM, and one that showed the corruption
|
||||
alone would not say what caught it.
|
||||
|
||||
Usage: pbglitch.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def frame(pb, op, space, address, count, damage=None, before_seal=False):
|
||||
"""A sealed command header, optionally with one byte damaged.
|
||||
|
||||
`damage` is (index, mask). Applied before the seal is computed it produces
|
||||
a valid command for whatever the damaged fields now say; applied after, a
|
||||
command whose seal no longer matches its own body - which is the shape a
|
||||
link fault actually has."""
|
||||
head = bytearray((op, pb.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
if damage and before_seal:
|
||||
head[damage[0]] ^= damage[1]
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
out = bytearray(head + bytes((seal,)))
|
||||
if damage and not before_seal:
|
||||
out[damage[0]] ^= damage[1]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
|
||||
base, page, baud = int(base_hex, 0), int(page), int(baud)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
import pureboot as pb
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
info = loader.connect(25)
|
||||
if info.version < pb.SEALED_LOADER:
|
||||
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
|
||||
|
||||
# A page of known bytes to watch. Everything below aims at it, so
|
||||
# "nothing happened" is a readable claim rather than an absence.
|
||||
marker = bytes((0x40 + (i & 0x3F)) for i in range(page))
|
||||
loader.write_page(0, marker)
|
||||
if loader.read_flash(0, page) != marker:
|
||||
fail("the marker page did not survive an undamaged write")
|
||||
|
||||
# Every field of the header, one bit each. A damaged seal must be
|
||||
# refused, the loader must re-prompt, and the page must be untouched -
|
||||
# and it is the erase being aimed at it, so a single escape is visible.
|
||||
for index in range(6):
|
||||
bad = frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE, damage=(index, 0x01))
|
||||
port.write(bad)
|
||||
answer = port.read_exact(1, 5.0)
|
||||
if answer != pb.NAK:
|
||||
fail(f"a header damaged in byte {index} was not refused (got {answer.hex()})")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail(f"no prompt after refusing a header damaged in byte {index}")
|
||||
if loader.read_flash(0, page) != marker:
|
||||
fail(f"damage in byte {index} reached flash - the marker page changed")
|
||||
|
||||
# The fill, whose payload is the protocol's one unacked burst: a
|
||||
# refused fill must be refused *before* the page is sent, or the host
|
||||
# is left pushing 128 bytes into a loader reading commands. Nothing is
|
||||
# sent after the verdict here, and the very next command must be
|
||||
# understood - that is the whole claim.
|
||||
bad = frame(pb, pb.OP_FILL, pb.SP_FLASH, 0, page, damage=(3, 0x80))
|
||||
port.write(bad)
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("a damaged fill header was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("no prompt after refusing a damaged fill header")
|
||||
if loader.identity().raw != info.raw:
|
||||
fail("the loader was out of step after refusing a fill")
|
||||
|
||||
# The pair's other half. The identical flip, applied before the seal:
|
||||
# a well-formed erase of the page one bit away from the one intended.
|
||||
# It must be obeyed - otherwise the refusals above prove nothing about
|
||||
# the seal and only that this loader stopped erasing.
|
||||
port.write(frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE,
|
||||
damage=(2, 0x01), before_seal=True))
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("a correctly sealed erase was refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("no prompt after a correctly sealed erase")
|
||||
if loader.read_flash(0, page) != b"\xff" * page:
|
||||
fail("the sealed erase did not reach flash - the marker page is intact")
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
print("pbglitch: damaged headers are refused, the identical damage sealed is obeyed")
|
||||
|
||||
|
||||
main()
|
||||
79
test/pbmute.py
Normal file
79
test/pbmute.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/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 re
|
||||
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")
|
||||
# A shared line (RX == TX) echoes the host's own bytes; discard them the
|
||||
# way the shipped --one-wire mode does.
|
||||
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1@[01]", link) is not None
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(25)
|
||||
resident = loader.info.version
|
||||
|
||||
# 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()
|
||||
@@ -1,19 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-homing acceptance test: a pureboot image programmed somewhere other
|
||||
than its canonical top slot must still be a working loader —
|
||||
position-independent, guarding its accidental slot — and the ordinary
|
||||
"""Re-homing acceptance test: an image programmed somewhere other than its
|
||||
canonical slot must still be a working loader, and the ordinary
|
||||
--update-loader flow must put a build into the top slot from there.
|
||||
|
||||
Two positions are exercised. Address 0 (a raw .bin handed to a programmer,
|
||||
which defaults to offset 0): the staging install and the word-0 redirect
|
||||
both run from copies whose slots are not page 0's, so the running-slot
|
||||
guard never blocks the flow. The staging slot itself: a loader already
|
||||
sitting there IS the installed staging copy — the tool recognizes it by
|
||||
its embedded info block and leaves it in place instead of tripping the
|
||||
copy's own guard on the composed through-word — and that (older) copy
|
||||
streams the new resident like any staged copy. In both cases flashing an
|
||||
application through the healed resident overwrites the stale copy, vector
|
||||
surgery included, and the banner proves the launch.
|
||||
Two positions. Address 0, a raw .bin handed to a programmer. And the staging
|
||||
slot itself, where a loader already sitting there IS the staging copy -
|
||||
recognized by its embedded block and left in place, then streaming the new
|
||||
resident like any staged copy.
|
||||
|
||||
Usage: pbrehome.py <device_bin> <pureboot_elf> <update_bin> <mcu> <hz>
|
||||
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
|
||||
@@ -28,7 +21,7 @@ def fail(message):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin, base, page, baud, app_bin, workdir,
|
||||
def rehome_from(pbsim, pb, device_bin, elf, place_hex, update_bin, base, page, baud, app_bin, workdir,
|
||||
mcu, hz):
|
||||
"""Place the loader at `place_hex`, heal through --update-loader, flash
|
||||
the application, expect the banner."""
|
||||
@@ -42,15 +35,7 @@ def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin,
|
||||
loader = pb.Loader(port)
|
||||
info = loader.connect(25)
|
||||
if info.base != base:
|
||||
fail(f"the misplaced copy reports base {info.base:#06x} — the info block must stay canonical")
|
||||
|
||||
# The accidental slot still guards itself; re-homing rides on the
|
||||
# canonical slots being writable from it.
|
||||
probe = int(guard_probe, 0)
|
||||
before = loader.read_flash(probe, info.page)
|
||||
loader.write_page(probe, bytes(info.page))
|
||||
if loader.read_flash(probe, info.page) != before:
|
||||
fail("the misplaced copy's guard let its own slot change")
|
||||
fail(f"the misplaced copy reports base {info.base:#06x} - the info block must stay canonical")
|
||||
|
||||
# The ordinary update flow puts the build into the top slot.
|
||||
pb.op_update_loader(loader, 25, update_bin, state, None)
|
||||
@@ -82,16 +67,15 @@ def main():
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
|
||||
# Address 0: the raw-.bin-to-a-programmer accident. The guard probe is
|
||||
# the copy's own page 0.
|
||||
rehome_from(pbsim, pb, device_bin, elf, "0x0", "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
|
||||
# Address 0: the raw-.bin-to-a-programmer accident.
|
||||
rehome_from(pbsim, pb, device_bin, elf, "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
|
||||
print("re-home from address 0: converged")
|
||||
|
||||
# The staging slot: erased flash with the loader sitting exactly where
|
||||
# a staging copy would — the tool must leave it in place and let it
|
||||
# a staging copy would - the tool must leave it in place and let it
|
||||
# stream the (different) update build into the resident slot.
|
||||
stage = base - 512
|
||||
rehome_from(pbsim, pb, device_bin, elf, hex(stage), hex(stage), update_bin, base, page, baud, app_bin, workdir,
|
||||
stage = base - pb.SLOT
|
||||
rehome_from(pbsim, pb, device_bin, elf, hex(stage), update_bin, base, page, baud, app_bin, workdir,
|
||||
mcu, hz)
|
||||
print("re-home from the staging slot: converged")
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Position-independence acceptance test: the identical pureboot binary,
|
||||
flashed one slot below the resident loader, must serve the complete command
|
||||
set from there. The resident installs it (through-word composed by the host
|
||||
layer), 'J' transfers control, and every command is exercised against the
|
||||
staged copy — the info block must come back byte-identical, the write guard
|
||||
must protect the staged copy's own slot and permit the resident's, and the
|
||||
staged copy must be able to rewrite the resident slot verbatim.
|
||||
"""Position-independence acceptance test: the identical binary, flashed one
|
||||
slot below the resident, must serve the complete command set from there. The
|
||||
info block must come back byte-identical, and the staged copy must be able to
|
||||
rewrite the resident verbatim - which is the whole of what relocation is for.
|
||||
|
||||
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
@@ -66,24 +63,20 @@ def main():
|
||||
if loader.read_eeprom(0, len(pattern)) != pattern:
|
||||
fail("EEPROM round-trip through the staged copy")
|
||||
|
||||
# The guard, both ways: its own slot refused (drained, unchanged), the
|
||||
# resident slot writable. The refusal leaves its drained words in the
|
||||
# SPM buffer, so the write that follows may take them — and clears
|
||||
# them by writing, so the retry must not.
|
||||
before = loader.read_flash(stage, page)
|
||||
loader.write_page(stage, bytes(page))
|
||||
if loader.read_flash(stage, page) != before:
|
||||
fail("the staged copy's guard let its own slot change")
|
||||
# The resident slot, written from the copy standing beside it - the
|
||||
# whole point of relocating. There is no running-slot guard to probe
|
||||
# against: nothing here refuses an address, and a copy that erases the
|
||||
# page it is executing from does not come back to report it.
|
||||
# pbselfwrite.py gates that direction on a device it is allowed to
|
||||
# destroy.
|
||||
marker = bytes((i * 3) & 0xFF for i in range(page))
|
||||
loader.write_page(base, marker)
|
||||
if loader.read_flash(base, page) != marker:
|
||||
loader.write_page(base, marker)
|
||||
if loader.read_flash(base, page) != marker:
|
||||
fail("the staged copy could not write the resident slot, even on retry")
|
||||
fail("the staged copy could not write the resident slot")
|
||||
|
||||
# Restore the resident image through the staged copy, then 'J' back
|
||||
# into it and prove it lives.
|
||||
resident = image + b"\xff" * (info.slot - len(image))
|
||||
resident = image + b"\xff" * (pb.SLOT - len(image))
|
||||
pb.write_differing(loader, base, resident)
|
||||
back_info = loader.enter_copy(base, 25)
|
||||
if back_info.raw != resident_info:
|
||||
|
||||
102
test/pbselfwrite.py
Normal file
102
test/pbselfwrite.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The seal, gated on the one command that proves it: erase the page the
|
||||
loader is executing from.
|
||||
|
||||
pureboot 9 dropped the running-slot write guard, so this command is now
|
||||
permitted - that is what lets a resident copy plant something in its own slot,
|
||||
which on a chip whose boot section *is* the loader slot is the only route a
|
||||
self-update has. Permitted means the loader must actually do it, and the only
|
||||
honest proof is the flash afterwards.
|
||||
|
||||
What stands in the guard's place is the seal, and the two halves are tested
|
||||
against each other here: the identical destructive command, refused when its
|
||||
seal is wrong and honoured when it is right. A test that only showed the
|
||||
refusal would pass just as well against a loader that ignores SPM entirely.
|
||||
|
||||
Usage: pbselfwrite.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <tool_py> <workdir>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sealed_frame(pb, op, space, address, count):
|
||||
"""A command header and its seal, built here rather than borrowed from the
|
||||
tool: this test is about what the loader accepts, and a probe that shares
|
||||
the host's frame builder cannot tell a wrong frame from a wrong loader."""
|
||||
head = bytes((op, pb.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
return head + bytes((seal,))
|
||||
|
||||
|
||||
def main():
|
||||
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
|
||||
base, page, baud = int(base_hex, 0), int(page), int(baud)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pbsim
|
||||
import pureboot as pb
|
||||
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
dump = os.path.join(workdir, "dump.bin")
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
|
||||
try:
|
||||
port = pb.Port(device.pty, baud)
|
||||
loader = pb.Loader(port)
|
||||
info = loader.connect(25)
|
||||
if info.version < pb.SEALED_LOADER:
|
||||
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
|
||||
|
||||
# Erase the first page of the running slot: the entry stub and the
|
||||
# command loop are both in it, so a loader that performs this does not
|
||||
# answer again. Nothing else in the protocol is as sharp a probe.
|
||||
frame = sealed_frame(pb, pb.OP_WRITE, pb.SP_SPM, base, pb.SPM_ERASE)
|
||||
|
||||
# Red: the same command with one bit wrong in its seal. Refused before
|
||||
# anything happens, and the loader is still there to say so.
|
||||
broken = bytearray(frame)
|
||||
broken[-1] ^= 0x01
|
||||
port.write(bytes(broken))
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("an unsealed erase of the running page was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("the loader did not re-prompt after refusing the erase")
|
||||
alive = loader.read_flash(base, 8)
|
||||
if alive == b"\xff" * 8:
|
||||
fail("the refused erase happened anyway - the running page reads erased")
|
||||
|
||||
# Green: the identical command, correctly sealed. Nothing is required
|
||||
# of the link from here on. The verdict is *issued* before the SPM, but
|
||||
# the erase takes the code that would have finished saying it, and how
|
||||
# much of it survives is the chip's business - an erase removes one page
|
||||
# and nothing else, so a loader whose command loop lives past the page
|
||||
# erased will prompt as usual where one with 128-byte pages goes with
|
||||
# the stub. None of that is the claim. The claim is that the erase
|
||||
# reached flash, and the dump is both the only witness for it and a
|
||||
# better one: it tells "accepted and performed" from "merely answered".
|
||||
port.write(frame)
|
||||
try:
|
||||
port.read_exact(2, 2.0)
|
||||
except pb.Error:
|
||||
pass
|
||||
port.close()
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
# Ground truth: the simulator's flash, not the loader's opinion of it.
|
||||
flash = open(dump, "rb").read()
|
||||
if flash[base : base + page] != b"\xff" * page:
|
||||
fail("the sealed erase did not reach flash - the running page is intact")
|
||||
print("pbselfwrite: the running slot is refused unsealed and erased sealed")
|
||||
|
||||
|
||||
main()
|
||||
@@ -8,14 +8,17 @@ import subprocess
|
||||
|
||||
|
||||
class Device:
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None):
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None,
|
||||
window=False):
|
||||
cmd = [binary]
|
||||
if link:
|
||||
cmd += ["-l", link]
|
||||
if window:
|
||||
cmd.append("-w") # report the first-transmit cycle, free-run idle
|
||||
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
|
||||
if reset_hex is not None or resume is not None:
|
||||
# Chips without a hardware boot section — the tinies and the
|
||||
# m48s — reset to address 0 like silicon; the boot-sectioned
|
||||
# Chips without a hardware boot section - the tinies and the
|
||||
# m48s - reset to address 0 like silicon; the boot-sectioned
|
||||
# megas re-vector to the loader base (BOOTRST).
|
||||
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
|
||||
cmd.append(reset_hex if reset_hex is not None else ("0" if patch else base_hex))
|
||||
@@ -41,7 +44,7 @@ class Device:
|
||||
self.proc.send_signal(signal.SIGUSR1)
|
||||
|
||||
def power_fail(self):
|
||||
"""SIGTERM: the runner dumps its flash and exits — the image a
|
||||
"""SIGTERM: the runner dumps its flash and exits - the image a
|
||||
restart resumes from."""
|
||||
self.stop()
|
||||
return self.dump
|
||||
|
||||
102
test/pbtest.py
102
test/pbtest.py
@@ -1,18 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end pureboot protocol test: spawn the simavr device, then drive it
|
||||
with the real host tool (pureboot.py, as a subprocess over the device's pty)
|
||||
through flash + EEPROM + fuse + hand-over scenarios, and cross-check
|
||||
the tool's view against the simulator's ground-truth memory dumps.
|
||||
"""End-to-end protocol test: drive the simavr device with the real host tool
|
||||
over its pty through flash, EEPROM, fuse and hand-over scenarios, and
|
||||
cross-check the tool's view against the simulator's ground-truth dumps.
|
||||
|
||||
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
||||
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link]
|
||||
|
||||
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...) for a
|
||||
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...), for a
|
||||
loader built off the chip's natural serial default.
|
||||
Exits 0 if every scenario passes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
@@ -22,7 +21,7 @@ def fail(message):
|
||||
|
||||
|
||||
def rjmp_decode(word, at, flash_words):
|
||||
"""Where an rjmp word at word-address `at` lands — deliberately written
|
||||
"""Where an rjmp word at word-address `at` lands - deliberately written
|
||||
against the instruction-set definition (12-bit signed offset), not with
|
||||
the host tool's encoder, so an encoding bug cannot verify itself."""
|
||||
if word & 0xF000 != 0xC000:
|
||||
@@ -57,30 +56,49 @@ def main():
|
||||
# the page byte is the wire's 0-means-256.
|
||||
mega = mcu.startswith("atmega")
|
||||
patch = not mega or mcu.startswith("atmega48")
|
||||
word_flash = base + 512 > 0x10000
|
||||
# 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)
|
||||
info = pb.Info(
|
||||
bytes([ord("P"), ord("B"), 1, 0, 0, 0, page & 0xFF])
|
||||
bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF])
|
||||
+ bytes([wire_base & 0xFF, wire_base >> 8, eeprom_size & 0xFF, eeprom_size >> 8])
|
||||
+ bytes([flags])
|
||||
)
|
||||
|
||||
# A shared-line link (RX == TX in the -l spec) makes the host read every
|
||||
# byte it sends back off the line; all sessions then discard the echo.
|
||||
one_wire = bool(link) and re.fullmatch(r"sw:([A-H][0-7]),\1(@[01])?", link) is not None
|
||||
extra = ("--one-wire",) if one_wire else ()
|
||||
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
||||
try:
|
||||
# Session 1: knock from reset, identify, program everything, stay.
|
||||
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
|
||||
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--info", "--fuses", "--flash", app_bin,
|
||||
"--eeprom", ee_path, "--stay")
|
||||
for needed in ("signature", "fuses", "verify:", "stays"):
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
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.
|
||||
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")
|
||||
# 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, *extra, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
|
||||
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
|
||||
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
|
||||
if out.count("verify:") != 2:
|
||||
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:
|
||||
@@ -99,9 +117,57 @@ def main():
|
||||
# land in the application, which banners on the same link.
|
||||
device.reset()
|
||||
port = pb.Port(device.pty, baud)
|
||||
if one_wire:
|
||||
port = pb.OneWirePort(port)
|
||||
try:
|
||||
loader = pb.Loader(port)
|
||||
loader.connect(15)
|
||||
live = loader.connect(15)
|
||||
# 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 fill addressed inside a page rather than at its base must still
|
||||
# consume exactly one page and prompt. The loader's own slot is the
|
||||
# target and the payload is erased-state bytes, so the probe can
|
||||
# disturb neither the image nor the page buffer it leaves behind: a
|
||||
# fill only loads the buffer, and nothing commits it. Hand-built
|
||||
# rather than through write_page(), which would follow the fill
|
||||
# with its erase and write; the point here is that the fill alone
|
||||
# consumes exactly one page whatever the address's low bits say.
|
||||
# Hand-sealed too - a protocol probe that borrowed the tool's own
|
||||
# frame builder could not tell a wrong frame from a wrong loader.
|
||||
wire = base + 1
|
||||
head = bytes((pb.OP_FILL, pb.selector(pb.SP_FLASH, wire), wire & 0xFF,
|
||||
(wire >> 8) & 0xFF, page & 0xFF))
|
||||
seal = pb.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
port.write(head + bytes((seal,)))
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("the loader refused a correctly sealed fill")
|
||||
port.write(b"\xff" * page)
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("unaligned fill did not return to the prompt")
|
||||
|
||||
# And the seal itself, red: one wrong bit in the address of that
|
||||
# same frame must be refused outright. The verdict has to arrive
|
||||
# *before* the page would have been sent - that ordering is what
|
||||
# keeps a refusal from turning into a desync - so the probe sends
|
||||
# no payload at all and expects the loader straight back at the
|
||||
# command level.
|
||||
broken = bytearray(head + bytes((seal,)))
|
||||
broken[2] ^= 0x01
|
||||
port.write(bytes(broken))
|
||||
if port.read_exact(1, 5.0) != pb.NAK:
|
||||
fail("a header with a broken seal was not refused")
|
||||
if port.read_exact(1, 5.0) != pb.PROMPT:
|
||||
fail("the loader did not re-prompt after refusing a broken seal")
|
||||
|
||||
loader.run_application()
|
||||
banner = port.read_exact(3, 5.0)
|
||||
if banner != b"APP":
|
||||
@@ -120,9 +186,9 @@ def main():
|
||||
|
||||
# The surgery, decoded independently: the patched vector must land on the
|
||||
# loader, the trampoline on the application's own entry (patched-vector
|
||||
# chips only — a boot-sectioned mega's word 0 stays the application's).
|
||||
# chips only - a boot-sectioned mega's word 0 stays the application's).
|
||||
if patch:
|
||||
flash_words = (base + 512) // 2
|
||||
flash_words = (base + pb.SLOT) // 2
|
||||
app = open(app_bin, "rb").read()
|
||||
word0 = flash_true[0] | (flash_true[1] << 8)
|
||||
if rjmp_decode(word0, 0, flash_words) != base // 2:
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-update end-to-end: an application is flashed, then the loader
|
||||
replaces itself with a re-timed build through the host tool's
|
||||
--update-loader — and the power-fail phases of that update are rehearsed by
|
||||
killing the simulated device mid-write, restarting it from its flash dump,
|
||||
and letting a re-run complete the update.
|
||||
"""Self-update end-to-end: an application is flashed, the loader replaces
|
||||
itself with a re-timed build, and every power-fail phase is rehearsed by
|
||||
killing the device mid-write, restarting it from its flash dump, and letting
|
||||
a re-run complete the update.
|
||||
|
||||
The boot-sectioned megas run the BOOTRST-unprogrammed profile (reset boots
|
||||
the application; the fixture application's 'L' jump is the application-owned
|
||||
loader entry), with --assume-fuses standing in for the fuse read simavr
|
||||
cannot model. The patched-vector chips — the tinies and the m48s — reset
|
||||
into a loader at every phase by construction: the t13a because its staging
|
||||
slot carries the reset vector itself, the others through the word-0 redirect
|
||||
the tool plants around the resident rewrite.
|
||||
The boot-sectioned megas run the BOOTRST-unprogrammed profile - reset boots
|
||||
the application, whose 'L' is the application-owned loader entry - with
|
||||
--assume-fuses standing in for the fuse read simavr cannot model.
|
||||
|
||||
Usage: pbupdate.py <device_bin> <pureboot_elf> <update_elf> <mcu> <hz>
|
||||
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
|
||||
@@ -44,13 +39,13 @@ class PowerFail(Exception):
|
||||
|
||||
def assumed_fuses(pb, image):
|
||||
"""Synthetic 'F' bytes for --assume-fuses: the smallest boot section
|
||||
covering both the resident and the staging slot (two slots — what a
|
||||
self-update needs), BOOTRST unprogrammed — the per-chip BOOTSZ ladder
|
||||
covering both the resident and the staging slot (two slots - what a
|
||||
self-update needs), BOOTRST unprogrammed - the per-chip BOOTSZ ladder
|
||||
and fuse byte come from the tool's own table, keyed by the update
|
||||
image's embedded signature."""
|
||||
info = pb.image_info(image)
|
||||
which, ladder = pb.BOOT_FUSE[bytes(info.signature[1:3])]
|
||||
bits = min((b for b in ladder if ladder[b] * 2 >= 2 * info.slot), key=lambda b: ladder[b])
|
||||
bits = min((b for b in ladder if ladder[b] * 2 >= 2 * pb.SLOT), key=lambda b: ladder[b])
|
||||
fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF))
|
||||
fuses[which] = 0xF8 | (bits << 1) | 1
|
||||
return bytes(fuses)
|
||||
@@ -93,16 +88,13 @@ def main():
|
||||
# The m48s are megas without a boot section: patched vector, no fuse
|
||||
# preflight, and the same reset-to-0 the tinies get.
|
||||
patch = not mega or mcu.startswith("atmega48")
|
||||
# Word-addressed (>64 KiB) chips use the 1 KiB slot; their loader base
|
||||
# itself sits beyond the 16-bit byte space — the 644's base + slot only
|
||||
# touches the 64 KiB boundary and stays byte-addressed.
|
||||
slot = 1024 if base >= 0x10000 and mega else 512
|
||||
reset_hex = "0" if mega else None # the boot-sectioned mega runs BOOTRST-unprogrammed here
|
||||
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
|
||||
|
||||
slot = pb.SLOT
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
objcopy = os.environ.get("PB_OBJCOPY", "avr-objcopy")
|
||||
images = {}
|
||||
@@ -146,7 +138,7 @@ def main():
|
||||
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=reset_hex)
|
||||
final = "v0"
|
||||
try:
|
||||
# The application first — its planner output is the restore truth.
|
||||
# The application first - its planner output is the restore truth.
|
||||
pbsim.run_tool(tool, device.pty, baud, "--flash", app_bin, "--stay")
|
||||
port, loader = connect(device)
|
||||
app_pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info)
|
||||
@@ -175,7 +167,7 @@ def main():
|
||||
# direction so the flash is never already at its target. The mega's
|
||||
# mid-resident-rewrite loss is exercised as a host crash instead:
|
||||
# with BOOTRST unprogrammed and the resident mid-erase, a power loss
|
||||
# there has no reset path into the staging copy — the documented
|
||||
# there has no reset path into the staging copy - the documented
|
||||
# cost of that profile (README).
|
||||
for kill_region, kill_hits, kill_device in (
|
||||
("stage", 2, True),
|
||||
|
||||
137
test/pbwindow.py
Normal file
137
test/pbwindow.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The activation window as a behavioral duration gate.
|
||||
|
||||
The loader's window is a counted poll loop whose per-poll cost is hand-counted
|
||||
in the source (`link::poll_cycles`) - but the loop compiles in consumer
|
||||
context, so only the running image can prove the count. This test installs a
|
||||
real application beside the loader (the host tool's own `plan_flash` supplies
|
||||
the reset-vector surgery), starts the simulator with the line idle, and reads
|
||||
the cycle of the first transmit activity: nothing talks until the window
|
||||
closes and the application banners, so that cycle *is* the window, give or
|
||||
take a banner lead measured in microseconds. Asserted at +/-2 % - one
|
||||
mis-counted cycle per poll shifts a window by 10 % and more.
|
||||
|
||||
Fixed-baud loaders declare their window in seconds (--seconds, the build's
|
||||
TIMEOUT). The autobaud loader's window is its calibration poll budget
|
||||
(--autobaud-polls); the seconds it amounts to are budget x 9 / f_cpu, the
|
||||
measured cost of the calibrate() wait loop this gate pins.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
from pbsim import Device
|
||||
|
||||
# The calibrate() budget loop's cycles per poll in the built image - what the
|
||||
# README's window arithmetic rests on, verified here. A measured fact, not a
|
||||
# design constant: the wait's exit branches land where the compiler's block
|
||||
# layout puts them, and the bounded-calibration rework moved the loop from
|
||||
# ten cycles to nine.
|
||||
AUTOBAUD_POLL_CYCLES = 9
|
||||
|
||||
|
||||
def load_tool(path):
|
||||
spec = importlib.util.spec_from_file_location("pureboot", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def compose_flash(pb, loader_bytes, app_bytes, mcu, base, page):
|
||||
"""The flash image a completed programming session leaves: application
|
||||
(with the tinies' vector surgery), loader at base - built through the
|
||||
host tool's own planner so the surgery is the shipped one, not a copy."""
|
||||
flash_size = base + pb.SLOT
|
||||
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
|
||||
word_flash = flash_size > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
raw = bytes((ord("P"), ord("B"), 5, 0, 0, 0, page & 0xFF,
|
||||
wire_base & 0xFF, wire_base >> 8, 0, 0, flags))
|
||||
info = pb.Info(raw)
|
||||
|
||||
flash = bytearray(b"\xff" * flash_size)
|
||||
for address, content in pb.plan_flash(app_bytes, info).items():
|
||||
flash[address:address + len(content)] = content
|
||||
flash[base:base + len(loader_bytes)] = loader_bytes
|
||||
return bytes(flash)
|
||||
|
||||
|
||||
def first_tx_cycle(device, deadline):
|
||||
"""The PB_WINDOW_TX report, or None. The runner prints it once."""
|
||||
stream = device.proc.stdout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
ready, _, _ = select.select([stream], [], [], remaining)
|
||||
if not ready:
|
||||
return None
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith("PB_WINDOW_TX"):
|
||||
return int(line.split()[1])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--device", required=True)
|
||||
parser.add_argument("--loader", required=True)
|
||||
parser.add_argument("--mcu", required=True)
|
||||
parser.add_argument("--hz", type=int, required=True)
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--page", type=int, required=True)
|
||||
parser.add_argument("--baud", type=int, required=True)
|
||||
parser.add_argument("--app", required=True)
|
||||
parser.add_argument("--tool", required=True)
|
||||
parser.add_argument("--workdir", required=True)
|
||||
parser.add_argument("--link", default=None)
|
||||
parser.add_argument("--seconds", type=float, default=None)
|
||||
parser.add_argument("--autobaud-polls", type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
if (args.seconds is None) == (args.autobaud_polls is None):
|
||||
parser.error("exactly one of --seconds / --autobaud-polls")
|
||||
|
||||
pb = load_tool(args.tool)
|
||||
base = int(args.base, 0)
|
||||
expected = (args.seconds if args.seconds is not None
|
||||
else args.autobaud_polls * AUTOBAUD_POLL_CYCLES / args.hz)
|
||||
|
||||
work = pathlib.Path(args.workdir)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
# Every loader target objcopies its slot content beside the ELF (.bin).
|
||||
loader_bytes = pathlib.Path(args.loader + ".bin").read_bytes()
|
||||
app_bytes = pathlib.Path(args.app).read_bytes()
|
||||
flash_file = work / "window-flash.bin"
|
||||
flash_file.write_bytes(compose_flash(pb, loader_bytes, app_bytes, args.mcu, base, args.page))
|
||||
|
||||
device = Device(args.device, args.loader, args.mcu, str(args.hz), args.base, args.page,
|
||||
args.baud, str(work / "window-dump.bin"), resume=str(flash_file),
|
||||
link=args.link, window=True)
|
||||
try:
|
||||
# Simulation speed is machine-dependent; a few hundred thousand
|
||||
# cycles per wall second is the pessimistic floor.
|
||||
budget = max(60.0, expected * args.hz / 300000)
|
||||
cycle = first_tx_cycle(device, time.monotonic() + budget)
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
if cycle is None:
|
||||
print(f" [FAIL] no transmit activity within {budget:.0f} s wall "
|
||||
f"(expected a {expected:.2f} s window)")
|
||||
return 1
|
||||
measured = cycle / args.hz
|
||||
error = (measured - expected) / expected
|
||||
ok = abs(error) <= 0.02
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] window {measured:.3f} s vs declared "
|
||||
f"{expected:.3f} s ({error:+.1%}, gate +/-2%)")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,461 +0,0 @@
|
||||
// simavr "device" for the pureboot protocol tests, every chip. Loads the
|
||||
// boot-linked ELF at the loader base, starts execution there (BOOTRST / the
|
||||
// patched vector are not what is under test), and exposes the loader's
|
||||
// serial link as a pty for the real host tool:
|
||||
//
|
||||
// - Hardware USART builds: simavr's uart_pty on the selected instance.
|
||||
// - Software UART builds: an 8N1 bridge between a pty and the GPIO pins,
|
||||
// timed against the simulated cycle counter (drives the loader's RX,
|
||||
// decodes its TX).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// missing module is supplied here: the SPM ioctl reads SPMCSR/Z/r1:r0 and
|
||||
// implements buffer fill, page erase, page write, and CTPB, completing
|
||||
// instantly. RFLB's LPM diversion (fuse readout) stays unmodeled, so the
|
||||
// 'F' command answers with flash bytes — the tests assert transport only.
|
||||
//
|
||||
// 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 <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>
|
||||
|
||||
#include "avr_eeprom.h"
|
||||
#include "avr_flash.h"
|
||||
#include "avr_ioport.h"
|
||||
#include "avr_uart.h"
|
||||
#include "sim_avr.h"
|
||||
#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;
|
||||
|
||||
static int parse_link(const char *spec)
|
||||
{
|
||||
if (strcmp(spec, "usart0") == 0 || strcmp(spec, "usart1") == 0) {
|
||||
link_software = 0;
|
||||
uart_digit = spec[5];
|
||||
return 0;
|
||||
}
|
||||
if (strncmp(spec, "sw", 2) == 0) {
|
||||
link_software = 1;
|
||||
if (spec[2] == '\0')
|
||||
return 0;
|
||||
if (sscanf(spec + 2, ":%c%d,%c%d", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit) == 4)
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// simavr 1.6's avr_flash PGERS handler erases spm_pagesize bytes starting at
|
||||
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly) —
|
||||
// hardware ignores the in-page bits (§26.8.1), so an erase issued with Z
|
||||
// anywhere inside the page wipes half the neighbouring page in simulation
|
||||
// only. Wrap the mega's registered flash ioctl and re-dispatch page erases
|
||||
// with Z forced to the page boundary; everything else passes through.
|
||||
//
|
||||
// A second gap on the boot-section-less m48s: their RWWSRE bit is the
|
||||
// temporary-buffer discard (Atmel-8271 §26.2/§26.3.1), but the stock model
|
||||
// gates its RWWSRE branch on AVR_SELFPROG_HAVE_RWW — absent on the m48
|
||||
// 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);
|
||||
|
||||
static int fixed_flash_ioctl(avr_io_t *io, 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);
|
||||
int result = mega_flash_ioctl(io, ctl, param);
|
||||
io->avr->data[30] = (uint8_t)z;
|
||||
io->avr->data[31] = (uint8_t)(z >> 8);
|
||||
return result;
|
||||
}
|
||||
if (ctl == AVR_IOCTL_FLASH_SPM && !(mega_flash->flags & AVR_SELFPROG_HAVE_RWW) &&
|
||||
(io->avr->data[mega_flash->r_spm] & 0x11) == 0x11) { // RWWSRE|SELFPRGEN: the m48 buffer discard
|
||||
for (int i = 0; i < mega_flash->spm_pagesize / 2; i++) {
|
||||
mega_flash->tmppage[i] = 0xffff;
|
||||
mega_flash->tmppage_used[i] = 0;
|
||||
}
|
||||
avr_regbit_clear(io->avr, mega_flash->selfprgen);
|
||||
return 0;
|
||||
}
|
||||
return mega_flash_ioctl(io, ctl, param);
|
||||
}
|
||||
|
||||
static void fix_mega_flash_erase(void)
|
||||
{
|
||||
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;
|
||||
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");
|
||||
}
|
||||
|
||||
static void request_reset(int sig)
|
||||
{
|
||||
(void)sig;
|
||||
reset_requested = 1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tiny NVM ---
|
||||
|
||||
typedef struct {
|
||||
avr_io_t io;
|
||||
uint8_t buffer[128];
|
||||
uint8_t used[128]; // a buffer word loads once until erased — like silicon
|
||||
unsigned page;
|
||||
} tiny_nvm_t;
|
||||
|
||||
static tiny_nvm_t nvm;
|
||||
|
||||
static int nvm_ioctl(avr_io_t *io, uint32_t ctl, void *param)
|
||||
{
|
||||
(void)param;
|
||||
if (ctl != AVR_IOCTL_FLASH_SPM)
|
||||
return -1;
|
||||
tiny_nvm_t *n = (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);
|
||||
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
|
||||
n->buffer[offset] = mcu->data[0];
|
||||
n->buffer[offset + 1] = mcu->data[1];
|
||||
n->used[offset] = 1;
|
||||
}
|
||||
} else if (command == 0x03) { // PGERS
|
||||
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);
|
||||
} else if (command == 0x11) { // CTPB
|
||||
memset(n->buffer, 0xff, n->page);
|
||||
memset(n->used, 0, n->page);
|
||||
}
|
||||
mcu->data[0x57] &= (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;
|
||||
|
||||
static int tx_level = 1, tx_active, tx_bit;
|
||||
static uint8_t tx_shift;
|
||||
|
||||
static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *param)
|
||||
{
|
||||
(void)mcu;
|
||||
(void)param;
|
||||
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
return when + bit_cycles;
|
||||
if (write(pty_master, &tx_shift, 1) != 1)
|
||||
fprintf(stderr, "device: pty write lost a byte\n");
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void tx_hook(avr_irq_t *irq, uint32_t value, void *param)
|
||||
{
|
||||
(void)irq;
|
||||
(void)param;
|
||||
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);
|
||||
}
|
||||
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;
|
||||
|
||||
static void rx_start_next(void);
|
||||
|
||||
static avr_cycle_count_t rx_step(avr_t *mcu, avr_cycle_count_t when, void *param)
|
||||
{
|
||||
(void)mcu;
|
||||
(void)param;
|
||||
if (rx_bit < 8) {
|
||||
avr_raise_irq(rx_pin, (rx_byte >> rx_bit) & 1);
|
||||
rx_bit++;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (rx_bit == 8) { // stop bit, plus one idle bit of margin
|
||||
avr_raise_irq(rx_pin, 1);
|
||||
rx_bit++;
|
||||
return when + 2 * bit_cycles;
|
||||
}
|
||||
rx_active = 0;
|
||||
rx_start_next();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void rx_start_next(void)
|
||||
{
|
||||
if (rx_active || rx_head == rx_tail)
|
||||
return;
|
||||
rx_byte = rx_queue[rx_head];
|
||||
rx_head = (rx_head + 1) % sizeof(rx_queue);
|
||||
rx_active = 1;
|
||||
rx_bit = 0;
|
||||
avr_raise_irq(rx_pin, 0); // start bit
|
||||
avr_cycle_timer_register(avr, bit_cycles, rx_step, NULL);
|
||||
}
|
||||
|
||||
// A reset abandons whatever the bridge was mid-transfer: bytes still queued
|
||||
// for a chip that no longer has the context to receive them meaningfully,
|
||||
// and a decode in progress on a TX line the reset may have already changed.
|
||||
// The pending cycle timers must go with the state: avr_reset drops the TX
|
||||
// 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)
|
||||
{
|
||||
avr_cycle_timer_cancel(avr, tx_sample, NULL);
|
||||
avr_cycle_timer_cancel(avr, rx_step, NULL);
|
||||
rx_head = rx_tail = 0;
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
tx_level = 1;
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
}
|
||||
|
||||
static void poll_pty(void)
|
||||
{
|
||||
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);
|
||||
if (next == rx_head)
|
||||
break; // full: the host will retry on timeout
|
||||
rx_queue[rx_tail] = chunk[i];
|
||||
rx_tail = next;
|
||||
}
|
||||
if (got > 0)
|
||||
rx_start_next();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main ---
|
||||
|
||||
static void finish(int sig)
|
||||
{
|
||||
(void)sig;
|
||||
if (dump_path) {
|
||||
FILE *f = fopen(dump_path, "wb");
|
||||
if (f) {
|
||||
fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
fclose(f);
|
||||
}
|
||||
avr_eeprom_desc_t ee = {.ee = NULL, .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");
|
||||
if (f) {
|
||||
fwrite(ee.ee, 1, ee.size, f);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!link_software)
|
||||
uart_pty_stop(&uart_pty);
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int link_given = 0;
|
||||
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");
|
||||
return 2;
|
||||
}
|
||||
link_given = 1;
|
||||
}
|
||||
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>"
|
||||
" [reset_hex] [resume_flash]\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1] (RX,TX); 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",
|
||||
argv[0]);
|
||||
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]);
|
||||
dump_path = argv[7];
|
||||
int is_mega = strncmp(mcu_name, "atmega", 6) == 0;
|
||||
if (!link_given)
|
||||
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
|
||||
|
||||
avr = avr_make_mcu_by_name(mcu_name);
|
||||
if (!avr) {
|
||||
fprintf(stderr, "device: no %s core\n", 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
|
||||
|
||||
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]);
|
||||
return 1;
|
||||
}
|
||||
fclose(f);
|
||||
} else {
|
||||
elf_firmware_t fw = {0};
|
||||
if (elf_read_firmware(argv[1], &fw) != 0) {
|
||||
fprintf(stderr, "device: cannot read %s\n", argv[1]);
|
||||
return 1;
|
||||
}
|
||||
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);
|
||||
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));
|
||||
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;
|
||||
avr_ioctl(avr, AVR_IOCTL_EEPROM_SET, &seed);
|
||||
}
|
||||
|
||||
// The megas carry simavr's avr_flash module (and its two gaps the wrap
|
||||
// above fixes); the tinies get the NVM module simavr lacks. Which serial
|
||||
// bridge runs is the link's business, not the chip class's.
|
||||
if (is_mega) {
|
||||
fix_mega_flash_erase();
|
||||
} else {
|
||||
nvm.page = page;
|
||||
memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
|
||||
nvm.io.kind = "tiny_nvm";
|
||||
nvm.io.ioctl = nvm_ioctl;
|
||||
avr_register_io(avr, &nvm.io);
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
} 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);
|
||||
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");
|
||||
return 1;
|
||||
}
|
||||
fcntl(pty_master, F_SETFL, O_NONBLOCK);
|
||||
printf("PB_PTY %s\n", ttyname(slave));
|
||||
}
|
||||
fflush(stdout);
|
||||
|
||||
signal(SIGTERM, finish);
|
||||
signal(SIGINT, finish);
|
||||
signal(SIGUSR1, request_reset); // an external reset line, for the tests
|
||||
|
||||
long since_poll = 0;
|
||||
for (;;) {
|
||||
int state = avr_run(avr);
|
||||
if (state == cpu_Done || state == cpu_Crashed)
|
||||
break;
|
||||
if (reset_requested) {
|
||||
reset_requested = 0;
|
||||
avr_reset(avr);
|
||||
avr->pc = reset_pc;
|
||||
if (!link_software) { // reset restores the pacing hack; re-clear it
|
||||
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();
|
||||
}
|
||||
}
|
||||
if (link_software && ++since_poll >= 2000) {
|
||||
since_poll = 0;
|
||||
poll_pty();
|
||||
// An unthrottled idle simulation runs the activation window out
|
||||
// from under the host's real-time knock cadence: a 1 MHz build's
|
||||
// 8 s window is 8 M cycles — tens of wall milliseconds — so a
|
||||
// first knock lost to an in-flight reset misses the window
|
||||
// entirely. Pace the simulation only while the bridge is fully
|
||||
// quiet (nothing decoding, nothing queued); transfers keep full
|
||||
// speed, and a quiet window stretches toward real time.
|
||||
if (!rx_active && !tx_active && rx_head == rx_tail)
|
||||
usleep(200);
|
||||
}
|
||||
}
|
||||
finish(0);
|
||||
return 0;
|
||||
}
|
||||
703
test/pureboot_device.cpp
Normal file
703
test/pureboot_device.cpp
Normal file
@@ -0,0 +1,703 @@
|
||||
// simavr "device" for the pureboot protocol tests, every chip. Loads the
|
||||
// boot-linked ELF at the loader base, starts execution there (BOOTRST / the
|
||||
// patched vector are not what is under test), and exposes the loader's
|
||||
// serial link as a pty for the real host tool:
|
||||
//
|
||||
// - Hardware USART builds: simavr's uart_pty on the selected instance.
|
||||
// - Software UART builds: an 8N1 bridge between a pty and the GPIO pins,
|
||||
// timed against the simulated cycle counter (drives the loader's RX,
|
||||
// decodes its TX).
|
||||
//
|
||||
// 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,
|
||||
// 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
|
||||
// missing module is supplied here: the SPM ioctl reads SPMCSR/Z/r1:r0 and
|
||||
// implements buffer fill, page erase, page write, and CTPB, completing
|
||||
// instantly. RFLB's LPM diversion (fuse readout) stays unmodeled, so the
|
||||
// 'F' command answers with flash bytes - the tests assert transport only.
|
||||
//
|
||||
// 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 <array>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <print>
|
||||
#include <string_view>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <pty.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"
|
||||
#include "avr_uart.h"
|
||||
#include "sim_avr.h"
|
||||
#include "sim_elf.h"
|
||||
#include "sim_io.h"
|
||||
#include "uart_pty.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
avr_t *avr;
|
||||
uart_pty_t uart_pty;
|
||||
bool link_software;
|
||||
avr_uart_t *hw_uart; // the pty-driven USART, for the datasheet-reset fix below
|
||||
char uart_digit = '0';
|
||||
char sw_rx_port = 'B', sw_tx_port = 'B';
|
||||
int sw_rx_bit = 0, sw_tx_bit = 1;
|
||||
char sw_tx_owner = 0; // the USART whose TXD the software link sits on
|
||||
const char *dump_path;
|
||||
std::uint32_t reset_pc;
|
||||
volatile std::sig_atomic_t reset_requested;
|
||||
|
||||
// -w: report the cycle of the first transmit activity, once. What the
|
||||
// activation-window gate reads - with an idle line and an application
|
||||
// installed, the first thing that ever talks is the application's banner,
|
||||
// so this cycle *is* the loader's window plus a banner lead measured in
|
||||
// microseconds. Idle pacing is skipped in this mode: there is no real-time
|
||||
// host in the loop, and a paced multi-second window would take hours.
|
||||
bool window_report;
|
||||
bool window_tx_seen;
|
||||
|
||||
void window_first_tx()
|
||||
{
|
||||
if (!window_report || window_tx_seen) {
|
||||
return;
|
||||
}
|
||||
window_tx_seen = true;
|
||||
std::println("PB_WINDOW_TX {}", avr->cycle);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
|
||||
{
|
||||
window_first_tx();
|
||||
}
|
||||
|
||||
// One-wire (RX == TX in the link spec): both directions on one GPIO line
|
||||
// idling on the firmware's pull-up. The bridge then follows the pin's
|
||||
// direction the way the real wiring does: it drives only while the
|
||||
// firmware's DDR bit reads input, decodes transitions as the firmware's
|
||||
// transmit only while the firmware owns the line, ignores its own raises
|
||||
// coming back through the shared irq - and echoes every byte it drives back
|
||||
// to the pty, which is what the host-side FTDI tie does and what the host
|
||||
// tool's --one-wire mode reads back and discards.
|
||||
bool link_one_wire;
|
||||
bool mcu_owns_line;
|
||||
bool self_drive;
|
||||
|
||||
int parse_link(std::string_view spec)
|
||||
{
|
||||
if (spec == "usart0" || spec == "usart1") {
|
||||
link_software = false;
|
||||
uart_digit = spec[5];
|
||||
return 0;
|
||||
}
|
||||
if (spec.starts_with("sw")) {
|
||||
link_software = true;
|
||||
if (spec.size() == 2) {
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
link_one_wire = sw_rx_port == sw_tx_port && sw_rx_bit == sw_tx_bit;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// simavr 1.6's avr_flash PGERS handler erases spm_pagesize bytes starting at
|
||||
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly) -
|
||||
// hardware ignores the in-page bits (section 26.8.1), so an erase issued with Z
|
||||
// anywhere inside the page wipes half the neighbouring page in simulation
|
||||
// only. Wrap the mega's registered flash ioctl and re-dispatch page erases
|
||||
// with Z forced to the page boundary; everything else passes through.
|
||||
//
|
||||
// A second gap on the boot-section-less m48s: their RWWSRE bit is the
|
||||
// temporary-buffer discard (Atmel-8271 section 26.2/section 26.3.1), but the stock model
|
||||
// gates its RWWSRE branch on AVR_SELFPROG_HAVE_RWW - absent on the m48
|
||||
// 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.
|
||||
avr_flash_t *mega_flash;
|
||||
int (*mega_flash_ioctl)(avr_io_t *io, std::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)) {
|
||||
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] = 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) &&
|
||||
(io->avr->data[mega_flash->r_spm] & 0x11) == 0x11) { // RWWSRE|SELFPRGEN: the m48 buffer discard
|
||||
for (int i = 0; i < mega_flash->spm_pagesize / 2; i++) {
|
||||
mega_flash->tmppage[i] = 0xffff;
|
||||
mega_flash->tmppage_used[i] = 0;
|
||||
}
|
||||
avr_regbit_clear(io->avr, mega_flash->selfprgen);
|
||||
return 0;
|
||||
}
|
||||
return mega_flash_ioctl(io, ctl, param);
|
||||
}
|
||||
|
||||
void fix_mega_flash_erase()
|
||||
{
|
||||
for (avr_io_t *io = avr->io_port; io; io = io->next) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
std::println(stderr, "device: no flash module to fix - SPM page erases may misalign");
|
||||
}
|
||||
|
||||
void request_reset(int)
|
||||
{
|
||||
reset_requested = 1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tiny NVM ---
|
||||
|
||||
struct tiny_nvm_t {
|
||||
avr_io_t io;
|
||||
std::array<std::uint8_t, 128> buffer;
|
||||
std::array<std::uint8_t, 128> used; // a buffer word loads once until erased - like silicon
|
||||
unsigned page;
|
||||
};
|
||||
|
||||
tiny_nvm_t nvm;
|
||||
|
||||
int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
|
||||
{
|
||||
if (ctl != AVR_IOCTL_FLASH_SPM) {
|
||||
return -1;
|
||||
}
|
||||
auto *n = reinterpret_cast<tiny_nvm_t *>(io);
|
||||
avr_t *mcu = io->avr;
|
||||
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
|
||||
n->buffer[offset] = mcu->data[0];
|
||||
n->buffer[offset + 1] = mcu->data[1];
|
||||
n->used[offset] = 1;
|
||||
}
|
||||
} else if (command == 0x03) { // PGERS
|
||||
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];
|
||||
}
|
||||
std::memset(n->buffer.data(), 0xff, n->page);
|
||||
std::memset(n->used.data(), 0, n->page);
|
||||
} else if (command == 0x11) { // CTPB
|
||||
std::memset(n->buffer.data(), 0xff, n->page);
|
||||
std::memset(n->used.data(), 0, n->page);
|
||||
}
|
||||
mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- GPIO bridge ---
|
||||
|
||||
int pty_master = -1;
|
||||
avr_irq_t *rx_pin; // the loader's RX (PB0), driven from the pty
|
||||
avr_cycle_count_t bit_cycles;
|
||||
|
||||
int tx_level = 1, tx_active, tx_bit;
|
||||
std::uint8_t tx_shift;
|
||||
|
||||
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
if (tx_bit < 0) {
|
||||
// Half a bit into the start bit: a real receiver re-samples here and
|
||||
// abandons a false start. The device's own init produces one - DDR
|
||||
// drives the pin low for the instructions until the idle level is
|
||||
// written - and without this check that glitch decodes as a stray
|
||||
// byte (and would read as first transmit activity under -w).
|
||||
if (tx_level) {
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
window_first_tx();
|
||||
tx_bit = 0;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (tx_bit < 8) {
|
||||
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8) {
|
||||
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) {
|
||||
std::println(stderr, "device: pty write lost a byte");
|
||||
}
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A USART owns its TxD pin whenever its transmitter is enabled, and the port
|
||||
// register cannot drive it (section 20.2 / Atmel-8271 section 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()
|
||||
{
|
||||
if (!tx_owner) {
|
||||
return false;
|
||||
}
|
||||
if (avr_regbit_get(avr, tx_owner->txen)) {
|
||||
return true;
|
||||
}
|
||||
// One-wire on the USART's RXD: RXEN forces the shared pin's direction to
|
||||
// input (section 20.7.3), so the firmware's drive goes nowhere until the
|
||||
// release - the receive-side twin of the TXD hold.
|
||||
return link_one_wire && avr_regbit_get(avr, tx_owner->rxen);
|
||||
}
|
||||
|
||||
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
|
||||
// whole register (section 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 (link_one_wire && (self_drive || !mcu_owns_line)) {
|
||||
// The bridge's own drive coming back through the shared irq, or a
|
||||
// transition while the line is the bridge's - either way not the
|
||||
// firmware talking: the decoder sees an idle line.
|
||||
tx_level = 1;
|
||||
return;
|
||||
}
|
||||
if (tx_pin_taken()) { // the USART holds the line; the port write goes nowhere
|
||||
tx_level = 1;
|
||||
return;
|
||||
}
|
||||
int level = value & 1;
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge, confirmed mid-bit
|
||||
tx_active = 1;
|
||||
tx_bit = -1;
|
||||
avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
|
||||
}
|
||||
tx_level = level;
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 8192> rx_queue;
|
||||
unsigned rx_head, rx_tail; // ring: head = next to send
|
||||
int rx_active, rx_bit;
|
||||
std::uint8_t rx_byte;
|
||||
|
||||
void rx_start_next();
|
||||
|
||||
// Every level the bridge itself puts on the line goes through here, so the
|
||||
// shared-pin decoder can tell its own drive from the firmware's.
|
||||
void bridge_drive(int level)
|
||||
{
|
||||
self_drive = true;
|
||||
avr_raise_irq(rx_pin, static_cast<std::uint32_t>(level));
|
||||
self_drive = false;
|
||||
}
|
||||
|
||||
avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
if (rx_bit < 8) {
|
||||
bridge_drive((rx_byte >> rx_bit) & 1);
|
||||
rx_bit++;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (rx_bit == 8) { // stop bit, plus one idle bit of margin
|
||||
bridge_drive(1);
|
||||
// The host-side tie: an FTDI adapter on a one-wire line reads every
|
||||
// byte it transmits - supply that echo, which the host tool's
|
||||
// --one-wire mode consumes as its wiring check.
|
||||
if (link_one_wire && write(pty_master, &rx_byte, 1) != 1) {
|
||||
std::println(stderr, "device: pty echo lost a byte");
|
||||
}
|
||||
rx_bit++;
|
||||
return when + 2 * bit_cycles;
|
||||
}
|
||||
rx_active = 0;
|
||||
rx_start_next();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void rx_start_next()
|
||||
{
|
||||
if (rx_active || rx_head == rx_tail) {
|
||||
return;
|
||||
}
|
||||
// The firmware is answering on the shared line: hold the byte - a real
|
||||
// host's transmission waits out the reply on the wire too. The next
|
||||
// poll_pty tick retries once the line is handed back.
|
||||
if (link_one_wire && mcu_owns_line) {
|
||||
return;
|
||||
}
|
||||
rx_byte = rx_queue[rx_head];
|
||||
rx_head = (rx_head + 1) % rx_queue.size();
|
||||
rx_active = 1;
|
||||
rx_bit = 0;
|
||||
bridge_drive(0); // start bit
|
||||
avr_cycle_timer_register(avr, bit_cycles, rx_step, nullptr);
|
||||
}
|
||||
|
||||
// The shared pin's direction is the line's ownership: DDR-out is the
|
||||
// firmware driving a frame, DDR-in hands the line back to the bridge.
|
||||
void on_ddr(avr_irq_t *, std::uint32_t value, void *)
|
||||
{
|
||||
const bool owns = (value >> sw_rx_bit) & 1;
|
||||
if (mcu_owns_line && !owns) {
|
||||
bridge_drive(1); // hand-back: a turn-based host idles here, and the cache stays truthful
|
||||
}
|
||||
mcu_owns_line = owns;
|
||||
// A byte held back while the firmware answered starts from the next
|
||||
// poll_pty tick, never from inside the DDR write itself - the port
|
||||
// model's own pull-up re-derivation runs right after this notify and
|
||||
// would erase a start edge raised here.
|
||||
}
|
||||
|
||||
// A reset abandons whatever the bridge was mid-transfer: bytes still queued
|
||||
// for a chip that no longer has the context to receive them meaningfully,
|
||||
// and a decode in progress on a TX line the reset may have already changed.
|
||||
// The pending cycle timers must go with the state: avr_reset drops the TX
|
||||
// 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.
|
||||
void bridge_reset()
|
||||
{
|
||||
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;
|
||||
tx_level = 1;
|
||||
mcu_owns_line = false; // avr_reset zeroed DDR: every pin reads input again
|
||||
// Re-drive the idle line through a forced transition: ioport pin irqs are
|
||||
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
|
||||
// keeps its pre-reset cached value - so a plain raise(1) against a cached
|
||||
// 1 is dropped and the device reads the line stuck low. A loader entering
|
||||
// calibration on that line measures reset-to-first-edge as one giant
|
||||
// pulse and mis-locks or boots the application on the first real knock.
|
||||
// No cycles run between the two raises, so the device only ever sees the
|
||||
// final idle-high.
|
||||
bridge_drive(0);
|
||||
bridge_drive(1);
|
||||
}
|
||||
|
||||
void poll_pty()
|
||||
{
|
||||
std::array<std::uint8_t, 256> chunk;
|
||||
ssize_t got = read(pty_master, chunk.data(), chunk.size());
|
||||
for (ssize_t i = 0; i < got; i++) {
|
||||
unsigned next = (rx_tail + 1) % rx_queue.size();
|
||||
if (next == rx_head) {
|
||||
break; // full: the host will retry on timeout
|
||||
}
|
||||
rx_queue[rx_tail] = chunk[i];
|
||||
rx_tail = next;
|
||||
}
|
||||
// Unconditional: a byte held back while the firmware owned a shared
|
||||
// line restarts from here once the hand-back has happened.
|
||||
rx_start_next();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main ---
|
||||
|
||||
[[noreturn]] void finish(int)
|
||||
{
|
||||
if (dump_path) {
|
||||
std::FILE *f = std::fopen(dump_path, "wb");
|
||||
if (f) {
|
||||
std::fwrite(avr->flash, 1, avr->flashend + 1, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
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) {
|
||||
std::array<char, 512> path;
|
||||
std::snprintf(path.data(), path.size(), "%s.eeprom", dump_path);
|
||||
f = std::fopen(path.data(), "wb");
|
||||
if (f) {
|
||||
std::fwrite(ee.ee, 1, ee.size, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!link_software) {
|
||||
uart_pty_stop(&uart_pty);
|
||||
}
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
bool link_given = false;
|
||||
for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) {
|
||||
if (opt == 'w') {
|
||||
window_report = true;
|
||||
continue;
|
||||
}
|
||||
if (opt != 'l' || parse_link(optarg) != 0) {
|
||||
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
|
||||
return 2;
|
||||
}
|
||||
link_given = true;
|
||||
}
|
||||
int args = argc - optind;
|
||||
if (args < 7 || args > 9) {
|
||||
std::print(stderr,
|
||||
"usage: {} [-l link] [-w] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
" [reset_hex] [resume_flash]\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
|
||||
" them); default: the chip's own\n"
|
||||
" -w: print PB_WINDOW_TX <cycle> at the first transmit activity and\n"
|
||||
" free-run idle time (window measurement mode)\n"
|
||||
" reset_hex: reset vector (default: base with a boot section, else 0)\n"
|
||||
" resume_flash: raw full-flash image loaded instead of the ELF - a prior\n"
|
||||
" run's dump, for power-fail resume tests\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
argv += optind - 1; // argv[1] is the ELF again, whatever was parsed
|
||||
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];
|
||||
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.data());
|
||||
if (!avr) {
|
||||
std::println(stderr, "device: no {} core", mcu_name);
|
||||
return 1;
|
||||
}
|
||||
avr_init(avr);
|
||||
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.
|
||||
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;
|
||||
}
|
||||
std::fclose(f);
|
||||
} else {
|
||||
elf_firmware_t fw{};
|
||||
if (elf_read_firmware(argv[1], &fw) != 0) {
|
||||
std::println(stderr, "device: cannot read {}", argv[1]);
|
||||
return 1;
|
||||
}
|
||||
// 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.
|
||||
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).
|
||||
std::array<std::uint8_t, 1024> blank;
|
||||
std::memset(blank.data(), 0xff, blank.size());
|
||||
avr_eeprom_desc_t seed = {
|
||||
.ee = blank.data(),
|
||||
.offset = 0,
|
||||
.size = 0,
|
||||
};
|
||||
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= blank.size()) {
|
||||
seed.ee = blank.data();
|
||||
avr_ioctl(avr, AVR_IOCTL_EEPROM_SET, &seed);
|
||||
}
|
||||
|
||||
// The megas carry simavr's avr_flash module (and its two gaps the wrap
|
||||
// above fixes); the tinies get the NVM module simavr lacks. Which serial
|
||||
// bridge runs is the link's business, not the chip class's.
|
||||
if (is_mega) {
|
||||
fix_mega_flash_erase();
|
||||
} else {
|
||||
nvm.page = page;
|
||||
std::memset(nvm.buffer.data(), 0xff, nvm.buffer.size());
|
||||
nvm.io.kind = "tiny_nvm";
|
||||
nvm.io.ioctl = nvm_ioctl;
|
||||
avr_register_io(avr, &nvm.io);
|
||||
}
|
||||
|
||||
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.
|
||||
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);
|
||||
// simavr leaves TXEN set out of reset where silicon clears the whole
|
||||
// UCSR#B (section 20.11.3). Harmless to a loader that enables TXEN itself -
|
||||
// but a half-duplex build's receiver-only init then *drops* TXEN,
|
||||
// and this uart model clears UDRE on that edge and never re-raises
|
||||
// it on a later enable: the first transmitter after the hand-over
|
||||
// waits UDRE forever, a wedge silicon does not have. Start from the
|
||||
// datasheet's zero, as the software bridge's tx-owner model does.
|
||||
for (avr_io_t *io = avr->io_port; io; io = io->next) {
|
||||
if (io->kind && std::string_view{io->kind} == "uart" &&
|
||||
reinterpret_cast<avr_uart_t *>(io)->name == uart_digit) {
|
||||
hw_uart = reinterpret_cast<avr_uart_t *>(io);
|
||||
}
|
||||
}
|
||||
if (hw_uart) {
|
||||
avr_regbit_clear(avr, hw_uart->txen);
|
||||
}
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, uart_digit);
|
||||
if (window_report) {
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_UART_GETIRQ(uart_digit), UART_IRQ_OUTPUT),
|
||||
window_uart_hook, nullptr);
|
||||
}
|
||||
std::println("PB_PTY {}", uart_pty.pty.slavename);
|
||||
} else {
|
||||
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
|
||||
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);
|
||||
if (link_one_wire) {
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), IOPORT_IRQ_DIRECTION_ALL),
|
||||
on_ddr, nullptr);
|
||||
}
|
||||
bridge_drive(1); // idle line
|
||||
|
||||
int slave;
|
||||
struct termios raw;
|
||||
cfmakeraw(&raw);
|
||||
if (openpty(&pty_master, &slave, nullptr, &raw, nullptr) != 0) {
|
||||
std::println(stderr, "device: openpty failed");
|
||||
return 1;
|
||||
}
|
||||
fcntl(pty_master, F_SETFL, O_NONBLOCK);
|
||||
std::println("PB_PTY {}", ttyname(slave));
|
||||
}
|
||||
std::fflush(stdout);
|
||||
|
||||
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 (;;) {
|
||||
int state = avr_run(avr);
|
||||
if (state == cpu_Done || state == cpu_Crashed) {
|
||||
break;
|
||||
}
|
||||
if (reset_requested) {
|
||||
reset_requested = 0;
|
||||
avr_reset(avr);
|
||||
avr->pc = reset_pc;
|
||||
if (!link_software) { // reset restores the pacing hack; re-clear it
|
||||
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);
|
||||
if (hw_uart) { // and simavr's bogus reset TXEN (section 20.11.3: zero)
|
||||
avr_regbit_clear(avr, hw_uart->txen);
|
||||
}
|
||||
} else {
|
||||
bridge_reset();
|
||||
reset_tx_owner();
|
||||
}
|
||||
}
|
||||
if (link_software && ++since_poll >= 2000) {
|
||||
since_poll = 0;
|
||||
poll_pty();
|
||||
// An unthrottled idle simulation runs the activation window out
|
||||
// from under the host's real-time knock cadence: a 1 MHz build's
|
||||
// 8 s window is 8 M cycles - tens of wall milliseconds - so a
|
||||
// first knock lost to an in-flight reset misses the window
|
||||
// entirely. Pace the simulation only while the bridge is fully
|
||||
// quiet (nothing decoding, nothing queued); transfers keep full
|
||||
// speed, and a quiet window stretches toward real time.
|
||||
if (!window_report && !rx_active && !tx_active && rx_head == rx_tail) {
|
||||
usleep(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(0);
|
||||
}
|
||||
212
test/test_handshake.py
Normal file
212
test/test_handshake.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-tool activation handshake: bounded against a line that misbehaves.
|
||||
|
||||
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
|
||||
trailing bytes before it asks for the identity. That drain must be bounded: a
|
||||
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.
|
||||
|
||||
The handshake must also survive its own leftovers: after `--stay` the loader's
|
||||
final prompt can still be in the USB pipeline when the next invocation opens
|
||||
the port, and on a board wired to reset on open, that opening starts a fresh
|
||||
activation window the stale prompt then betrays - the tool commits to an
|
||||
identity read against a device that never heard its knock, and what it finally
|
||||
collects is the application's banner. StaleDTRPort is that moment as a port.
|
||||
|
||||
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
|
||||
beside pureboot.planner.
|
||||
"""
|
||||
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: a prompt to the knock, then quiet, then the
|
||||
slim identity (version 5 + m328p signature) and a closing prompt."""
|
||||
|
||||
def __init__(self):
|
||||
self.pending = b""
|
||||
self.exacts = 0
|
||||
|
||||
def flush_input(self):
|
||||
self.pending = b""
|
||||
|
||||
def write(self, data):
|
||||
if b"p" in data:
|
||||
self.pending = b"+" # the prompt answers the knock, nothing else
|
||||
|
||||
def read_available(self, wait):
|
||||
data, self.pending = self.pending, b""
|
||||
return data
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
self.exacts += 1
|
||||
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
|
||||
|
||||
|
||||
class StaleDTRPort:
|
||||
"""`--stay`, then a fresh invocation on a board that resets when its port
|
||||
opens. Three facts of that moment, all timed from the open: the previous
|
||||
session's final prompt is still in transit and lands only after the
|
||||
opening flush has already run; the reset holds the device off the line
|
||||
at first, eating anything written before it completes; and the fresh
|
||||
window is finite - once it expires the application boots and prints a
|
||||
banner whose bytes are what a pending identity read collects. A
|
||||
handshake that trusts the stale prompt spends the whole window waiting
|
||||
on a device that never heard its knock; one that drains the line first
|
||||
knocks into the real window and connects."""
|
||||
|
||||
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
|
||||
READY_AT = 0.05 # reset complete, activation window opens
|
||||
WINDOW = 1.0 # window length; expiry boots the application
|
||||
|
||||
def __init__(self):
|
||||
self.t0 = time.monotonic()
|
||||
# (visible-from, bytes): the line as a timed queue.
|
||||
self.queue = [(self.t0 + self.STALE_AT, b"+")]
|
||||
self.armed = False # a 'p' heard inside the window arms 'b'
|
||||
self.booted = False
|
||||
|
||||
def _boot_check(self):
|
||||
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
|
||||
self.booted = True
|
||||
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
|
||||
b"W r libavr tempmon\r\n"))
|
||||
|
||||
def _visible(self):
|
||||
self._boot_check()
|
||||
now = time.monotonic()
|
||||
return b"".join(d for t, d in self.queue if t <= now)
|
||||
|
||||
def _consume(self, n):
|
||||
now = time.monotonic()
|
||||
left = []
|
||||
for t, d in self.queue:
|
||||
if t <= now and n:
|
||||
take = min(n, len(d))
|
||||
d = d[take:]
|
||||
n -= take
|
||||
if d:
|
||||
left.append((t, d))
|
||||
self.queue = left
|
||||
|
||||
def flush_input(self):
|
||||
self._consume(len(self._visible()))
|
||||
|
||||
def write(self, data):
|
||||
self._boot_check()
|
||||
now = time.monotonic()
|
||||
if now < self.t0 + self.READY_AT or self.booted:
|
||||
return # still in reset, or the application owns the line
|
||||
if b"p" in data:
|
||||
self.armed = True
|
||||
self.queue.append((now + 0.01, b"+"))
|
||||
if b"b" in data and self.armed:
|
||||
# The slim identity (version 5 + m328p signature) and a prompt.
|
||||
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
|
||||
|
||||
def read_available(self, wait):
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
data = self._visible()
|
||||
if data:
|
||||
self._consume(len(data))
|
||||
return data
|
||||
if time.monotonic() >= deadline:
|
||||
return b""
|
||||
time.sleep(0.005)
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
data = b""
|
||||
while len(data) < count:
|
||||
visible = self._visible()
|
||||
if visible:
|
||||
take = visible[:count - len(data)]
|
||||
self._consume(len(take))
|
||||
data += take
|
||||
elif time.monotonic() >= deadline:
|
||||
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
|
||||
else:
|
||||
time.sleep(0.005)
|
||||
return data
|
||||
|
||||
|
||||
def terminates(port, wait, budget):
|
||||
"""Run connect_autobaud in a thread; True if it returns/raises within
|
||||
`budget` seconds rather than hanging."""
|
||||
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)
|
||||
|
||||
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
|
||||
# fresh window - the pre-knock drain absorbs it and the first real knock
|
||||
# lands inside the window.
|
||||
try:
|
||||
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
|
||||
except pb.Error as failed:
|
||||
print(f" ({failed})")
|
||||
stale_ok = False
|
||||
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
|
||||
|
||||
print(f"\n {P} passed, {F} failed")
|
||||
return 1 if F else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-tool unit tests — the pure planning and policy logic, no simulator:
|
||||
the flash-programming orders and their recovery properties, the reset-vector
|
||||
surgery, the staging-slot composition, the mega boot-fuse decode, and the
|
||||
update preflight's error/warning matrix (fuse combinations simavr cannot
|
||||
model reach it here as synthetic bytes).
|
||||
"""Host-tool unit tests - the planning and policy logic, no simulator:
|
||||
programming orders and their recovery properties, the reset-vector surgery,
|
||||
the staging composition, the boot-fuse decode, and the update preflight over
|
||||
fuse combinations simavr cannot model.
|
||||
|
||||
Usage: test_planner.py <tool_py>
|
||||
"""
|
||||
@@ -27,14 +26,21 @@ def expect_error(what, fn, *needles):
|
||||
fail(f"{what}: no error raised")
|
||||
|
||||
|
||||
def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_flash=False):
|
||||
def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_flash=False, version=None):
|
||||
scale = 2 if word_flash else 1
|
||||
wire_base = base // scale
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
raw = bytes((0x50, 0x42, 1, *signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
|
||||
0, 2, flags))
|
||||
# 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,
|
||||
eeprom & 0xFF, eeprom >> 8, flags))
|
||||
info = pb.Info(raw)
|
||||
assert info.flash_size == flash
|
||||
if info.flash_size != flash:
|
||||
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
|
||||
return info
|
||||
|
||||
|
||||
@@ -55,7 +61,22 @@ def main():
|
||||
tiny = info_of(pb, 0x1E00, 64, True, 0x2000)
|
||||
mega = info_of(pb, 0x7E00, 128, False, 0x8000, signature=(0x1E, 0x95, 0x0F))
|
||||
|
||||
# mega_boot: BOOTSZ words and the BOOTRST sense per chip — the fuse byte
|
||||
# Versioning: the block's third byte is the loader's version, and the tool
|
||||
# speaks a window of them. Every version in the window decodes, so an older
|
||||
# deployed loader stays usable; one above the window is refused by name,
|
||||
# since which version changed the protocol is knowledge only the tool
|
||||
# holds, and it holds none about a version it has never heard of.
|
||||
for version in range(pb.OLDEST_LOADER, pb.NEWEST_LOADER + 1):
|
||||
if info_of(pb, 0x1E00, 64, True, 0x2000, version=version).version != version:
|
||||
fail(f"pureboot {version} does not decode")
|
||||
expect_error(
|
||||
"unknown loader version",
|
||||
lambda: info_of(pb, 0x1E00, 64, True, 0x2000, version=pb.NEWEST_LOADER + 1),
|
||||
f"pureboot {pb.NEWEST_LOADER + 1}",
|
||||
"newer tool",
|
||||
)
|
||||
|
||||
# mega_boot: BOOTSZ words and the BOOTRST sense per chip - the fuse byte
|
||||
# index (EXTENDED on the x8 line except the m328s' HIGH, HIGH elsewhere)
|
||||
# and the per-family ladders (Atmel-2486/2466/2503/2545/8271/DS40002065/
|
||||
# 8272/8011/2593/42719). Synthetic 'F' replies: only the boot byte
|
||||
@@ -79,9 +100,7 @@ def main():
|
||||
((0x1E, 0x97, 0x05), 0x20000, 3, {0b11: 0x1FC00, 0b10: 0x1F800, 0b01: 0x1F000, 0b00: 0x1E000}), # 1284P
|
||||
)
|
||||
for signature, flash, which, ladder in cases:
|
||||
# Word-addressed chips carry the 1 KiB slot (their smallest boot sector).
|
||||
slot = 1024 if flash > 0x10000 else 512
|
||||
chip = info_of(pb, flash - slot, 128 if flash < 0x20000 else 0, False, flash,
|
||||
chip = info_of(pb, flash - pb.SLOT, 128 if flash < 0x20000 else 0, False, flash,
|
||||
signature=signature, word_flash=flash > 0x10000)
|
||||
for bits, start in ladder.items():
|
||||
fuses = bytearray((0xFF, 0xFF, 0xFF, 0xFF))
|
||||
@@ -94,14 +113,16 @@ def main():
|
||||
if prog or at != start:
|
||||
fail(f"mega_boot {signature[1]:02x}{signature[2]:02b} unprogrammed: {prog} {at:#07x}")
|
||||
|
||||
# Word-addressed info decode: the 1284P's base/page ride the wire scaled,
|
||||
# and its slot is 1 KiB.
|
||||
big = info_of(pb, 0x1FC00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True)
|
||||
if big.page != 256 or big.base != 0x1FC00 or big.stage != 0x1F800 or big.slot != 1024:
|
||||
# Word-addressed info decode: the 1284P's base and page ride the wire
|
||||
# scaled - a 17-bit base halved into the block's two bytes, a 256-byte page
|
||||
# spelled 0 - and its slot is the same 512 bytes as everywhere else, so its
|
||||
# staging slot lands inside the 1 KiB minimum boot section.
|
||||
big = info_of(pb, 0x1FE00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True)
|
||||
if big.page != 256 or big.base != 0x1FE00 or big.stage != 0x1FC00:
|
||||
fail(f"word-addressed info decode: page {big.page}, base {big.base:#x}, stage {big.stage:#x}")
|
||||
|
||||
# Surgery: word 0 lands on the loader, the trampoline on the original
|
||||
# entry — checked with an independent decoder.
|
||||
# entry - checked with an independent decoder.
|
||||
app = bytes((0xC0 | 0x00, 0xC0)) + bytes((0x12,)) * 300 # rjmp .+0x00C0... entry word 0xC0C0
|
||||
entry = rjmp_decode(app[0] | (app[1] << 8), 0, tiny.flash_size // 2)
|
||||
pages = pb.plan_flash(app, tiny)
|
||||
@@ -137,7 +158,7 @@ def main():
|
||||
|
||||
# Staging content: the identical image plus the through-word on a
|
||||
# patched-vector chip; hard size clamps either way.
|
||||
image = bytes(range(256)) * 2 # 512 B — too big for a tiny slot
|
||||
image = bytes(range(256)) * 2 # 512 B - too big for a tiny slot
|
||||
expect_error("tiny staging size", lambda: pb.staging_content(image, tiny), "510")
|
||||
staged = pb.staging_content(image[:508], tiny)
|
||||
through = staged[510] | (staged[511] << 8)
|
||||
@@ -147,13 +168,24 @@ 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
|
||||
# identified and installed like any other.
|
||||
old = info_of(pb, 0x1E00, 64, True, 0x2000, version=pb.OLDEST_LOADER)
|
||||
found_old = pb.image_info(bytes((0xAA,)) * 10 + old.raw)
|
||||
if found_old is None or found_old.version != pb.OLDEST_LOADER:
|
||||
fail("image_info misses an older loader's block")
|
||||
|
||||
# loader_image must peel a padded image down to the slot content: a raw
|
||||
# .bin padded from address 0 (or a whole-flash read-back with the loader
|
||||
@@ -194,6 +226,15 @@ def main():
|
||||
if pb.update_preflight(bytes((0xAA,)) * 8 + tiny.raw, tiny, None) != []:
|
||||
fail("tiny preflight should pass without fuses")
|
||||
|
||||
# The 1284s' smallest boot section (512 words) is exactly the resident
|
||||
# slot plus its staging slot, so self-update is possible at the minimum
|
||||
# BOOTSZ - no fuse step up, the 644's geometry. That holds only while a
|
||||
# slot is 512 B: at 1 KiB the staging slot would fall outside the section
|
||||
# and the preflight would refuse.
|
||||
notes = pb.update_preflight(bytes((0xAA,)) * 8 + big.raw, big, fuses(0xFE))
|
||||
if not any("staging slot" in n for n in notes):
|
||||
fail(f"1284 minimum-BOOTSZ notes: {notes}")
|
||||
|
||||
# The walk-region refusal: BOOTRST aimed below the loader plus app data
|
||||
# in the walk span errors without --force; erased spans and unprogrammed
|
||||
# BOOTRST pass.
|
||||
@@ -250,6 +291,68 @@ def main():
|
||||
if device.writes != pb.RETRIES + 1:
|
||||
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
|
||||
|
||||
# The knock handshake against a device that is not listening yet - the
|
||||
# state a port open leaves behind: it resets the chip into a fresh
|
||||
# activation window while the previous session's prompt is still in
|
||||
# flight, so the first knock is lost and a prompt arrives anyway.
|
||||
class FakePort:
|
||||
"""A loader in its activation window, plus `lost` leading writes the
|
||||
reset swallows and one stale prompt still on the wire."""
|
||||
|
||||
def __init__(self, info_raw, lost=0, stale=b"", active=False):
|
||||
self.info_raw = info_raw
|
||||
self.lost = lost
|
||||
self.inflight = bytearray(stale)
|
||||
self.rx = bytearray()
|
||||
self.active = active
|
||||
self.last = None
|
||||
|
||||
def flush_input(self):
|
||||
self.rx.clear()
|
||||
|
||||
def write(self, data):
|
||||
if self.lost:
|
||||
self.lost -= 1
|
||||
return
|
||||
for byte in bytes(data):
|
||||
if not self.active:
|
||||
self.active = self.last == ord("p") and byte == ord("b")
|
||||
self.last = byte
|
||||
if self.active:
|
||||
self.rx += pb.PROMPT
|
||||
elif byte == ord("b"):
|
||||
self.rx += self.info_raw + pb.PROMPT
|
||||
else:
|
||||
self.rx += pb.PROMPT
|
||||
|
||||
def read_available(self, wait):
|
||||
self.rx = self.inflight + self.rx # the stale prompt lands late
|
||||
self.inflight.clear()
|
||||
out, self.rx = bytes(self.rx), bytearray()
|
||||
return out
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
if len(self.rx) < count:
|
||||
raise pb.Error(f"timeout: got {len(self.rx)} of {count} bytes")
|
||||
out, self.rx = bytes(self.rx[:count]), self.rx[count:]
|
||||
return out
|
||||
|
||||
raw = info_of(pb, 0x7E00, 128, False, 0x8000).raw
|
||||
for what, port in (
|
||||
("clean window", FakePort(raw)),
|
||||
("stale prompt over a lost knock", FakePort(raw, lost=1, stale=pb.PROMPT)),
|
||||
("live session", FakePort(raw, active=True)),
|
||||
):
|
||||
info = pb.Loader(port).connect(5)
|
||||
if info.raw != raw:
|
||||
fail(f"connect ({what}) returned {info.raw.hex()}")
|
||||
|
||||
# A device that never answers still says so, and a version the tool cannot
|
||||
# speak is reported as such rather than retried into a timeout.
|
||||
expect_error("dead device", lambda: pb.Loader(FakePort(raw, lost=99)).connect(0), "no answer")
|
||||
old = bytes(raw[:2]) + bytes((pb.NEWEST_LOADER + 1,)) + bytes(raw[3:])
|
||||
expect_error("unspeakable version", lambda: pb.Loader(FakePort(old)).connect(5), "needs a newer tool")
|
||||
|
||||
print("test_planner: all planner and policy checks pass")
|
||||
|
||||
|
||||
|
||||
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())
|
||||
@@ -144,7 +144,7 @@ class Host:
|
||||
self._expect(CONFIRM, "C end")
|
||||
return echo
|
||||
|
||||
# Activation when the config page carries a password: 3×'@' then the
|
||||
# Activation when the config page carries a password: 3x'@' then the
|
||||
# password bytes, then the info block + mainloop '!'.
|
||||
def activate_password(self, password):
|
||||
self.s.reset_input_buffer()
|
||||
@@ -167,6 +167,20 @@ class Host:
|
||||
self._expect(CONFIRM, "emergency mainloop ready")
|
||||
|
||||
|
||||
# A wrong password byte hangs the loader, still draining the line. Two
|
||||
# things must not happen: it must not activate, and it must not fall
|
||||
# through to the emergency erase - a byte the gate has already refused
|
||||
# reaching the erase would let a guess wipe the part.
|
||||
def refuse_password(self, byte):
|
||||
self.s.reset_input_buffer()
|
||||
self.s.write(bytes([KNOCK, KNOCK, KNOCK, byte]))
|
||||
return self.s.read(1)
|
||||
|
||||
def say(self, byte):
|
||||
self.s.write(bytes([byte]))
|
||||
return self.s.read(1)
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
raise AssertionError(msg)
|
||||
@@ -181,7 +195,7 @@ PW_BYTES = bytes([0x50, 0x57])
|
||||
|
||||
def scenario_roundtrip(host):
|
||||
"""Activation + info block + flash/EEPROM/config read-write round-trips, on
|
||||
a device with a blank (erased) config page — the usual no-password case."""
|
||||
a device with a blank (erased) config page - the usual no-password case."""
|
||||
info = host.activate()
|
||||
check(info[0:3] == b"TSB", f"magic 'TSB' (got {info[0:3]!r})")
|
||||
check(info[6:9] == bytes([0x1E, 0x95, 0x0F]), f"signature 1E 95 0F (got {info[6:9].hex()})")
|
||||
@@ -219,6 +233,15 @@ def scenario_emergency(host):
|
||||
check(host.read_eeprom(1) == b"\xff" * PAGE, "EEPROM wiped")
|
||||
|
||||
|
||||
def scenario_wrong_password(host):
|
||||
"""A wrong password byte neither activates the loader nor opens the
|
||||
emergency erase behind it - the oracle carries a dedicated fix for the
|
||||
second, and nothing here exercised either half."""
|
||||
check(host.refuse_password(PW_BYTES[0] ^ 1) == b"", "a wrong password byte draws no reply")
|
||||
check(host.say(0x00) == b"", "a 0 byte after it does not request the erase")
|
||||
check(host.say(CONFIRM) == b"", "and neither does a confirm")
|
||||
|
||||
|
||||
def main():
|
||||
binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
failures = []
|
||||
@@ -229,6 +252,7 @@ def main():
|
||||
("round-trip", None, scenario_roundtrip),
|
||||
("password activation", PW_CONFIG, scenario_password),
|
||||
("emergency erase", PW_CONFIG, scenario_emergency),
|
||||
("wrong password", PW_CONFIG, scenario_wrong_password),
|
||||
]
|
||||
for name, config, fn in groups:
|
||||
print(f"--- {name} ---")
|
||||
|
||||
@@ -1,37 +1,68 @@
|
||||
#!/bin/bash
|
||||
# The port's gate: every chip's generated workflow — build, size matrix, and
|
||||
# The port's gate: every chip's generated workflow - build, size matrix, and
|
||||
# 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). LIBAVR_ROOT must point at the libavr checkout.
|
||||
# full matrix) and swaps the compact size matrix for the exhaustive
|
||||
# clock x baud x backend cross product. libavr resolves from the `libavr/`
|
||||
# submodule; LIBAVR_ROOT overrides it for a working tree.
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
full=0
|
||||
[[ "$1" == "--full" ]] && { full=1; shift; }
|
||||
[[ "$1" == "--full" ]] && { full=1; shift; export PUREBOOT_FULL_MATRIX=1; }
|
||||
|
||||
CHIPS=(attiny13 attiny13a attiny25 attiny45 attiny85
|
||||
atmega8 atmega8a atmega16 atmega16a atmega32 atmega32a
|
||||
atmega48 atmega48a atmega48p atmega48pa
|
||||
atmega88 atmega88a atmega88p atmega88pa
|
||||
atmega168 atmega168a atmega168p atmega168pa
|
||||
atmega328 atmega328p
|
||||
atmega164a atmega164p atmega164pa
|
||||
atmega324a atmega324p atmega324pa
|
||||
atmega644 atmega644a atmega644p atmega644pa
|
||||
atmega1284 atmega1284p)
|
||||
REFLECT_SPOT=(attiny13a attiny85 atmega8 atmega16a atmega32a atmega48pa
|
||||
atmega88 atmega168pa atmega328p atmega164a atmega644p atmega1284)
|
||||
# The chip lists come from the presets rather than being spelled a second time
|
||||
# here: a chip added to make_presets.py and missed in a copy of its list would
|
||||
# be a gate that silently never builds it, which is the one failure mode a gate
|
||||
# cannot report. tools/make_presets.py is the single source, CMakePresets.json
|
||||
# is its output, and this reads that.
|
||||
readarray -t WORKFLOWS < <(python3 -c '
|
||||
import json, sys
|
||||
presets = json.load(open("CMakePresets.json"))["workflowPresets"]
|
||||
print("\n".join(p["name"] for p in presets))')
|
||||
if ((${#WORKFLOWS[@]} == 0)); then
|
||||
echo "no workflow presets in CMakePresets.json - run tools/make_presets.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
CHIPS=()
|
||||
REFLECT_SPOT=()
|
||||
for workflow in "${WORKFLOWS[@]}"; do
|
||||
case $workflow in
|
||||
*-generated) CHIPS+=("${workflow%-generated}") ;;
|
||||
*-reflect) REFLECT_SPOT+=("${workflow%-reflect}") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Every preset runs even after one goes red, and the gate fails at the end
|
||||
# naming all of them: stopping at the first failure turns a red - a stale size
|
||||
# canary above all - into an alibi for every chip behind it, and a loader can
|
||||
# ship on a chip this gate has not compiled since.
|
||||
red=()
|
||||
run_preset() {
|
||||
echo "==== $1 ===="
|
||||
cmake --workflow --preset "$1" "${@:2}" || red+=("$1")
|
||||
}
|
||||
|
||||
for chip in "${CHIPS[@]}"; do
|
||||
echo "==== $chip ===="
|
||||
cmake --workflow --preset "$chip-generated" "$@"
|
||||
run_preset "$chip-generated" "$@"
|
||||
done
|
||||
|
||||
if ((full)); then
|
||||
for chip in "${REFLECT_SPOT[@]}"; do
|
||||
echo "==== $chip reflect ===="
|
||||
cmake --workflow --preset "$chip-reflect" "$@"
|
||||
run_preset "$chip-reflect" "$@"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((${#red[@]})); then
|
||||
printf '==== red presets ====\n' >&2
|
||||
printf ' %s\n' "${red[@]}" >&2
|
||||
exit 1
|
||||
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"
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate CMakePresets.json — one uniform pipeline per chip.
|
||||
"""Regenerate CMakePresets.json - one uniform pipeline per chip.
|
||||
|
||||
Every chip gets generated-mode configure/build/test presets and a workflow
|
||||
running all three. Reflect-mode presets (configure + build, no tests — the
|
||||
running all three. Reflect-mode presets (configure + build, no tests - the
|
||||
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())
|
||||
|
||||
377
tools/pbhw.py
Executable file
377
tools/pbhw.py
Executable file
@@ -0,0 +1,377 @@
|
||||
#!/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 = self.rig.open_port() # wrapped for the echo where the line is shared
|
||||
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:
|
||||
# Same wrap as identity(): on a shared line an undiscarded
|
||||
# echo answers every rate a scan probes, so the walk would
|
||||
# report the first one it tried.
|
||||
port = self.rig.open_port(rate)
|
||||
except module.Error as error:
|
||||
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
|
||||
return
|
||||
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,
|
||||
marker_wait: float = 2.5) -> None:
|
||||
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
|
||||
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
|
||||
|
||||
if marker:
|
||||
# The tool hands over as it ends its session, so the application is
|
||||
# already running - but only on a board whose DTR is unwired, where
|
||||
# opening a port simply listens. Where DTR *is* wired to reset (an
|
||||
# Arduino, most USB-serial dev boards), this open resets the part
|
||||
# and the activation window comes first, so a marker emitted once at
|
||||
# startup happens on the far side of a wait this cannot know the
|
||||
# length of: the window is a compile-time constant and nothing on
|
||||
# the wire reports it. Hence --marker-wait, and a fixture that
|
||||
# repeats its banner (PUREBOOT_HEARTBEAT) rather than saying it once.
|
||||
data = self.rig.capture(seconds=marker_wait)
|
||||
seen = marker.encode() in data
|
||||
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
|
||||
self.check(f"application runs (emits {marker!r})", seen,
|
||||
f"|{sample}|" if seen or data else
|
||||
f"nothing in {marker_wait:g} s - if this board resets when its port "
|
||||
f"opens, that wait has to outlast the activation window")
|
||||
|
||||
back = self.work / "app-back.bin"
|
||||
rc, out = self.rig.pureboot("--read-flash", str(back))
|
||||
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 _witness(self, info, slot_length: int):
|
||||
"""Read back the erased region and the loader slot: (erased, slot, how).
|
||||
|
||||
Prefers ISP, because an independent reader is the only one that can
|
||||
testify about a loader just asked to erase around itself. Where no
|
||||
programmer is attached the link answers instead - which is weaker for
|
||||
exactly the reason it is worth having, a destroyed loader being unable
|
||||
to report anything at all. The two are never printed under one word:
|
||||
an absent probe is a fact about the bench, a wrong byte is a verdict on
|
||||
the loader, and a check that conflates them stops being read.
|
||||
"""
|
||||
limit = info.base - 2 if info.patch_vector else info.base
|
||||
whole = self.work / "whole.bin"
|
||||
if self.rig.read_memory("flash", whole, "r"):
|
||||
image = whole.read_bytes()
|
||||
image += b"\xff" * (info.flash_size - len(image))
|
||||
return image[0:limit], image[info.base:info.base + slot_length], "ISP"
|
||||
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
return (loader.read_flash(0, limit),
|
||||
loader.read_flash(info.base, slot_length),
|
||||
"the link, no probe attached - the loader's own account")
|
||||
except Exception as error: # noqa: BLE001 - a dead link is a result
|
||||
print(f" skip slot checks: no programmer, and the link did not "
|
||||
f"answer either ({str(error)[:60]})")
|
||||
return None, None, ""
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def erase_and_slot(self, info, loader_image: pathlib.Path | None) -> None:
|
||||
rc, out = self.rig.pureboot("--erase-flash")
|
||||
self.check("application region erases", rc == 0, self._brief(out))
|
||||
|
||||
want = loader_image.read_bytes() if loader_image and loader_image.exists() else b""
|
||||
erased, slot, how = self._witness(info, len(want))
|
||||
if erased is None:
|
||||
return
|
||||
|
||||
# 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(erased) <= {0xFF}, f"0x0000..{limit:#06x} via {how}")
|
||||
|
||||
if want:
|
||||
self.check("loader slot survives the erase", slot == want,
|
||||
f"{len(want)} B at {info.base:#06x} via {how}")
|
||||
else:
|
||||
print(" skip loader slot comparison (pass --loader <image.bin>)")
|
||||
|
||||
def seal(self, info, rounds: int = 1) -> None:
|
||||
"""The seal, adversarially, over the real link.
|
||||
|
||||
pureboot 9 has no running-slot guard: what stops a mangled command from
|
||||
erasing the loader is the seal and nothing else. So this aims the worst
|
||||
command the protocol has - an SPM erase at the loader's own first page -
|
||||
and damages one header byte at a time. Every one must come back NAK with
|
||||
the slot untouched and the session still in step.
|
||||
|
||||
On a board whose link drops or mangles bytes of its own accord this is
|
||||
also the stress test: `--seal-rounds` repeats it, and a link fault
|
||||
during a round is indistinguishable to the loader from the damage being
|
||||
injected, which is the point.
|
||||
"""
|
||||
module = pbrig.load_pureboot(self.rig.d.pureboot)
|
||||
self.rig.reset()
|
||||
port = self.rig.open_port()
|
||||
try:
|
||||
loader = module.Loader(port)
|
||||
if self.rig.d.autobaud:
|
||||
loader.connect_autobaud(self.rig.d.wait)
|
||||
else:
|
||||
loader.connect(self.rig.d.wait)
|
||||
if loader.info.version < module.SEALED_LOADER:
|
||||
print(f" skip seal checks (loader is pureboot {loader.info.version})")
|
||||
return
|
||||
|
||||
head_of = lambda dmg: self._sealed(module, module.OP_WRITE, module.SP_SPM,
|
||||
info.base, module.SPM_ERASE, dmg)
|
||||
refused = 0
|
||||
attempts = 0
|
||||
for _ in range(rounds):
|
||||
for index in range(6):
|
||||
attempts += 1
|
||||
port.write(head_of((index, 0x01)))
|
||||
verdict = port.read_exact(1, 5.0)
|
||||
if verdict != module.NAK:
|
||||
self.check(f"damaged byte {index} refused", False,
|
||||
f"verdict {verdict.hex()}")
|
||||
return
|
||||
if port.read_exact(1, 5.0) != module.PROMPT:
|
||||
self.check(f"re-prompt after byte {index}", False, "no prompt")
|
||||
return
|
||||
refused += 1
|
||||
self.check("damaged headers refused", refused == attempts,
|
||||
f"{refused}/{attempts}, every header byte")
|
||||
self.check("session still in step", loader.identity().raw == info.raw)
|
||||
|
||||
# And the slot itself, read back over the link: the loader is the
|
||||
# thing that would have been erased, so its own account of its
|
||||
# first bytes is a real witness - an erased page reads all 0xff.
|
||||
head = loader.read_flash(info.base, 16)
|
||||
self.check("loader slot intact", set(head) != {0xFF}, head[:8].hex())
|
||||
except Exception as error: # noqa: BLE001 - a dead link is a result
|
||||
self.check("seal checks", False, str(error)[:70])
|
||||
finally:
|
||||
try:
|
||||
port.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _sealed(module, op, space, address, count, damage=None):
|
||||
"""A sealed header, damaged after sealing - the shape a link fault has."""
|
||||
head = bytearray((op, module.selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = module.SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
out = bytearray(head + bytes((seal,)))
|
||||
if damage:
|
||||
out[damage[0]] ^= damage[1]
|
||||
return bytes(out)
|
||||
|
||||
def refusals(self, info) -> None:
|
||||
# One word too many: a patched-vector part spends the slot's last word
|
||||
# on the trampoline, so its application stops two bytes short.
|
||||
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, marker_wait: float = 2.5, seal_rounds: int = 1) -> 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, marker_wait)
|
||||
else:
|
||||
print("\nskip application checks (pass --app <image.hex>)")
|
||||
|
||||
print("\nerase and the slot boundary")
|
||||
self.erase_and_slot(info, loader_image)
|
||||
|
||||
print("\nthe seal")
|
||||
self.seal(info, seal_rounds)
|
||||
|
||||
print("\nrefusals")
|
||||
self.refusals(info)
|
||||
|
||||
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("--seal-rounds", type=int, default=1,
|
||||
help="repeat the adversarial seal sweep N times (a lossy board's stress test)")
|
||||
parser.add_argument("--marker", default="",
|
||||
help="text the application emits when it runs, e.g. APP")
|
||||
parser.add_argument("--marker-wait", type=float, default=2.5,
|
||||
help="seconds to listen for it. On a board whose DTR is wired to "
|
||||
"reset, opening the port resets the part, so this must outlast "
|
||||
"the activation window (default 2.5)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
|
||||
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,
|
||||
args.marker_wait, args.seal_rounds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except pbrig.Error as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
445
tools/pbrig.py
Executable file
445
tools/pbrig.py
Executable file
@@ -0,0 +1,445 @@
|
||||
#!/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
|
||||
rungs = (1000, 4000, 8000, 32000, 125000, 400000)
|
||||
if ceiling < rungs[0]:
|
||||
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
|
||||
best = max(rung for rung in rungs if rung <= ceiling)
|
||||
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
|
||||
one_wire: bool = False # shared line: the host discards its own echo
|
||||
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"),
|
||||
one_wire=os.environ.get("PUREBOOT_ONE_WIRE", "") not in ("", "0"),
|
||||
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
|
||||
part=os.environ.get("PUREBOOT_PART", ""),
|
||||
avrdude=os.environ.get("AVRDUDE", "avrdude"),
|
||||
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("--one-wire", action="store_true", default=env.one_wire,
|
||||
help="shared line: pass the tool its echo discard")
|
||||
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
|
||||
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
|
||||
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
|
||||
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,
|
||||
one_wire=args.one_wire,
|
||||
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")
|
||||
if self.d.one_wire:
|
||||
command.append("--one-wire")
|
||||
command += [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
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 open_port(self, baud: int | None = None):
|
||||
"""A port opened the way this deployment says to speak to the board.
|
||||
|
||||
Everything the rig runs as a *subprocess* gets its flags from
|
||||
`pureboot()` above; anything that drives the protocol in-process has
|
||||
to reach the same facts, and until this existed only the subprocess
|
||||
path could. A shared line is the one where that gap is fatal rather
|
||||
than untidy: the host reads back every byte it writes, so an
|
||||
undiscarded echo answers the knock before the device does. Open
|
||||
through here and a one-wire deployment cannot be silently driven as
|
||||
a two-wire one.
|
||||
"""
|
||||
module = load_pureboot(self.d.pureboot)
|
||||
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
|
||||
return module.OneWirePort(port) if self.d.one_wire else port
|
||||
|
||||
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
|
||||
"""Listen to whatever the board is saying, at an arbitrary rate.
|
||||
|
||||
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)
|
||||
194
tools/sizes.py
Executable file
194
tools/sizes.py
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/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)
|
||||
# A chip's generated and reflect trees must answer with the same bytes
|
||||
# (the identity invariant), so the same target measuring two sizes means
|
||||
# a stale tree - or an identity breach. Either is a finding; picking one
|
||||
# silently is how a gate reports another build's numbers as today's.
|
||||
for chip, rows in found.items():
|
||||
seen: dict[str, tuple[int, str]] = {}
|
||||
for name, elf, _ in rows:
|
||||
if elf not in sizes:
|
||||
continue
|
||||
if name in seen and seen[name][0] != sizes[elf]:
|
||||
sys.exit(f"{chip} {name}: {seen[name][0]} B in {seen[name][1]} but "
|
||||
f"{sizes[elf]} B in {elf} - a stale tree (rebuild or remove it) "
|
||||
f"or a cross-mode identity breach")
|
||||
seen.setdefault(name, (sizes[elf], elf))
|
||||
measured = {
|
||||
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
|
||||
key=lambda row: -row[1])
|
||||
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 build and the worst
|
||||
autobaud configuration (OSCCAL baked, plus the USART-pin release where
|
||||
the chip has a USART; the one-wire fold of the same build is its twin
|
||||
and competes for the same cell) - the config the Autobaud column
|
||||
documents."""
|
||||
readme = (ROOT / "pureboot" / "README.md").read_text()
|
||||
measured = collect()
|
||||
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
|
||||
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" plus any footnote mark - the first name
|
||||
# is the family's base, and the sub strips the rest.
|
||||
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
||||
built = {name: text for name, text, _ in measured.get(chip, [])}
|
||||
# The on-USART pair defines the column where the chip has a USART;
|
||||
# the default-pin pair is the whole space elsewhere. Whichever twin
|
||||
# measures larger is the number the cell must state.
|
||||
candidates = [name for name in ("pureboot_autobaud_osccal_on_usart0",
|
||||
"pureboot_1w_autobaud_osccal_on_usart0") if name in built]
|
||||
if not candidates:
|
||||
candidates = [name for name in ("pureboot_autobaud_osccal",
|
||||
"pureboot_1w_autobaud_osccal") if name in built]
|
||||
worst = max(candidates, key=lambda name: built[name], default="pureboot_autobaud_osccal")
|
||||
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
|
||||
if target not in built:
|
||||
skipped += 1
|
||||
continue
|
||||
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
|
||||
# A row whose target was not built is only skipped, so a chip-name change
|
||||
# or a build tree that holds nothing would otherwise skip every row and
|
||||
# report a match over an empty comparison.
|
||||
if skipped == 2 * len(rows):
|
||||
sys.exit(f"none of the {len(rows)} README rows matched a built image - "
|
||||
f"refusing to report a match over nothing")
|
||||
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())
|
||||
135
tsb/tsb_asm.cpp
135
tsb/tsb_asm.cpp
@@ -1,14 +1,15 @@
|
||||
// TinySafeBoot on libavr — tier 3: full feature parity in the 512-byte boot
|
||||
// TinySafeBoot on libavr - tier 3: full feature parity in the 512-byte boot
|
||||
// section, in C++ except where the C ABI itself is the cost.
|
||||
//
|
||||
// The complete TinySafeBoot feature set — watchdog-reset bail, one-wire
|
||||
// The complete TinySafeBoot feature set - watchdog-reset bail, one-wire
|
||||
// half-duplex UART, a config-page activation timeout, the password gate,
|
||||
// emergency erase, and config/flash/EEPROM read-write — at 510 bytes in the
|
||||
// 512-byte BOOTSZ=11 section the hand-written oracle occupies (500 B). This tier used to be one
|
||||
// monolithic inline-asm routine; it is now the tricks tier's C++ (same
|
||||
// register protocol, same structure — see tsb_tricks.cpp, including the
|
||||
// global-register miscompile rules) with exactly two routines kept in
|
||||
// assembly, the two whose remaining cost *is* the calling convention:
|
||||
// emergency erase, and config/flash/EEPROM read-write - inside the 512-byte
|
||||
// BOOTSZ=11 section the hand-written oracle occupies (oracle/README.md holds
|
||||
// what each tier measures, in one table rather than four). The
|
||||
// body is the tricks tier's C++ (same register protocol, same structure - see
|
||||
// tsb_tricks.cpp, including the global-register miscompile rules) with exactly
|
||||
// two routines kept in assembly, the two whose remaining cost *is* the calling
|
||||
// convention:
|
||||
//
|
||||
// rx the bounded receive: C++ must re-floor the timeout window on every
|
||||
// call (the global-register-store miscompile) and split it across
|
||||
@@ -16,12 +17,12 @@
|
||||
// countdown.
|
||||
// store the page-store loop: C++ cannot hold the receive byte pair and the
|
||||
// walked Z pointer across the rx calls without call-saved staging
|
||||
// (push/pop + a Y→Z copy per word); the asm calls rx knowing exactly
|
||||
// (push/pop + a Y->Z copy per word); the asm calls rx knowing exactly
|
||||
// which registers it touches and walks Z live across the whole page.
|
||||
//
|
||||
// Everything else — bring-up, activation, password gate, emergency erase,
|
||||
// Everything else - bring-up, activation, password gate, emergency erase,
|
||||
// dispatch, every SPM/EEPROM/flash primitive, every geometry/baud/info
|
||||
// constant — is C++ on libavr, and the two asm routines splice into the same
|
||||
// constant - is C++ on libavr, and the two asm routines splice into the same
|
||||
// global-register protocol the C++ uses (g_addr in Y, g_cnt in r16, g_window
|
||||
// in r7, g_receiving in r6), so calls cross the boundary with no marshalling.
|
||||
//
|
||||
@@ -41,10 +42,16 @@ namespace hw = avr::hw;
|
||||
namespace tsb {
|
||||
namespace {
|
||||
|
||||
// The loader is purely polled — it never enables interrupts — so every SPM and
|
||||
// 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;
|
||||
|
||||
// Strict request/response: every SPM operation is waited out before the next
|
||||
// byte moves, so no flash operation is ever in flight at an EEPROM access -
|
||||
// the write procedure's step 2 has nothing to guard, the omission the
|
||||
// datasheet grants (DS40002061B section 8.6.3).
|
||||
constexpr auto no_spm = ee::spm_interlock::omitted;
|
||||
|
||||
constexpr std::uint8_t confirm = '!';
|
||||
constexpr std::uint8_t request = '?';
|
||||
constexpr std::uint8_t knock = '@';
|
||||
@@ -57,28 +64,34 @@ constexpr std::uint16_t boot_bytes = 512;
|
||||
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;
|
||||
// Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so
|
||||
// it follows the clock rather than restating it (rule 41).
|
||||
constexpr auto act_min = static_cast<std::uint8_t>((16_MHz).hz / 1'000'000);
|
||||
// Post-activation window: the host gets seconds, not milliseconds, mid-session.
|
||||
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, 8, avr::uart::parity::none);
|
||||
|
||||
// One bit time on the wire: the turn-around a shared-line peer needs to stop
|
||||
// driving before this one starts. Derived from the solved rate, so it follows
|
||||
// the link rather than a count measured against one.
|
||||
constexpr auto guard_cycles = static_cast<std::uint32_t>((16_MHz).hz / baud.actual);
|
||||
|
||||
// The 16-byte device-info block, streamed out on activation.
|
||||
// clang-format off
|
||||
[[gnu::progmem]] constexpr std::uint8_t info[16] = {
|
||||
[[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({
|
||||
'T', 'S', 'B',
|
||||
build_date & 0xFF, build_date >> 8,
|
||||
0xF3, // status: native-UART fixed-baud lineage
|
||||
0x1E, 0x95, 0x0F, // ATmega328P signature
|
||||
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,
|
||||
eeprom_end & 0xFF, eeprom_end >> 8,
|
||||
0xAA, 0xAA,
|
||||
};
|
||||
});
|
||||
// clang-format on
|
||||
|
||||
register std::uint16_t g_addr asm("r28");
|
||||
@@ -94,7 +107,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
// Bounded byte receive (asm 1 of 2): release the one-wire line on a direction
|
||||
// change, poll RXC0 under the oracle's nested X-register countdown seeded from
|
||||
// g_window (floored against lockout), byte or 0-on-silence in r24. Z survives
|
||||
// — the property the store's word loop rides on.
|
||||
// - the property the store's word loop rides on.
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t rx()
|
||||
{
|
||||
std::uint8_t byte;
|
||||
@@ -115,7 +128,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
" brne 3b \n\t"
|
||||
" sbiw r26, 1 \n\t"
|
||||
" brcc 2b \n\t"
|
||||
" clr %[b] \n\t" // silence → 0, which no compare accepts
|
||||
" clr %[b] \n\t" // silence -> 0, which no compare accepts
|
||||
" rjmp 5f \n\t"
|
||||
"4: lds %[b], %[udr0] \n\t"
|
||||
"5: \n\t"
|
||||
@@ -129,14 +142,13 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
// One-wire transmit: take the line (TXEN0 alone) on a direction change with a
|
||||
// turn-around guard, put the byte out, hold the line until the whole frame is
|
||||
// out (TXC0, not UDRE0), W1C TXC0 by storing the sampled status back (keeps
|
||||
// U2X0). Plain C++ — it compiles *smaller* than the oracle's routine.
|
||||
// U2X0). Plain C++ - it compiles *smaller* than the oracle's routine.
|
||||
[[gnu::noinline, gnu::noclone]] void tx(std::uint8_t byte)
|
||||
{
|
||||
if (g_receiving) {
|
||||
g_receiving = 0;
|
||||
hw::ucsr0b::write(hw::ucsr0b::txen0(1));
|
||||
for (std::uint8_t guard = 46; guard; --guard)
|
||||
;
|
||||
avr::delay::cycles<guard_cycles>();
|
||||
}
|
||||
hw::udr0::write(byte);
|
||||
std::uint8_t status;
|
||||
@@ -153,7 +165,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
return rx();
|
||||
}
|
||||
|
||||
// One flash byte ← [g_addr++] (the advance right before ret — the
|
||||
// One flash byte <- [g_addr++] (the advance right before ret - the
|
||||
// global-register rule, see tsb_tricks.cpp).
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t sflash()
|
||||
{
|
||||
@@ -162,18 +174,18 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
return byte;
|
||||
}
|
||||
|
||||
// One EEPROM byte ← [g_addr++].
|
||||
// One EEPROM byte <- [g_addr++].
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t eerd()
|
||||
{
|
||||
std::uint8_t byte = ee::read(g_addr);
|
||||
std::uint8_t byte = ee::read<no_spm>(g_addr);
|
||||
++g_addr;
|
||||
return byte;
|
||||
}
|
||||
|
||||
// One EEPROM byte → [g_addr++].
|
||||
// One EEPROM byte -> [g_addr++].
|
||||
[[gnu::noinline, gnu::noclone]] void eewr(std::uint8_t byte)
|
||||
{
|
||||
ee::write<off>(g_addr, byte);
|
||||
ee::write<off, no_spm>(g_addr, byte);
|
||||
++g_addr;
|
||||
}
|
||||
|
||||
@@ -185,7 +197,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
} while (--g_cnt);
|
||||
}
|
||||
|
||||
// Wait out a running SPM op, then re-open the RWW section — after every page
|
||||
// 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, gnu::noclone]] void settle()
|
||||
{
|
||||
@@ -201,12 +213,12 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
tsb_app();
|
||||
}
|
||||
|
||||
// Step g_addr one page down and erase that page (the decrement lives here —
|
||||
// Step g_addr one page down and erase that page (the decrement lives here -
|
||||
// the global-register rule).
|
||||
[[gnu::noinline, gnu::noclone]] void erase_below()
|
||||
{
|
||||
g_addr -= page;
|
||||
spm::erase_page<off>(g_addr);
|
||||
spm::command<off>(spm::op::erase, g_addr);
|
||||
settle();
|
||||
}
|
||||
|
||||
@@ -221,7 +233,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
}
|
||||
|
||||
// Stream one host page into the erased flash page at g_addr (asm 2 of 2): the
|
||||
// word pair stages in r0:r1 straight from rx (whose register set is known —
|
||||
// word pair stages in r0:r1 straight from rx (whose register set is known -
|
||||
// the cross-call liveness C++ cannot express), Z walks the page and PGWRT
|
||||
// programs it. g_addr is left at the next page base.
|
||||
[[gnu::noinline, gnu::noclone]] void store_flash()
|
||||
@@ -256,14 +268,15 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
{
|
||||
// A watchdog reset hands straight back to the application, as the
|
||||
// reference loader does, rather than re-entering the bootloader.
|
||||
if (hw::mcusr::wdrf.test())
|
||||
if (hw::mcusr::wdrf.test()) {
|
||||
appjump();
|
||||
}
|
||||
|
||||
// Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use - only the divisor low
|
||||
// byte and U2X0 need a store. The library still does the datasheet work.
|
||||
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
|
||||
// General-purpose registers are undefined at power-on (no crt zeroes them);
|
||||
// the direction latch must start "not receiving" so the first rx() enables
|
||||
@@ -271,13 +284,15 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
// same reason.
|
||||
g_receiving = 0;
|
||||
|
||||
// Activation: 3×'@', each inside the config page's timeout window (rx
|
||||
// floors it so a corrupt page cannot lock the loader out); anything else —
|
||||
// including silence — hands over.
|
||||
// Activation: 3x'@', each inside the config page's timeout window (rx
|
||||
// floors it so a corrupt page cannot lock the loader out); anything else -
|
||||
// including silence - hands over.
|
||||
g_window = avr::flash_load(flash_ptr(app_end + 2));
|
||||
for (std::uint8_t k = 3; k; --k)
|
||||
if (rx() != knock)
|
||||
for (std::uint8_t k = 3; k; --k) {
|
||||
if (rx() != knock) {
|
||||
appjump();
|
||||
}
|
||||
}
|
||||
g_window = comm_window;
|
||||
|
||||
// Password gate (config page from app_end+3, 0xff-terminated; a blank
|
||||
@@ -291,17 +306,19 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
std::uint8_t expected = avr::flash_load(flash_ptr(g_addr)) & mask;
|
||||
++g_addr;
|
||||
if (expected == 0xff) {
|
||||
g_addr = reinterpret_cast<std::uint16_t>(&info[0]);
|
||||
g_addr = reinterpret_cast<std::uint16_t>(info.data());
|
||||
g_cnt = sizeof(info);
|
||||
sendf();
|
||||
break;
|
||||
}
|
||||
std::uint8_t got = rx();
|
||||
if (got == 0) {
|
||||
if (mask == 0)
|
||||
if (mask == 0) {
|
||||
continue;
|
||||
if (rcnf() != confirm || rcnf() != confirm)
|
||||
}
|
||||
if (rcnf() != confirm || rcnf() != confirm) {
|
||||
appjump();
|
||||
}
|
||||
erase_application(); // leaves g_addr = 0 for the EEPROM walk
|
||||
do {
|
||||
eewr(0xff);
|
||||
@@ -310,9 +327,10 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
erase_below();
|
||||
break;
|
||||
}
|
||||
if (got != expected)
|
||||
if (got != expected) {
|
||||
mask = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
tx(confirm); // Mainloop ready
|
||||
@@ -320,23 +338,27 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
switch (rx()) {
|
||||
case 'f': // read application flash, one page per host '!'
|
||||
for (;;) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_cnt = page;
|
||||
sendf();
|
||||
if (g_addr >= app_end)
|
||||
if (g_addr >= app_end) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'F': // erase the application, then take pages behind '?'
|
||||
erase_application(); // leaves g_addr = 0, the write start
|
||||
while (rcnf() == confirm)
|
||||
while (rcnf() == confirm) {
|
||||
store_flash();
|
||||
}
|
||||
break;
|
||||
case 'e': // read EEPROM, one page per host '!', until the host stops
|
||||
for (;;) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_cnt = page;
|
||||
do {
|
||||
tx(eerd());
|
||||
@@ -358,8 +380,9 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
sendf();
|
||||
break;
|
||||
case 'C': // replace the config page, then echo it back to verify
|
||||
if (rcnf() != confirm)
|
||||
if (rcnf() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_addr = app_end + page;
|
||||
erase_below(); // leaves g_addr = app_end, the store target
|
||||
store_flash();
|
||||
@@ -373,14 +396,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
} // namespace
|
||||
} // namespace tsb
|
||||
|
||||
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is
|
||||
// laid first, so this is the first instruction executed. No crt ran, so set
|
||||
// the stack pointer before anything is called.
|
||||
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
|
||||
{
|
||||
SP = RAMEND;
|
||||
// The one line of crt this loader needs: compiled code assumes
|
||||
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
|
||||
asm volatile("clr __zero_reg__");
|
||||
tsb::run();
|
||||
}
|
||||
// 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, avr::startup::stack::hardware>;
|
||||
|
||||
329
tsb/tsb_policy.cpp
Normal file
329
tsb/tsb_policy.cpp
Normal file
@@ -0,0 +1,329 @@
|
||||
// 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 sits below the idiomatic tier and above the 512 B boot
|
||||
// section the tricks/asm tiers reach with the banned mechanisms
|
||||
// (oracle/README.md holds all four). This tier exists to keep that gap 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.
|
||||
// 115200 at 16 MHz lands +2.1 % off, past the receiver-tolerance table the
|
||||
// solver holds rates to - the oracle's own deployment has run there for a
|
||||
// decade, so the override states that it is meant.
|
||||
using serial_t = dev::uart0<{
|
||||
.baud = 115200_Bd,
|
||||
.allow_baud_error = true,
|
||||
.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;
|
||||
|
||||
// Strict request/response: every SPM operation is waited out before the next
|
||||
// byte moves, so no flash operation is ever in flight at an EEPROM access -
|
||||
// the write procedure's step 2 has nothing to guard, the omission the
|
||||
// datasheet grants (DS40002061B section 8.6.3).
|
||||
constexpr auto no_spm = ee::spm_interlock::omitted;
|
||||
|
||||
// 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, so
|
||||
// it follows the clock rather than restating it (rule 41).
|
||||
constexpr auto act_min = static_cast<std::uint8_t>(dev::clock.hz / 1'000'000);
|
||||
// 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 auto info = std::to_array<std::uint8_t>({
|
||||
'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<no_spm>(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, no_spm>(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)
|
||||
{
|
||||
const auto open = spm::page::begin<spm::from::boot_section, off>(at);
|
||||
do {
|
||||
std::uint8_t low = rx();
|
||||
std::uint8_t high = rx();
|
||||
spm::fill<off>(open, at, std::bit_cast<std::uint16_t>(std::array{low, high}));
|
||||
at += 2;
|
||||
} while (static_cast<std::uint8_t>(at) & (page - 1));
|
||||
spm::command<off>(spm::op::write, 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::command<off>(spm::op::erase, 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, 8, avr::uart::parity::none);
|
||||
static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
avr::hw::ubrr0::write(static_cast<std::uint8_t>(sol.ubrr));
|
||||
avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
|
||||
}
|
||||
|
||||
// Activation: 3x'@', 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.data()), info.size());
|
||||
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, avr::startup::stack::hardware>;
|
||||
149
tsb/tsb_pure.cpp
149
tsb/tsb_pure.cpp
@@ -1,10 +1,10 @@
|
||||
// TinySafeBoot on libavr — tier 1: pure, idiomatic C++.
|
||||
// TinySafeBoot on libavr - tier 1: pure, idiomatic C++.
|
||||
//
|
||||
// A serial flash bootloader for the ATmega328P boot section, reimplementing the
|
||||
// TinySafeBoot native-UART fixed-baud protocol on libavr with the full feature
|
||||
// set of the hand-written oracle: a watchdog-reset bail, one-wire half-duplex,
|
||||
// a config-page activation timeout, the password gate, emergency erase, and
|
||||
// config/flash/EEPROM read-write. This variant is written for clarity —
|
||||
// config/flash/EEPROM read-write. This variant is written for clarity -
|
||||
// well-factored functions, no compiler-specific size hacks, no inline assembly.
|
||||
// The one-wire wiring, the flash-resident info block and every SPM/EEPROM lock
|
||||
// are libavr's to handle; the only attribute is the naked reset entry that
|
||||
@@ -20,16 +20,29 @@ 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}>;
|
||||
// 115200 at 16 MHz lands +2.1 % off, past the receiver-tolerance table the
|
||||
// solver holds rates to - the oracle's own deployment has run there for a
|
||||
// decade, so the override states that it is meant.
|
||||
using serial_t = dev::uart0<{
|
||||
.baud = 115200_Bd,
|
||||
.allow_baud_error = true,
|
||||
.half_duplex = true,
|
||||
}>;
|
||||
inline constexpr serial_t serial{};
|
||||
|
||||
namespace tsb {
|
||||
namespace {
|
||||
|
||||
// The loader is purely polled — it never enables interrupts — so every SPM and
|
||||
// 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;
|
||||
|
||||
// Strict request/response: every SPM operation is waited out before the next
|
||||
// byte moves, so no flash operation is ever in flight at an EEPROM access -
|
||||
// the write procedure's step 2 has nothing to guard, the omission the
|
||||
// datasheet grants (DS40002061B section 8.6.3).
|
||||
constexpr auto no_spm = ee::spm_interlock::omitted;
|
||||
|
||||
// The handshake bytes, identical across every TSB host.
|
||||
constexpr std::uint8_t confirm = '!';
|
||||
constexpr std::uint8_t request = '?';
|
||||
@@ -50,26 +63,48 @@ constexpr std::uint16_t build_date = 26 * 512 + 7 * 32 + 20;
|
||||
// The 16-byte device-info block the host reads on activation. A flash_table
|
||||
// keeps it in progmem with no .data image (there is no crt to copy one).
|
||||
// clang-format off
|
||||
inline constexpr std::array<std::uint8_t, 16> info_data = {
|
||||
inline constexpr auto info_data = std::to_array<std::uint8_t>({
|
||||
'T', 'S', 'B',
|
||||
build_date & 0xFF, build_date >> 8,
|
||||
0xF3, // status byte (native-UART fixed-baud lineage)
|
||||
0x1E, 0x95, 0x0F, // ATmega328P signature
|
||||
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
|
||||
using info = avr::flash_table<info_data>;
|
||||
|
||||
// Blocking byte read/write over the one-wire line: read() releases the line to
|
||||
// the receiver, write() takes it and holds it until the frame is out.
|
||||
// The lockout-proof floor for the receive window: the oracle's F_CPU/1MHz, so
|
||||
// it follows the clock rather than restating it.
|
||||
constexpr auto act_min = static_cast<std::uint8_t>(dev::clock.hz / 1'000'000);
|
||||
|
||||
// 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;
|
||||
|
||||
// Bounded byte read over the one-wire line - read() releases the line to the
|
||||
// receiver - answering 0 on silence. That 0 falls through every compare below:
|
||||
// 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 oracle lists that timeout among its own fixes, and a blocking
|
||||
// read is how a tier loses it.
|
||||
std::uint8_t rx()
|
||||
{
|
||||
return serial.read_blocking();
|
||||
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;
|
||||
}
|
||||
|
||||
// write() takes the line and holds it until the frame is out.
|
||||
void tx(std::uint8_t byte)
|
||||
{
|
||||
serial.write(byte);
|
||||
@@ -83,14 +118,16 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
// Stream `count` bytes to the host, from flash (LPM) or from EEPROM.
|
||||
void send_flash(std::uint16_t addr, std::uint8_t count)
|
||||
{
|
||||
while (count--)
|
||||
while (count--) {
|
||||
tx(avr::flash_load(flash_ptr(addr++)));
|
||||
}
|
||||
}
|
||||
|
||||
void send_eeprom(std::uint16_t addr, std::uint8_t count)
|
||||
{
|
||||
while (count--)
|
||||
tx(ee::read(addr++));
|
||||
while (count--) {
|
||||
tx(ee::read<no_spm>(addr++));
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt the host with '?' and report whether it answered '!'.
|
||||
@@ -101,32 +138,33 @@ bool request_confirm()
|
||||
}
|
||||
|
||||
// Stream one page from the host straight into the already-erased flash page at
|
||||
// `addr`, filling the SPM word buffer low byte then high — no SRAM staging, so
|
||||
// `addr`, filling the SPM word buffer low byte then high - no SRAM staging, so
|
||||
// receiving and programming are the same loop.
|
||||
void store_flash_page(std::uint16_t addr)
|
||||
{
|
||||
const auto open = spm::page::begin<spm::from::boot_section, off>(addr);
|
||||
for (std::uint16_t i = 0; i < page; i += 2) {
|
||||
std::uint8_t lo = rx();
|
||||
std::uint8_t hi = rx();
|
||||
spm::fill<off>(addr + i, static_cast<std::uint16_t>(lo | (hi << 8)));
|
||||
spm::fill<off>(open, addr + i, static_cast<std::uint16_t>(lo | (hi << 8)));
|
||||
}
|
||||
spm::write_page<off>(addr);
|
||||
spm::wait();
|
||||
spm::write_page<spm::from::boot_section, off>(addr); // blocking: waits the write out
|
||||
}
|
||||
|
||||
// Stream one page from the host straight into EEPROM, byte by byte.
|
||||
void store_eeprom_page(std::uint16_t addr)
|
||||
{
|
||||
for (std::uint16_t i = 0; i < page; ++i)
|
||||
ee::write<off>(addr + i, rx());
|
||||
for (std::uint16_t i = 0; i < page; ++i) {
|
||||
ee::write<off, no_spm>(addr + i, rx());
|
||||
}
|
||||
}
|
||||
|
||||
// Erase one flash page and wait it out — the erase step shared by the whole-app
|
||||
// erase, the config-page rewrite and the emergency wipe.
|
||||
// Erase one flash page, waited out by the blocking spelling - the erase step
|
||||
// shared by the whole-app erase, the config-page rewrite and the emergency
|
||||
// wipe.
|
||||
void erase_page(std::uint16_t addr)
|
||||
{
|
||||
spm::erase_page<off>(addr);
|
||||
spm::wait();
|
||||
spm::erase_page<spm::from::boot_section, off>(addr);
|
||||
}
|
||||
|
||||
// Erase the whole application, one page at a time, top-down as the reference
|
||||
@@ -157,8 +195,9 @@ extern "C" [[noreturn]] void tsb_app();
|
||||
void read_flash()
|
||||
{
|
||||
for (std::uint16_t a = 0; a < app_end; a += page) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
return;
|
||||
}
|
||||
send_flash(a, page);
|
||||
}
|
||||
}
|
||||
@@ -167,8 +206,9 @@ void read_flash()
|
||||
void read_eeprom()
|
||||
{
|
||||
for (std::uint16_t a = 0;; a += page) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
return;
|
||||
}
|
||||
send_eeprom(a, page);
|
||||
}
|
||||
}
|
||||
@@ -178,22 +218,25 @@ void read_eeprom()
|
||||
void write_flash()
|
||||
{
|
||||
erase_application();
|
||||
for (std::uint16_t a = 0; request_confirm(); a += page)
|
||||
for (std::uint16_t a = 0; request_confirm(); a += page) {
|
||||
store_flash_page(a);
|
||||
}
|
||||
}
|
||||
|
||||
// 'E': take pages the host offers behind '?' into EEPROM.
|
||||
void write_eeprom()
|
||||
{
|
||||
for (std::uint16_t a = 0; request_confirm(); a += page)
|
||||
for (std::uint16_t a = 0; request_confirm(); a += page) {
|
||||
store_eeprom_page(a);
|
||||
}
|
||||
}
|
||||
|
||||
// 'C': replace the config page, then echo it back for the host to verify.
|
||||
void write_config()
|
||||
{
|
||||
if (!request_confirm())
|
||||
if (!request_confirm()) {
|
||||
return;
|
||||
}
|
||||
erase_page(app_end);
|
||||
store_flash_page(app_end);
|
||||
spm::rww_enable<off>();
|
||||
@@ -206,8 +249,9 @@ void write_config()
|
||||
void emergency_erase()
|
||||
{
|
||||
erase_application();
|
||||
for (std::uint16_t a = 0; a <= eeprom_end; ++a)
|
||||
ee::write<off>(a, 0xff);
|
||||
for (std::uint16_t a = 0; a <= eeprom_end; ++a) {
|
||||
ee::write<off, no_spm>(a, 0xff);
|
||||
}
|
||||
erase_page(app_end);
|
||||
spm::rww_enable<off>();
|
||||
}
|
||||
@@ -222,45 +266,54 @@ gate password_gate()
|
||||
{
|
||||
for (const std::uint8_t *pw = flash_ptr(app_end + 3);; ++pw) {
|
||||
std::uint8_t expected = avr::flash_load(pw);
|
||||
if (expected == 0xff)
|
||||
if (expected == 0xff) {
|
||||
return gate::pass;
|
||||
}
|
||||
std::uint8_t got = rx();
|
||||
if (got == 0)
|
||||
if (got == 0) {
|
||||
return gate::emergency;
|
||||
if (got != expected)
|
||||
for (;;)
|
||||
}
|
||||
if (got != expected) {
|
||||
for (;;) {
|
||||
rx();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[[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())
|
||||
if (avr::hw::mcusr::wdrf.test()) {
|
||||
appjump();
|
||||
}
|
||||
|
||||
avr::init<serial_t>();
|
||||
|
||||
// Activation: the host knocks three '@' inside a window whose length is the
|
||||
// config page's timeout byte (floored so a corrupt page can never lock the
|
||||
// loader out). An idle port times out and boots the application.
|
||||
__uint24 idle = static_cast<__uint24>(avr::flash_load(flash_ptr(app_end + 2)) | 16) << 16;
|
||||
// config page's timeout byte, floored so a corrupt page can never lock the
|
||||
// loader out. An idle port times out and boots the application; the same
|
||||
// window then bounds every receive of the session.
|
||||
window = avr::flash_load(flash_ptr(app_end + 2)) | act_min;
|
||||
__uint24 idle = static_cast<__uint24>(window) << 16;
|
||||
std::uint8_t knocks = 0;
|
||||
while (knocks < 3) {
|
||||
if (auto byte = serial.read())
|
||||
if (auto byte = serial.read()) {
|
||||
knocks = *byte == knock ? knocks + 1 : 0;
|
||||
else if (--idle == 0)
|
||||
} else if (--idle == 0) {
|
||||
appjump();
|
||||
}
|
||||
}
|
||||
|
||||
switch (password_gate()) {
|
||||
case gate::pass:
|
||||
send_flash(reinterpret_cast<std::uint16_t>(info::storage.data()), info::size());
|
||||
break;
|
||||
case gate::emergency:
|
||||
if (!request_confirm() || !request_confirm())
|
||||
if (!request_confirm() || !request_confirm()) {
|
||||
appjump();
|
||||
}
|
||||
emergency_erase();
|
||||
break;
|
||||
}
|
||||
@@ -295,14 +348,6 @@ gate password_gate()
|
||||
} // namespace
|
||||
} // namespace tsb
|
||||
|
||||
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is
|
||||
// laid first, so this is the first instruction executed. No crt ran, so set the
|
||||
// stack pointer before anything is called.
|
||||
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
|
||||
{
|
||||
SP = RAMEND;
|
||||
// The one line of crt this loader needs: compiled code assumes
|
||||
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
|
||||
asm volatile("clr __zero_reg__");
|
||||
tsb::run();
|
||||
}
|
||||
// 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, avr::startup::stack::hardware>;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// TinySafeBoot on libavr — tier 2: C++ with compiler trickery, no assembly.
|
||||
// TinySafeBoot on libavr - tier 2: C++ with compiler trickery, no assembly.
|
||||
//
|
||||
// The full TinySafeBoot feature set — watchdog bail, one-wire half-duplex,
|
||||
// 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 — in pure C++, 526 bytes: 14 over the 512-byte
|
||||
// boot section the hand-written oracle fits, from 168 over at this tier's first
|
||||
// floor. The structure mirrors the oracle's: a handful of tiny noinline
|
||||
// config/flash/EEPROM read-write - in pure C++, a little over the 512-byte boot
|
||||
// section the hand-written oracle fits (oracle/README.md holds what each tier
|
||||
// measures). The structure mirrors the oracle's: a handful of tiny noinline
|
||||
// primitives sharing one whole-loader register allocation, expressed as global
|
||||
// register variables so no helper ever saves, spills, or reloads any of it.
|
||||
//
|
||||
// The register protocol (all call-saved, so calls preserve them by ABI):
|
||||
// Y (r28:r29) g_addr the walked flash/EEPROM address — adiw-able
|
||||
// r16 g_cnt byte countdown of the running block — ldi-able
|
||||
// Y (r28:r29) g_addr the walked flash/EEPROM address - adiw-able
|
||||
// r16 g_cnt byte countdown of the running block - ldi-able
|
||||
// r7 g_window rx timeout, roughly 30 ms units at 16 MHz
|
||||
// r6 g_receiving one-wire direction latch, cleared at bring-up
|
||||
// (power-on registers are undefined)
|
||||
@@ -18,10 +18,10 @@
|
||||
// GCC 16.1 miscompiles stores into global register variables: an update whose
|
||||
// remaining uses all hide inside callees is deleted whenever a CALL follows it
|
||||
// before any jump/ret (the backend's liveness walk lumps fixed registers with
|
||||
// call-clobbered ones — minimal repro in libavr's
|
||||
// local/scratch/probes/gcc-avr-globalreg-repro.cpp, lessons.md entry). Every
|
||||
// call-clobbered ones - minimal repro in libavr's
|
||||
// test/upstream/gcc-avr-globalreg-repro.cpp). Every
|
||||
// g_* update below therefore sits where a *local* read or a jump/ret follows
|
||||
// it — the helpers advance g_addr immediately before returning, and rx()
|
||||
// it - the helpers advance g_addr immediately before returning, and rx()
|
||||
// re-floors the window on every call instead of storing the floored value
|
||||
// once. The layout is load-bearing; do not "simplify" it.
|
||||
//
|
||||
@@ -41,10 +41,16 @@ namespace hw = avr::hw;
|
||||
namespace tsb {
|
||||
namespace {
|
||||
|
||||
// The loader is purely polled — it never enables interrupts — so every SPM and
|
||||
// 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;
|
||||
|
||||
// Strict request/response: every SPM operation is waited out before the next
|
||||
// byte moves, so no flash operation is ever in flight at an EEPROM access -
|
||||
// the write procedure's step 2 has nothing to guard, the omission the
|
||||
// datasheet grants (DS40002061B section 8.6.3).
|
||||
constexpr auto no_spm = ee::spm_interlock::omitted;
|
||||
|
||||
constexpr std::uint8_t confirm = '!';
|
||||
constexpr std::uint8_t request = '?';
|
||||
constexpr std::uint8_t knock = '@';
|
||||
@@ -57,28 +63,34 @@ 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;
|
||||
// Lockout-proof floor for the activation window: the oracle's F_CPU/1MHz, so
|
||||
// it follows the clock rather than restating it (rule 41).
|
||||
constexpr auto act_min = static_cast<std::uint8_t>((16_MHz).hz / 1'000'000);
|
||||
// Post-activation window: the host gets seconds, not milliseconds, mid-session.
|
||||
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, 8, avr::uart::parity::none);
|
||||
|
||||
// One bit time on the wire: the turn-around a shared-line peer needs to stop
|
||||
// driving before this one starts. Derived from the solved rate, so it follows
|
||||
// the link rather than a count measured against one.
|
||||
constexpr auto guard_cycles = static_cast<std::uint32_t>((16_MHz).hz / baud.actual);
|
||||
|
||||
// The 16-byte device-info block, streamed out on activation.
|
||||
// clang-format off
|
||||
[[gnu::progmem]] constexpr std::uint8_t info[16] = {
|
||||
[[gnu::progmem]] constexpr auto info = std::to_array<std::uint8_t>({
|
||||
'T', 'S', 'B',
|
||||
build_date & 0xFF, build_date >> 8,
|
||||
0xF3, // status: native-UART fixed-baud lineage
|
||||
0x1E, 0x95, 0x0F, // ATmega328P signature
|
||||
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,
|
||||
eeprom_end & 0xFF, eeprom_end >> 8,
|
||||
0xAA, 0xAA,
|
||||
};
|
||||
});
|
||||
// clang-format on
|
||||
|
||||
register std::uint16_t g_addr asm("r28");
|
||||
@@ -93,8 +105,8 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
|
||||
// Bounded byte receive, the oracle's shape: release the one-wire line on a
|
||||
// direction change, poll RXC0 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
|
||||
// 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.
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t rx()
|
||||
{
|
||||
@@ -102,24 +114,25 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
g_receiving = 1;
|
||||
hw::ucsr0b::write(hw::ucsr0b::rxen0(1)); // RXEN0 alone: release and listen
|
||||
}
|
||||
// act_min ORs in here, per call, not once into g_window at setup — the
|
||||
// act_min ORs in here, per call, not once into g_window at setup - the
|
||||
// one placement the global-register-store miscompile cannot delete.
|
||||
std::uint16_t outer = static_cast<std::uint16_t>(g_window | act_min) << 8;
|
||||
do {
|
||||
std::uint8_t fine = 0;
|
||||
do {
|
||||
auto status = hw::ucsr0a::read();
|
||||
if (status & hw::ucsr0a::rxc0(1).value)
|
||||
if (status & hw::ucsr0a::rxc0(1).value) {
|
||||
return hw::udr0::read();
|
||||
}
|
||||
} while (--fine);
|
||||
} while (--outer);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One-wire transmit: take the line (TXEN0 alone — the receiver must be off
|
||||
// One-wire transmit: take the line (TXEN0 alone - the receiver must be off
|
||||
// while driving) on a direction change, with a turn-around guard so a shorted
|
||||
// peer can switch first; then hold the line until the whole frame is out
|
||||
// (TXC0, not UDRE0 — the stop bit must be on the wire before a caller may
|
||||
// (TXC0, not UDRE0 - the stop bit must be on the wire before a caller may
|
||||
// release the line), and W1C TXC0 by storing the sampled status back, which
|
||||
// keeps U2X0.
|
||||
[[gnu::noinline, gnu::noclone]] void tx(std::uint8_t byte)
|
||||
@@ -127,8 +140,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
if (g_receiving) {
|
||||
g_receiving = 0;
|
||||
hw::ucsr0b::write(hw::ucsr0b::txen0(1));
|
||||
for (std::uint8_t guard = 46; guard; --guard)
|
||||
;
|
||||
avr::delay::cycles<guard_cycles>();
|
||||
}
|
||||
hw::udr0::write(byte);
|
||||
std::uint8_t status;
|
||||
@@ -145,7 +157,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
return rx();
|
||||
}
|
||||
|
||||
// One flash byte ← [g_addr++] (the advance right before ret — see header).
|
||||
// One flash byte <- [g_addr++] (the advance right before ret - see header).
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t sflash()
|
||||
{
|
||||
std::uint8_t byte = avr::flash_load(flash_ptr(g_addr));
|
||||
@@ -153,18 +165,18 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
return byte;
|
||||
}
|
||||
|
||||
// One EEPROM byte ← [g_addr++].
|
||||
// One EEPROM byte <- [g_addr++].
|
||||
[[gnu::noinline, gnu::noclone]] std::uint8_t eerd()
|
||||
{
|
||||
std::uint8_t byte = ee::read(g_addr);
|
||||
std::uint8_t byte = ee::read<no_spm>(g_addr);
|
||||
++g_addr;
|
||||
return byte;
|
||||
}
|
||||
|
||||
// One EEPROM byte → [g_addr++].
|
||||
// One EEPROM byte -> [g_addr++].
|
||||
[[gnu::noinline, gnu::noclone]] void eewr(std::uint8_t byte)
|
||||
{
|
||||
ee::write<off>(g_addr, byte);
|
||||
ee::write<off, no_spm>(g_addr, byte);
|
||||
++g_addr;
|
||||
}
|
||||
|
||||
@@ -176,7 +188,7 @@ const std::uint8_t *flash_ptr(std::uint16_t addr)
|
||||
} while (--g_cnt);
|
||||
}
|
||||
|
||||
// Wait out a running SPM op, then re-open the RWW section — after every page
|
||||
// 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, gnu::noclone]] void settle()
|
||||
{
|
||||
@@ -198,12 +210,12 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
[[gnu::noinline, gnu::noclone]] void erase_below()
|
||||
{
|
||||
g_addr -= page;
|
||||
spm::erase_page<off>(g_addr);
|
||||
spm::command<off>(spm::op::erase, g_addr);
|
||||
settle();
|
||||
}
|
||||
|
||||
// Erase the whole application, top-down like the oracle: the loop bound is a
|
||||
// compare with zero, and g_addr = 0 — the value every caller wants next — is
|
||||
// compare with zero, and g_addr = 0 - the value every caller wants next - is
|
||||
// handed back for free.
|
||||
[[gnu::noinline, gnu::noclone]] void erase_application()
|
||||
{
|
||||
@@ -214,18 +226,19 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
}
|
||||
|
||||
// Stream one host page into the erased flash page at g_addr (SPM word buffer,
|
||||
// low byte then high) — no SRAM staging, receive and program are one loop.
|
||||
// low byte then high) - no SRAM staging, receive and program are one loop.
|
||||
// g_addr is left at the next page base.
|
||||
[[gnu::noinline, gnu::noclone]] void store_flash()
|
||||
{
|
||||
const auto open = spm::page::begin<spm::from::boot_section, off>(g_addr);
|
||||
g_cnt = page / 2;
|
||||
do {
|
||||
std::uint16_t word = rx();
|
||||
word |= static_cast<std::uint16_t>(rx()) << 8;
|
||||
spm::fill<off>(g_addr, word);
|
||||
spm::fill<off>(open, g_addr, word);
|
||||
g_addr += 2;
|
||||
} while (--g_cnt);
|
||||
spm::write_page<off>(g_addr - page);
|
||||
spm::command<off>(spm::op::write, g_addr - page);
|
||||
settle();
|
||||
}
|
||||
|
||||
@@ -233,14 +246,15 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
{
|
||||
// A watchdog reset hands straight back to the application, as the
|
||||
// reference loader does, rather than re-entering the bootloader.
|
||||
if (hw::mcusr::wdrf.test())
|
||||
if (hw::mcusr::wdrf.test()) {
|
||||
appjump();
|
||||
}
|
||||
|
||||
// Lean bring-up from reset state: UCSR0C already reads 8N1, UBRR0H reads
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use - only the divisor low
|
||||
// byte and U2X0 need a store. The library still does the datasheet work.
|
||||
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
|
||||
// General-purpose registers are undefined at power-on (no crt zeroes them);
|
||||
// the direction latch must start "not receiving" so the first rx() enables
|
||||
@@ -248,13 +262,15 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
// same reason.
|
||||
g_receiving = 0;
|
||||
|
||||
// Activation: 3×'@', each inside the config page's timeout window (rx
|
||||
// floors it so a corrupt page cannot lock the loader out); anything else —
|
||||
// including silence — hands over.
|
||||
// Activation: 3x'@', each inside the config page's timeout window (rx
|
||||
// floors it so a corrupt page cannot lock the loader out); anything else -
|
||||
// including silence - hands over.
|
||||
g_window = avr::flash_load(flash_ptr(app_end + 2));
|
||||
for (std::uint8_t k = 3; k; --k)
|
||||
if (rx() != knock)
|
||||
for (std::uint8_t k = 3; k; --k) {
|
||||
if (rx() != knock) {
|
||||
appjump();
|
||||
}
|
||||
}
|
||||
g_window = comm_window;
|
||||
|
||||
// Password gate (config page from app_end+3, 0xff-terminated; a blank
|
||||
@@ -268,17 +284,19 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
std::uint8_t expected = avr::flash_load(flash_ptr(g_addr)) & mask;
|
||||
++g_addr;
|
||||
if (expected == 0xff) {
|
||||
g_addr = reinterpret_cast<std::uint16_t>(&info[0]);
|
||||
g_addr = reinterpret_cast<std::uint16_t>(info.data());
|
||||
g_cnt = sizeof(info);
|
||||
sendf();
|
||||
break;
|
||||
}
|
||||
std::uint8_t got = rx();
|
||||
if (got == 0) {
|
||||
if (mask == 0)
|
||||
if (mask == 0) {
|
||||
continue;
|
||||
if (rcnf() != confirm || rcnf() != confirm)
|
||||
}
|
||||
if (rcnf() != confirm || rcnf() != confirm) {
|
||||
appjump();
|
||||
}
|
||||
erase_application(); // leaves g_addr = 0 for the EEPROM walk
|
||||
do {
|
||||
eewr(0xff);
|
||||
@@ -287,9 +305,10 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
erase_below();
|
||||
break;
|
||||
}
|
||||
if (got != expected)
|
||||
if (got != expected) {
|
||||
mask = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
tx(confirm); // Mainloop ready
|
||||
@@ -297,23 +316,27 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
switch (rx()) {
|
||||
case 'f': // read application flash, one page per host '!'
|
||||
for (;;) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_cnt = page;
|
||||
sendf();
|
||||
if (g_addr >= app_end)
|
||||
if (g_addr >= app_end) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'F': // erase the application, then take pages behind '?'
|
||||
erase_application(); // leaves g_addr = 0, the write start
|
||||
while (rcnf() == confirm)
|
||||
while (rcnf() == confirm) {
|
||||
store_flash();
|
||||
}
|
||||
break;
|
||||
case 'e': // read EEPROM, one page per host '!', until the host stops
|
||||
for (;;) {
|
||||
if (rx() != confirm)
|
||||
if (rx() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_cnt = page;
|
||||
do {
|
||||
tx(eerd());
|
||||
@@ -335,8 +358,9 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
sendf();
|
||||
break;
|
||||
case 'C': // replace the config page, then echo it back to verify
|
||||
if (rcnf() != confirm)
|
||||
if (rcnf() != confirm) {
|
||||
break;
|
||||
}
|
||||
g_addr = app_end + page;
|
||||
erase_below(); // leaves g_addr = app_end, the store target
|
||||
store_flash();
|
||||
@@ -350,14 +374,6 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
} // namespace
|
||||
} // namespace tsb
|
||||
|
||||
// Reset lands here: BOOTRST vectors to the boot section base and .vectors is
|
||||
// laid first, so this is the first instruction executed. No crt ran, so set
|
||||
// the stack pointer before anything is called.
|
||||
extern "C" [[gnu::naked, gnu::used, gnu::section(".vectors")]] void __boot_entry()
|
||||
{
|
||||
SP = RAMEND;
|
||||
// The one line of crt this loader needs: compiled code assumes
|
||||
// __zero_reg__ (r1) is 0, and power-on registers are undefined.
|
||||
asm volatile("clr __zero_reg__");
|
||||
tsb::run();
|
||||
}
|
||||
// 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, avr::startup::stack::hardware>;
|
||||
|
||||
Reference in New Issue
Block a user