diff --git a/CMakeLists.txt b/CMakeLists.txt index dbd3bb5..be5d795 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,7 +150,10 @@ if(PROJECT_IS_TOP_LEVEL) if(Python3_FOUND) add_test(NAME pureboot.pi COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/check_pi.py - ${CMAKE_OBJDUMP} ${CMAKE_NM} $ ${PUREBOOT_BASE_HEX}) + ${CMAKE_OBJDUMP} ${CMAKE_OBJCOPY} ${CMAKE_CXX_COMPILER} ${LIBAVR_MCU} + $ + ${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) @@ -245,23 +248,14 @@ if(PROJECT_IS_TOP_LEVEL) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) endfunction() - # The autobaud loader (pureboot/autobaud.md): one clock-agnostic image per - # chip, no clock × baud axis, size-tested against the same per-chip budget on - # every chip. - # - # Only the unified version is built. Its two predecessors — - # pureboot_autobaud_pure.cpp at 508 B and pureboot_autobaud_reg.cpp at 512 — - # had 4 B and 0 B of margin on the 1284P, and the fix for the activation hang - # costs ~22, which puts them at 530 and 534. Neither can ship, so the choice - # the branch existed to offer is settled by measurement rather than taste. - # The sources stay for the record; autobaud.md carries the numbers. - function(pureboot_autobaud_variant name source) - pureboot_add_autobaud(${name} ${source}) - add_test(NAME ${name}.size - COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$ - -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) - endfunction() - pureboot_autobaud_variant(pureboot_autobaud_uni pureboot_autobaud_uni.cpp) + # 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=$ + -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) # One point of the exhaustive matrix, named from its resolved parameters # so the enumeration cannot collide with itself. Unreachable rates drop @@ -302,16 +296,15 @@ if(PROJECT_IS_TOP_LEVEL) # sites), the largest image the space produces and a shape the ladder # default — always the *fastest* rate a clock reaches — never picks. # - # Bounded to one chip per size-bearing class: flash addressing (the - # word-addressed 1284), hand-over shape (the patched vector on the tinies - # and m48s), page size, and USART inventory. Everything else in the image - # is chip-independent code, so a further chip buys builds and no - # coverage; every chip outside the set carries the compact matrix. + # Every chip runs the full cross product: the size-bearing classes (flash + # addressing, hand-over shape, page size, USART inventory) are what make + # the image differ, and a chip outside them is expected to match its class + # — but "expected" is what a matrix is for, and the whole sweep is cheap + # enough to run rather than reason about. PUREBOOT_FULL_MATRIX is what + # selects it; the compact matrix below is the per-commit default. get_property(_full_bauds GLOBAL PROPERTY PUREBOOT_BAUD_LADDER) list(APPEND _full_bauds 16000 4800 2400 1200) - set(_matrix_spot attiny13a attiny85 atmega48pa atmega8a atmega168pa - atmega328p atmega164a atmega644a atmega1284p) - if(DEFINED ENV{PUREBOOT_FULL_MATRIX} AND LIBAVR_MCU IN_LIST _matrix_spot) + if(DEFINED ENV{PUREBOOT_FULL_MATRIX}) foreach(_matrix_hz IN LISTS _full_clocks) foreach(_matrix_baud IN LISTS _full_bauds) pureboot_matrix_point(${_matrix_hz} ${_matrix_baud} software) @@ -409,14 +402,12 @@ if(PROJECT_IS_TOP_LEVEL) add_custom_command(TARGET pbapp_autobaud POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $ $.bin) - foreach(_variant uni) - add_test(NAME pureboot.autobaud_${_variant} - COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py - ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} - ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $.bin - 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py - ${CMAKE_BINARY_DIR}/pbautobaud-${_variant}-work) - set_tests_properties(pureboot.autobaud_${_variant} PROPERTIES TIMEOUT 240) - endforeach() + add_test(NAME pureboot.autobaud + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbautobaud.py + ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} + ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} $.bin + 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbautobaud-work) + set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240) endif() endif() diff --git a/pureboot/CMakeLists.txt b/pureboot/CMakeLists.txt index 610cb0a..3eda44f 100644 --- a/pureboot/CMakeLists.txt +++ b/pureboot/CMakeLists.txt @@ -194,13 +194,19 @@ function(pureboot_default_baud clock software outvar) endfunction() # pureboot_add_loader( [CLOCK ] [BAUD ] -# [SERIAL auto|hardware|software] [USART ] +# [SERIAL auto|hardware|software|autobaud] [USART ] # [RX ] [TX ] [TIMEOUT ]) # # The loader target plus its flashable images (.hex for a programmer, # .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:,) — what a test harness speaks to it with. +# +# SERIAL autobaud measures the host's bit timing at run time, so the image +# carries no clock and no baud: CLOCK and BAUD are not build parameters there, +# and one binary per chip serves every F_CPU and every rate. The stamped +# PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at, +# not what it was built for. function(pureboot_add_loader name) cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN}) if(PB_UNPARSED_ARGUMENTS) @@ -222,8 +228,8 @@ function(pureboot_add_loader name) if(NOT PB_SERIAL) set(PB_SERIAL auto) endif() - if(DEFINED PB_USART AND PB_SERIAL STREQUAL "software") - message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL software") + if(DEFINED PB_USART AND NOT PB_SERIAL MATCHES "^(auto|hardware)$") + message(FATAL_ERROR "pureboot_add_loader(${name}): USART ${PB_USART} contradicts SERIAL ${PB_SERIAL}") endif() if(DEFINED PB_USART) set(PB_SERIAL hardware) @@ -252,7 +258,7 @@ function(pureboot_add_loader name) set(PB_SERIAL software) endif() endif() - if(PB_SERIAL STREQUAL "software") + if(PB_SERIAL MATCHES "^(software|autobaud)$") if(NOT PB_RX) set(PB_RX pb0) endif() @@ -264,7 +270,11 @@ function(pureboot_add_loader name) message(FATAL_ERROR "pureboot_add_loader(${name}): pin '${_pin}' is not of the form pb1") endif() endforeach() - set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) + if(PB_SERIAL STREQUAL "autobaud") + set(_serial_defines PUREBOOT_AUTOBAUD PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) + else() + set(_serial_defines PUREBOOT_SOFT_SERIAL PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) + endif() # sw:, as port letter and bit, upcased. string(SUBSTRING ${PB_RX} 1 2 _rx_pin) string(SUBSTRING ${PB_TX} 1 2 _tx_pin) @@ -280,23 +290,29 @@ function(pureboot_add_loader name) endif() endif() - set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} - ${_serial_defines}) + if(PB_SERIAL STREQUAL "autobaud") + # No clock and no baud reach the image; the window is a poll budget. + set(_defines ${_serial_defines}) + else() + set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} + ${_serial_defines}) + endif() add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) target_link_libraries(${name} PRIVATE libavr) target_compile_definitions(${name} PRIVATE ${_defines}) - # Codegen shaping for the loader TU only, worth 14–36 B depending on the - # chip. At -Os GCC otherwise rewrites the byte-stream loops' counters into - # end-pointer forms that cost registers (-fno-ivopts, - # -fno-split-wide-types), leaves register pressure on the table with the - # default allocator (-fira-algorithm=priority), and keeps loop-invariant - # immediates and expression temporaries in registers - # (-fno-move-loop-invariants, -fno-tree-ter) — but every loop body here - # contains a call, so a register held across it costs more than the - # load-immediate it saves. + # Codegen shaping for the loader TU only. At -Os GCC otherwise rewrites the + # byte-stream loops' counters into end-pointer forms that cost registers + # (-fno-ivopts, -fno-split-wide-types), leaves register pressure on the + # table with the default allocator (-fira-algorithm=priority), and keeps + # expression temporaries in registers (-fno-tree-ter) — but every loop body + # here contains a call, so a register held across it costs more than the + # load-immediate it saves. The set is fitted to the loader's body and has to + # be re-measured when that body changes: -fno-move-loop-invariants belonged + # here while the command loop carried four transfer bodies and costs bytes + # now that it carries one. target_compile_options(${name} PRIVATE - -fno-ivopts -fira-algorithm=priority -fno-move-loop-invariants -fno-tree-ter -fno-split-wide-types) + -fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types) target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} -Wl,--defsym=pureboot_app=${_app} ${_wrap}) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) @@ -311,37 +327,3 @@ function(pureboot_add_loader name) PUREBOOT_LINK ${_link}) endfunction() -# pureboot_add_autobaud( [RX ] [TX ]) -# -# An autobaud software-serial loader from (pureboot_autobaud_*.cpp). -# Autobaud measures the host's bit timing at runtime, so the image carries no -# clock and no baud — one binary per chip runs at any F_CPU. Same per-chip -# geometry, link and codegen flags as pureboot_add_loader(); only the clock and -# baud axes fall away. Two source files are under review (autobaud.md): -# pureboot_autobaud_pure.cpp and pureboot_autobaud_reg.cpp. -function(pureboot_add_autobaud name source) - cmake_parse_arguments(PB "" "RX;TX" "" ${ARGN}) - if(NOT PB_RX) - set(PB_RX pb0) - endif() - if(NOT PB_TX) - set(PB_TX pb1) - endif() - foreach(_pin ${PB_RX} ${PB_TX}) - if(NOT _pin MATCHES "^p[a-h][0-7]$") - message(FATAL_ERROR "pureboot_add_autobaud(${name}): pin '${_pin}' is not of the form pb1") - endif() - endforeach() - get_property(_base_hex GLOBAL PROPERTY PUREBOOT_BASE_HEX) - get_property(_app GLOBAL PROPERTY PUREBOOT_APP) - get_property(_wrap GLOBAL PROPERTY PUREBOOT_WRAP) - - add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/${source}) - target_link_libraries(${name} PRIVATE libavr) - target_compile_definitions(${name} PRIVATE PUREBOOT_RX=${PB_RX} PUREBOOT_TX=${PB_TX}) - target_compile_options(${name} PRIVATE - -fno-ivopts -fira-algorithm=priority -fno-move-loop-invariants -fno-tree-ter -fno-split-wide-types) - target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} - -Wl,--defsym=pureboot_app=${_app} ${_wrap}) - add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $) -endfunction() diff --git a/pureboot/README.md b/pureboot/README.md index 9ab006d..dad28ef 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -8,46 +8,51 @@ erase, reset-vector surgery, updating the loader itself — lives in the host tool (`pureboot.py`). The image is **position-independent**: control flow is PC-relative, the -read/write paths take wire addresses, the write guard protects the slot the -code is *running* in (from the runtime return address), the info block is -addressed from that same anchor, and the application jump is an indirect call +transfer paths take wire addresses, the write guard protects the slot the code +is *running* in (from the runtime return address), nothing else is +flash-resident to address at all, and the application jump is an indirect call to an absolute entry. The identical binary therefore runs from any slot with -every command intact, which makes pureboot **its own staging loader**: the -host installs the same binary one slot below the resident, jumps into it, and -lets it rewrite the resident. +every command intact, which makes pureboot **its own staging loader**: the host +installs the same binary one slot below the resident, jumps into it, and lets +it rewrite the resident. The lint holds it to that literally — the image must +come out byte-identical linked at a different base. ## Chips Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves -per build — see *Configuration*; the largest image any of them produces is a -software UART at a slow baud, which on the 1284s is 494 B, the tightest fit in -the whole matrix at 18 B spare. +per build — see *Configuration*. The autobaud column is the clock-free build, +which is the largest the space produces and the tightest fit in the matrix; +it carries the calibration machinery and no clock at all. -| Chip | Flash | Loader at | Link | Size | -|---|---|---|---|---| -| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 416 B | -| ATtiny25 † | 2 KiB | 0x0600 | software | 420 B | -| ATtiny45 † | 4 KiB | 0x0e00 | software | 424 B | -| ATtiny85 † | 8 KiB | 0x1e00 | software | 424 B | -| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 396 B | -| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 400 B | -| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 400 B | -| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 414 B | -| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 434 B | -| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 438 B | -| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 438 B | -| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 438 B | -| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 438 B | -| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 432 B | -| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 478 B | +| Chip | Flash | Loader at | Link | Stock | Autobaud | +|---|---|---|---|---|---| +| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 402 B | 472 B | +| ATtiny25 † | 2 KiB | 0x0600 | software | 406 B | 476 B | +| ATtiny45 † | 4 KiB | 0x0e00 | software | 410 B | 480 B | +| ATtiny85 † | 8 KiB | 0x1e00 | software | 410 B | 480 B | +| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 372 B | 486 B | +| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 374 B | 490 B | +| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 374 B | 490 B | +| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 400 B | 476 B | +| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 410 B | 486 B | +| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B | +| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B | +| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 412 B | 490 B | +| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 412 B | 490 B | +| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 406 B | 484 B | +| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 432 B | 510 B | † No hardware boot section: the host patches the reset vector, and the budget is 510 bytes, since the slot's last word is the trampoline. -The 1284s are the heaviest because they alone carry the far-flash machinery — -ELPM reads, RAMPZ page commands, a word-addressed wire. +The tightest fit in the whole space is the 1284s' autobaud build, 510 of its +512 — they alone carry the far-flash machinery (ELPM reads, RAMPZ page +commands) and autobaud alone carries the calibration loop. Everything else has +20 B of headroom or more. The flash bank riding in a transfer's selector byte +keeps even those chips' addressing the same 16-bit form every other chip uses, +which is why they are no longer the outlier they were. The software UART enables the RX pull-up; TX idles high. All multi-byte wire quantities are little-endian. @@ -62,7 +67,7 @@ repo's build and by a downstream project alike: |---|---|---| | `CLOCK ` | the clock the board runs | 16 MHz megas, 8 MHz t25/45/85, 9.6 MHz t13s | | `BAUD ` | the wire rate | the ladder below | -| `SERIAL auto\|hardware\|software` | the link backend | `auto`: the hardware USART where the chip has one | +| `SERIAL auto\|hardware\|software\|autobaud` | the link backend | `auto`: the hardware USART where the chip has one | | `USART ` | the USART instance (x4 megas carry two) | 0 | | `RX `, `TX ` | software-UART pins | `pb0`, `pb1` | | `TIMEOUT ` | the activation window | 8 | @@ -74,6 +79,17 @@ receiver's 100-cycles-a-bit floor. Whatever is picked or overridden is re-checked in the compile: an infeasible combination, or a USART the chip does not have, fails with a named static assert. +`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing +at run time, so `CLOCK` and `BAUD` are not build parameters there and one +binary per chip serves every clock and every rate. It is for the deployments +whose clock is not known at build time and does not hold still — the internal +RC oscillator, ±10 % from the factory and moving with supply and temperature — +where a fixed-baud software build has to be rebuilt per clock and still drifts +out of tolerance. The cost is that it is software-serial only (a hardware USART +needs its divisor programmed) and that activation counts poll iterations rather +than seconds, since there is no clock to convert them against +(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). + A downstream project brings its usual libavr setup (the `libavr` target, the chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and states its deployment — an ATmega328P on its shipped 1 MHz fuses with the @@ -113,9 +129,20 @@ window; any other byte is discarded and awaited again, so line noise can delay the loader but never lock it. A window expiring on an idle line boots the application. +An autobaud build opens differently, because it has to learn the rate before it +can read a byte at all: the host sends the **calibration byte 0xC0** — a start +bit plus six zero data bits form one low pulse of seven bit-times — and the +loader times that pulse into its bit period. A single `p` then activates; the +pulse has already proven a host is present, which the two-byte knock exists to +establish elsewhere. Both waits are bounded, so a stray low pulse with no host +behind it costs one window and then boots the application rather than holding +the loader. + The window is a compile-time constant (`TIMEOUT`, 8 s by default), so the whole EEPROM belongs to the application — pureboot keeps no state of its own. -Re-timing a deployed loader is a self-update with a re-timed build. +Re-timing a deployed loader is a self-update with a re-timed build. An autobaud +build counts poll iterations instead (`PUREBOOT_AUTOBAUD_POLLS`), there being +no clock to turn into seconds. ## Session @@ -125,75 +152,116 @@ write and sends the prompt `+` (0x2b), which is therefore also the previous command's completion ack. A session is: await `+`, send a command, read its reply, repeat. -On chips whose flash exceeds 64 KiB (the 1284s — info-block flag bit 1) the -`R`/`W` flash addresses are **word** addresses; everywhere else they are byte -addresses (the 644s' 64 KiB is exactly the 16-bit byte space). EEPROM -addresses and all counts are bytes. +Addresses are **byte addresses within a 64 KiB bank**, and the bank rides in +the command's selector byte, so no command has to speak word addresses. `J` is +the exception: it takes a word address, because that is what the hardware's own +jump takes. EEPROM and data-space addresses and all counts are bytes. The loader trusts the host to keep addresses in range: it does not bound them -against the info block. **Gotcha:** a `w` (or `r`) that runs past `E2END` wraps -— EEAR is only as wide as the array, so an address past the end truncates onto -low EEPROM and the write silently overwrites it. Keeping writes within the -advertised sizes is the host's job (the shipped tool does); the flash budget -is better spent on features than on re-checking a bound the host already holds. +against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps — +EEAR is only as wide as the array, so an address past the end truncates onto +low EEPROM and the write silently overwrites it. Keeping transfers within the +real sizes is the host's job (the shipped tool does); the flash budget is +better spent on features than on re-checking a bound the host already holds. | Cmd | Arguments | Reply | |---|---|---| -| `b` | — | the 12-byte info block | -| `R` | addr16, n8 | n flash bytes (n = 0 means 256) | -| `W` | addr16 (any address in the page), then one page of data | — (completion = next prompt) | -| `r` | addr16, n8 | n EEPROM bytes (n = 0 means 256) | -| `w` | addr16, n8, then n data bytes | `+` per byte, sent once its write has begun | -| `F` | — | 4 bytes: low fuse, lock, extended fuse, high fuse | +| `b` | — | 4 bytes: the pureboot version, then the three signature bytes | +| `G` | sel8, addr16, n8 | n bytes from the selected space (n = 0 means 256) | +| `g` | sel8, addr16, n8, then n data bytes | `+` per byte, sent once its write has begun | +| `W` | sel8, addr16, then one page of data | — (completion = next prompt) | | `J` | word address (16-bit) | `+`, then execution continues there | | other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) | -`W` streams exactly one SPM page (size from the info block) into the buffer, -then erases and programs — except pages inside the 512-byte slot -the loader is *running* in, which are drained and left alone, so a broken host -cannot brick the running copy and a staged copy may rewrite the resident. +`G` and `g` are one letter in two cases, which is the whole command set for +every memory: the **selector** byte's low nibble names the space and its high +nibble carries the flash bank. + +| Space | | | +|---|---|---| +| 0 | flash | read-only here; it is written through `W` and the SPM space | +| 1 | EEPROM | | +| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR | +| 3 | fuse and lock | index 0..3 in the hardware's own Z order: low, lock, extended, high | +| 4 | SPM | write-only: the byte goes to SPMCSR and fires the instruction at the address | + +The data space is worth more than it looks. pureboot keeps **zero static RAM** +and pushes no register, so at loader entry an application's SRAM is still +whatever the application left there, bar the handful of bytes of return-address +stack — which makes `G` over space 2 a post-mortem of a running application, +not just a poke hole. The same address space carries the register file and the +I/O registers, so peripheral state is readable too; reading some of those has +side effects (reading UDR clears its flags), which is the host's business to +know. + +Programming a page is therefore `W` to fill the buffer, then a `g` to the SPM +space for the erase, another for the write, and on a boot-sectioned chip a +third to re-enable the RWW section — `0x03`, `0x05` and `0x11`, the SPMCSR +encodings every part pureboot targets shares. The loader carries no page-commit +logic of its own, and the same primitive reaches every other SPM operation, +lock bits included. + +The SPM store and the SPM instruction must issue within four cycles of each +other (§26.2), which no host can hit across a serial link — so this one +primitive is *fused* rather than being a poke of SPMCSR followed by a poke of +something else. That four-cycle window is the floor on how low-level a +bootloader's primitives can go; it is not a byte-count decision. + +An SPM command aimed at the 512-byte slot the loader is **running in** is +dropped, so a broken host cannot brick the running copy, while a staged copy +one slot lower may rewrite the resident — which is what a self-update is. The loader never clears the SPM buffer before a fill, so **one `W` may program the wrong bytes, and the host is what fixes it**. The buffer is write-once per word until cleared, and two things leave words in it: a refused page, and — where SPM runs from anywhere, the tinies and the m48s — an application that -self-programmed before entering. The next `W` takes those stale words and -clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on the -tinies), so repeating it programs correctly. The host therefore verifies every -page it writes and rewrites what comes back wrong (three retries, then it +self-programmed before entering. The next page write takes those stale words +and clears them, since a page write auto-erases the buffer (§26.2.1; §19.2 on +the tinies), so repeating it programs correctly. The host therefore verifies +every page it writes and rewrites what comes back wrong (three retries, then it stops). -`w` is host-paced: send the next byte only after the previous byte's `+`. `F` -returns the bytes in the hardware's Z order; on a chip without an extended -fuse byte that slot carries no meaning. Fuse *writing* does not exist — SPM -reaches flash and boot lock bits only. +`g` is host-paced: send the next byte only after the previous byte's `+`. Fuse +*writing* does not exist — SPM reaches flash and boot lock bits only. `J` is the one control-transfer primitive: it runs the application (word 0 or -the trampoline word, both known from the info block) and moves between loader +the trampoline word, both derived from the chip) and moves between loader copies during a self-update. A jump to a slot's base re-enters that copy's own startup, which must then be knocked afresh. -The info block (`b`): +`b` answers with the loader's identity — its version and the chip's signature — +and nothing else. Everything else the host needs (page size, loader base, +EEPROM size, whether the reset vector must be patched, how many flash banks) +follows from the signature, and the host holds that table; the loader derived +the same facts from its own chip database at build time, so nothing is guessed, +it is simply not sent twice. -| Offset | Content | -|---|---| -| 0–2 | `'P'`, `'B'`, pureboot version (3) | -| 3–5 | device signature | -| 6 | SPM page size in bytes (0 means 256) | -| 7–8 | loader base — application flash ends here (a word address when bit 1 is set) | -| 9–10 | EEPROM size | -| 11 | bit 0: host must patch the reset vector (no hardware boot section); bit 1: flash wire addresses are word addresses | +An update image, though, is a bare 512-byte slot with no device to ask, and +installing one built for another chip bricks the target. Every loader image +therefore carries a six-byte **stamp** — `'P'`, `'B'`, the version, the three +signature bytes — which the loader itself never reads and the host tool refuses +to install a mismatch against. ## Version -The info block's third byte is the **pureboot version** — the loader's one -identity number, and the only way to tell what a deployed loader is. Nothing -else is numbered: the wire protocol has no version, a pureboot version implies -it, and the host tool holds that map. The tool states the window of loader -versions it speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a -version that changes the protocol becomes the new floor there. None has so -far: 1 through 3 speak the identical session. A loader newer than the tool is -refused by name rather than decoded on the assumption that nothing moved. +`b`'s first byte is the **pureboot version** — the loader's one identity +number, and the only way to tell what a deployed loader is. Nothing else is +numbered: the wire protocol has no version, a pureboot version implies it, and +the host tool holds that map. The tool states the window of loader versions it +speaks (`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`), and a version that +changes the protocol becomes the new floor there. A loader newer than the tool +is refused by name rather than decoded on the assumption that nothing moved. + +Two generations exist. **1 through 4** speak one session — a 12-byte info block +from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses). +**5** replaced those with the single `G`/`g` pair over selector-named spaces +above; the shipped tool speaks both, choosing on the version it reads, so a +deployed pureboot 4 stays drivable and self-updatable to 5. + +Collapsing four command bodies into one transfer loop is what paid for the +version: the data space, the host-issued SPM operations and the fuses now share +the loop, the cursor and the argument decode that `R`/`r`/`w` each carried a +copy of. The loader shrank while gaining all three. The tool carries its own version, free to drift; `--version` prints it and the window. @@ -252,11 +320,10 @@ with any pureboot build — a re-timed window, a newer version — using the loader itself as its own staging loader. The image is the loader's own 512 bytes as a raw binary, or the Intel HEX the build emits beside it. -The preflight refuses an image built for another chip: the info block embedded -in every pureboot binary (signature, page size, loader base, EEPROM size, -flags) must match the device's own, and the error names both. Die revisions -share their base signature and geometry, so their images are interchangeable — -as the silicon is. +The preflight refuses an image built for another chip: the stamp every pureboot +binary carries must resolve to the device's own geometry, and the error names +both. Die revisions share their base signature and geometry, so their images +are interchangeable — as the silicon is. 1. The staging slot `[base−512, base)` is saved to a host-side state file (on the 1 KB tiny13s that is the whole application, vectors included). @@ -293,8 +360,8 @@ to reset gets its reset pulse and opens the activation window by itself. --info --fuses --flash app.hex Operations run in a fixed order within one session: info, fuses, loader -update, flash (erase / program / read / verify), EEPROM (the same) — then the -loader hands over to the application. `--stay` keeps the session alive +update, flash (erase / program / read / verify), EEPROM (the same), then +`--peek`/`--poke` — then the loader hands over to the application. `--stay` keeps the session alive instead, and a later invocation reconnects into it. `--flash` and `--eeprom` verify by read-back unless `--no-verify`, and a flash page that reads back wrong is rewritten up to three times before the run stops (see `W` above). @@ -302,9 +369,18 @@ wrong is rewritten up to three times before the run stops (see `W` above). extension. `--force` overrides the refusable safety checks — today, flashing application data into a mega's reset walk region. -Readouts come one fact per line: `--info` decodes the info block field by -field, `--fuses` each fuse byte plus, on a boot-sectioned mega, its decoded -meaning. Transfers that take wire time draw a transient progress bar on stderr +`--autobaud` opens with the calibration pulse instead of the plain knock, for a +loader built `SERIAL autobaud`; the rest of the session is identical, at +whatever `--baud` the host chose. + +`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) — +SRAM, and through the same address space the register file and every I/O +register. Reading an I/O register can have side effects (reading UDR clears its +flags), which is the caller's business to know. + +Readouts come one fact per line: `--info` prints the device's version and +signature and the geometry that follows from them, `--fuses` each fuse byte +plus, on a boot-sectioned mega, its decoded meaning. Transfers that take wire time draw a transient progress bar on stderr when it is a tty. `-v`/`--verbose` adds the decisions as they happen: knock counts, the programming plan, update state handling and per-phase page counts. @@ -323,15 +399,19 @@ Per chip preset, `ctest` runs: the configuration space produces, and a shape the ladder default (always the *fastest* rate a clock reaches) never picks. Pins are immediate operands and the timeout is a constant: neither is an axis; -- `pbm_*.size` — under `--full`, the exhaustive cross product replacing that - compact matrix: every plausible oscillator (the internal ones, the CKDIV8 - floor, the plain and the UART crystals) × every rate reachable from it × - every backend, unreachable combinations dropping out rather than aborting - the configure. Bounded to one chip per size-bearing class — flash - addressing, hand-over shape, page size, USART inventory — since everything - else in the image is chip-independent code; -- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, the - info block within the image's first 256 bytes; +- `pureboot_autobaud.size` — the clock-free build, which has no clock or baud + axis of its own: one binary per chip has to serve every point the matrix + below sweeps; +- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product + replacing that compact matrix, on **every** chip: every plausible oscillator + (the internal ones, the CKDIV8 floor, the plain and the UART crystals) × + every rate reachable from it × every backend, unreachable combinations + dropping out rather than aborting the configure. Thousands of points per + chip, and cheap enough to run rather than reason about; +- `pureboot.pi` — the position-independence lint: no absolute `jmp`/`call`, no + flash-resident section but `.text`, and the image byte-identical when linked + at a different base — which is position independence itself rather than a + proxy for it; - `pureboot.planner` — the host tool's pure logic: programming orders and their recovery properties, the surgery, the staging composition, the boot-fuse decode, the update preflight over synthetic fuse bytes, and the repairing @@ -361,7 +441,13 @@ Per chip preset, `ctest` runs: anywhere, which is what makes the path constructible; - `pureboot.update` — the full `--update-loader` flow, then every power-fail phase: the device is killed mid-write, restarted from its flash dump, and a - re-run must complete the update with the application intact. + re-run must complete the update with the application intact; +- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty + bridge: the calibration handshake, a flash + EEPROM + fuse round trip against + the simulator's own memory, a data-space round trip, the hand-over — then the + same binary again at double the clock, which is the property the backend + exists for. A lone calibration pulse with no knock behind it must still let + the application boot, so no wait in activation can be unbounded. `size`, `pi` and `planner` are host logic and run anywhere; the simulator-driven targets need simavr and a pty, so they are POSIX-only. diff --git a/pureboot/autobaud.md b/pureboot/autobaud.md deleted file mode 100644 index 1b4de02..0000000 --- a/pureboot/autobaud.md +++ /dev/null @@ -1,251 +0,0 @@ -# pureboot autobaud — findings, and how the version question settled itself - -The autobaud loader measures the host's bit timing **at runtime** from a -calibration pulse, so the image carries no clock: one clock-agnostic binary per -chip runs at any F_CPU and locks onto whatever baud the host sends. It exists -for the software-serial deployments — the RC-oscillator parts (the tinies, -internal-oscillator megas) whose exact clock is uncertain and drifts, so today -each needs a per-clock build. Autobaud erases that axis. That is the win — -deployment, not bytes. - -This document records how the fit was established, why the two versions that -were under review are both dead, and what the loader looks like now. - -## The decision, settled by measurement - -Two autobaud loaders were built for review, differing in one tradeoff — the -running-slot write guard against strict purity. `pureboot_autobaud_pure.cpp` -landed at **508 B** on the 1284P (4 B spare) and `pureboot_autobaud_reg.cpp` at -**512** (zero spare). - -Then hardware testing found a defect that neither could absorb. - -**A single spurious calibration pulse wedged the loader.** `run()` budgeted only -the start-edge wait inside `measure()`; the `rx()` that read the knock behind it -was unbudgeted and blocked forever. One stray low pulse on an unattended device -— EMI, or a host that opens the port and never knocks — held the loader in its -activation loop and the application never ran. On a field device that is a hang, -not a hiccup, and it is exactly the deployment autobaud is for. - -The fix is to bound the whole activation: an expired knock budget returns a byte -that cannot be the knock, so control falls back into the budgeted `measure()`, -and a line that stays idle boots the application there. It costs about 22 bytes. - -| 1284P, with the activation fix | size | 512 B budget | -|---|---|---| -| `pureboot_autobaud_pure.cpp` | 530 | **over by 18** | -| `pureboot_autobaud_reg.cpp` | 534 | **over by 22** | -| `pureboot_autobaud_uni.cpp` | **464** | **48 B spare** | - -Both candidates were unshippable, and the margin they were competing over was -never real — it was the space the missing fix should have occupied. So the -choice is not between them. It is the third loader below, which fits with room -to spare *and* carries features neither had. The two sources stay in the tree -for the record; only the unified one is built. - -## The unified loader - -The insight that paid was the one that had already paid once: **merging command -bodies removes cost that moving them around only redistributes.** Folding `R`, -`r` and `w` into a single address-and-count path had been worth 14 B earlier. -Pushed further — one read command and one write command over *named spaces* — -it is worth far more, because four transfer loops collapse into one. - -`pureboot_autobaud_uni.cpp` is pureboot 5. It is strictly pure: no inline -assembly, no global register variable, and **no GPIOR either** — the measured -unit lives in a plain static, so the loader claims no chip resource an -application might want, and the GPIOR-versus-static question disappears along -with the chips that have no GPIOR. - -### The protocol - -| command | arguments | | -|---|---|---| -| `b` | — | version, then the three signature bytes | -| `J` | addr16 | ack, then jump (word address) | -| `W` | sel8, addr16, page bytes | fill the flash page buffer | -| `G` | sel8, addr16, n8 | read n bytes (0 means 256) | -| `g` | sel8, addr16, n8, then n bytes | write, each byte acked | - -`sel` is `space | bank << 4`. The low nibble names the space; the high nibble is -flash's third address byte, so every transfer speaks a **byte** address inside a -64 KiB bank and no command has to carry word addresses. The host must not span a -bank boundary in one transfer — it already chunks by page, so nothing it does -comes close. - -| space | | | -|---|---|---| -| 0 | flash | `lpm`/`elpm` | -| 1 | EEPROM | | -| 2 | data | SRAM — and with it the register file and every I/O register, which share the data address space on AVR | -| 3 | fuse and lock | | -| 4 | SPM | write-only: the data byte goes to SPMCSR and fires the instruction at the selected address | - -Three things follow from that table that the loader never had: - -- **RAM read and write**, the missing feature. In a per-command design it would - have cost a fresh dispatch arm and a fresh loop, ~30 B, on a loader with 4 B - spare. As one more space on a shared loop it is a single `ld`/`st`. It also - hands the host arbitrary **I/O register access** for free, because AVR maps - the peripherals into the same address space. -- **Host-driven SPM.** `W` used to end with a hardcoded erase, write and RWW - re-enable — 42 B. Those are now three writes to the SPM space, reusing the - store path's own address, data byte and ack. The host pays three extra - round-trips per page (18 wire bytes against 256 of data) and gains the ability - to issue *any* SPM operation, lock bits included. -- **`W` on the same footing as everything else.** It takes the same selector and - the same byte address instead of a word address of its own, which made flash - addressing uniform across the protocol *and* was 20 B cheaper than keeping its - private convention. - -Read and write are `G` and `g` — the same letter, one bit apart — so the -transfer loop picks its direction with a one-word skip rather than a compare. - -### Why the SPM space has to be one primitive - -It is tempting to go further and expose a generic "poke this I/O register", from -which the host could drive SPM itself. The hardware forbids it: `out SPMCSR, x` -and the `spm` that follows must issue within four cycles, and EEPROM's -EEMPE→EEPE window is the same shape. A host cannot hit a four-cycle window -across a serial link. **Atomicity is the floor, and the atomic unit must be -resident.** That is the real limit on how low-level a bootloader's primitives -can go — not the byte count. - -## Where the bytes went - -The starting point was the 508 B pure build, disassembled and attributed: - -| phase | bytes | -|---|---| -| `link::rx` + `link::tx` (bit-banged UART) | 102 | -| autobaud measure + knock | 62 | -| reset vector, pin init, WDRF check, jump, ack, EEPROM wait | 50 | -| command loop head + dispatch tree | 42 | -| `W` program flash page | 98 | -| `R` / `r` / `w` / `F` bodies, plus the shared address decode | 122 | -| `b` info, `J` jump | 32 | - -**208 B — 41% — is physical layer and activation**, which no protocol change can -touch. The command bodies were the entire addressable surface, and they were -four copies of one idea. - -The route from a first attempt to the final loader, all on the 1284P: - -| step | size | -|---|---| -| unified `G`/`P`, `load`/`store` outlined, `__uint24` cursor | 600 | -| …`load`/`store` inlined; unit in `.noinit`, not `.bss` | 534 | -| …16-bit cursor with the bank in the selector; direction as a command bit | 510 | -| …erase/write/RWW moved out to the SPM space | 484 | -| …`W` sharing the selector-and-address decode | **464** | - -Three of those steps are worth keeping as lessons: - -- **Outlining `load`/`store` cost more than the four bodies they replaced.** As - functions they were 110 B against the 106 B of inline bodies — the AVR ABI's - argument marshalling plus prologue ate the entire saving. Inlined into the one - shared loop they cost only their own instructions. The call-site lesson cuts - both ways: *merging* call sites pays, *creating* one does not. -- **A `.bss` static drags in `__do_clear_bss`** — 18 B of startup code to zero a - variable that is always measured before it is read. `.noinit` is correct here - and free. -- **A three-byte cursor taxes every space.** Widening the shared cursor so flash - could reach past 64 KiB put an extra increment on EEPROM and RAM reads that - never need it. Moving the bank into the selector byte kept the cursor at - sixteen bits and cost nothing on the wire. - -### What did not work - -- **Encoding the space in the command byte** (so dispatch becomes masking rather - than a compare tree) cannot carry the bank's four bits alongside a space. The - cheap half of the idea survived as the direction bit; the rest lost to the - selector byte, which is also more extensible. -- **A generic primitive interpreter** — a loader with no logic at all, driven - entirely by the host — is not reachable on AVR. Harvard architecture means the - program counter cannot fetch from data space, so the classic "upload a flash - algorithm into RAM and jump to it" bootstrap is impossible, and on every - boot-sectioned part SPM only takes effect from the boot section anyway. What - remains is a fixed primitive set: still a protocol, still logic, only at a - different granularity. debugWIRE reaches that design point only because its - interpreter is *in silicon*; it costs the loader nothing because it is not in - the loader. -- Below 512 B the saved bytes are largely unspendable on the boot-sectioned - chips: the 328P's smallest boot section is exactly 512 B, and the 1284P's is - 1024 B, of which pureboot already occupies only the top half. The margin - matters as headroom for correctness fixes — as this defect showed — not as - flash returned to the application. On the patch-vector parts, which have no - boot section, it *is* returned: on the ATtiny13 the loader is 43% of a 1 KiB - part, and every byte is real. - -## The codegen coupling, still load-bearing - -`count >> 2` is exact only because the calibration pulse's bit-count (7, from -the 0xC0 byte) equals the poll loop's cycles per iteration (7 — `sbis` 1, -`rjmp` 2, `adiw` 2, `rjmp` 2). The loop shape survived every restructuring here, -verified in the disassembly, but a toolchain bump that reshapes it would break -the lock silently. `test/pbautobaud.py` is what pins it: a wrong unit fails the -flash verify. - -## Sizes — every chip - -Budget 510 B on the patch-vector parts, 512 elsewhere. The 1284P is no longer -the tight one: the bank nibble made far flash *cheaper* than the near-flash -arithmetic it replaced. - -| size | chips | budget | spare | -|---|---|---|---| -| 444 | ATtiny13, 13A | 510 | 66 | -| 448 | ATmega48, 48A, 48P, 48PA; ATtiny25 | 510 | 62 | -| 452 | ATtiny45, 85 | 510 | 58 | -| 460 | ATmega644, 644A, 644P, 644PA | 512 | 52 | -| 464 | **ATmega1284, 1284P**; ATmega8, 8A, 88, 88A, 88P, 88PA | 512 | 48 | -| 466 | ATmega16, 16A, 32, 32A; 164A/P/PA, 168/A/P/PA, 324A/P/PA, 328, 328P | 512 | 46 | - -All 37 chips build and size-test green, plus the 12-preset reflect spot set -(guidance rule 4 — the reflect matrix is never run in full), which matches its -generated counterpart byte for byte on every chip in the set. Worst case across -the whole set is **466 B, 46 under budget**. - -## Host tool and simulation - -- **`pureboot.py`** speaks both generations. `Info.version >= 5` selects the - unified path; everything below it keeps the four-command protocol, so the - fixed-baud loader is untouched. `--autobaud` sends the 0xC0 pulse and one - knock, then derives full geometry from the signature. New: `--peek ADDR[:N]` - and `--poke ADDR:HEX` reach the data space. -- **`test/pbautobaud.py`** drives the loader over the GPIO⇄pty software-UART - bridge through the calibration handshake, a flash + EEPROM + fuse round-trip - cross-checked against the simulator's own memory, a RAM read/write round-trip, - and a hand-over to the fixture application — then repeats at double the F_CPU - with the same binary, which is the clock-agnostic property autobaud exists - for. Run on the near-flash 328P and the word-addressed 1284P. -- It also **pins the activation hang**: the test sends a lone calibration pulse - with no knock behind it and requires the application to boot. Against the - unfixed loader that assertion never returns. - -## What remains - -- **Real-hardware acceptance.** A cycle-exact simulator cannot produce what - autobaud exists for: a real RC oscillator at ±10% with drift and jitter. - simavr proves the arithmetic and the fit at exact clocks; only silicon proves - the feature. Drive an internal-oscillator ATtiny at a fixed host baud and - confirm lock plus a full flash and verify. -- **A generic `spm::command()` in libavr.** The SPM space issues a runtime - command through `spm::detail::page_command` where RAMPZ exists, and falls back - to a dispatch over the known operations where it does not — the one - preprocessor branch in the file. A two-line library addition would make it - uniform and save a few bytes on the 36 non-RAMPZ chips, none of which are - tight. -- **Retire or revive the two dead variants.** They are kept only as the record - of the measurement; nothing builds them. - -## Files - -- `pureboot_autobaud_uni.cpp` — the loader. pureboot 5. -- `pureboot_autobaud_pure.cpp`, `pureboot_autobaud_reg.cpp` — superseded, not - built; 530 and 534 B on the 1284P once the activation hang is fixed. -- `pureboot.py` — `--autobaud`, the unified transfer path, `--peek`/`--poke`. -- `test/pbautobaud.py` — the end-to-end sim test and the hang regression. -- `local/scratch/autobaud/floor_1284.S` (libavr checkout) — the hand-asm floor - probe at 506 B, off-tree and gitignored; a size reference only. The unified - loader is 42 B under it, with features the probe never had. diff --git a/pureboot/pureboot.cpp b/pureboot/pureboot.cpp index 17c3d26..dab1429 100644 --- a/pureboot/pureboot.cpp +++ b/pureboot/pureboot.cpp @@ -26,14 +26,17 @@ constexpr std::uint8_t ack = '+'; // Deployment parameters come from the build (pureboot_add_loader()). The // signature is not one of them: the chip database is the only universal -// source — a tiny13A cannot read its own signature row from code. -#if !defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD) +// source — a tiny13A cannot read its own signature row from code. An autobaud +// build carries no clock and no baud at all; it measures both. +#if !defined(PUREBOOT_AUTOBAUD) && (!defined(PUREBOOT_CLOCK_HZ) || !defined(PUREBOOT_BAUD)) #error \ - "PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader() (README.md)" + "PUREBOOT_CLOCK_HZ and PUREBOOT_BAUD select this build's clock and baud — create loader targets with pureboot_add_loader(), or PUREBOOT_AUTOBAUD for a clock-free one (README.md)" #endif +#if !defined(PUREBOOT_AUTOBAUD) using dev = avr::device<{.clock = avr::hertz_t{PUREBOOT_CLOCK_HZ}}>; constexpr avr::baud_t wire_baud{PUREBOOT_BAUD}; +#endif // The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. consteval std::int16_t wdrf_field() @@ -47,60 +50,110 @@ consteval std::int16_t wdrf_field() // runs from anywhere (Atmel-8271 §26) — keep the application's relocated // reset vector in the word under the slot. constexpr std::uint16_t slot_bytes = 512; -constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; constexpr std::uint16_t page = spm::page_bytes; constexpr bool boot_section = avr::hw::curated::has_boot_section(); -// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash -// addresses there are word addresses ('J' always was one). A slot is 256 of -// those — one value of a wire address's high byte, where 512 bytes span two. -constexpr bool word_flash = spm::flash_bytes > 65536; -constexpr std::uint16_t wire_base = - word_flash ? static_cast(base / 2) : static_cast(base); +// Past 64 KiB one bank of flash does not cover the chip, so a transfer's +// selector byte carries the bank and the wire address stays a byte address +// within it. 'J' is the exception: it is a word address everywhere, because +// that is what the hardware's own jump takes. +constexpr bool banked_flash = spm::flash_bytes > 65536; // A compile-time window, so the whole EEPROM belongs to the application; -// re-timing a deployed loader is a self-update with a re-timed build. +// re-timing a deployed loader is a self-update with a re-timed build. An +// autobaud build has no clock to convert seconds against and counts polls. #if !defined(PUREBOOT_TIMEOUT) #define PUREBOOT_TIMEOUT 8 #endif constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; +#if !defined(PUREBOOT_AUTOBAUD_POLLS) +#define PUREBOOT_AUTOBAUD_POLLS 4000000 +#endif +constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; + // The loader's one identity number. The protocol carries none of its own — // a version implies it, and the host tool holds that map (README.md). -constexpr std::uint8_t version = 4; +constexpr std::uint8_t version = 5; -// The 'b' reply, byte for byte (layout: README.md). Flash-resident because -// no crt copies a .data image — and flash_table's storage carries the word -// alignment 'b' needs to halve the address on the large chips. -// One wire byte per line: this is the reply's layout, not a list. +// The image's identity stamp, for the host tool rather than for the wire: an +// update image is a bare 512-byte slot, and without this nothing in it says +// which chip it was built for. The tool refuses to install an image whose +// stamp does not match the device — flashing a foreign loader bricks the +// target, and the loader itself cannot check what has already replaced it. +// +// Never read from flash by the loader — 'b' answers out of this array, but at +// constant indices, so those fold to immediates and no runtime address of it +// is ever formed. `used` keeps the compiler from dropping the copy the host +// needs and `retain` keeps --gc-sections from collecting it. // clang-format off -inline constexpr avr::flash_table{ - 'P', - 'B', - version, +[[gnu::used, gnu::retain, gnu::section(".text.stamp")]] +inline constexpr std::uint8_t identity_stamp[]{ + 'P', 'B', // the magic the host scans an image for + version, // and from here on, exactly what 'b' answers avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2], - static_cast(page), // 0 means 256 - wire_base & 0xff, - wire_base >> 8, - avr::hw::db.mem.eeprom_size & 0xff, - avr::hw::db.mem.eeprom_size >> 8, - static_cast((boot_section ? 0 : 1) | (word_flash ? 2 : 0)), // patch-vector, word-addressed -}> - info_data; +}; // clang-format on +// Where the identity proper starts: past the magic the host scans for. +constexpr std::uint8_t stamp_identity = 2; -// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL, -// defaulting to the chip's USART0 where it has one. The software receiver is -// the polled one: the vector table belongs to the application. Templates on -// the clock, so only the selected backend instantiates. pending() is the -// cheap line test the activation window polls; drain() holds until the last -// frame is off the wire, so a hand-over cannot let the target's re-init clip -// the ack. +// The address spaces a transfer can name, in a selector byte's low nibble. +// Flash is 0 so it is the cheapest to select. +// +// spm_ops is the one that is not memory: a write there hands its byte to +// SPMCSR and fires the instruction at the transfer's address, which is how +// page erase, page write and RWW re-enable reach the wire without the loader +// carrying a command for each. The hardware's four-cycle store-to-SPM window +// is why this is one fused primitive and not a poke of SPMCSR — no host can +// hit that window across a serial link. +enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_data = 2, sp_fuse = 3, sp_spm = 4 }; + +// A selector's high nibble is the flash bank — the address bits above the +// 16-bit wire address, RAMPZ on the chips that have one. Keeping it here +// rather than widening the wire address is what lets one 16-bit cursor serve +// every space: a 24-bit cursor would pay its extra byte on EEPROM and data +// reads that can never need it. +[[gnu::always_inline]] inline std::uint8_t space_of(std::uint8_t selector) +{ + return selector & 0x0f; +} + +[[gnu::always_inline]] inline std::uint8_t bank_of(std::uint8_t selector) +{ + return static_cast(selector >> 4); +} + +// The slot a flash address falls in, as one byte. A slot is half as many words +// as bytes, so the word address's high byte is exactly this index — which is +// what lets the write guard compare a single byte, and what the running copy's +// own return address yields for free. +constexpr std::uint8_t slot_shift = std::countr_zero(slot_bytes); +constexpr std::uint8_t bank_shift = 16 - slot_shift; + +[[gnu::always_inline]] inline std::uint8_t slot_of([[maybe_unused]] std::uint8_t bank, std::uint16_t at) +{ + const auto within = static_cast(at >> slot_shift); + if constexpr (banked_flash) + return static_cast((bank << bank_shift) | within); + else + return within; +} + +// The serial link, per the build's PUREBOOT_USART / PUREBOOT_SOFT_SERIAL / +// PUREBOOT_AUTOBAUD, defaulting to the chip's USART0 where it has one. The +// software receiver is the polled one: the vector table belongs to the +// application. Templates on the clock, so only the selected backend +// instantiates. pending() is the cheap line test the activation window polls; +// drain() holds until the last frame is off the wire, so a hand-over cannot +// let the target's re-init clip the ack. #if defined(PUREBOOT_SOFT_SERIAL) && defined(PUREBOOT_USART) #error "PUREBOOT_SOFT_SERIAL and PUREBOOT_USART select opposing serial backends" #endif +#if defined(PUREBOOT_AUTOBAUD) && defined(PUREBOOT_USART) +#error "PUREBOOT_AUTOBAUD measures a software link; it cannot drive a hardware USART" +#endif #if !defined(PUREBOOT_RX) #define PUREBOOT_RX pb0 #endif @@ -113,9 +166,9 @@ constexpr char usart_digit = '0' + PUREBOOT_USART; constexpr char usart_digit = '0'; #endif -template +template struct hardware_link { - using uart = avr::uart::usart; + using uart = avr::uart::usart; // The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // sbiw + sbci + sbci + brne (6). @@ -147,10 +200,10 @@ struct hardware_link { } }; -template +template struct software_link { - using rx_t = avr::uart::software_rx_polled; - using tx_t = avr::uart::software_tx; + using rx_t = avr::uart::software_rx_polled; + using tx_t = avr::uart::software_tx; // The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // sbci + brne (6). @@ -182,14 +235,44 @@ struct software_link { } }; -#if defined(PUREBOOT_USART) +// The clock-free link: the bit period is measured from the host's calibration +// pulse instead of derived from a clock, so one image serves every F_CPU and +// every rate. Activation differs in kind from the other two — there is no +// clock to time a window against — so this backend brings its own, below. +struct autobaud_link { + using uart = avr::uart::software_autobaud; + + static void init() + { + avr::init(); + } + + static std::uint8_t rx() + { + return uart::template read(); + } + + static void tx(std::uint8_t byte) + { + uart::template write(byte); + } + + static void drain() + { + uart::drain(); + } +}; + +#if defined(PUREBOOT_AUTOBAUD) +using link = autobaud_link; +#elif defined(PUREBOOT_USART) static_assert(avr::uart::has_usart(), "PUREBOOT_USART selects a hardware USART this chip does not have"); -using link = hardware_link; +using link = hardware_link; #elif defined(PUREBOOT_SOFT_SERIAL) -using link = software_link; +using link = software_link; #else -using link = - std::conditional_t(), hardware_link, software_link>; +using link = std::conditional_t(), hardware_link, + software_link>; #endif // The application's entry, pinned by the linker (--defsym): word 0 on a @@ -209,6 +292,27 @@ extern "C" [[noreturn]] void pureboot_app(); jump(pureboot_app); } +// Activation: a bounded wait for the host, then the knock. Both forms boot the +// application when the window closes on an idle line, and both bound *every* +// wait — a knock awaited without a deadline would let one stray edge hold an +// unattended device in the loader forever. +#if defined(PUREBOOT_AUTOBAUD) +// The window is a fixed poll budget: with no clock, whole seconds cannot be +// timed. A uint24_t holds it — a fourth byte would cost two words at every +// countdown step for range never used. +void await_host() +{ + for (;;) { + if (!link::uart::calibrate(autobaud_budget)) + run_app(); + // The calibration pulse has already proven a host is there, so one + // byte activates. A knock that never arrives falls back to calibrate(), + // whose own budget then boots the application. + if (link::uart::template read(autobaud_budget) == 'p') + return; + } +} +#else // The window as one 32-bit countdown, divided by the backend's counted // poll-loop cycles. Whole seconds is all it promises. consteval std::uint32_t window_polls() @@ -235,6 +339,14 @@ std::uint8_t rx_deadline() return link::rx(); } +void await_host() +{ + // 'p' then 'b', each under a fresh window; anything else is line noise. + while (rx_deadline() != 'p' || rx_deadline() != 'b') { + } +} +#endif + // Inlined: read across a call, the first byte strands in a call-saved // register the caller has to push and pop. [[gnu::always_inline]] inline std::uint16_t rx16() @@ -252,132 +364,92 @@ std::uint8_t rx_deadline() return std::bit_cast(pair); } -// Counts arrive in the wire's 8-bit form: 0 means 256. Both streamers fold -// into the one command that reads flash, which is what lets the far one's -// 24-bit cursor sit in the command loop's own call-saved registers. -[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count) -{ - do - link::tx(avr::flash_load(reinterpret_cast(address++))); - while (--count); -} - -// The 24-bit cursor as the machine holds it — the RAMPZ byte and a 16-bit Z, -// carried apart; the reassembled address folds away inside the far load. -[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count) -{ - std::uint8_t rampz = static_cast(address >> 15); - std::uint16_t z = static_cast(address << 1); - do { - link::tx(avr::flash_load_far((static_cast(rampz) << 16) | z)); - // Carrying the wrap is smaller than the flat 32-bit cursor GCC - // builds without it. - if (++z == 0) - ++rampz; - } while (--count); -} - -[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count) -{ - if constexpr (word_flash) - send_flash_far(address, count); - else - send_flash_near(address, count); -} - -// Out of line: three sites send it, and a call is shorter than three -// load-immediates. +// Out of line: several sites send it, and a call is shorter than a +// load-immediate at each. [[gnu::noinline]] void tx_ack() { link::tx(ack); } -void send_eeprom(std::uint16_t address, std::uint8_t count) +// A wire address and its selector's bank as the flash address they name. +[[gnu::always_inline]] inline spm::flash_address_t flash_address([[maybe_unused]] std::uint8_t bank, std::uint16_t at) { - do - link::tx(ee::read(address++)); - while (--count); + if constexpr (banked_flash) + return (static_cast(bank) << 16) | at; + else + return at; } -// Host-paced: the ack goes out once the write has begun, so the next byte -// arrives while it completes and nothing is missed without a buffer. -void store_eeprom(std::uint16_t address, std::uint8_t count) +// One byte out of any space. Every accessor shares the transfer's cursor, its +// loop and its call site, so a space costs only its own instruction rather +// than a body, a loop and a dispatch arm of its own. +[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, [[maybe_unused]] std::uint8_t bank, + std::uint16_t at) { - do { - ee::write(address++, link::rx()); - tx_ack(); - } while (--count); + if (space == sp_eeprom) + return ee::read(at); + if (space == sp_data) + return *reinterpret_cast(at); + if (space == sp_fuse) + return spm::read_fuse(static_cast(at)); + if constexpr (banked_flash) + return avr::flash_load_far(flash_address(bank, at)); + else + return avr::flash_load(reinterpret_cast(at)); } -// One page into the SPM buffer, then erase and program — except the slot -// this code is running in (`slot_high`, from run()), which is drained and -// left alone. A broken host therefore cannot brick the running loader, and a -// copy one slot lower may rewrite the resident one. +// One byte into a writable space. Flash is not one of them — it arrives a +// page at a time through 'W' and is committed through sp_spm — and the fuses +// are not writable at all: SPM reaches flash and boot lock bits only. +[[gnu::always_inline]] inline void store(std::uint8_t space, std::uint8_t bank, std::uint16_t at, std::uint8_t value, + std::uint8_t slot_high) +{ + if (space == sp_data) { + *reinterpret_cast(at) = value; + return; + } + if (space == sp_spm) { + // The running-slot write guard. An SPM command aimed at the slot this + // code executes from is dropped, so a broken host cannot brick the + // running loader — while a copy one slot lower may still rewrite the + // resident one, which is what a self-update is. Guarding the commit + // rather than the page fill covers erase and write both, and leaves a + // refused page's words in the buffer: harmless, since the next page + // write auto-erases it (§26.2.1). + if (slot_of(bank, at) != slot_high) + spm::command(value, flash_address(bank, at)); + // Only a boot-sectioned mega runs on while its RWW section programs; + // everywhere else the CPU halts through erase and write, so the wait + // is already over by the time it returns. + if constexpr (boot_section) + spm::wait(); + return; + } + // Host-paced: the ack goes out once the write has begun, so the next byte + // arrives while it completes and nothing is missed without a buffer. + ee::write(at, value); +} + +// One page into the SPM buffer, and only that: the erase and the write that +// commit it are host-issued sp_spm stores, which reach the same fused +// store-and-SPM pair through the transfer path's own address and data. // // Nothing discards the buffer first: it is write-once per word (§26.2.1), so // filling over a refused page or an application's leavings programs stale // words — but a page write auto-erases it (§26.2.1; §19.2 on the tinies), so // that write clears the condition and the host's read-back rewrites the page. -void program_flash(std::uint16_t wire_address, std::uint8_t slot_high) +void fill_page(std::uint8_t bank, std::uint16_t at) { // The address names a page, so its in-page bits are dropped and the walk - // starts at the page base — one induction either way: a byte-addressed - // wire address walks the page itself (the offset bits wrap back to zero), - // while a word one becomes a byte cursor once. The slot index is the wire - // address's high byte — on byte-addressed chips the byte address's, with - // the low bit dropped, since a slot is two of those. - spm::flash_address_t address; - std::uint8_t page_high; - if constexpr (word_flash) { - // A page is aligned, so it never crosses 64 KiB: RAMPZ is a per-page - // constant and the 16-bit Z's low byte is the whole in-page offset. - const std::uint8_t rampz = static_cast(wire_address >> 15); - const std::uint16_t z0 = static_cast(wire_address << 1) & ~static_cast(page - 1); - std::uint16_t z = z0; - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill((static_cast(rampz) << 16) | z, word_of({low, high})); - z += 2; - } while (static_cast(z)); - address = (static_cast(rampz) << 16) | z0; - page_high = static_cast(wire_address >> 8); - } else { - address = static_cast(wire_address & ~static_cast(page - 1)); - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill(address, word_of({low, high})); - address += 2; - } while (static_cast(address) & (page - 1)); - address -= 2; // back inside the page — erase and write ignore the word bits - page_high = static_cast(address >> 8) & 0xfe; - } - if (page_high != slot_high) { - // Only a boot-sectioned mega runs on while its RWW section programs; - // everywhere else the CPU halts through erase and write. - spm::erase_page(address); - if constexpr (boot_section) - spm::wait(); - spm::write_page(address); - if constexpr (boot_section) - spm::wait(); - } - // Programming leaves the RWW section disabled; reads need it back on. The - // same store discards the buffer (§26.2.2), so a boot-sectioned mega never - // meets the stale-word case above. - if constexpr (boot_section) - spm::rww_enable(); -} - -// The four fuse and lock bytes in the hardware's own Z order: low, lock, -// extended, high. -void send_fuses() -{ - std::uint8_t which = 0; - do - link::tx(spm::read_fuse(static_cast(which))); - while (++which != 4); + // starts at the page base; the low byte of the cursor is the whole in-page + // offset, since a page is aligned and never crosses a bank. + std::uint16_t z = at & ~static_cast(page - 1); + do { + std::uint8_t low = link::rx(); + std::uint8_t high = link::rx(); + spm::fill(flash_address(bank, z), word_of({low, high})); + z += 2; + } while (static_cast(z) & (page - 1)); } [[noreturn]] void run() @@ -389,19 +461,14 @@ void send_fuses() link::init(); - // The high byte of the slot this copy runs at, which the write guard and - // the info block both follow: the return address is a word address, so its - // high byte is the 256-word slot index, doubled back into byte terms where - // the wire counts bytes. Taken as byteswap's low byte — the builtin already - // swaps the two stacked bytes, and the double swap folds away, where `>> 8` - // would leave the swap materialized. - const std::uint16_t ra_words = reinterpret_cast(__builtin_return_address(0)); - const std::uint8_t ra_high = static_cast(std::byteswap(ra_words)); - const std::uint8_t slot_high = word_flash ? ra_high : static_cast(ra_high << 1); + // The slot this copy runs in, which the write guard follows: the return + // address is a word address and a slot is half as many words as bytes, so + // its high byte is the slot index outright. No absolute address is ever + // formed, so the image stays position-independent. + const auto return_words = reinterpret_cast(__builtin_return_address(0)); + const auto slot_high = static_cast(return_words >> 8); - // 'p' then 'b', each under a fresh window; anything else is line noise. - while (rx_deadline() != 'p' || rx_deadline() != 'b') { - } + await_host(); for (;;) { // No prompt while an EEPROM write runs: it blocks SPM and fuse reads @@ -416,47 +483,43 @@ void send_fuses() link::drain(); jump(target); } - case 'b': // info block, read relative to the running slot - case 'R': // read flash: addr16, n8 (0 = 256) - case 'r': // read EEPROM: addr16, n8 - case 'w': { // write EEPROM: addr16, n8, then n bytes each acked - // One address-and-count path for all four: 'b' is a flash read - // whose arguments the loader already knows, so it joins the - // wire-argument three rather than streaming from a call site of its - // own. That leaves one flash streamer in the image, and lets its - // cursor live in this never-returning loop's own call-saved - // registers instead of being saved and restored around a call. - std::uint16_t address; - std::uint8_t count; - if (command == 'b') { - // The block sits in the image's first 256 bytes (check_pi.py - // asserts it) and slots are 512-aligned, so the low byte of its - // link address is its offset in any slot — halved where wire - // units are words. The high byte is runtime data, so no - // absolute address is ever materialized. - const auto link_byte = - static_cast(reinterpret_cast(info_data.storage.data())); - const std::uint8_t low = word_flash ? static_cast(link_byte >> 1) : link_byte; - address = static_cast(low | (slot_high << 8)); - count = static_cast(info_data.size()); - } else { - address = rx16(); - count = link::rx(); + case 'b': // identity: the version, then the three signature bytes + // Straight out of the stamp, so the wire and the image can never + // disagree about what this loader is. The indices are constant and + // the array is constexpr, so these are immediates, not flash reads: + // nothing here needs the stamp's runtime address. + for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at) + link::tx(identity_stamp[at]); + break; + case 'W': // fill one flash page buffer: sel8, addr16, then page bytes + case 'G': // read: sel8, addr16, n8 (0 = 256) + case 'g': { // write: sel8, addr16, n8, then n bytes, each acked + // One decode, one cursor and one loop for every space and both + // directions: a command per memory would carry a copy of all three + // each. 'W' joins the same decode rather than keeping an address + // form of its own, so flash addressing is uniform across every + // command that names it. + const std::uint8_t selector = link::rx(); + const std::uint8_t space = space_of(selector); + const std::uint8_t bank = bank_of(selector); + std::uint16_t at = rx16(); + if (command == 'W') { + fill_page(bank, at); + break; } - if (command == 'r') - send_eeprom(address, count); - else if (command == 'w') - store_eeprom(address, count); - else - send_flash(address, count); + std::uint8_t count = link::rx(); + do { + // Read and write are one letter apart in case, so the direction + // is a single bit and the loop picks it with a one-word skip. + if (command & 0x20) { + store(space, bank, at, link::rx(), slot_high); + tx_ack(); + } else + link::tx(load(space, bank, at)); + ++at; + } while (--count); break; } - case 'W': // program one flash page: addr16, page bytes - program_flash(rx16(), slot_high); - break; - case 'F': // fuse and lock bytes - send_fuses(); - break; default: // unknown bytes are ignored; the loop re-acks break; } diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 6ce42a9..99893d4 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -24,7 +24,7 @@ else: import termios PROMPT = b"+" -VERSION = 3 # this tool's own version — free to drift from a loader's +VERSION = 4 # this tool's own version — free to drift from a loader's # The loader versions this tool speaks. A pureboot version implies its wire # protocol, which carries no number of its own, so this window is where that # map lives: every version so far speaks the same protocol, and one that @@ -34,36 +34,38 @@ NEWEST_LOADER = 5 SLOT = 512 # the loader slot, on every chip RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops -# pureboot 5 replaced the four per-memory commands with one unified pair: 'G' -# reads and 'g' writes, both taking a selector byte, a 16-bit address and a -# count, over the spaces below. The loader carries one transfer loop instead of -# four bodies, which is what paid for RAM access and for the calibration fix. +# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and +# 'g' writes, each taking a selector byte, a 16-bit address and a count, over +# the spaces below. The loader carries one transfer loop instead of four bodies +# — which is what buys the data space and the host-issued SPM operations. UNIFIED_LOADER = 5 SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 -# A selector's high nibble is flash's third address byte, so a transfer names a -# byte address within a 64 KiB bank and never has to speak word addresses. No -# single transfer may cross a bank boundary — the host chunks to keep that true. +# A selector's high nibble is the flash bank — the address bits above the 16-bit +# wire address — so a transfer names a byte address within one 64 KiB bank and +# no command has to speak word addresses. No single transfer may cross a bank +# boundary; the host chunks to keep that true. def selector(space, address): return space | ((address >> 16) << 4) # The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its -# data byte to SPMCSR and fires the instruction at the selected flash address. -# Every part pureboot targets agrees on these encodings. +# byte to SPMCSR and fires the instruction at the selected flash address. Every +# part pureboot targets agrees on these encodings. SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11 -# Calibration byte for the autobaud loader: 0xC0 is a start bit plus six zero +# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero # data bits — one low pulse of seven bit-times, which the loader times into its # per-bit unit. Sent at whatever baud the host chose; the loader locks to it. CALIBRATE = 0xC0 -# The autobaud loader slims its info block to the version and signature; the host -# derives the rest of the geometry from the signature. flash, page, eeprom, -# patch-vector per distinct signature, over every chip pureboot targets (the -# loader computes the same from its chip database at build time). Die revisions -# that share a signature share this row, as they share the silicon. -AUTOBAUD_GEOMETRY = { +# pureboot 5 answers 'b' with its version and the chip signature; the host +# derives the rest of the geometry from the signature rather than reading a +# table off the device. flash, page, eeprom, patch-vector per distinct +# signature, over every chip pureboot targets (the loader computes the same +# from its chip database at build time). Die revisions that share a signature +# share this row, as they share the silicon. +CHIP_GEOMETRY = { # signature : (flash, page, eeprom, patch_vector) (0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A (0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25 @@ -359,18 +361,26 @@ class Info: """The 12-byte info block.""" @classmethod - def from_slim(cls, raw): - """The autobaud loader's slimmed reply — version and signature only — - with the rest of the geometry looked up from the signature (the loader - derived it from the same chip facts at build time). Reconstructs the full - block so every derived attribute matches the fixed-baud path exactly.""" + def from_identity(cls, raw): + """pureboot 5's reply: the version and the chip signature. The rest of + the geometry is looked up from the signature — the loader derived the + same facts from its chip database at build time, so nothing is guessed, + it is simply not sent. Reconstructs a block in the older layout, so + every derived attribute below is shared with the loaders that do send + one. + + The base is where application flash ends, which is a property of the + chip and not of the copy answering: a loader staged one slot lower + reports the same geometry the resident one does, exactly as the loaders + that send a block do. Which slot a copy runs in matters only to its own + write guard, which is the loader's business.""" if len(raw) != 4: - raise Error(f"bad slim info block: {raw.hex()}") + raise Error(f"bad identity reply: {raw.hex()}") version, signature = raw[0], tuple(raw[1:4]) - geometry = AUTOBAUD_GEOMETRY.get(signature) + geometry = CHIP_GEOMETRY.get(signature) if geometry is None: sig = " ".join(f"{b:02x}" for b in signature) - raise Error(f"unknown signature {sig} — this tool has no autobaud geometry for it") + raise Error(f"unknown signature {sig} — this tool has no geometry for it") flash, page, eeprom, patch = geometry base = flash - SLOT word_flash = flash > 0x10000 @@ -393,8 +403,11 @@ class Info: self.signature = raw[3:6] self.page = raw[6] or 256 # the wire count convention: 0 means 256 self.patch_vector = bool(raw[11] & 1) - # Bit 1: flash addresses are words on the wire. Every address here - # stays a byte address and converts at the wire. + # Bit 1: the flash runs past what one 16-bit address covers. Through + # pureboot 4 that made flash addresses words on the wire; pureboot 5 + # keeps them bytes and carries the bank in the selector instead. Every + # address in this tool stays a byte address either way and converts at + # the wire. self.word_flash = bool(raw[11] & 2) scale = 2 if self.word_flash else 1 self.base = (raw[7] | (raw[8] << 8)) * scale @@ -424,7 +437,7 @@ class Info: f"version pureboot {self.version}", f"signature {' '.join(f'{b:02x}' for b in self.signature)}", f"flash {self.flash_size} B, {self.page} B pages" - + (", word-addressed wire" if self.word_flash else ""), + + (", past one 16-bit bank" if self.word_flash else ""), f"application 0x0000..{self.base - 1:#06x} ({self.base} B)", f"loader {self.base:#06x} ({SLOT} B slot)", f"staging {self.stage:#06x}", @@ -441,70 +454,69 @@ class Loader: def __init__(self, port): self.port = port self.info = None + # Set once a session is established over an autobaud link, so a + # re-entry after 'J' repeats the handshake that worked. + self.autobaud = False + + def _read_identity(self): + """The 'b' reply, in either of the two layouts a loader may send. + pureboot 5 answers with its version and the signature; older loaders + answer with a 12-byte block. The version byte cannot be mistaken for + the older block's 'P', so four bytes are enough to tell them apart.""" + head = self.port.read_exact(4, 2.0) + if head[0:2] == b"PB": + return Info(head + self.port.read_exact(8, 2.0)) + return Info.from_identity(head) + + def _handshake(self, wait, knock, what): + """One activation, retried until the loader answers or the window + closes. The identity reply is what proves the loader is listening — a + prompt byte alone does not, since one left over from a previous session + can still be in the pipeline while the port opening resets the device + into a fresh window, where a command without its knock is discarded. + Each attempt is therefore the whole handshake. This also converges into + an already-live session: the knock bytes are ignored there and the + drain absorbs whatever they produced.""" + deadline = time.monotonic() + wait + knocks = 0 + while True: + self.port.flush_input() + self.port.write(knock) + knocks += 1 + if PROMPT in self.port.read_available(0.4): + while self.port.read_available(0.3): + pass + self.port.write(b"b") + try: + # A version the tool cannot speak is the loader's own + # answer, not a failed knock: Info reports it rather than + # sending the tool round the loop again. + self.info = self._read_identity() + except Error as failed: + if "pureboot" in str(failed): + raise + self.info = None + if self.info is not None: + self._expect_prompt() + verbose(f"loader answered {what} {knocks}; identity read") + return self.info + if time.monotonic() > deadline: + raise Error("no answer — reset the device within its activation window") def connect(self, wait): - """Knock until the info block comes back. The block is what proves the - loader is listening — a prompt byte alone does not, since one left over - from a previous session can still be in the pipeline while the port - opening resets the device into a fresh activation window, where a - command without its knock is discarded. Each attempt is therefore the - whole handshake, retried until it produces the block or the window - closes. Also converges into a live session: the knock bytes are ignored - there and the drain absorbs whatever they produced.""" - deadline = time.monotonic() + wait - knocks = 0 - while True: - self.port.flush_input() - self.port.write(b"pb") - knocks += 1 - if PROMPT in self.port.read_available(0.4): - while self.port.read_available(0.3): - pass - self.port.write(b"b") - try: - block = self.port.read_exact(12, 2.0) - except Error: - block = b"" - # A version the tool cannot speak is the loader's own answer, - # not a failed knock: Info reports it rather than retrying. - if block[0:2] == b"PB": - self.info = Info(block) - self._expect_prompt() - verbose(f"loader answered knock {knocks}; info block read") - return self.info - if time.monotonic() > deadline: - raise Error("no answer — reset the device within its activation window") + """Knock 'p' then 'b' and read the identity.""" + return self._handshake(wait, b"pb", "knock") def connect_autobaud(self, wait): - """The autobaud handshake. Instead of the p+b knock, the host sends the - 0xC0 calibration pulse — a single seven-bit-time low pulse at the host's - chosen baud — which the loader times into its per-bit unit, then a single - 'p' knock the loader decodes at the rate it just measured. As with - connect(), each attempt is the whole handshake, retried until the slim - info block comes back or the window closes: a lost pulse or a knock that - lands while the loader is mid-frame simply fails to answer, and the - loader's measurement loop is back waiting for the next pulse.""" - deadline = time.monotonic() + wait - knocks = 0 - while True: - self.port.flush_input() - self.port.write(bytes((CALIBRATE, ord("p")))) - knocks += 1 - if PROMPT in self.port.read_available(0.4): - while self.port.read_available(0.3): - pass - self.port.write(b"b") - try: - block = self.port.read_exact(4, 2.0) - except Error: - block = b"" - if len(block) == 4: - self.info = Info.from_slim(block) - self._expect_prompt() - verbose(f"loader locked on knock {knocks}; slim info read") - return self.info - if time.monotonic() > deadline: - raise Error("no answer — reset the device within its activation window") + """The autobaud handshake. In place of the p+b knock the host sends the + calibration pulse — one seven-bit-time low pulse at the host's chosen + baud, which the loader times into its per-bit unit — then a single 'p' + the loader decodes at the rate it just measured. A lost pulse, or a + knock landing while the loader is mid-frame, simply fails to answer and + leaves the measurement loop waiting for the next pulse, so the retry in + _handshake covers it.""" + self.autobaud = True + return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration") def _expect_prompt(self, timeout=2.0): byte = self.port.read_exact(1, timeout) @@ -650,8 +662,9 @@ class Loader: def enter_copy(self, byte_address, wait): """Jump into the loader copy at `byte_address` and knock it — a slot base is that copy's entry stub, so it can only land there.""" + autobaud = self.autobaud self.jump(byte_address // 2) - return self.connect(wait) + return self.connect_autobaud(wait) if autobaud else self.connect(wait) def run_application(self): self.jump(self.info.app_entry_word) @@ -818,12 +831,26 @@ def mega_boot(info, fuse_bytes): def image_info(image): - """The info block embedded in a pureboot binary, or None. Searched once - per known version, so the magic stays three selective bytes rather than - two that code could carry by chance.""" + """What a pureboot binary says about itself, or None. + + An update image is a bare slot: nothing about it names the chip it was + built for, and installing a foreign one bricks the target — so every + loader carries a stamp for this. Through pureboot 4 the stamp is the + 12-byte info block the device also serves; pureboot 5 serves its identity + from immediates and carries a 6-byte stamp (magic, version, signature) + that only this exists for, from which the geometry is looked up exactly as + it is for a live device. + + Searched once per known version, so the magic stays three selective bytes + rather than two that code could carry by chance.""" for version in range(OLDEST_LOADER, NEWEST_LOADER + 1): at = image.find(b"PB" + bytes((version,))) - if 0 <= at <= len(image) - 12: + if at < 0: + continue + if version >= UNIFIED_LOADER: + if at <= len(image) - 6: + return Info.from_identity(image[at + 2 : at + 6]) + elif at <= len(image) - 12: return Info(image[at : at + 12]) return None @@ -1013,7 +1040,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes): # it and matching byte for byte, and the slot unchanged since this update # began, so a half-written install takes the path below instead. current = loader.read_flash(info.stage, SLOT) - staged_loader = image_info(current[:268]) + # The whole slot is searched: a loader's stamp sits wherever its image put + # it, which is the end of the code on pureboot 5 and the front of it + # before that. + staged_loader = image_info(current) if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging: print("staging slot already holds a loader — left in place") else: diff --git a/pureboot/pureboot_autobaud_pure.cpp b/pureboot/pureboot_autobaud_pure.cpp deleted file mode 100644 index 8a8d911..0000000 --- a/pureboot/pureboot_autobaud_pure.cpp +++ /dev/null @@ -1,396 +0,0 @@ -// SUPERSEDED — kept for the record, not built. With the activation hang fixed -// (a lone calibration pulse used to wedge the loader, see autobaud.md) this -// version is 530 B on the 1284P against a 512 B slot. Its 4 B of margin was -// never spare capacity; it was the space the missing fix should have occupied. -// pureboot_autobaud_uni.cpp replaces it at 464 B with more features. -// -// pureboot autobaud — the software-serial variant that measures the host's bit -// timing at runtime, so one binary runs at any F_CPU: the image carries no -// clock. THE PURE VERSION — no inline assembly, no global register variables, -// exactly the constraints the fixed-baud loader keeps. -// -// Two source files exist for review (autobaud.md): -// this one, pure, and pureboot_autobaud_reg.cpp, which keeps the running-slot -// write guard at the cost of one global register variable. They differ only in -// where the measured unit lives and whether the guard is present. -// -// What this version trades to fit 512 B in pure C++ (the 1284 at 508), each -// licensed by "the host guarantees safety" (README.md) and the owner's approval -// to simplify the info block: -// - the measured per-bit unit lives in the two general-purpose I/O scratch -// registers (GPIOR) where the chip has them, in a static otherwise — -// reached through libavr's named register surface, so no asm and no global -// register variable; the loader stays pure; -// - the info block is slimmed to the version and the signature — the chip's -// identity — from which the host derives page size, loader base, EEPROM -// size and the addressing flags via its own chip database; -// - no running-slot write guard: the host never programs the loader's own -// slot, and a broken host bricking the target is the host's bug; -// - a single-byte activation knock: the calibration pulse already proves a -// host is present. -// -// Position independence is kept and is in fact total here: control flow is -// PC-relative, the wire carries addresses, and with the slimmed info block and -// no write guard nothing anchors on the runtime address at all. - -#include - -#include - -using namespace avr::literals; -namespace spm = avr::spm; -namespace ee = avr::eeprom; -namespace hw = avr::hw; - -namespace pureboot { -namespace { - -// Purely polled: every interrupt guard folds to nothing. -constexpr auto off = avr::irq::guard_policy::unused; - -constexpr std::uint8_t ack = '+'; - -// Autobaud carries no clock, so PUREBOOT_CLOCK_HZ / PUREBOOT_BAUD are not -// consulted; only the software-UART pins are a deployment parameter. -#if !defined(PUREBOOT_RX) -#define PUREBOOT_RX pb0 -#endif -#if !defined(PUREBOOT_TX) -#define PUREBOOT_TX pb1 -#endif - -// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. -consteval std::int16_t wdrf_field() -{ - auto reg = std::string_view{hw::db.regs[static_cast(avr::power::detail::reset_reg())].name}; - return hw::db.field_index(reg, "WDRF"); -} - -// The loader owns the top 512 bytes; a staging copy goes in the slot below. -constexpr std::uint16_t slot_bytes = 512; -constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; -constexpr std::uint16_t page = spm::page_bytes; -constexpr bool boot_section = hw::curated::has_boot_section(); - -// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash -// addresses there are word addresses ('J' always was one). -constexpr bool word_flash = spm::flash_bytes > 65536; - -// The loader's one identity number (README.md); the slimmed info block carries -// it and the signature, and the host maps a version to its protocol. -constexpr std::uint8_t version = 4; - -// The activation window as a fixed poll budget: with no clock, whole seconds -// cannot be timed. A __uint24 (AVR's three-byte type) holds it — a fourth byte -// would cost two words at each countdown step for range never used. -#if !defined(PUREBOOT_AUTOBAUD_POLLS) -#define PUREBOOT_AUTOBAUD_POLLS 4000000 -#endif -constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; - -// The measured per-bit delay (in _delay_loop_2 four-cycle iterations). It lives -// in the two adjacent general-purpose I/O scratch registers (GPIOR1:GPIOR2) -// where the chip has them — in/out reach them in one word where a static's -// lds/sts take two, and there is no .bss to clear — and in a plain static -// otherwise (the t13, m8 and m16/32 have no GPIOR). Both are pure: the named -// register surface, no inline asm, no global register variable. -constexpr bool have_gpior = hw::db.reg_index("GPIOR1") >= 0 && hw::db.reg_index("GPIOR2") >= 0; - -std::uint16_t unit_backing; - -template -[[gnu::always_inline]] inline std::uint16_t get_unit() -{ - if constexpr (Gpior) - return static_cast(hw::reg_impl::read() | - (hw::reg_impl::read() << 8)); - else - return unit_backing; -} - -template -[[gnu::always_inline]] inline void put_unit(std::uint16_t u) -{ - if constexpr (Gpior) { - hw::reg_impl::write(static_cast(u)); - hw::reg_impl::write(static_cast(u >> 8)); - } else - unit_backing = u; -} - -// The autobaud software link: bit-banged with cycle-counted delays like the -// fixed-baud software backend, but the per-bit delay is the measured unit, not -// a consteval constant. rx/tx load the unit once into a local, so each bit -// spins from a register with no per-bit reload. -struct link { - using in_t = avr::io::input; - using out_t = avr::io::output; - - static void init() - { - avr::init(); - out_t::set(); // idle high - } - - // Time the calibration pulse into the unit. The host sends 0xC0 — a start - // bit plus six zero data bits are one low pulse of seven bit-times — and the - // counted poll loop is seven cycles an iteration, so the count is the pulse - // length in cycles ÷ 7 × 7 = one bit period in cycles, and count >> 2 is that - // period in _delay_loop_2's four-cycle iterations. Waits for the start edge - // under the poll budget; 0 (returned, and stored) means the budget expired. - static std::uint16_t measure(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - std::uint16_t count = 0; - while (!in_t::read()) - ++count; - const std::uint16_t u = static_cast(count >> 2); - put_unit(u); - return u; - } - - // The eight data bits, entered just past the falling edge of the start bit. - // Split out of rx() so the activation path can wait for that edge under a - // budget while the command loop waits for it indefinitely. - static std::uint8_t sample() - { - const std::uint16_t unit = get_unit(); - _delay_loop_2(static_cast(unit + (unit >> 1))); // 1.5 bits to the LSB centre - std::uint8_t value = 0; - for (std::uint8_t bit = 0; bit < 8; ++bit) { - value >>= 1; - if (in_t::read()) - value |= 0x80; - _delay_loop_2(unit); - } - return value; - } - - static std::uint8_t rx() - { - while (in_t::read()) // await the start edge - ; - return sample(); - } - - // A byte under the poll budget, for the activation knock. An expired budget - // returns 0, which is not the knock, so the caller falls back into the - // budgeted measure() — and a line that stays idle boots the application - // there. Without this the knock's edge wait was unbounded, so a single - // spurious calibration pulse (EMI, or a host that opens the port and never - // knocks) wedged the loader and the application never ran. - static std::uint8_t rx_bounded(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - return sample(); - } - - static void tx(std::uint8_t value) - { - const std::uint16_t unit = get_unit(); - out_t::clear(); // start bit - _delay_loop_2(unit); - for (std::uint8_t bit = 0; bit < 8; ++bit) { - out_t::write(value & 1); - value >>= 1; - _delay_loop_2(unit); - } - out_t::set(); // stop bit - _delay_loop_2(unit); - } - - static void drain() - { - // The software transmitter returns only after the stop bit. - } -}; - -extern "C" [[noreturn]] void pureboot_app(); - -[[gnu::noipa, noreturn]] void jump(void (*target)()) -{ - target(); - __builtin_unreachable(); -} - -[[gnu::noinline, noreturn]] void run_app() -{ - jump(pureboot_app); -} - -// Inlined: read across a call, the first byte strands in a call-saved register -// the caller has to push and pop. -[[gnu::always_inline]] inline std::uint16_t rx16() -{ - std::uint16_t low = link::rx(); - return static_cast(low | (link::rx() << 8)); -} - -[[gnu::always_inline]] inline std::uint16_t word_of(std::array pair) -{ - return std::bit_cast(pair); -} - -[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count) -{ - do - link::tx(avr::flash_load(reinterpret_cast(address++))); - while (--count); -} - -[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count) -{ - std::uint8_t rampz = static_cast(address >> 15); - std::uint16_t z = static_cast(address << 1); - do { - link::tx(avr::flash_load_far((static_cast(rampz) << 16) | z)); - if (++z == 0) - ++rampz; - } while (--count); -} - -[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count) -{ - if constexpr (word_flash) - send_flash_far(address, count); - else - send_flash_near(address, count); -} - -[[gnu::noinline]] void tx_ack() -{ - link::tx(ack); -} - -void send_eeprom(std::uint16_t address, std::uint8_t count) -{ - do - link::tx(ee::read(address++)); - while (--count); -} - -void store_eeprom(std::uint16_t address, std::uint8_t count) -{ - do { - ee::write(address++, link::rx()); - tx_ack(); - } while (--count); -} - -// One page into the SPM buffer, then erase and program. No running-slot write -// guard: the host guarantees it never targets the loader's own slot (the pure -// version's one dropped safety net, licensed — README.md). -void program_flash(std::uint16_t wire_address) -{ - spm::flash_address_t address; - if constexpr (word_flash) { - const std::uint8_t rampz = static_cast(wire_address >> 15); - const std::uint16_t z0 = static_cast(wire_address << 1) & ~static_cast(page - 1); - std::uint16_t z = z0; - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill((static_cast(rampz) << 16) | z, word_of({low, high})); - z += 2; - } while (static_cast(z)); - address = (static_cast(rampz) << 16) | z0; - } else { - address = static_cast(wire_address & ~static_cast(page - 1)); - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill(address, word_of({low, high})); - address += 2; - } while (static_cast(address) & (page - 1)); - address -= 2; // back inside the page — erase and write ignore the word bits - } - spm::erase_page(address); - if constexpr (boot_section) - spm::wait(); - spm::write_page(address); - if constexpr (boot_section) - spm::wait(); - if constexpr (boot_section) - spm::rww_enable(); -} - -void send_fuses() -{ - std::uint8_t which = 0; - do - link::tx(spm::read_fuse(static_cast(which))); - while (++which != 4); -} - -[[noreturn]] void run() -{ - if (hw::field_impl::test()) - run_app(); - - link::init(); - - // Measure the calibration pulse into the unit, then take one 'p' knock. An - // expired budget (no host) boots the application; a pulse that decodes to - // anything but 'p' re-measures. - for (;;) { - if (link::measure(autobaud_budget) == 0) - run_app(); - if (link::rx_bounded(autobaud_budget) == 'p') - break; - } - - for (;;) { - ee::wait(); - tx_ack(); - const std::uint8_t command = link::rx(); - switch (command) { - case 'J': { // jump to a wire word address: hand-over and staging transfer - auto target = reinterpret_cast(rx16()); - tx_ack(); - link::drain(); - jump(target); - } - case 'b': // chip identity: version then the three signature bytes - link::tx(version); - link::tx(hw::db.signature[0]); - link::tx(hw::db.signature[1]); - link::tx(hw::db.signature[2]); - break; - case 'R': // read flash: addr16, n8 (0 = 256) - case 'r': // read EEPROM: addr16, n8 - case 'w': { // write EEPROM: addr16, n8, then n bytes each acked - // One address-and-count path for the three, so the flash streamer - // keeps a single call site and inlines into this never-returning - // loop — its cursor then lives in the loop's own call-saved - // registers instead of being saved and restored around a call - // (the call-site-count lesson, autobaud.md). - const std::uint16_t address = rx16(); - const std::uint8_t count = link::rx(); - if (command == 'r') - send_eeprom(address, count); - else if (command == 'w') - store_eeprom(address, count); - else - send_flash(address, count); - break; - } - case 'W': // program one flash page: addr16, page bytes - program_flash(rx16()); - break; - case 'F': // fuse and lock bytes - send_fuses(); - break; - default: // unknown bytes are ignored; the loop re-acks - break; - } - } -} - -} // namespace -} // namespace pureboot - -template struct avr::startup::entry; diff --git a/pureboot/pureboot_autobaud_reg.cpp b/pureboot/pureboot_autobaud_reg.cpp deleted file mode 100644 index 3a42790..0000000 --- a/pureboot/pureboot_autobaud_reg.cpp +++ /dev/null @@ -1,371 +0,0 @@ -// SUPERSEDED — kept for the record, not built. With the activation hang fixed -// (a lone calibration pulse used to wedge the loader, see autobaud.md) this -// version is 534 B on the 1284P against a 512 B slot, so the global register -// variable it broke purity for buys nothing. pureboot_autobaud_uni.cpp replaces -// it at 464 B, strictly pure and with more features. -// -// pureboot autobaud — the software-serial variant that measures the host's bit -// timing at runtime, so one binary runs at any F_CPU: the image carries no -// clock. THE REGISTER VERSION — keeps the running-slot write guard, at the cost -// of one global register variable (r4) holding the measured unit. That variable -// is pureboot's single, deliberate break from its no-global-register-variable -// rule, present only in this variant; everything else stays pure C++. -// -// Two source files exist for review (autobaud.md): -// pureboot_autobaud_pure.cpp, fully pure but dropping the write guard, and this -// one. They differ only in where the measured unit lives (a call-saved register -// here, GPIOR/RAM there) and whether the guard is present. -// -// The register buys ~32 B over a RAM home — an outlined rx/tx reads it with one -// move where a static costs an lds — and that is what lets the write guard stay -// while the image still fits 512 B (the 1284 at 512, exactly). The unit is -// written once through a noinline setter so the store lands immediately before a -// ret: GCC otherwise deletes a global-register store whose only readers are -// callees (autobaud.md, upstream bug 6). -// -// Simplifications shared with the pure version, each licensed: a slimmed info -// block (version + signature; the host derives geometry from its chip database) -// and a single-byte activation knock (the calibration pulse already proves a -// host). Position independence is kept: control flow is PC-relative and the -// write guard anchors on the runtime return address, as the fixed-baud loader. - -#include - -#include - -using namespace avr::literals; -namespace spm = avr::spm; -namespace ee = avr::eeprom; -namespace hw = avr::hw; - -// The measured per-bit delay (in _delay_loop_2 four-cycle iterations) in a -// call-saved register that the serial callees read directly, so no wire path -// threads it and rx/tx reach it with a move, not a load. Written only through -// set_unit() below. -register std::uint16_t g_unit asm("r4"); - -namespace pureboot { -namespace { - -// Purely polled: every interrupt guard folds to nothing. -constexpr auto off = avr::irq::guard_policy::unused; - -constexpr std::uint8_t ack = '+'; - -// Autobaud carries no clock; only the software-UART pins are a parameter. -#if !defined(PUREBOOT_RX) -#define PUREBOOT_RX pb0 -#endif -#if !defined(PUREBOOT_TX) -#define PUREBOOT_TX pb1 -#endif - -consteval std::int16_t wdrf_field() -{ - auto reg = std::string_view{hw::db.regs[static_cast(avr::power::detail::reset_reg())].name}; - return hw::db.field_index(reg, "WDRF"); -} - -constexpr std::uint16_t slot_bytes = 512; -constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; -constexpr std::uint16_t page = spm::page_bytes; -constexpr bool boot_section = hw::curated::has_boot_section(); -constexpr bool word_flash = spm::flash_bytes > 65536; - -constexpr std::uint8_t version = 4; - -#if !defined(PUREBOOT_AUTOBAUD_POLLS) -#define PUREBOOT_AUTOBAUD_POLLS 4000000 -#endif -constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; - -// The one store into g_unit, isolated so it lands right before the ret: a -// global-register store whose only later readers are callees is dropped -// otherwise (autobaud.md, upstream bug 6). -[[gnu::noinline]] void set_unit(std::uint16_t v) -{ - g_unit = v; -} - -// The autobaud software link: bit-banged with cycle-counted delays, but the -// per-bit delay is g_unit, measured from the host's calibration pulse. -struct link { - using in_t = avr::io::input; - using out_t = avr::io::output; - - static void init() - { - avr::init(); - out_t::set(); // idle high - } - - // Time the calibration pulse into g_unit. The host sends 0xC0 — a start bit - // plus six zero data bits are one low pulse of seven bit-times — and the - // counted poll loop is seven cycles an iteration, so count >> 2 is the bit - // period in _delay_loop_2's four-cycle iterations. 0 means the budget - // expired. - static std::uint16_t measure(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - std::uint16_t count = 0; - while (!in_t::read()) - ++count; - const std::uint16_t u = static_cast(count >> 2); - set_unit(u); - return u; - } - - // The eight data bits, entered just past the falling edge of the start bit. - // Split out of rx() so the activation path can wait for that edge under a - // budget while the command loop waits for it indefinitely. - static std::uint8_t sample() - { - _delay_loop_2(static_cast(g_unit + (g_unit >> 1))); // 1.5 bits to the LSB centre - std::uint8_t value = 0; - for (std::uint8_t bit = 0; bit < 8; ++bit) { - value >>= 1; - if (in_t::read()) - value |= 0x80; - _delay_loop_2(g_unit); - } - return value; - } - - static std::uint8_t rx() - { - while (in_t::read()) // await the start edge - ; - return sample(); - } - - // A byte under the poll budget, for the activation knock. An expired budget - // returns 0, which is not the knock, so the caller falls back into the - // budgeted measure() — and a line that stays idle boots the application - // there. Without this the knock's edge wait was unbounded, so a single - // spurious calibration pulse (EMI, or a host that opens the port and never - // knocks) wedged the loader and the application never ran. - static std::uint8_t rx_bounded(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - return sample(); - } - - static void tx(std::uint8_t value) - { - out_t::clear(); // start bit - _delay_loop_2(g_unit); - for (std::uint8_t bit = 0; bit < 8; ++bit) { - out_t::write(value & 1); - value >>= 1; - _delay_loop_2(g_unit); - } - out_t::set(); // stop bit - _delay_loop_2(g_unit); - } - - static void drain() - { - // The software transmitter returns only after the stop bit. - } -}; - -extern "C" [[noreturn]] void pureboot_app(); - -[[gnu::noipa, noreturn]] void jump(void (*target)()) -{ - target(); - __builtin_unreachable(); -} - -[[gnu::noinline, noreturn]] void run_app() -{ - jump(pureboot_app); -} - -[[gnu::always_inline]] inline std::uint16_t rx16() -{ - std::uint16_t low = link::rx(); - return static_cast(low | (link::rx() << 8)); -} - -[[gnu::always_inline]] inline std::uint16_t word_of(std::array pair) -{ - return std::bit_cast(pair); -} - -[[maybe_unused, gnu::always_inline]] inline void send_flash_near(std::uint16_t address, std::uint8_t count) -{ - do - link::tx(avr::flash_load(reinterpret_cast(address++))); - while (--count); -} - -[[maybe_unused, gnu::always_inline]] inline void send_flash_far(std::uint16_t address, std::uint8_t count) -{ - std::uint8_t rampz = static_cast(address >> 15); - std::uint16_t z = static_cast(address << 1); - do { - link::tx(avr::flash_load_far((static_cast(rampz) << 16) | z)); - if (++z == 0) - ++rampz; - } while (--count); -} - -[[gnu::always_inline]] inline void send_flash(std::uint16_t address, std::uint8_t count) -{ - if constexpr (word_flash) - send_flash_far(address, count); - else - send_flash_near(address, count); -} - -[[gnu::noinline]] void tx_ack() -{ - link::tx(ack); -} - -void send_eeprom(std::uint16_t address, std::uint8_t count) -{ - do - link::tx(ee::read(address++)); - while (--count); -} - -void store_eeprom(std::uint16_t address, std::uint8_t count) -{ - do { - ee::write(address++, link::rx()); - tx_ack(); - } while (--count); -} - -// One page into the SPM buffer, then erase and program — except the slot this -// code is running in (`slot_high`, from run()), which is drained and left -// alone. A broken host therefore cannot brick the running loader, and a copy one -// slot lower may rewrite the resident one. -void program_flash(std::uint16_t wire_address, std::uint8_t slot_high) -{ - spm::flash_address_t address; - std::uint8_t page_high; - if constexpr (word_flash) { - const std::uint8_t rampz = static_cast(wire_address >> 15); - const std::uint16_t z0 = static_cast(wire_address << 1) & ~static_cast(page - 1); - std::uint16_t z = z0; - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill((static_cast(rampz) << 16) | z, word_of({low, high})); - z += 2; - } while (static_cast(z)); - address = (static_cast(rampz) << 16) | z0; - page_high = static_cast(wire_address >> 8); - } else { - address = static_cast(wire_address & ~static_cast(page - 1)); - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); - spm::fill(address, word_of({low, high})); - address += 2; - } while (static_cast(address) & (page - 1)); - address -= 2; // back inside the page — erase and write ignore the word bits - page_high = static_cast(address >> 8) & 0xfe; - } - if (page_high != slot_high) { - spm::erase_page(address); - if constexpr (boot_section) - spm::wait(); - spm::write_page(address); - if constexpr (boot_section) - spm::wait(); - } - if constexpr (boot_section) - spm::rww_enable(); -} - -void send_fuses() -{ - std::uint8_t which = 0; - do - link::tx(spm::read_fuse(static_cast(which))); - while (++which != 4); -} - -[[noreturn]] void run() -{ - if (hw::field_impl::test()) - run_app(); - - link::init(); - - // The high byte of the slot this copy runs at, which the write guard - // follows: the return address is a word address, so its high byte is the - // 256-word slot index, doubled back into byte terms on a byte-addressed - // chip. Taken as byteswap's low byte — the builtin already swaps the two - // stacked bytes, and the double swap folds away. - const std::uint16_t ra_words = reinterpret_cast(__builtin_return_address(0)); - const std::uint8_t ra_high = static_cast(std::byteswap(ra_words)); - const std::uint8_t slot_high = word_flash ? ra_high : static_cast(ra_high << 1); - - // Measure the calibration pulse into g_unit, then take one 'p' knock. An - // expired budget (no host) boots the application; a pulse that decodes to - // anything but 'p' re-measures. - for (;;) { - if (link::measure(autobaud_budget) == 0) - run_app(); - if (link::rx_bounded(autobaud_budget) == 'p') - break; - } - - for (;;) { - ee::wait(); - tx_ack(); - const std::uint8_t command = link::rx(); - switch (command) { - case 'J': { // jump to a wire word address: hand-over and staging transfer - auto target = reinterpret_cast(rx16()); - tx_ack(); - link::drain(); - jump(target); - } - case 'b': // chip identity: version then the three signature bytes - link::tx(version); - link::tx(hw::db.signature[0]); - link::tx(hw::db.signature[1]); - link::tx(hw::db.signature[2]); - break; - case 'R': // read flash: addr16, n8 (0 = 256) - case 'r': // read EEPROM: addr16, n8 - case 'w': { // write EEPROM: addr16, n8, then n bytes each acked - // One address-and-count path for the three, so the flash streamer - // keeps a single call site and inlines into this never-returning - // loop (autobaud.md). - const std::uint16_t address = rx16(); - const std::uint8_t count = link::rx(); - if (command == 'r') - send_eeprom(address, count); - else if (command == 'w') - store_eeprom(address, count); - else - send_flash(address, count); - break; - } - case 'W': // program one flash page: addr16, page bytes - program_flash(rx16(), slot_high); - break; - case 'F': // fuse and lock bytes - send_fuses(); - break; - default: // unknown bytes are ignored; the loop re-acks - break; - } - } -} - -} // namespace -} // namespace pureboot - -template struct avr::startup::entry; diff --git a/pureboot/pureboot_autobaud_uni.cpp b/pureboot/pureboot_autobaud_uni.cpp deleted file mode 100644 index 8cb97b5..0000000 --- a/pureboot/pureboot_autobaud_uni.cpp +++ /dev/null @@ -1,411 +0,0 @@ -// pureboot autobaud, the unified-primitive version — VARIANT A, an explicit -// space byte. One read command and one write command carry a space selector, so -// flash, EEPROM, RAM and the fuses share a single cursor, a single transfer loop -// and a single argument decode instead of one command body each. -// -// Strictly pure: no inline assembly, no global register variables, and no GPIOR -// either — the measured unit lives in a plain static, so the loader claims no -// chip resource an application might want. (PUREBOOT_UNIT_GPIOR=1 puts it back -// in the I/O scratch registers, kept only as a measurement axis.) -// -// Against pureboot_autobaud_pure.cpp this version: -// - adds RAM read and write, which the loader has never had. Because AVR maps -// the register file and the whole I/O space into the data address space, -// that one space also gives the host arbitrary peripheral access for free; -// - collapses 'R' (read flash), 'r' (read EEPROM), 'w' (write EEPROM) and 'F' -// (fuses) — four bodies, four loops — into 'G' and 'P' over four spaces; -// - fixes the activation hang: a lone calibration pulse used to leave the -// loader blocked forever in the knock's rx(), so a stray edge on an -// unattended device wedged it in the loader and the application never ran. -// -// Position independence is kept and is total: control flow is PC-relative, the -// wire carries addresses, and nothing anchors on the runtime address. - -#include - -#include - -using namespace avr::literals; -namespace spm = avr::spm; -namespace ee = avr::eeprom; -namespace hw = avr::hw; - -namespace pureboot { -namespace { - -// Purely polled: every interrupt guard folds to nothing. -constexpr auto off = avr::irq::guard_policy::unused; - -constexpr std::uint8_t ack = '+'; - -// Autobaud carries no clock, so PUREBOOT_CLOCK_HZ / PUREBOOT_BAUD are not -// consulted; only the software-UART pins are a deployment parameter. -#if !defined(PUREBOOT_RX) -#define PUREBOOT_RX pb0 -#endif -#if !defined(PUREBOOT_TX) -#define PUREBOOT_TX pb1 -#endif - -// The unit's home. A plain static by default — pureboot claims no GPIOR, so the -// application keeps both scratch registers. The GPIOR spelling is retained -// behind a macro purely so the two can be measured against each other. -#if !defined(PUREBOOT_UNIT_GPIOR) -#define PUREBOOT_UNIT_GPIOR 0 -#endif - -// The watchdog reset flag's home: MCUSR, or the classic megas' MCUCSR. -consteval std::int16_t wdrf_field() -{ - auto reg = std::string_view{hw::db.regs[static_cast(avr::power::detail::reset_reg())].name}; - return hw::db.field_index(reg, "WDRF"); -} - -// The loader owns the top 512 bytes; a staging copy goes in the slot below. -constexpr std::uint16_t slot_bytes = 512; -constexpr std::uint32_t base = spm::flash_bytes - slot_bytes; -constexpr std::uint16_t page = spm::page_bytes; -constexpr bool boot_section = hw::curated::has_boot_section(); - -// Past 64 KiB a byte address no longer fits the wire's 16 bits, so flash -// addresses there are word addresses ('J' always was one). -constexpr bool word_flash = spm::flash_bytes > 65536; - -// The loader's one identity number (README.md); the slimmed info block carries -// it and the signature, and the host maps a version to its protocol. -constexpr std::uint8_t version = 5; - -// The activation window as a fixed poll budget: with no clock, whole seconds -// cannot be timed. A __uint24 (AVR's three-byte type) holds it — a fourth byte -// would cost two words at each countdown step for range never used. -#if !defined(PUREBOOT_AUTOBAUD_POLLS) -#define PUREBOOT_AUTOBAUD_POLLS 4000000 -#endif -constexpr __uint24 autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; - -// The two SPM commands the loader still has to recognise by value, on the chips -// where it cannot issue a runtime one generically. Taken from the chip's own -// definitions rather than spelled 3 and 5 — though every part pureboot targets -// agrees on those, which is what lets the host send the raw SPMCSR byte. -constexpr std::uint8_t spm_erase = __BOOT_PAGE_ERASE; -constexpr std::uint8_t spm_write = __BOOT_PAGE_WRITE; - -// The spaces a transfer can name. Flash is 0 so it is the cheap default. -// -// sp_spm is the one that is not memory: a write there hands its data byte to -// SPMCSR and fires the instruction at the given flash address, so page erase, -// page write and RWW re-enable become host-issued commands instead of a -// hardcoded tail inside 'W'. The store side already owns an address, a data -// byte and an ack, so the whole sequence costs only the fused out/spm pair. It -// also lets the host reach every other SPM operation — lock bits included — -// which the loader previously had no way to expose. -enum : std::uint8_t { sp_flash = 0, sp_eeprom = 1, sp_ram = 2, sp_fuse = 3, sp_spm = 4 }; - -// A transfer's selector byte is `space | bank << 4`: the low nibble names the -// space, the high nibble carries flash's third address byte (RAMPZ) on the -// chips that have one. Putting the bank here rather than widening the address -// keeps the shared cursor sixteen bits for every space — a three-byte cursor -// costs its extra increment on EEPROM and RAM reads too, which never need it. -// The host must not span a bank boundary in one transfer; it already chunks by -// page, so nothing it does today comes close. - -// .noinit, not .bss: the unit is always measured before it is read, so it needs -// no zeroing — and a zeroed .bss would drag in __do_clear_bss, 18 bytes of -// startup code for a variable that is written before its first use. -[[gnu::section(".noinit")]] std::uint16_t unit_backing; - -[[gnu::always_inline]] inline std::uint16_t get_unit() -{ -#if PUREBOOT_UNIT_GPIOR - return static_cast(hw::reg_impl::read() | - (hw::reg_impl::read() << 8)); -#else - return unit_backing; -#endif -} - -[[gnu::always_inline]] inline void put_unit(std::uint16_t u) -{ -#if PUREBOOT_UNIT_GPIOR - hw::reg_impl::write(static_cast(u)); - hw::reg_impl::write(static_cast(u >> 8)); -#else - unit_backing = u; -#endif -} - -// The autobaud software link: bit-banged with cycle-counted delays like the -// fixed-baud software backend, but the per-bit delay is the measured unit, not -// a consteval constant. rx/tx load the unit once into a local, so each bit -// spins from a register with no per-bit reload. -struct link { - using in_t = avr::io::input; - using out_t = avr::io::output; - - static void init() - { - avr::init(); - out_t::set(); // idle high - } - - // Time the calibration pulse into the unit. The host sends 0xC0 — a start - // bit plus six zero data bits are one low pulse of seven bit-times — and the - // counted poll loop is seven cycles an iteration, so the count is the pulse - // length in cycles ÷ 7 × 7 = one bit period in cycles, and count >> 2 is that - // period in _delay_loop_2's four-cycle iterations. Waits for the start edge - // under the poll budget; 0 (returned, and stored) means the budget expired. - // - // The shape of the counting loop is load-bearing, not incidental: the >> 2 - // is exact only while the pulse's bit-count equals the loop's cycles per - // iteration. Both are 7 here (sbis 1 + rjmp 2 + adiw 2 + rjmp 2). Reshaping - // this loop silently changes the lock; test/pbautobaud.py is what pins it. - static std::uint16_t measure(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - std::uint16_t count = 0; - while (!in_t::read()) - ++count; - const std::uint16_t u = static_cast(count >> 2); - put_unit(u); - return u; - } - - // The eight data bits, entered just past the falling edge of the start bit. - // Split out of rx() so the activation path can wait for that edge under a - // budget while the command loop waits for it indefinitely. - static std::uint8_t sample() - { - const std::uint16_t unit = get_unit(); - _delay_loop_2(static_cast(unit + (unit >> 1))); // 1.5 bits to the LSB centre - std::uint8_t value = 0; - for (std::uint8_t bit = 0; bit < 8; ++bit) { - value >>= 1; - if (in_t::read()) - value |= 0x80; - _delay_loop_2(unit); - } - return value; - } - - static std::uint8_t rx() - { - while (in_t::read()) // await the start edge - ; - return sample(); - } - - // A byte under the poll budget, for the activation knock. An expired budget - // returns 0, which is not the knock, so the caller falls back into the - // budgeted measure() — and a line that stays idle boots the application - // there. That is the whole hang fix: no wait during activation is unbounded, - // so a stray calibration pulse can no longer wedge the loader. - static std::uint8_t rx_bounded(__uint24 budget) - { - while (in_t::read()) - if (!--budget) - return 0; - return sample(); - } - - static void tx(std::uint8_t value) - { - const std::uint16_t unit = get_unit(); - out_t::clear(); // start bit - _delay_loop_2(unit); - for (std::uint8_t bit = 0; bit < 8; ++bit) { - out_t::write(value & 1); - value >>= 1; - _delay_loop_2(unit); - } - out_t::set(); // stop bit - _delay_loop_2(unit); - } - - static void drain() - { - // The software transmitter returns only after the stop bit. - } -}; - -extern "C" [[noreturn]] void pureboot_app(); - -[[gnu::noipa, noreturn]] void jump(void (*target)()) -{ - target(); - __builtin_unreachable(); -} - -[[gnu::noinline, noreturn]] void run_app() -{ - jump(pureboot_app); -} - -// Inlined: read across a call, the first byte strands in a call-saved register -// the caller has to push and pop. -[[gnu::always_inline]] inline std::uint16_t rx16() -{ - std::uint16_t low = link::rx(); - return static_cast(low | (link::rx() << 8)); -} - -[[gnu::always_inline]] inline std::uint16_t word_of(std::array pair) -{ - return std::bit_cast(pair); -} - -[[gnu::noinline]] void tx_ack() -{ - link::tx(ack); -} - -// One byte out of any space. The four accessors share the cursor, the loop and -// the call site — the whole point of the unified commands — so each costs only -// its own instruction rather than a body, a loop and a dispatch arm. -[[gnu::always_inline]] inline std::uint8_t load(std::uint8_t space, std::uint8_t bank, std::uint16_t at) -{ - if (space == sp_eeprom) - return ee::read(at); - if (space == sp_ram) - return *reinterpret_cast(at); - if (space == sp_fuse) - return spm::read_fuse(static_cast(at)); - if constexpr (word_flash) - return avr::flash_load_far((static_cast(bank) << 16) | at); - else - return avr::flash_load(reinterpret_cast(at)); -} - -// One byte into a writable space. Flash is not one of them — it arrives a page -// at a time through 'W' — and the fuses are not writable at all. -[[gnu::always_inline]] inline void store(std::uint8_t space, [[maybe_unused]] std::uint8_t bank, std::uint16_t at, - std::uint8_t value) -{ - if (space == sp_ram) { - *reinterpret_cast(at) = value; - return; - } - if (space == sp_spm) { - // The fused store-and-fire: SPMCSR takes the data byte and the SPM - // issues against Z in the same unscheduled pair the hardware's - // four-cycle window demands, which is exactly why this is one - // primitive and not a poke of SPMCSR followed by a poke of anything - // else. A host cannot hit that window across a serial link. - // The preprocessor rather than `if constexpr` only because - // spm::detail::page_command does not exist at all where there is no - // RAMPZ, and a discarded constexpr branch outside a template is still - // name-checked. A generic spm::command() in libavr would let the - // runtime command through on every chip and retire the dispatch below. -#if defined(RAMPZ) - spm::detail::page_command(value, (static_cast(bank) << 16) | at); -#else - if (value == spm_erase) - spm::erase_page(at); - else if (value == spm_write) - spm::write_page(at); - else if constexpr (boot_section) - spm::rww_enable(); -#endif - if constexpr (boot_section) - spm::wait(); - return; - } - ee::write(at, value); -} - -// One page into the SPM buffer, and only that: the erase, the write and the RWW -// re-enable that used to follow are now three host-issued writes to sp_spm, -// which reach the same fused out/spm pair through the store path's own address -// and data. No running-slot write guard: the host guarantees it never targets -// the loader's own slot (licensed — README.md). -void program_flash([[maybe_unused]] std::uint8_t bank, std::uint16_t at) -{ - std::uint16_t z = at & ~static_cast(page - 1); - do { - std::uint8_t low = link::rx(); - std::uint8_t high = link::rx(); -#if defined(RAMPZ) - spm::fill((static_cast(bank) << 16) | z, word_of({low, high})); -#else - spm::fill(z, word_of({low, high})); -#endif - z += 2; - } while (static_cast(z) & (page - 1)); -} - -[[noreturn]] void run() -{ - if (hw::field_impl::test()) - run_app(); - - link::init(); - - // Measure the calibration pulse into the unit, then take one 'p' knock — - // both under the poll budget. An expired budget (no host) boots the - // application; anything but 'p', including the knock timing out, re-measures - // and so returns to the budgeted wait that boots it. - for (;;) { - if (link::measure(autobaud_budget) == 0) - run_app(); - if (link::rx_bounded(autobaud_budget) == 'p') - break; - } - - for (;;) { - ee::wait(); - tx_ack(); - const std::uint8_t command = link::rx(); - switch (command) { - case 'J': { // jump to a wire word address: hand-over and staging transfer - auto target = reinterpret_cast(rx16()); - tx_ack(); - link::drain(); - jump(target); - } - case 'b': // chip identity: version then the three signature bytes - link::tx(version); - link::tx(hw::db.signature[0]); - link::tx(hw::db.signature[1]); - link::tx(hw::db.signature[2]); - break; - case 'W': // program one flash page: sel8, addr16, then page bytes - case 'G': // read: sel8, addr16, n8 (0 = 256) - case 'g': { // write: sel8, addr16, n8, then n bytes each acked - // The unified transfer. One decode, one cursor, one loop for every - // space and both directions — the four command bodies this replaces - // each carried their own copy of all three. Read and write are the - // same letter in the two cases, so the direction is bit 5 of the - // command and the loop tests it with a one-word skip. 'W' joins the - // same selector-and-address decode rather than keeping a word - // address of its own, which makes flash addressing uniform across - // every command that names it and costs nothing to share. - const std::uint8_t sel = link::rx(); - const std::uint8_t space = sel & 0x0f; - const std::uint8_t bank = static_cast(sel >> 4); - std::uint16_t at = rx16(); - if (command == 'W') { - program_flash(bank, at); - break; - } - std::uint8_t count = link::rx(); - do { - if (command & 0x20) { - store(space, bank, at, link::rx()); - tx_ack(); - } else - link::tx(load(space, bank, at)); - ++at; - } while (--count); - break; - } - default: // unknown bytes are ignored; the loop re-acks - break; - } - } -} - -} // namespace -} // namespace pureboot - -template struct avr::startup::entry; diff --git a/test/check_pi.py b/test/check_pi.py index e366d75..ac4d821 100644 --- a/test/check_pi.py +++ b/test/check_pi.py @@ -1,47 +1,75 @@ #!/usr/bin/env python3 -"""Position-independence lint: the two link-time facts that let the identical -image run from any slot, asserted from the built ELF. +"""Position-independence lint: the property that lets the identical image run +from any slot, asserted from the built ELF and its object. 1. No absolute jmp/call — -mrelax normally guarantees it, but a branch that grows out of relaxation range would break it silently. - 2. The info block within the image's first 256 bytes: 'b' rebuilds its - address as (running slot high byte : link address low byte). + 2. Nothing flash-resident to address: the image is .text alone, so there is + no table whose runtime address has to be reconstructed. + 3. The image is byte-identical when linked at a different base. This is + position independence itself rather than a proxy for it — an absolute + address anywhere in the image would move with the link and show up as a + differing byte. -Usage: check_pi.py +Usage: check_pi.py """ +import os import re import subprocess import sys +import tempfile + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) def main(): - objdump, nm, elf, text_start = sys.argv[1:] + objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:] text_start = int(text_start, 0) listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout - absolute = [ - line - for line in listing.splitlines() - if re.search(r"\t(jmp|call)\t", line) - ] + absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)] if absolute: - print("FAIL: absolute control flow in the image:") - print("\n".join(absolute)) - sys.exit(1) + fail("absolute control flow in the image:\n" + "\n".join(absolute)) - symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout - info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line] - if len(info) != 1: - print(f"FAIL: expected one info-block storage symbol, found {len(info)}") - sys.exit(1) - address = int(info[0].split()[0], 16) - offset = address - text_start - if not 0 <= offset < 256: - print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes") - sys.exit(1) + # Allocated flash beyond .text would be data the running copy has to find. + # Only ALLOC sections reach the device at all; .comment and the debug + # sections ride along in the ELF container and are never flashed. objdump + # prints each section's flags on the line following its header. + headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines() + for index, line in enumerate(headers): + fields = line.split() + if len(fields) < 6 or not fields[0].isdigit(): + continue + name, size = fields[1], int(fields[2], 16) + flags = headers[index + 1] if index + 1 < len(headers) else "" + if "ALLOC" not in flags or not size: + continue + if name not in (".text", ".noinit", ".bss"): + fail(f"flash-resident section {name} ({size} bytes): the image must be .text alone") - print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}") + # Relink at a different base and compare the bytes. + with tempfile.TemporaryDirectory() as work: + elsewhere = text_start - 0x200 if text_start >= 0x200 else text_start + 0x200 + images = [] + for base, tag in ((text_start, "here"), (elsewhere, "there")): + relinked = os.path.join(work, f"{tag}.elf") + binary = os.path.join(work, f"{tag}.bin") + subprocess.run( + [cxx, f"-mmcu={mcu}", "-nostartfiles", f"-Wl,--section-start=.text={base:#x}", + "-Wl,--defsym=pureboot_app=0", "-mrelax", obj, "-o", relinked], + check=True, capture_output=True) + subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True) + images.append(open(binary, "rb").read()) + if images[0] != images[1]: + differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b] + fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: " + f"{len(differing)} byte(s) differ, first at offset {differing[0]:#x}") + + print(f"PI lint: control flow PC-relative, .text only, identical linked at {text_start:#x} and {elsewhere:#x}") if __name__ == "__main__": diff --git a/test/pbtest.py b/test/pbtest.py index 75189c8..b840760 100644 --- a/test/pbtest.py +++ b/test/pbtest.py @@ -55,6 +55,12 @@ def main(): # the page byte is the wire's 0-means-256. mega = mcu.startswith("atmega") patch = not mega or mcu.startswith("atmega48") + # Where SRAM begins: the x8 and x4 megas push it past their extended I/O + # space, everything else starts right after the plain I/O registers. The + # loader keeps no statics and its stack sits at RAMEND, so the first SRAM + # byte is free for the data-space probe below. + classic = mcu in ("atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a") + ram_base = 0x0100 if mega and not classic else 0x0060 word_flash = base + pb.SLOT > 0x10000 wire_base = base // 2 if word_flash else base flags = (1 if patch else 0) | (2 if word_flash else 0) @@ -73,12 +79,20 @@ def main(): if needed not in out: fail(f"session 1 output lacks {needed!r}") - # Session 2: reconnect into the live session, verify, dump, hand over - # is deferred — the pty must be reopened for the APP banner first. + # Session 2: reconnect into the live session, verify, dump, exercise + # the data space; hand over is deferred — the pty must be reopened for + # the APP banner first. + probe = "c0ffee" out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path, - "--read-flash", read_flash, "--read-eeprom", read_eeprom, "--stay") + "--read-flash", read_flash, "--read-eeprom", read_eeprom, + "--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay") if out.count("verify:") != 2: fail("session 2 did not verify both memories") + # What went into SRAM must come back out of it: the data space is one + # more selector on the same transfer as flash and EEPROM, so a wrong + # selector decode would show up here and nowhere else. + if probe not in out.replace(" ", ""): + fail(f"data-space round trip at {ram_base:#x} did not read back {probe}\n{out}") eeprom_back = open(read_eeprom, "rb").read() if eeprom_back[: len(ee_image)] != ee_image: @@ -110,12 +124,16 @@ def main(): f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}") # A W addressed inside a page rather than at its base must still - # consume exactly one page and prompt. The loader's own slot is - # the target — it is drained and never programmed — and the - # payload is erased-state bytes, so the probe can disturb neither - # the image nor the page buffer it leaves behind. - wire = wire_base + 1 - port.write(bytes((ord("W"), wire & 0xFF, wire >> 8)) + b"\xff" * page) + # consume exactly one page and prompt. The loader's own slot is the + # target — the guard refuses to commit it — and the payload is + # erased-state bytes, so the probe can disturb neither the image nor + # the page buffer it leaves behind. Hand-built rather than through + # write_page(), which would follow the fill with its erase and + # write; the point here is that the fill alone consumes exactly one + # page whatever the address's low bits say. + wire = base + 1 + port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF)) + + b"\xff" * page) if port.read_exact(1, 5.0) != pb.PROMPT: fail("unaligned W did not return to the prompt") diff --git a/test/pureboot_device.c b/test/pureboot_device.c index e1dc147..143aa29 100644 --- a/test/pureboot_device.c +++ b/test/pureboot_device.c @@ -182,9 +182,16 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par { (void)mcu; (void)param; - tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); - if (++tx_bit < 8) + if (tx_bit < 8) { + tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); + if (++tx_bit < 8) + return when + bit_cycles; + /* The byte is not delivered until its stop bit has passed. A real + * receiver cannot answer sooner, and a host that did would put its + * start bit on the wire while the device is still driving the stop + * bit — which the device, transmitting, is not watching for. */ return when + bit_cycles; + } if (write(pty_master, &tx_shift, 1) != 1) fprintf(stderr, "device: pty write lost a byte\n"); tx_active = 0; diff --git a/test/test_planner.py b/test/test_planner.py index 918270f..690d82a 100644 --- a/test/test_planner.py +++ b/test/test_planner.py @@ -30,8 +30,14 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla scale = 2 if word_flash else 1 wire_base = base // scale flags = (1 if patch else 0) | (2 if word_flash else 0) + # The EEPROM size comes from the signature, as it must: pureboot 5 derives + # the whole geometry from the signature rather than sending it, so a + # synthetic block that disagreed with its own signature would describe a + # chip that cannot exist. + eeprom = pb.CHIP_GEOMETRY[signature][2] raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version, - *signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags)) + *signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, + eeprom & 0xFF, eeprom >> 8, flags)) info = pb.Info(raw) if info.flash_size != flash: fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}") @@ -162,11 +168,16 @@ def main(): fail("mega staging content should be the bare image") expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512") - # The embedded info block: found in a synthetic binary, absent in noise. - binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10 + # The image stamp: found in a synthetic binary, absent in noise. pureboot + # 5 stamps the magic, its version and the signature, and the geometry is + # looked up from there — so what comes back must equal what a live device + # of the same chip reports. + stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature) + binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10 found = pb.image_info(binary) if found is None or found.raw != tiny.raw: - fail("image_info misses the embedded block") + fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: " + f"{found.raw.hex() if found else None} != {tiny.raw.hex()}") if pb.image_info(bytes((0xAA,)) * 40) is not None: fail("image_info invents a block") # An older loader's image stays readable, so a deployed build can be