3 Commits

Author SHA1 Message Date
1b18f10f4e docs: the OSCCAL axis, --scan, and the RC-oscillator deployment risk
Configuration gains the OSCCAL row; Deployment says what the build cannot
see (±10 % factory trim against a frame's ~±4 %, and silence that reads as
wiring); the update section names an OSCCAL bake as a link change in effect,
declared with --staged-baud; the host-tool section documents --scan and the
measured clock --info adds on an autobaud session; the version map gains 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:03:15 +02:00
52c4cdab32 pureboot.py: --scan walks a silent loader's rate; --info decodes the clock
The RC-oscillator answer's host half. --scan probes ±10 % around the built
rate in 2 % steps, nearest first, one activation window (one reset) per
probe: a fixed-baud loader whose oscillator drifted answers at the ratio,
and the report gives the session workaround (--baud), the offset, the
OSCCAL direction at ~1 %/step, and the autobaud way out. The walk and the
advice are logic-tested (test_scan.py, red-proven on the trim direction) —
a pty carries bytes at any rate, so the wire cannot arbitrate them.

On an autobaud session --info now reads the measured bit period from
ram_start — the geometry table gains that column — and undoes the unit's
encoding ((cycles − 8) / 4, floored: libavr's spin granule and per-bit
overhead), so the printed clock is the true one within a granule; --clock
turns it into a stated drift. The autobaud end-to-end asserts the figure
inside exactly that envelope at both clock points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:01:35 +02:00
69f089e53a pureboot 6: a build-time OSCCAL trim, applied ahead of every reset path
The RC-oscillator answer's device half (dev/tasks.md in libavr): OSCCAL joins
pureboot_add_loader() as one optional byte, written at the top of run() before
the WDRF bail so the watchdog hand-over inherits the corrected clock too.
Orthogonal to the backend — an autobaud build may carry it purely for the
application. No value, no code: the stock image differs from v5 in exactly
the version's two bytes (the stamp and the 'b' immediate).

Measured: +6 B where OSCCAL takes sts (328P, 404→410), +4 B in low I/O
(t85, 402→406); the tightest image in the space (1284 autobaud on USART
pins, 504) carries the sts form at 510 of 512. New gates: the OSCCAL size
points on every chip, the wire-observed trim byte on both addressing
classes (test/pbosccal.py, red-green), and the autobaud unit pinned to
ram_start (test/check_unit.cmake, red-green) — the address --info's
measured-clock read is about to rely on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:53:14 +02:00
9 changed files with 381 additions and 38 deletions

View File

@@ -167,6 +167,9 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.planner add_test(NAME pureboot.planner
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
add_test(NAME pureboot.scan
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
add_test(NAME pureboot.handshake add_test(NAME pureboot.handshake
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
add_test(NAME pureboot.updatelink add_test(NAME pureboot.updatelink
@@ -270,6 +273,12 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot_autobaud.size add_test(NAME pureboot_autobaud.size
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud> COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake) -DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
# The measured unit is the loader's only RAM object and sits at the very
# start of SRAM — where the host reads the bit period from (--info's
# measured clock), so the address is wire contract, not layout accident.
add_test(NAME pureboot_autobaud.unit
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
-DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
# One point of the exhaustive matrix, named from its resolved parameters # One point of the exhaustive matrix, named from its resolved parameters
# so the enumeration cannot collide with itself. `pins` is empty for the # so the enumeration cannot collide with itself. `pins` is empty for the
@@ -390,6 +399,38 @@ if(PROJECT_IS_TOP_LEVEL)
RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX}) RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX})
endif() endif()
# The OSCCAL axis at its fixed points: the stock shape, and the tightest
# image in the space with the trim on top — the axis adds one register
# write, and these points hold both of its addressing encodings to every
# chip's budget.
pureboot_size_variant(pureboot_osccal OSCCAL 0x9c)
pureboot_size_variant(pureboot_autobaud_osccal SERIAL autobaud OSCCAL 0x9c)
if(PUREBOOT_HAS_USART)
pureboot_size_variant(pureboot_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c
RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX})
endif()
# The trim byte, observed through the wire from the first prompt — one
# chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66,
# an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out —
# Atmel-2586 §21).
if(LIBAVR_MCU MATCHES "^(atmega328p|attiny85)$" AND DEFINED PB_DEVICE)
if(LIBAVR_MCU STREQUAL "atmega328p")
set(_osccal_addr 0x66)
else()
set(_osccal_addr 0x51)
endif()
get_target_property(_osccal_hz pureboot_osccal PUREBOOT_HZ)
get_target_property(_osccal_baud pureboot_osccal PUREBOOT_BAUD)
add_test(NAME pureboot.osccal
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbosccal.py
${PB_DEVICE} $<TARGET_FILE:pureboot_osccal> ${PUREBOOT_SIM_MCU}
${_osccal_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_osccal_baud}
${_osccal_addr} 0x9c ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbosccal-work)
set_tests_properties(pureboot.osccal PROPERTIES TIMEOUT 120)
endif()
# One configured deployment end to end — a real board's shape rather # One configured deployment end to end — a real board's shape rather
# than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses,
# the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder

View File

@@ -136,6 +136,19 @@ elseif(LIBAVR_MCU STREQUAL "atmega644pa")
set(_pb_sim_mcu atmega644p) set(_pb_sim_mcu atmega644p)
endif() endif()
# Where SRAM begins: the classic megas keep it right after the plain I/O
# registers, the x8/x4 generations push it past their extended I/O file, and
# the tinies match the classics. An autobaud loader's measured unit lives at
# exactly this address (the host reads it there — pureboot.py), and the
# unit-position test holds the layout to it.
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
set(_pb_ram 0x60)
elseif(LIBAVR_MCU MATCHES "^atmega")
set(_pb_ram 0x100)
else()
set(_pb_ram 0x60)
endif()
# The function runs in its caller's scope, so everything it needs crosses # The function runs in its caller's scope, so everything it needs crosses
# scopes as global properties. # scopes as global properties.
set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex}) set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex})
@@ -155,6 +168,7 @@ set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE)
set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE) set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE) set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE) set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE)
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE) set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE) set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE) set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
@@ -212,7 +226,7 @@ endfunction()
# pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>] # pureboot_add_loader(<name> [CLOCK <hz>] [BAUD <bd>]
# [SERIAL auto|hardware|software|autobaud] [USART <n>] # [SERIAL auto|hardware|software|autobaud] [USART <n>]
# [RX <pin>] [TX <pin>] [TIMEOUT <s>]) # [RX <pin>] [TX <pin>] [TIMEOUT <s>] [OSCCAL <byte>])
# #
# The loader target plus its flashable images (<name>.hex for a programmer, # The loader target plus its flashable images (<name>.hex for a programmer,
# <name>.bin for --update-loader). The resolved deployment is stamped on the # <name>.bin for --update-loader). The resolved deployment is stamped on the
@@ -225,8 +239,15 @@ endfunction()
# and one binary per chip serves every F_CPU and every rate. The stamped # 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, # PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at,
# not what it was built for. # not what it was built for.
#
# OSCCAL bakes a measured oscillator trim into the loader (README.md: the
# RC-oscillator deployment answer): the byte is written at the top of run(),
# so every reset path — the watchdog hand-over included — runs on the
# corrected clock. Orthogonal to the backend: an autobaud build may carry it
# purely for the application's benefit, its own link being clock-free. No
# value, no code.
function(pureboot_add_loader name) function(pureboot_add_loader name)
cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN}) cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN})
if(PB_UNPARSED_ARGUMENTS) if(PB_UNPARSED_ARGUMENTS)
message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}") message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}")
endif() endif()
@@ -325,6 +346,13 @@ function(pureboot_add_loader name)
set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT}
${_serial_defines}) ${_serial_defines})
endif() endif()
if(DEFINED PB_OSCCAL)
math(EXPR _osccal "${PB_OSCCAL}" OUTPUT_FORMAT DECIMAL)
if(_osccal LESS 0 OR _osccal GREATER 255)
message(FATAL_ERROR "pureboot_add_loader(${name}): OSCCAL ${PB_OSCCAL} is not one byte")
endif()
list(APPEND _defines PUREBOOT_OSCCAL=${_osccal})
endif()
add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp)
target_link_libraries(${name} PRIVATE libavr) target_link_libraries(${name} PRIVATE libavr)

View File

@@ -72,6 +72,7 @@ repo's build and by a downstream project alike:
| `USART <n>` | the USART instance (x4 megas carry two) | 0 | | `USART <n>` | the USART instance (x4 megas carry two) | 0 |
| `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` | | `RX <pin>`, `TX <pin>` | software-UART pins | `pb0`, `pb1` |
| `TIMEOUT <s>` | the activation window | 8 | | `TIMEOUT <s>` | the activation window | 8 |
| `OSCCAL <byte>` | a measured oscillator trim, applied before anything runs | none — no value, no code |
The default baud is the fastest of 115200/57600/38400/19200/9600 the clock The default baud is the fastest of 115200/57600/38400/19200/9600 the clock
reaches within 2.5 % — the same U2X-included divisor search libavr's baud reaches within 2.5 % — the same U2X-included divisor search libavr's baud
@@ -285,7 +286,9 @@ Two generations exist. **1 through 4** speak one session — a 12-byte info bloc
from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses). 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 **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 above; the shipped tool speaks both, choosing on the version it reads, so a
deployed pureboot 4 stays drivable and self-updatable to 5. deployed pureboot 4 stays drivable and self-updatable to 5. **6** changes
nothing on the wire: it marks the builds that may carry a baked `OSCCAL` trim
(Configuration), so a tool driving an update knows such images exist.
Every closed generation is tagged in this repo at its era's last commit — the Every closed generation is tagged in this repo at its era's last commit — the
commit just before the next version bump, so a tag holds everything its commit just before the next version bump, so a tag holds everything its
@@ -350,6 +353,18 @@ mega (SPM only executes from the boot section — reflash the .hex), but *runs*
on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it on a patched-vector chip, and the ordinary `--update-loader` flow re-homes it
into the top slot from there (`pureboot.rehome`). into the top slot from there (`pureboot.rehome`).
**Fixed-baud on an internal RC oscillator is a deployment risk the build
cannot see.** The factory trim is ±10 % where an 8N1 frame survives about
±4: a part at the edge answers nothing at the built rate, and the symptom —
silence — reads as a wiring fault (a real ATtiny13A measured 5.5 %, outside
every standard rate at its own documented default). The **autobaud build is
the deployment-proof backend**: it has no rate to miss. Where fixed-baud on
RC is wanted anyway, measure first and bake the trim: an autobaud session's
`--info` prints the part's true clock from the loader's own measured bit
period, OSCCAL moves the oscillator about 1 % per step, and `OSCCAL <byte>`
builds the correction in — one buildmeasure iteration converges. A loader
already deployed and silent is diagnosed with `--scan` (Host tool).
## Updating the loader ## Updating the loader
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader `pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
@@ -374,6 +389,11 @@ The host retunes on the open port, so no DTR pulse resets the copy it is talking
to. Omit them against a changed link and the update stops after installing the to. Omit them against a changed link and the update stops after installing the
staging copy, saying so and naming this as the cause. staging copy, saying so and naming this as the cause.
An `OSCCAL`-baked image is a link change in effect even at an unchanged rate
on paper: the staging copy shifts the physical clock the moment its `run()`
starts, and from then on speaks exactly what it was built for. Declare it
like any other link change — `--staged-baud` with the new build's rate.
The preflight refuses an image built for another chip: the stamp every pureboot 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 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 both. Die revisions share their base signature and geometry, so their images
@@ -430,7 +450,19 @@ application data into a mega's reset walk region.
`--autobaud` opens with the calibration pulse instead of the plain knock, for a `--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 loader built `SERIAL autobaud`; the rest of the session is identical, at
whatever `--baud` the host chose. whatever `--baud` the host chose. Its `--info` adds the **measured clock**
the loader's bit-period unit, decoded and multiplied by the session rate —
which is the number an `OSCCAL` bake or a fixed-baud build for the part is
held against; `--clock <hz>` states the drift against a nominal.
`--scan` is the diagnosis once a fixed-baud loader has gone silent: it walks
±10 % around `--baud` in 2 % steps, nearest first, one probe per activation
window — reset the target as each probe announces itself (a board with DTR
wired to reset is pulsed by the probe's own port-open). A loader
off-frequency answers at its oscillator's ratio, and the report gives the
found rate as the session workaround, the offset, the OSCCAL correction's
direction at ~1 % per step, and the autobaud way out. Standalone — no other
operation combines with it.
`--peek ADDR[:N]` and `--poke ADDR:HEX` reach the data space (pureboot 5) — `--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 SRAM, and through the same address space the register file and every I/O

View File

@@ -72,9 +72,17 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT;
#endif #endif
constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS;
// A build may bake a measured oscillator trim (README.md: the RC-oscillator
// deployment answer); the byte is applied at the top of run(). Orthogonal to
// the serial backend — an autobaud build may carry it for the application's
// benefit alone.
#if defined(PUREBOOT_OSCCAL)
static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte");
#endif
// The loader's one identity number. The protocol carries none of its own — // 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). // a version implies it, and the host tool holds that map (README.md).
constexpr std::uint8_t version = 5; constexpr std::uint8_t version = 6;
// The image's identity stamp, for the host tool rather than for the wire: an // 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 // update image is a bare 512-byte slot, and without this nothing in it says
@@ -477,6 +485,12 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
[[noreturn]] void run() [[noreturn]] void run()
{ {
#if defined(PUREBOOT_OSCCAL)
// The build's oscillator trim, ahead of everything — the WDRF bail
// included — so every path out of reset, the watchdog hand-over to the
// application first among them, runs on the corrected clock.
avr::clock::calibrate(PUREBOOT_OSCCAL);
#endif
// A watchdog reset belongs to the application, whose watchdog stays forced // A watchdog reset belongs to the application, whose watchdog stays forced
// on until it clears WDRF — no activation window in its way. // on until it clears WDRF — no activation window in its way.
if (avr::hw::field_impl<wdrf_field()>::test()) if (avr::hw::field_impl<wdrf_field()>::test())

View File

@@ -24,13 +24,15 @@ else:
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 5 # this tool's own version — free to drift from a loader's VERSION = 6 # this tool's own version — free to drift from a loader's
# The loader versions this tool speaks. A pureboot version implies its wire # The loader versions this tool can drive. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that # 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 # map lives: the tool keeps a decoder for every generation in it (14 speak
# changes it becomes the new floor here. # the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
# builds and changes nothing on the wire), and a version it has no decoder
# for moves the floor.
OLDEST_LOADER = 1 OLDEST_LOADER = 1
NEWEST_LOADER = 5 NEWEST_LOADER = 6
SLOT = 512 # the loader slot, on every chip SLOT = 512 # the loader slot, on every chip
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
@@ -41,6 +43,13 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
UNIFIED_LOADER = 5 UNIFIED_LOADER = 5
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4 SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
# as delay-loop counts: (bit cycles UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
# floored — the spin granule and per-bit overhead of libavr's software UART.
# --info undoes the encoding to report the true clock, which therefore sits
# within one granule below it.
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
# A selector's high nibble is the flash bank — the address bits above the 16-bit # 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 # 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 # no command has to speak word addresses. No single transfer may cross a bank
@@ -66,31 +75,36 @@ CALIBRATE = 0xC0
# from its chip database at build time). Die revisions that share a signature # from its chip database at build time). Die revisions that share a signature
# share this row, as they share the silicon. # share this row, as they share the silicon.
CHIP_GEOMETRY = { CHIP_GEOMETRY = {
# signature : (flash, page, eeprom, patch_vector) # signature : (flash, page, eeprom, patch_vector, ram_start)
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A # ram_start is where SRAM begins in data space: the classic megas and the
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25 # tinies keep it right after the plain I/O registers (0x60), the x8/x4
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45 # generations past their extended I/O file (0x100). An autobaud loader's
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85 # measured bit period lives at exactly ram_start (its only RAM object;
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A # the loader's own build pins the layout), which is what --info reads.
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA (0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A (0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A (0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA (0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A (0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A (0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA (0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA (0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A (0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A (0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P (0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328 (0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P (0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA (0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A (0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A (0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA (0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P (0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284 (0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
} }
VERBOSE = False VERBOSE = False
@@ -410,7 +424,7 @@ class Info:
if geometry is None: if geometry is None:
sig = " ".join(f"{b:02x}" for b in signature) sig = " ".join(f"{b:02x}" for b in signature)
raise Error(f"unknown signature {sig} — this tool has no geometry for it") raise Error(f"unknown signature {sig} — this tool has no geometry for it")
flash, page, eeprom, patch = geometry flash, page, eeprom, patch, _ = geometry
base = flash - SLOT base = flash - SLOT
word_flash = flash > 0x10000 word_flash = flash > 0x10000
wire_base = base // 2 if word_flash else base wire_base = base // 2 if word_flash else base
@@ -446,6 +460,11 @@ class Info:
# The hand-over target as 'J' takes it: the trampoline below the # The hand-over target as 'J' takes it: the trampoline below the
# loader, or word 0 where BOOTRST re-vectors reset in hardware. # loader, or word 0 where BOOTRST re-vectors reset in hardware.
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0 self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
# Where SRAM begins, from the signature — None only for a chip this
# tool has no geometry row for, which the wire-block path (v14)
# permits where from_identity refuses.
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
self.ram = geometry[4] if geometry else None
def describe(self): def describe(self):
sig = " ".join(f"{b:02x}" for b in self.signature) sig = " ".join(f"{b:02x}" for b in self.signature)
@@ -1351,6 +1370,58 @@ def op_fuses(loader):
return fuse_bytes return fuse_bytes
def scan_ratios():
"""The probe walk, in percent of the built rate: the built rate itself
first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near
its trim is the common case, and each probe costs a reset."""
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
def scan_rate(baud, pct):
return round(baud * (100 + pct) / 100)
def scan_report(baud, pct, version, clock=None):
"""The findings, one per line: the found rate is the session workaround,
its ratio to the built rate is the oscillator's offset, and the fixes are
the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build."""
rate = scan_rate(baud, pct)
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}",
f" session --baud {rate}"]
if clock:
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
if pct:
direction = "lower" if pct > 0 else "higher"
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
"or the autobaud build")
else:
lines.append(" fix none — the built rate answers; check the earlier wiring instead")
return lines
def op_scan(port_path, baud, wait, clock=None):
"""A fixed-baud loader whose oscillator drifted still answers — at the
drifted ratio, since its rate scales with its clock. One probe per
activation window, and with an application resident the window opens
exactly once per reset, so each probe announces itself and expects a
fresh reset before knocking."""
for pct in scan_ratios():
rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
port = Port(port_path, rate)
try:
info = Loader(port).connect(wait)
except Error:
continue
finally:
port.close()
for line in scan_report(baud, pct, info.version, clock):
print(line)
return
raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the "
"autobaud build, which has no rate to miss (README.md)")
# -------------------------------------------------------------------- cli --- # -------------------------------------------------------------------- cli ---
@@ -1366,6 +1437,12 @@ def main():
parser.add_argument("--autobaud", action="store_true", parser.add_argument("--autobaud", action="store_true",
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single " help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
"knock, and take geometry from the signature (no clock/baud baked in)") "knock, and take geometry from the signature (no clock/baud baked in)")
parser.add_argument("--scan", action="store_true",
help="walk ±10%% around --baud for a fixed-baud loader gone silent — one "
"reset per probe, standalone (README.md: deployment)")
parser.add_argument("--clock", type=int, metavar="HZ",
help="the clock the loader was built for — lets --scan and an autobaud "
"--info state drift in absolute terms")
parser.add_argument("--info", action="store_true", help="print the device info block") parser.add_argument("--info", action="store_true", help="print the device info block")
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes") parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary") parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
@@ -1412,6 +1489,12 @@ def main():
except (ValueError, AssertionError): except (ValueError, AssertionError):
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
if args.scan:
if args.autobaud:
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
op_scan(args.port, args.baud, args.wait, args.clock)
return
port = Port(args.port, args.baud) port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted") verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try: try:
@@ -1421,6 +1504,16 @@ def main():
print("device:") print("device:")
for line in info.lines(): for line in info.lines():
print(f" {line}") print(f" {line}")
if args.autobaud:
# The whole of the loader's RAM is the measured bit period at
# ram_start; decoded and times the rate this session drives,
# that is the true clock — the number to hold an OSCCAL bake
# or a fixed-baud build against (README.md: deployment).
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
fuse_bytes = fuse_override fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader) read = op_fuses(loader)

23
test/check_unit.cmake Normal file
View File

@@ -0,0 +1,23 @@
# Asserts the autobaud loader's measured unit is the first RAM object: the
# host tool reads the bit period from ram_start (--info's measured clock), so
# the unit's address is wire contract. Run as
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> -P check_unit.cmake
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
if(NOT _res EQUAL 0)
message(FATAL_ERROR "${OBJDUMP} -t ${ELF} failed")
endif()
# The symbol line: "00800100 l O .noinit 00000002 <mangled>unit_E".
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_[^ \t\n]*\n" _line "${_syms}")
if(NOT _line)
message(FATAL_ERROR "no unit_ symbol in ${ELF} — is this the autobaud loader?")
endif()
# AVR data-space symbols carry the 0x800000 VMA offset.
math(EXPR _want "0x800000 + ${RAM_START}" OUTPUT_FORMAT HEXADECIMAL)
math(EXPR _have "0x${CMAKE_MATCH_1}" OUTPUT_FORMAT HEXADECIMAL)
if(NOT _have STREQUAL _want)
message(FATAL_ERROR "unit_ sits at ${_have}, ram_start is ${_want} — the host peeks ram_start")
endif()
message(STATUS "unit_ at ${_have} == ram_start")

View File

@@ -16,6 +16,7 @@ baked in.
""" """
import os import os
import re
import sys import sys
import time import time
@@ -58,11 +59,23 @@ def main():
try: try:
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse # The host tool, in autobaud mode, sends the 0xC0 calibration pulse
# and a single knock at `baud`; the loader locks to it. # and a single knock at `baud`; the loader locks to it.
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses", out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
"--flash", app_bin, "--eeprom", ee_path, "--stay") "--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
for needed in ("version", "signature", "fuses", "verify:", "stays"): for needed in ("version", "signature", "fuses", "verify:", "stays"):
if needed not in out: if needed not in out:
fail(f"{label}: session output lacks {needed!r}\n{out}") fail(f"{label}: session output lacks {needed!r}\n{out}")
# The measured clock, decoded from the unit at ram_start. The
# runner's clock is exact, so the figure must land inside the
# encoding's own envelope: the loader floors the bit period to
# 4-cycle spin granules after an 8-cycle discount, and the edge
# poll can shave a few cycles more — one granule of slack below
# the true clock, none above (in cycles per bit, times the rate).
measured = re.search(r"measured\s+(\d+) Hz", out)
if not measured:
fail(f"{label}: --info lacks the measured clock\n{out}")
measured = int(measured.group(1))
if not hz - 19 * baud <= measured <= hz + 4 * baud:
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
# Read both memories back over the locked link and check them. # Read both memories back over the locked link and check them.
read_flash = os.path.join(workdir, f"rf_{label}.bin") read_flash = os.path.join(workdir, f"rf_{label}.bin")
read_eeprom = os.path.join(workdir, f"re_{label}.bin") read_eeprom = os.path.join(workdir, f"re_{label}.bin")

46
test/pbosccal.py Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""The build-time OSCCAL trim, observed through the wire: a loader built with
the OSCCAL axis holds the trim register at the built byte from its first
prompt on — the write sits at the top of run(), ahead of the WDRF bail, so
every path out of reset runs on the corrected clock. simavr's clock does not
follow OSCCAL, which is what makes the value assertable at all: the register
is plain state there, and the peek must return exactly what the build
declared rather than whatever the oscillator needed.
Usage: pbosccal.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <osccal_addr> <osccal_value> <tool_py> <workdir>
[link]
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
args = sys.argv[1:]
link = args.pop() if len(args) == 12 else None
(device_bin, elf, mcu, hz, base_hex, page, baud, addr, value, tool, workdir) = args
addr, value, baud = int(addr, 0), int(value, 0), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "flash_dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
try:
out = pbsim.run_tool(tool, device.pty, baud, "--peek", f"{addr:#x}:1")
want = f"{addr:#06x} {value:02x}"
if want not in out:
fail(f"OSCCAL at {addr:#x} did not read back {value:#04x}:\n{out}")
finally:
device.stop()
print("OK")
if __name__ == "__main__":
main()

53
test/test_scan.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""--scan's walk and report logic, no simulator: the probe order, the rate
arithmetic, and the advice's direction. The rate physics itself is not
sim-testable — a pty carries bytes at any termios rate — so what the wire
would arbitrate is pinned here as logic instead.
Usage: test_scan.py <tool_py>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
import pureboot as pb
walk = pb.scan_ratios()
if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]:
fail(f"probe walk is not built-rate-first, nearest-out: {walk}")
if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216:
fail("probe rate arithmetic")
if pb.scan_rate(115200, 0) != 115200:
fail("the built rate must probe unchanged")
# A loader answering fast means a fast oscillator: the trim goes down.
report = "\n".join(pb.scan_report(9600, 4, 6))
for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"):
if needle not in report:
fail(f"+4 % report lacks {needle!r}:\n{report}")
report = "\n".join(pb.scan_report(9600, -6, 6))
if "6 steps higher" not in report:
fail(f"-6 % report advises the wrong direction:\n{report}")
report = "\n".join(pb.scan_report(9600, 0, 6))
if "none" not in report or "steps" in report:
fail(f"an on-rate answer must advise no trim:\n{report}")
report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000))
if "9984000" not in report:
fail(f"the absolute clock must scale with the found ratio:\n{report}")
print("OK")
if __name__ == "__main__":
main()