diff --git a/CMakeLists.txt b/CMakeLists.txt index a0f9cbb..d579684 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -270,6 +270,12 @@ if(PROJECT_IS_TOP_LEVEL) 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) + # 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=$ + -DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake) # One point of the exhaustive matrix, named from its resolved parameters # so the enumeration cannot collide with itself. `pins` is empty for the @@ -390,6 +396,38 @@ if(PROJECT_IS_TOP_LEVEL) RX ${PUREBOOT_USART1_RX} TX ${PUREBOOT_USART1_TX}) endif() + # The OSCCAL axis at its fixed points: the stock shape, and the tightest + # image in the space with the trim on top — the axis adds one register + # write, and these points hold both of its addressing encodings to every + # chip's budget. + pureboot_size_variant(pureboot_osccal OSCCAL 0x9c) + pureboot_size_variant(pureboot_autobaud_osccal SERIAL autobaud OSCCAL 0x9c) + if(PUREBOOT_HAS_USART) + pureboot_size_variant(pureboot_autobaud_osccal_on_usart0 SERIAL autobaud OSCCAL 0x9c + RX ${PUREBOOT_USART0_RX} TX ${PUREBOOT_USART0_TX}) + endif() + + # The trim byte, observed through the wire from the first prompt — one + # chip per OSCCAL addressing class: extended I/O on the 328P (data 0x66, + # an sts — DS40002061B §36), plain I/O on the 85 (data 0x51, an out — + # Atmel-2586 §21). + if(LIBAVR_MCU MATCHES "^(atmega328p|attiny85)$" AND DEFINED PB_DEVICE) + if(LIBAVR_MCU STREQUAL "atmega328p") + set(_osccal_addr 0x66) + else() + set(_osccal_addr 0x51) + endif() + get_target_property(_osccal_hz pureboot_osccal PUREBOOT_HZ) + get_target_property(_osccal_baud pureboot_osccal PUREBOOT_BAUD) + add_test(NAME pureboot.osccal + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbosccal.py + ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} + ${_osccal_hz} ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_osccal_baud} + ${_osccal_addr} 0x9c ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbosccal-work) + set_tests_properties(pureboot.osccal PROPERTIES TIMEOUT 120) + endif() + # One configured deployment end to end — a real board's shape rather # than the stock assumption: the ATmega328P on its shipped 1 MHz fuses, # the software UART on hand-picked pins (TX = PB1, RX = PB5), the ladder diff --git a/pureboot/CMakeLists.txt b/pureboot/CMakeLists.txt index a15f814..b5c793f 100644 --- a/pureboot/CMakeLists.txt +++ b/pureboot/CMakeLists.txt @@ -136,6 +136,19 @@ elseif(LIBAVR_MCU STREQUAL "atmega644pa") set(_pb_sim_mcu atmega644p) endif() +# Where SRAM begins: the classic megas keep it right after the plain I/O +# registers, the x8/x4 generations push it past their extended I/O file, and +# the tinies match the classics. An autobaud loader's measured unit lives at +# exactly this address (the host reads it there — pureboot.py), and the +# unit-position test holds the layout to it. +if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$") + set(_pb_ram 0x60) +elseif(LIBAVR_MCU MATCHES "^atmega") + set(_pb_ram 0x100) +else() + set(_pb_ram 0x60) +endif() + # The function runs in its caller's scope, so everything it needs crosses # scopes as global properties. set_property(GLOBAL PROPERTY PUREBOOT_BASE_HEX ${_pb_base_hex}) @@ -155,6 +168,7 @@ set(PUREBOOT_SLOT ${_pb_slot} PARENT_SCOPE) set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE) set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE) set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE) +set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE) set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE) set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE) set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE) @@ -212,7 +226,7 @@ endfunction() # pureboot_add_loader( [CLOCK ] [BAUD ] # [SERIAL auto|hardware|software|autobaud] [USART ] -# [RX ] [TX ] [TIMEOUT ]) +# [RX ] [TX ] [TIMEOUT ] [OSCCAL ]) # # The loader target plus its flashable images (.hex for a programmer, # .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 # PUREBOOT_HZ/PUREBOOT_BAUD then record what a harness should *drive* it at, # not what it was built for. +# +# OSCCAL bakes a measured oscillator trim into the loader (README.md: the +# RC-oscillator deployment answer): the byte is written at the top of run(), +# so every reset path — the watchdog hand-over included — runs on the +# corrected clock. Orthogonal to the backend: an autobaud build may carry it +# purely for the application's benefit, its own link being clock-free. No +# value, no code. function(pureboot_add_loader name) - cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT" "" ${ARGN}) + cmake_parse_arguments(PB "" "CLOCK;BAUD;SERIAL;USART;RX;TX;TIMEOUT;OSCCAL" "" ${ARGN}) if(PB_UNPARSED_ARGUMENTS) message(FATAL_ERROR "pureboot_add_loader(${name}): unknown arguments ${PB_UNPARSED_ARGUMENTS}") endif() @@ -325,6 +346,13 @@ function(pureboot_add_loader name) set(_defines PUREBOOT_CLOCK_HZ=${PB_CLOCK} PUREBOOT_BAUD=${PB_BAUD} PUREBOOT_TIMEOUT=${PB_TIMEOUT} ${_serial_defines}) endif() + if(DEFINED PB_OSCCAL) + math(EXPR _osccal "${PB_OSCCAL}" OUTPUT_FORMAT DECIMAL) + if(_osccal LESS 0 OR _osccal GREATER 255) + message(FATAL_ERROR "pureboot_add_loader(${name}): OSCCAL ${PB_OSCCAL} is not one byte") + endif() + list(APPEND _defines PUREBOOT_OSCCAL=${_osccal}) + endif() add_executable(${name} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/pureboot.cpp) target_link_libraries(${name} PRIVATE libavr) diff --git a/pureboot/pureboot.cpp b/pureboot/pureboot.cpp index 9dd36db..c521b8f 100644 --- a/pureboot/pureboot.cpp +++ b/pureboot/pureboot.cpp @@ -72,9 +72,17 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; #endif constexpr avr::uint24_t autobaud_budget = PUREBOOT_AUTOBAUD_POLLS; +// A build may bake a measured oscillator trim (README.md: the RC-oscillator +// deployment answer); the byte is applied at the top of run(). Orthogonal to +// the serial backend — an autobaud build may carry it for the application's +// benefit alone. +#if defined(PUREBOOT_OSCCAL) +static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL is one OSCCAL byte"); +#endif + // The loader's one identity number. The protocol carries none of its own — // a version implies it, and the host tool holds that map (README.md). -constexpr std::uint8_t version = 5; +constexpr std::uint8_t version = 6; // 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 @@ -477,6 +485,12 @@ void fill_page(std::uint8_t bank, std::uint16_t at) [[noreturn]] void run() { +#if defined(PUREBOOT_OSCCAL) + // The build's oscillator trim, ahead of everything — the WDRF bail + // included — so every path out of reset, the watchdog hand-over to the + // application first among them, runs on the corrected clock. + avr::clock::calibrate(PUREBOOT_OSCCAL); +#endif // A watchdog reset belongs to the application, whose watchdog stays forced // on until it clears WDRF — no activation window in its way. if (avr::hw::field_impl::test()) diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index cf3f12e..c741959 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -24,13 +24,15 @@ else: import termios PROMPT = b"+" -VERSION = 5 # this tool's own version — free to drift from a loader's -# The loader versions this tool speaks. A pureboot version implies its wire +VERSION = 6 # this tool's own version — free to drift from a loader's +# The loader versions this tool can drive. A pureboot version implies its wire # protocol, which carries no number of its own, so this window is where that -# map lives: every version so far speaks the same protocol, and one that -# changes it becomes the new floor here. +# map lives: the tool keeps a decoder for every generation in it (1–4 speak +# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying +# builds and changes nothing on the wire), and a version it has no decoder +# for moves the floor. OLDEST_LOADER = 1 -NEWEST_LOADER = 5 +NEWEST_LOADER = 6 SLOT = 512 # the loader slot, on every chip RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops diff --git a/test/check_unit.cmake b/test/check_unit.cmake new file mode 100644 index 0000000..4833dc3 --- /dev/null +++ b/test/check_unit.cmake @@ -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= -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 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") diff --git a/test/pbosccal.py b/test/pbosccal.py new file mode 100644 index 0000000..29ba121 --- /dev/null +++ b/test/pbosccal.py @@ -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 + + [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()