Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad8b6b43e | |||
| 5d1b4497d4 | |||
| a743ea64a3 | |||
| aa66cfccff | |||
| 459d463283 | |||
| 8e7cc86fb3 | |||
| c8ac61779e | |||
| 4362886c39 | |||
| 0513d07e87 | |||
| d4ab28aa17 | |||
| b60f182105 | |||
| 5bc35a9733 | |||
| 8f6319c068 |
@@ -202,6 +202,21 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
${CMAKE_BINARY_DIR}/pbtest-work)
|
||||
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
|
||||
|
||||
# The activation window as a measured duration: application installed,
|
||||
# line idle, the first transmit is the application's banner — its
|
||||
# cycle is the window the source declares, held to ±2 % (one
|
||||
# mis-counted cycle per poll is a 10 % shift).
|
||||
add_test(NAME pureboot.window
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
|
||||
--seconds ${PUREBOOT_TIMEOUT}
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
|
||||
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
|
||||
|
||||
# The position-independence acceptance test: the identical image,
|
||||
# installed one slot lower, must serve the full command set.
|
||||
add_test(NAME pureboot.reloc
|
||||
@@ -283,12 +298,14 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
add_test(NAME pureboot_autobaud.size
|
||||
COMMAND ${CMAKE_COMMAND} -DSIZE_TOOL=${CMAKE_SIZE} -DELF=$<TARGET_FILE:pureboot_autobaud>
|
||||
-DLIMIT=${PUREBOOT_LIMIT} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_size.cmake)
|
||||
# The measured unit is the loader's only RAM object and sits at the very
|
||||
# start of SRAM — where the host reads the bit period from (--info's
|
||||
# measured clock), so the address is wire contract, not layout accident.
|
||||
# The measured unit's home is wire contract, not layout accident: the
|
||||
# host reads the bit period from it (--info's measured clock). In the
|
||||
# GPIOR home the image must carry no RAM copy at all; in the RAM home it
|
||||
# is the loader's only RAM object, at the very start of SRAM.
|
||||
add_test(NAME pureboot_autobaud.unit
|
||||
COMMAND ${CMAKE_COMMAND} -DOBJDUMP=${CMAKE_OBJDUMP} -DELF=$<TARGET_FILE:pureboot_autobaud>
|
||||
-DRAM_START=${PUREBOOT_RAM_START} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
|
||||
-DRAM_START=${PUREBOOT_RAM_START} -DGPIOR=${PUREBOOT_UNIT_GPIOR}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/check_unit.cmake)
|
||||
|
||||
# One point of the exhaustive matrix, named from its resolved parameters
|
||||
# so the enumeration cannot collide with itself. `pins` is empty for the
|
||||
@@ -541,5 +558,19 @@ if(PROJECT_IS_TOP_LEVEL)
|
||||
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
${CMAKE_BINARY_DIR}/pbautobaud-work)
|
||||
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
|
||||
|
||||
# The autobaud window: the calibration poll budget, at the measured
|
||||
# 10 cycles a poll (pbwindow.py pins the constant the README's
|
||||
# seconds arithmetic uses; the budget itself is the clock-free knob).
|
||||
add_test(NAME pureboot.window.autobaud
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
|
||||
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_autobaud>
|
||||
--mcu ${PUREBOOT_SIM_MCU} --hz 1000000
|
||||
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
|
||||
--baud 9600 --app $<TARGET_FILE:pbapp_autobaud>.bin
|
||||
--autobaud-polls 4000000 --link sw
|
||||
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
|
||||
--workdir ${CMAKE_BINARY_DIR}/pbwindow-autobaud-work)
|
||||
set_tests_properties(pureboot.window.autobaud PROPERTIES TIMEOUT 300)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`master` carries `bootloader.atsln`, so this branch does too: `ide/bootloader.atsln`
|
||||
builds the loaders from the same sources Ninja does, to a **byte-identical
|
||||
`.text`** — 400 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
|
||||
`.text`** — 390 B for the 328P pureboot loader, 510 B for the `tsb_asm` tier in
|
||||
its 512-byte section. CMake remains the build system; the solution is here so the
|
||||
port opens in Studio as its predecessor did.
|
||||
|
||||
|
||||
2
libavr
2
libavr
Submodule libavr updated: 911a87538f...a9fe6bed50
@@ -138,15 +138,24 @@ 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.
|
||||
# the tinies match the classics. An autobaud loader keeps its measured unit
|
||||
# in GPIOR2:GPIOR1 wherever the chip has the pair (data 0x32 on the
|
||||
# t25/45/85, 0x4A from the x8 generation on) and as the first RAM object at
|
||||
# SRAM start where it does not (the t13s and classic megas). The host reads
|
||||
# whichever home applies (pureboot.py's geometry), and the unit-position
|
||||
# test holds the image to the same split.
|
||||
if(LIBAVR_MCU MATCHES "^atmega(8|16|32)a?$")
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior "")
|
||||
elseif(LIBAVR_MCU MATCHES "^atmega")
|
||||
set(_pb_ram 0x100)
|
||||
set(_pb_unit_gpior 0x4A)
|
||||
elseif(LIBAVR_MCU MATCHES "^attiny13")
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior "")
|
||||
else()
|
||||
set(_pb_ram 0x60)
|
||||
set(_pb_unit_gpior 0x32)
|
||||
endif()
|
||||
|
||||
# The function runs in its caller's scope, so everything it needs crosses
|
||||
@@ -169,6 +178,7 @@ set(PUREBOOT_LIMIT ${_pb_limit} PARENT_SCOPE)
|
||||
set(PUREBOOT_EEPROM ${_pb_eeprom} PARENT_SCOPE)
|
||||
set(PUREBOOT_DEFAULT_HZ ${_pb_hz} PARENT_SCOPE)
|
||||
set(PUREBOOT_RAM_START ${_pb_ram} PARENT_SCOPE)
|
||||
set(PUREBOOT_UNIT_GPIOR "${_pb_unit_gpior}" PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART ${_pb_has_usart} PARENT_SCOPE)
|
||||
set(PUREBOOT_HAS_USART1 ${_pb_has_usart1} PARENT_SCOPE)
|
||||
set(PUREBOOT_SIM_MCU ${_pb_sim_mcu} PARENT_SCOPE)
|
||||
@@ -366,9 +376,18 @@ function(pureboot_add_loader name)
|
||||
# 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-tree-ter -fno-split-wide-types)
|
||||
# now that it carries one, and -fno-ivopts is fitted per backend — an
|
||||
# autobaud body needs ivopts to keep the calibration countdown a single
|
||||
# induction variable (without it the counter is duplicated and the
|
||||
# measurement loop runs 9 cycles instead of its contracted 7), while the
|
||||
# fixed-baud bodies still measure smaller with it off.
|
||||
if(PB_SERIAL STREQUAL "autobaud")
|
||||
target_compile_options(${name} PRIVATE
|
||||
-fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
|
||||
else()
|
||||
target_compile_options(${name} PRIVATE
|
||||
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
|
||||
endif()
|
||||
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
|
||||
-Wl,--defsym=pureboot_app=${_app} ${_wrap})
|
||||
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A serial bootloader on [libavr](https://git.blackmark.me/avr/libavr), pure by
|
||||
constraint: one C++ source, no inline assembly, no global register variables
|
||||
(attributes and compiler flags allowed), **512 bytes on every chip libavr
|
||||
targets — all 37**. The device speaks primitives; every composite — verify,
|
||||
(attributes and compiler flags allowed), **a 512-byte slot on every chip
|
||||
libavr targets — all 37**. The device speaks primitives; every composite — verify,
|
||||
erase, reset-vector surgery, updating the loader itself — lives in the host
|
||||
tool (`pureboot.py`).
|
||||
|
||||
@@ -19,42 +19,42 @@ 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 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.
|
||||
The Stock column is 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 Autobaud column is the
|
||||
worst configuration the space produces for the chip: the clock-free build —
|
||||
it alone carries the calibration machinery — with the `OSCCAL` trim baked
|
||||
and, where the chip has a USART, the link deployed on that USART's own pins,
|
||||
which the loader then has to release (*Pin ownership*). On default pins
|
||||
without the trim the same loaders run 4–10 B smaller.
|
||||
|
||||
| Chip | Flash | Loader at | Link | Stock | Autobaud |
|
||||
|---|---|---|---|---|---|
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 390 B | 460 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 394 B | 464 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 398 B | 468 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 398 B | 468 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 360 B | 474 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 362 B | 480 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 362 B | 480 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 388 B | 464 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 398 B | 474 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 400 B | 480 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 394 B | 474 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 420 B | 500 B |
|
||||
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
|
||||
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
|
||||
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
|
||||
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
|
||||
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
|
||||
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
|
||||
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
|
||||
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
|
||||
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
|
||||
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
|
||||
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
|
||||
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 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 tightest fit in the whole space is the 1284s' autobaud build deployed on a
|
||||
USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone
|
||||
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud
|
||||
alone carries the calibration loop, a bit-banged link on a USART's pins alone
|
||||
has to release it (below), and the trim adds its one register write. Without
|
||||
the trim that build is 504; on the default pins, 500. The flash bank riding
|
||||
in a transfer's selector byte keeps even those chips' addressing the same
|
||||
16-bit form every other chip uses, which is why they are no longer the
|
||||
The tightest fit in the whole space is therefore the 1284s' 502 of their
|
||||
512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
|
||||
commands) on top of everything the column already stacks. 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
|
||||
@@ -88,7 +88,8 @@ the usual one where a board's USB bridge is wired to RXD/TXD: the link's `init`
|
||||
clears that USART's `UCSRnB` first, because while its `TXEN` is set the USART —
|
||||
not the port register — owns the TX pin, and a loader entered from an
|
||||
application that left it enabled would receive and obey while answering nothing
|
||||
(§20.2). It costs four bytes, and only on those pins.
|
||||
(§20.6.3). It costs one store — four bytes on the extended-I/O chips, two on
|
||||
the classic megas — and only on those pins.
|
||||
|
||||
`SERIAL autobaud` takes neither: the loader **measures** the host's bit timing
|
||||
at run time, so `CLOCK` and `BAUD` are not build parameters there and one
|
||||
@@ -99,7 +100,10 @@ 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).
|
||||
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). The wait spends nine cycles a
|
||||
poll (measured, and held by the `pureboot.window.autobaud` gate), so the
|
||||
default window is 36 M cycles: 4.5 s at 8 MHz, 3.75 s at 9.6 MHz, 36 s at
|
||||
1 MHz.
|
||||
|
||||
**Pick the rate by cycles a bit, and leave the oscillator room.** What the
|
||||
calibration can measure is bounded by how many clock cycles one bit lasts, so a
|
||||
@@ -187,8 +191,10 @@ reply, repeat.
|
||||
|
||||
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 exception: its address is a word address, because that is what the
|
||||
hardware's own jump takes — it still carries a selector byte (reserved,
|
||||
ignored) so its decode is the same three reads as every other command's.
|
||||
EEPROM and data-space addresses and all counts are bytes.
|
||||
|
||||
The loader trusts the host to keep addresses in range: it does not bound them
|
||||
against the chip. **Gotcha:** a write (or read) that runs past `E2END` wraps —
|
||||
@@ -203,7 +209,7 @@ better spent on features than on re-checking a bound the host already holds.
|
||||
| `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 |
|
||||
| `J` | sel8 (reserved), word address (16-bit) | `+`, then execution continues there |
|
||||
| other | — | ignored; the loop re-prompts (send a junk byte, await `+`, to resync) |
|
||||
|
||||
`G` and `g` are one letter in two cases, which is the whole command set for
|
||||
@@ -291,7 +297,12 @@ from `b`, and a command per memory (`R`/`W` flash, `r`/`w` EEPROM, `F` fuses).
|
||||
above; the shipped tool speaks both, choosing on the version it reads, so a
|
||||
deployed pureboot 4 stays drivable and self-updatable to 5. **6** changes
|
||||
nothing on the wire: it marks the builds that may carry a baked `OSCCAL` trim
|
||||
(Configuration), so a tool driving an update knows such images exist.
|
||||
(Configuration), so a tool driving an update knows such images exist. **7**
|
||||
moves `J` onto the unified decode — it gains the selector byte the table
|
||||
shows, which older loaders do not read, so the tool sends each form to the
|
||||
version that speaks it — and re-homes the autobaud unit into the GPIOR pair
|
||||
on the chips that have one (Session: what must not be written), which is
|
||||
where `--info`'s measured clock now reads it on those parts.
|
||||
|
||||
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
|
||||
@@ -476,11 +487,14 @@ Reads are safe anywhere; **two small regions cannot be written without ending th
|
||||
session,** because they are what the loader is standing on:
|
||||
|
||||
- the **top of SRAM**, where its stack lives — a handful of bytes below RAMEND;
|
||||
- on an **autobaud** build, the **two bytes at RAMSTART**: the measured bit
|
||||
period, in `.noinit`, which is the whole of that loader's static RAM. Overwrite
|
||||
it and its next reply is timed against garbage. On an ATtiny13A that is
|
||||
`0x60..0x61`, and the symptom is a mangled prompt byte rather than any error —
|
||||
the loader is fine, it simply is no longer speaking the agreed rate.
|
||||
- on an **autobaud** build, the **measured bit period**: two bytes in
|
||||
GPIOR2:GPIOR1 where the chip has the pair (data `0x32..0x33` on the
|
||||
t25/45/85, `0x4A..0x4B` from the x8 generation on — such a loader has *no*
|
||||
static RAM at all), and the two bytes at RAMSTART on the chips without one
|
||||
(the t13s and classic megas), where they are the whole of the loader's
|
||||
static RAM. Overwrite either home and the next reply is timed against
|
||||
garbage — the symptom is a mangled prompt byte rather than any error; the
|
||||
loader is fine, it simply is no longer speaking the agreed rate.
|
||||
|
||||
Both are self-inflicted rather than defects, and a reset clears them. Note also
|
||||
that `--poke` can write OSCCAL, which does take effect — but a session can only
|
||||
@@ -518,9 +532,10 @@ Per chip preset, `ctest` runs:
|
||||
below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock
|
||||
shape and on the tightest image in the space (autobaud on a USART's own
|
||||
pins), holding both of the trim write's addressing encodings to the budget;
|
||||
- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
|
||||
object and sits exactly at ram_start, where `--info` reads it: wire
|
||||
contract, not layout accident;
|
||||
- `pureboot_autobaud.unit` — the measured bit period sits where `--info`
|
||||
reads it (wire contract, not layout accident): in the GPIOR pair, with no
|
||||
RAM object at all, on the chips that have one; as the loader's only RAM
|
||||
object at exactly ram_start elsewhere;
|
||||
- `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) ×
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// pureboot — a serial bootloader on libavr: one C++ source, no inline
|
||||
// assembly, no global register variables, 512 bytes on every chip libavr
|
||||
// targets. The device speaks primitives; every composite (verify, erase,
|
||||
// assembly, no global register variables, a 512-byte slot on every chip
|
||||
// libavr targets. The device speaks primitives; every composite (verify, erase,
|
||||
// reset-vector surgery, self-update) lives in the host tool. Protocol,
|
||||
// deployment and configuration: README.md next to this file.
|
||||
//
|
||||
@@ -10,6 +10,8 @@
|
||||
// is what makes a copy one slot below able to rewrite the resident one, and
|
||||
// every change here has to keep it (test/check_pi.py).
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include <libavr/libavr.hpp>
|
||||
|
||||
using namespace avr::literals;
|
||||
@@ -75,7 +77,7 @@ static_assert(PUREBOOT_OSCCAL >= 0 && PUREBOOT_OSCCAL <= 0xff, "PUREBOOT_OSCCAL
|
||||
|
||||
// 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 = 6;
|
||||
constexpr std::uint8_t version = 7;
|
||||
|
||||
// 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
|
||||
@@ -162,18 +164,30 @@ constexpr std::uint8_t bank_shift = 16 - slot_shift;
|
||||
#define PUREBOOT_TX pb1
|
||||
#endif
|
||||
#if defined(PUREBOOT_USART)
|
||||
constexpr char usart_digit = '0' + PUREBOOT_USART;
|
||||
constexpr int usart_unit = PUREBOOT_USART;
|
||||
#else
|
||||
constexpr char usart_digit = '0';
|
||||
constexpr int usart_unit = 0;
|
||||
#endif
|
||||
|
||||
template <avr::hertz_t C, avr::baud_t B>
|
||||
struct hardware_link {
|
||||
using uart = avr::uart::usart<usart_digit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
|
||||
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
|
||||
|
||||
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2),
|
||||
// sbiw + sbci + sbci + brne (6).
|
||||
static constexpr std::uint8_t poll_cycles = 10;
|
||||
// The compiled idle poll around the window's narrow (uint24_t) countdown:
|
||||
// the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
|
||||
// status register's home — a 2-cycle bit-skip where UCSRnA sits in
|
||||
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
|
||||
// I/O. A uint32_t countdown pays one more sbci — window_polls() adds it
|
||||
// where the count forces the wide type. Held by the pureboot.window gate.
|
||||
// The lookup rides the baud parameter so it stays dependent: the trait is
|
||||
// an incomplete type on the USART-less chips, which parse this template
|
||||
// without ever instantiating it.
|
||||
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
|
||||
static consteval std::uint8_t poll_cost()
|
||||
{
|
||||
return U::ucsra::addr < 0x40 ? 7 : 9;
|
||||
}
|
||||
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
|
||||
|
||||
static void init()
|
||||
{
|
||||
@@ -197,7 +211,10 @@ struct hardware_link {
|
||||
|
||||
static void drain()
|
||||
{
|
||||
uart::drain();
|
||||
// A drain here always follows this link's own write — the frame is
|
||||
// in flight by construction, so the completion the wait needs is
|
||||
// guaranteed and the bounded default's countdown would be dead bytes.
|
||||
uart::drain_unbounded();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,9 +223,11 @@ struct software_link {
|
||||
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
|
||||
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
|
||||
|
||||
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci +
|
||||
// sbci + brne (6).
|
||||
static constexpr std::uint8_t poll_cycles = 8;
|
||||
// The compiled idle poll around the window's narrow (uint24_t) countdown:
|
||||
// sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
|
||||
// countdown pays one more sbci — window_polls() adds it where the count
|
||||
// forces the wide type. Held by the pureboot.window gate.
|
||||
static constexpr std::uint8_t poll_cycles = 7;
|
||||
|
||||
static void init()
|
||||
{
|
||||
@@ -241,7 +260,10 @@ struct software_link {
|
||||
// every rate. Activation differs in kind from the other two — there is no
|
||||
// clock to time a window against — so this backend brings its own, below.
|
||||
struct autobaud_link {
|
||||
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX>;
|
||||
// The unit in GPIOR2:GPIOR1 where the chip has them: the loader owns the
|
||||
// whole chip while it runs, and the pair costs one word per access where
|
||||
// the RAM word costs two — six words across the image.
|
||||
using uart = avr::uart::software_autobaud<avr::PUREBOOT_RX, avr::PUREBOOT_TX, avr::uart::unit_home::gpior>;
|
||||
|
||||
static void init()
|
||||
{
|
||||
@@ -260,19 +282,22 @@ struct autobaud_link {
|
||||
|
||||
static void drain()
|
||||
{
|
||||
uart::drain();
|
||||
// A drain here always follows this link's own write — the frame is
|
||||
// in flight by construction, so the completion the wait needs is
|
||||
// guaranteed and the bounded default's countdown would be dead bytes.
|
||||
uart::drain_unbounded();
|
||||
}
|
||||
};
|
||||
|
||||
#if defined(PUREBOOT_AUTOBAUD)
|
||||
using link = autobaud_link;
|
||||
#elif defined(PUREBOOT_USART)
|
||||
static_assert(avr::uart::has_usart<usart_digit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
|
||||
static_assert(avr::uart::has_usart<usart_unit>(), "PUREBOOT_USART selects a hardware USART this chip does not have");
|
||||
using link = hardware_link<dev::clock, wire_baud>;
|
||||
#elif defined(PUREBOOT_SOFT_SERIAL)
|
||||
using link = software_link<dev::clock, wire_baud>;
|
||||
#else
|
||||
using link = std::conditional_t<avr::uart::has_usart<usart_digit>(), hardware_link<dev::clock, wire_baud>,
|
||||
using link = std::conditional_t<avr::uart::has_usart<usart_unit>(), hardware_link<dev::clock, wire_baud>,
|
||||
software_link<dev::clock, wire_baud>>;
|
||||
#endif
|
||||
|
||||
@@ -288,7 +313,7 @@ extern "C" [[noreturn]] void pureboot_app();
|
||||
__builtin_unreachable();
|
||||
}
|
||||
|
||||
[[gnu::noinline, noreturn]] void run_app()
|
||||
[[noreturn]] void run_app()
|
||||
{
|
||||
jump(pureboot_app);
|
||||
}
|
||||
@@ -314,17 +339,35 @@ void await_host()
|
||||
}
|
||||
}
|
||||
#else
|
||||
// The window as one 32-bit countdown, divided by the backend's counted
|
||||
// poll-loop cycles. Whole seconds is all it promises.
|
||||
// The window as one countdown, divided by the backend's counted poll-loop
|
||||
// cycles. Whole seconds is all it promises. The per-poll cost depends on the
|
||||
// countdown's own width (a uint32_t decrement chain is one sbci longer), and
|
||||
// the width depends on the poll count — solved narrow-first: a count that
|
||||
// fits 24 bits at the narrow cost keeps the narrow loop, anything else takes
|
||||
// the wide loop at its own cost. A count fitting 24 bits only at the wide
|
||||
// cost stays wide, so the choice cannot oscillate on the boundary.
|
||||
consteval std::uint32_t polls_at(std::uint32_t per_poll)
|
||||
{
|
||||
// Whole-window cycles first, then the per-poll division: one truncation
|
||||
// instead of one per second. Same instructions either way — only the
|
||||
// countdown's immediate moves.
|
||||
return static_cast<std::uint32_t>(dev::cycles_for<std::chrono::seconds{timeout_seconds}>() / per_poll);
|
||||
}
|
||||
|
||||
consteval bool narrow_window()
|
||||
{
|
||||
return polls_at(link::poll_cycles) <= 0xffffff;
|
||||
}
|
||||
|
||||
consteval std::uint32_t window_polls()
|
||||
{
|
||||
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles);
|
||||
return polls_at(narrow_window() ? link::poll_cycles : link::poll_cycles + 1u);
|
||||
}
|
||||
|
||||
// The countdown in the narrowest type that holds it: a fourth byte would
|
||||
// cost a wider decrement chain at every poll for range most windows never
|
||||
// use (the autobaud budget makes the same choice).
|
||||
using window_t = std::conditional_t<window_polls() <= 0xffffff, avr::uint24_t, std::uint32_t>;
|
||||
using window_t = std::conditional_t<narrow_window(), avr::uint24_t, std::uint32_t>;
|
||||
|
||||
bool pending_before_deadline()
|
||||
{
|
||||
@@ -488,12 +531,6 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
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<void (*)()>(rx16());
|
||||
tx_ack();
|
||||
link::drain();
|
||||
jump(target);
|
||||
}
|
||||
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
|
||||
@@ -502,18 +539,26 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
for (std::uint8_t at = stamp_identity; at != sizeof identity_stamp; ++at)
|
||||
link::tx(identity_stamp[at]);
|
||||
break;
|
||||
case 'J': // jump: sel8 (reserved), addr16 as a wire word address
|
||||
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.
|
||||
// One decode, one cursor and one loop for every space, both
|
||||
// directions and the jump: a command per memory would carry a copy
|
||||
// of all three each. 'J' — the hand-over and staging transfer —
|
||||
// carries a selector it ignores so its address rides the same two
|
||||
// reads as everything else; '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 == 'J') {
|
||||
tx_ack();
|
||||
link::drain();
|
||||
jump(reinterpret_cast<void (*)()>(at));
|
||||
}
|
||||
if (command == 'W') {
|
||||
fill_page(bank, at);
|
||||
break;
|
||||
@@ -540,4 +585,7 @@ void fill_page(std::uint8_t bank, std::uint16_t at)
|
||||
} // namespace
|
||||
} // namespace pureboot
|
||||
|
||||
template struct avr::startup::entry<pureboot::run>;
|
||||
// stack::hardware: activation is reset-only, so the reset logic's own
|
||||
// SP = RAMEND stands wherever the datasheet guarantees it (the classic
|
||||
// megas still get the write); a 'J' entry runs on the caller's live stack.
|
||||
template struct avr::startup::entry<pureboot::run, avr::startup::stack::hardware>;
|
||||
|
||||
@@ -26,7 +26,7 @@ else:
|
||||
import termios
|
||||
|
||||
PROMPT = b"+"
|
||||
VERSION = 6 # this tool's own version — free to drift from a loader's
|
||||
VERSION = 8 # 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: the tool keeps a decoder for every generation in it (1–4 speak
|
||||
@@ -34,7 +34,7 @@ VERSION = 6 # this tool's own version — free to drift from a loader's
|
||||
# builds and changes nothing on the wire), and a version it has no decoder
|
||||
# for moves the floor.
|
||||
OLDEST_LOADER = 1
|
||||
NEWEST_LOADER = 6
|
||||
NEWEST_LOADER = 7
|
||||
SLOT = 512 # the loader slot, on every chip
|
||||
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
|
||||
@@ -42,14 +42,20 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
# '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.
|
||||
# 6 marks the builds that may carry a baked OSCCAL trim, nothing on the wire;
|
||||
# 7 gives 'J' a selector byte (older loaders take the bare address — jump()
|
||||
# sends each form to the version that speaks it) and re-homes the autobaud
|
||||
# unit into the GPIOR pair where the chip has one.
|
||||
UNIFIED_LOADER = 5
|
||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||
|
||||
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
|
||||
# as delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
|
||||
# An autobaud loader keeps its measured bit period readable, 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.
|
||||
# v5/v6 keep it at ram_start; v7 moves it into GPIOR2:GPIOR1 on the chips
|
||||
# that have the pair (their data addresses are in the geometry) and keeps
|
||||
# ram_start only where they do not exist. --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
|
||||
@@ -77,36 +83,40 @@ CALIBRATE = 0xC0
|
||||
# from its chip database at build time). Die revisions that share a signature
|
||||
# share this row, as they share the silicon.
|
||||
CHIP_GEOMETRY = {
|
||||
# signature : (flash, page, eeprom, patch_vector, ram_start)
|
||||
# signature : (flash, page, eeprom, patch_vector, ram_start, gpior1)
|
||||
# ram_start is where SRAM begins in data space: the classic megas and the
|
||||
# tinies keep it right after the plain I/O registers (0x60), the x8/x4
|
||||
# generations past their extended I/O file (0x100). An autobaud loader's
|
||||
# measured bit period lives at exactly ram_start (its only RAM object;
|
||||
# the loader's own build pins the layout), which is what --info reads.
|
||||
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
|
||||
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
|
||||
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
|
||||
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
|
||||
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
|
||||
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
|
||||
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
|
||||
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
|
||||
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
|
||||
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
|
||||
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
|
||||
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
|
||||
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
|
||||
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
|
||||
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
|
||||
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
|
||||
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
|
||||
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
|
||||
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA
|
||||
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
|
||||
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
|
||||
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
|
||||
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
|
||||
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
|
||||
# generations past their extended I/O file (0x100). gpior1 is GPIOR1's
|
||||
# data address — 0x32 on the t25/45/85, 0x4A from the x8 generation on,
|
||||
# None where the chip has no pair (t13, classic megas). A v7 autobaud
|
||||
# loader's measured bit period lives in GPIOR2:GPIOR1 where they exist
|
||||
# and at exactly ram_start elsewhere (its only RAM object; the loader's
|
||||
# own build pins the layout); v5/v6 always used ram_start. --info reads
|
||||
# whichever home the answering version implies.
|
||||
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60, None), # ATtiny13/13A
|
||||
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60, 0x32), # ATtiny25
|
||||
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60, 0x32), # ATtiny45
|
||||
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60, 0x32), # ATtiny85
|
||||
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48/48A
|
||||
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100, 0x4A), # ATmega48P/48PA
|
||||
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60, None), # ATmega8/8A
|
||||
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88/88A
|
||||
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100, 0x4A), # ATmega88P/88PA
|
||||
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60, None), # ATmega16/16A
|
||||
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168/168A
|
||||
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100, 0x4A), # ATmega168P/168PA
|
||||
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164P/164PA
|
||||
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100, 0x4A), # ATmega164A
|
||||
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60, None), # ATmega32/32A
|
||||
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328P
|
||||
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega328
|
||||
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324P
|
||||
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324PA
|
||||
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100, 0x4A), # ATmega324A
|
||||
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644/644A
|
||||
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100, 0x4A), # ATmega644P/644PA
|
||||
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284P
|
||||
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100, 0x4A),# ATmega1284
|
||||
}
|
||||
|
||||
VERBOSE = False
|
||||
@@ -450,7 +460,7 @@ class Info:
|
||||
if geometry is None:
|
||||
sig = " ".join(f"{b:02x}" for b in signature)
|
||||
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
|
||||
flash, page, eeprom, patch, _ = geometry
|
||||
flash, page, eeprom, patch, _, _ = geometry
|
||||
base = flash - SLOT
|
||||
word_flash = flash > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
@@ -491,6 +501,11 @@ class Info:
|
||||
# permits where from_identity refuses.
|
||||
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
|
||||
self.ram = geometry[4] if geometry else None
|
||||
# Where this loader keeps the measured bit period (None when a fixed
|
||||
# signature row is missing): the GPIOR pair from v7 where the chip
|
||||
# has one, ram_start before that and everywhere without the pair.
|
||||
gpior1 = geometry[5] if geometry else None
|
||||
self.unit_home = gpior1 if self.version >= 7 and gpior1 is not None else self.ram
|
||||
|
||||
def describe(self):
|
||||
sig = " ".join(f"{b:02x}" for b in self.signature)
|
||||
@@ -531,6 +546,11 @@ class Loader:
|
||||
# Set once a session is established over an autobaud link, so a
|
||||
# re-entry after 'J' repeats the handshake that worked.
|
||||
self.autobaud = False
|
||||
# The pre-knock drain runs once per port: the bytes it exists for are
|
||||
# leftovers from before this process opened the port. Re-knocks later
|
||||
# in the same session must not pay it — a fresh activation window is
|
||||
# already burning while they wait.
|
||||
self._line_drained = False
|
||||
# The link this session is speaking. It moves when the host follows a
|
||||
# staging copy built for another one (enter_copy).
|
||||
self.baud = getattr(port, "baud", None)
|
||||
@@ -540,10 +560,16 @@ class Loader:
|
||||
"""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)
|
||||
the older block's 'P', so four bytes are enough to tell them apart.
|
||||
|
||||
The timeout is short on purpose: a real answer follows the prompt
|
||||
within a frame time or two, so half a second is dozens of times the
|
||||
worst case — while a *false* prompt match (a stale byte, reset
|
||||
garbage) makes this read collect noise, and every second spent on it
|
||||
comes out of the activation window the retry needs."""
|
||||
head = self.port.read_exact(4, 0.5)
|
||||
if head[0:2] == b"PB":
|
||||
return Info(head + self.port.read_exact(8, 2.0))
|
||||
return Info(head + self.port.read_exact(8, 0.5))
|
||||
return Info.from_identity(head)
|
||||
|
||||
def _handshake(self, wait, knock, what):
|
||||
@@ -554,8 +580,23 @@ class Loader:
|
||||
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."""
|
||||
drain absorbs whatever they produced.
|
||||
|
||||
Before the port's first knock ever, the line is drained until quiet: a
|
||||
prompt from a previous session (`--stay`) can still be in the USB
|
||||
pipeline when the port opens, where a flush cannot clear what has not
|
||||
arrived yet — and on a board that resets when its port opens, trusting
|
||||
that stale byte would spend the fresh activation window reading noise
|
||||
from a device that never heard the knock. Once only, and bounded:
|
||||
later re-knocks in this session face no foreign leftovers, and their
|
||||
own window is already burning."""
|
||||
deadline = time.monotonic() + wait
|
||||
if not self._line_drained:
|
||||
self._line_drained = True
|
||||
drain = time.monotonic() + 0.25
|
||||
while self.port.read_available(0.05):
|
||||
if time.monotonic() > drain:
|
||||
break
|
||||
knocks = 0
|
||||
refusal = None
|
||||
while True:
|
||||
@@ -745,8 +786,13 @@ class Loader:
|
||||
return self._command(b"F", 4, 2.0)
|
||||
|
||||
def jump(self, word_address):
|
||||
"""The device acks, then execution continues at the word address."""
|
||||
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
|
||||
"""The device acks, then execution continues at the word address.
|
||||
From v7 'J' rides the unified decode, so it carries a selector byte
|
||||
the loader ignores; older loaders take the bare address."""
|
||||
if self.info.version >= 7:
|
||||
self.port.write(bytes((ord("J"), 0, word_address & 0xFF, word_address >> 8)))
|
||||
else:
|
||||
self.port.write(bytes((ord("J"), word_address & 0xFF, word_address >> 8)))
|
||||
self._expect_prompt()
|
||||
|
||||
def enter_copy(self, byte_address, wait, link=None):
|
||||
@@ -1541,14 +1587,14 @@ def main():
|
||||
print("device:")
|
||||
for line in info.lines():
|
||||
print(f" {line}")
|
||||
if args.autobaud and info.ram is not None:
|
||||
# The whole of the loader's RAM is the measured bit period at
|
||||
# ram_start; decoded and times the rate this session drives,
|
||||
if args.autobaud and info.unit_home is not None:
|
||||
# The measured bit period, from wherever this version keeps it
|
||||
# (unit_home); decoded and times the rate this session drives,
|
||||
# that is the true clock — the number to hold an OSCCAL bake
|
||||
# or a fixed-baud build against (README.md: deployment). The
|
||||
# autobaud identity path refuses unknown signatures, so ram is
|
||||
# always known here; the guard states that dependency.
|
||||
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
|
||||
# autobaud identity path refuses unknown signatures, so the
|
||||
# home is always known here; the guard states that dependency.
|
||||
unit = int.from_bytes(loader.read_ram(info.unit_home, 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 ""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# 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
|
||||
# Asserts the autobaud loader's measured unit sits where the host will read
|
||||
# it (--info's measured clock — the address is wire contract). Two homes: on
|
||||
# a chip with the GPIOR pair the unit lives there and the image must carry no
|
||||
# RAM word for it at all; elsewhere it is the first RAM object at SRAM start.
|
||||
# Run as
|
||||
# cmake -DOBJDUMP=... -DELF=... -DRAM_START=<data address> [-DGPIOR=<data address>]
|
||||
# -P check_unit.cmake
|
||||
|
||||
execute_process(COMMAND ${OBJDUMP} -t ${ELF} OUTPUT_VARIABLE _syms RESULT_VARIABLE _res)
|
||||
if(NOT _res EQUAL 0)
|
||||
@@ -9,7 +12,17 @@ if(NOT _res EQUAL 0)
|
||||
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}")
|
||||
string(REGEX MATCH "\n0*([0-9a-f]+)[^\n]+[ \t][^ \t\n]*unit_E\n" _line "${_syms}")
|
||||
|
||||
if(GPIOR)
|
||||
if(_line)
|
||||
message(FATAL_ERROR "unit_ RAM symbol present although the unit's home is GPIOR ${GPIOR} — "
|
||||
"the host peeks the pair, and a RAM copy would be dead weight")
|
||||
endif()
|
||||
message(STATUS "no unit_ RAM object — the unit lives in the GPIOR pair at ${GPIOR}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT _line)
|
||||
message(FATAL_ERROR "no unit_ symbol in ${ELF} — is this the autobaud loader?")
|
||||
endif()
|
||||
|
||||
@@ -50,7 +50,7 @@ consteval bool use_hardware()
|
||||
#if defined(PUREBOOT_SOFT_SERIAL)
|
||||
return false;
|
||||
#else
|
||||
return avr::hw::db.has_instance("USART0") || avr::hw::db.has_instance("USART");
|
||||
return avr::uart::has_usart<0>();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ struct link {
|
||||
#else
|
||||
static constexpr avr::baud_t baud{115200};
|
||||
#endif
|
||||
using tx_t = avr::uart::usart<'0' + PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
|
||||
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
|
||||
static void tx(char c)
|
||||
{
|
||||
tx_t::write(static_cast<std::uint8_t>(c));
|
||||
|
||||
@@ -64,8 +64,9 @@ def main():
|
||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||
if needed not in out:
|
||||
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
||||
# The measured clock, decoded from the unit at ram_start. The
|
||||
# runner's clock is exact, so the figure must land inside the
|
||||
# The measured clock, decoded from the unit at whichever home this
|
||||
# version keeps it in. The runner's clock is exact, so the figure
|
||||
# must land inside the
|
||||
# encoding's own envelope: the loader floors the bit period to
|
||||
# 4-cycle spin granules after an 8-cycle discount, and the edge
|
||||
# poll can shave a few cycles more — one granule of slack below
|
||||
|
||||
@@ -8,10 +8,13 @@ import subprocess
|
||||
|
||||
|
||||
class Device:
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None):
|
||||
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None,
|
||||
window=False):
|
||||
cmd = [binary]
|
||||
if link:
|
||||
cmd += ["-l", link]
|
||||
if window:
|
||||
cmd.append("-w") # report the first-transmit cycle, free-run idle
|
||||
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
|
||||
if reset_hex is not None or resume is not None:
|
||||
# Chips without a hardware boot section — the tinies and the
|
||||
|
||||
137
test/pbwindow.py
Normal file
137
test/pbwindow.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The activation window as a behavioral duration gate.
|
||||
|
||||
The loader's window is a counted poll loop whose per-poll cost is hand-counted
|
||||
in the source (`link::poll_cycles`) — but the loop compiles in consumer
|
||||
context, so only the running image can prove the count. This test installs a
|
||||
real application beside the loader (the host tool's own `plan_flash` supplies
|
||||
the reset-vector surgery), starts the simulator with the line idle, and reads
|
||||
the cycle of the first transmit activity: nothing talks until the window
|
||||
closes and the application banners, so that cycle *is* the window, give or
|
||||
take a banner lead measured in microseconds. Asserted at ±2 % — one
|
||||
mis-counted cycle per poll shifts a window by 10 % and more.
|
||||
|
||||
Fixed-baud loaders declare their window in seconds (--seconds, the build's
|
||||
TIMEOUT). The autobaud loader's window is its calibration poll budget
|
||||
(--autobaud-polls); the seconds it amounts to are budget × 10 / f_cpu, the
|
||||
measured cost of the calibrate() wait loop this gate pins.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
from pbsim import Device
|
||||
|
||||
# The calibrate() budget loop's cycles per poll in the built image — what the
|
||||
# README's window arithmetic rests on, verified here. A measured fact, not a
|
||||
# design constant: the wait's exit branches land where the compiler's block
|
||||
# layout puts them, and the bounded-calibration rework moved the loop from
|
||||
# ten cycles to nine.
|
||||
AUTOBAUD_POLL_CYCLES = 9
|
||||
|
||||
|
||||
def load_tool(path):
|
||||
spec = importlib.util.spec_from_file_location("pureboot", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def compose_flash(pb, loader_bytes, app_bytes, mcu, base, page):
|
||||
"""The flash image a completed programming session leaves: application
|
||||
(with the tinies' vector surgery), loader at base — built through the
|
||||
host tool's own planner so the surgery is the shipped one, not a copy."""
|
||||
flash_size = base + pb.SLOT
|
||||
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
|
||||
word_flash = flash_size > 0x10000
|
||||
wire_base = base // 2 if word_flash else base
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
raw = bytes((ord("P"), ord("B"), 5, 0, 0, 0, page & 0xFF,
|
||||
wire_base & 0xFF, wire_base >> 8, 0, 0, flags))
|
||||
info = pb.Info(raw)
|
||||
|
||||
flash = bytearray(b"\xff" * flash_size)
|
||||
for address, content in pb.plan_flash(app_bytes, info).items():
|
||||
flash[address:address + len(content)] = content
|
||||
flash[base:base + len(loader_bytes)] = loader_bytes
|
||||
return bytes(flash)
|
||||
|
||||
|
||||
def first_tx_cycle(device, deadline):
|
||||
"""The PB_WINDOW_TX report, or None. The runner prints it once."""
|
||||
stream = device.proc.stdout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
ready, _, _ = select.select([stream], [], [], remaining)
|
||||
if not ready:
|
||||
return None
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith("PB_WINDOW_TX"):
|
||||
return int(line.split()[1])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--device", required=True)
|
||||
parser.add_argument("--loader", required=True)
|
||||
parser.add_argument("--mcu", required=True)
|
||||
parser.add_argument("--hz", type=int, required=True)
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--page", type=int, required=True)
|
||||
parser.add_argument("--baud", type=int, required=True)
|
||||
parser.add_argument("--app", required=True)
|
||||
parser.add_argument("--tool", required=True)
|
||||
parser.add_argument("--workdir", required=True)
|
||||
parser.add_argument("--link", default=None)
|
||||
parser.add_argument("--seconds", type=float, default=None)
|
||||
parser.add_argument("--autobaud-polls", type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
if (args.seconds is None) == (args.autobaud_polls is None):
|
||||
parser.error("exactly one of --seconds / --autobaud-polls")
|
||||
|
||||
pb = load_tool(args.tool)
|
||||
base = int(args.base, 0)
|
||||
expected = (args.seconds if args.seconds is not None
|
||||
else args.autobaud_polls * AUTOBAUD_POLL_CYCLES / args.hz)
|
||||
|
||||
work = pathlib.Path(args.workdir)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
# Every loader target objcopies its slot content beside the ELF (.bin).
|
||||
loader_bytes = pathlib.Path(args.loader + ".bin").read_bytes()
|
||||
app_bytes = pathlib.Path(args.app).read_bytes()
|
||||
flash_file = work / "window-flash.bin"
|
||||
flash_file.write_bytes(compose_flash(pb, loader_bytes, app_bytes, args.mcu, base, args.page))
|
||||
|
||||
device = Device(args.device, args.loader, args.mcu, str(args.hz), args.base, args.page,
|
||||
args.baud, str(work / "window-dump.bin"), resume=str(flash_file),
|
||||
link=args.link, window=True)
|
||||
try:
|
||||
# Simulation speed is machine-dependent; a few hundred thousand
|
||||
# cycles per wall second is the pessimistic floor.
|
||||
budget = max(60.0, expected * args.hz / 300000)
|
||||
cycle = first_tx_cycle(device, time.monotonic() + budget)
|
||||
finally:
|
||||
device.stop()
|
||||
|
||||
if cycle is None:
|
||||
print(f" [FAIL] no transmit activity within {budget:.0f} s wall "
|
||||
f"(expected a {expected:.2f} s window)")
|
||||
return 1
|
||||
measured = cycle / args.hz
|
||||
error = (measured - expected) / expected
|
||||
ok = abs(error) <= 0.02
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] window {measured:.3f} s vs declared "
|
||||
f"{expected:.3f} s ({error:+.1%}, gate ±2%)")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -62,6 +62,29 @@ const char *dump_path;
|
||||
std::uint32_t reset_pc;
|
||||
volatile std::sig_atomic_t reset_requested;
|
||||
|
||||
// -w: report the cycle of the first transmit activity, once. What the
|
||||
// activation-window gate reads — with an idle line and an application
|
||||
// installed, the first thing that ever talks is the application's banner,
|
||||
// so this cycle *is* the loader's window plus a banner lead measured in
|
||||
// microseconds. Idle pacing is skipped in this mode: there is no real-time
|
||||
// host in the loop, and a paced multi-second window would take hours.
|
||||
bool window_report;
|
||||
bool window_tx_seen;
|
||||
|
||||
void window_first_tx()
|
||||
{
|
||||
if (!window_report || window_tx_seen)
|
||||
return;
|
||||
window_tx_seen = true;
|
||||
std::println("PB_WINDOW_TX {}", avr->cycle);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
|
||||
{
|
||||
window_first_tx();
|
||||
}
|
||||
|
||||
int parse_link(std::string_view spec)
|
||||
{
|
||||
if (spec == "usart0" || spec == "usart1") {
|
||||
@@ -195,6 +218,20 @@ std::uint8_t tx_shift;
|
||||
|
||||
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
|
||||
{
|
||||
if (tx_bit < 0) {
|
||||
// Half a bit into the start bit: a real receiver re-samples here and
|
||||
// abandons a false start. The device's own init produces one — DDR
|
||||
// drives the pin low for the instructions until the idle level is
|
||||
// written — and without this check that glitch decodes as a stray
|
||||
// byte (and would read as first transmit activity under -w).
|
||||
if (tx_level) {
|
||||
tx_active = 0;
|
||||
return 0;
|
||||
}
|
||||
window_first_tx();
|
||||
tx_bit = 0;
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (tx_bit < 8) {
|
||||
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
@@ -256,10 +293,10 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
|
||||
return;
|
||||
}
|
||||
int level = value & 1;
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge
|
||||
if (!tx_active && tx_level == 1 && level == 0) { // start edge, confirmed mid-bit
|
||||
tx_active = 1;
|
||||
tx_bit = 0;
|
||||
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr);
|
||||
tx_bit = -1;
|
||||
avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
|
||||
}
|
||||
tx_level = level;
|
||||
}
|
||||
@@ -315,7 +352,16 @@ void bridge_reset()
|
||||
rx_active = 0;
|
||||
tx_active = 0;
|
||||
tx_level = 1;
|
||||
avr_raise_irq(rx_pin, 1); // idle line
|
||||
// Re-drive the idle line through a forced transition: ioport pin irqs are
|
||||
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
|
||||
// keeps its pre-reset cached value — so a plain raise(1) against a cached
|
||||
// 1 is dropped and the device reads the line stuck low. A loader entering
|
||||
// calibration on that line measures reset-to-first-edge as one giant
|
||||
// pulse and mis-locks or boots the application on the first real knock.
|
||||
// No cycles run between the two raises, so the device only ever sees the
|
||||
// final idle-high.
|
||||
avr_raise_irq(rx_pin, 0);
|
||||
avr_raise_irq(rx_pin, 1);
|
||||
}
|
||||
|
||||
void poll_pty()
|
||||
@@ -364,7 +410,11 @@ void poll_pty()
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
bool link_given = false;
|
||||
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) {
|
||||
for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) {
|
||||
if (opt == 'w') {
|
||||
window_report = true;
|
||||
continue;
|
||||
}
|
||||
if (opt != 'l' || parse_link(optarg) != 0) {
|
||||
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
|
||||
return 2;
|
||||
@@ -374,10 +424,12 @@ int main(int argc, char *argv[])
|
||||
int args = argc - optind;
|
||||
if (args < 7 || args > 9) {
|
||||
std::print(stderr,
|
||||
"usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
"usage: {} [-l link] [-w] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
|
||||
" [reset_hex] [resume_flash]\n"
|
||||
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
|
||||
" them); default: the chip's own\n"
|
||||
" -w: print PB_WINDOW_TX <cycle> at the first transmit activity and\n"
|
||||
" free-run idle time (window measurement mode)\n"
|
||||
" reset_hex: reset vector (default: base with a boot section, else 0)\n"
|
||||
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
|
||||
" run's dump, for power-fail resume tests\n",
|
||||
@@ -468,6 +520,9 @@ int main(int argc, char *argv[])
|
||||
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
|
||||
uart_pty_init(avr, &uart_pty);
|
||||
uart_pty_connect(&uart_pty, uart_digit);
|
||||
if (window_report)
|
||||
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_UART_GETIRQ(uart_digit), UART_IRQ_OUTPUT),
|
||||
window_uart_hook, nullptr);
|
||||
std::println("PB_PTY {}", uart_pty.pty.slavename);
|
||||
} else {
|
||||
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
|
||||
@@ -524,7 +579,7 @@ int main(int argc, char *argv[])
|
||||
// entirely. Pace the simulation only while the bridge is fully
|
||||
// quiet (nothing decoding, nothing queued); transfers keep full
|
||||
// speed, and a quiet window stretches toward real time.
|
||||
if (!rx_active && !tx_active && rx_head == rx_tail)
|
||||
if (!window_report && !rx_active && !tx_active && rx_head == rx_tail)
|
||||
usleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-tool activation handshake: it must not hang on a flooding target.
|
||||
"""Host-tool activation handshake: bounded against a line that misbehaves.
|
||||
|
||||
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
|
||||
trailing bytes before it asks for the identity. That drain must be bounded: a
|
||||
@@ -8,6 +8,13 @@ this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
|
||||
prompt — otherwise spins the tool forever. Regression for that hang, plus a
|
||||
control that a well-behaved loader still connects.
|
||||
|
||||
The handshake must also survive its own leftovers: after `--stay` the loader's
|
||||
final prompt can still be in the USB pipeline when the next invocation opens
|
||||
the port, and on a board wired to reset on open, that opening starts a fresh
|
||||
activation window the stale prompt then betrays — the tool commits to an
|
||||
identity read against a device that never heard its knock, and what it finally
|
||||
collects is the application's banner. StaleDTRPort is that moment as a port.
|
||||
|
||||
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
|
||||
beside pureboot.planner.
|
||||
"""
|
||||
@@ -49,27 +56,117 @@ class FloodPort:
|
||||
|
||||
|
||||
class LoaderPort:
|
||||
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the
|
||||
"""A well-behaved pureboot 5: a prompt to the knock, then quiet, then the
|
||||
slim identity (version 5 + m328p signature) and a closing prompt."""
|
||||
|
||||
def __init__(self):
|
||||
self.reads = self.exacts = 0
|
||||
self.pending = b""
|
||||
self.exacts = 0
|
||||
|
||||
def flush_input(self):
|
||||
pass
|
||||
self.pending = b""
|
||||
|
||||
def write(self, data):
|
||||
pass
|
||||
if b"p" in data:
|
||||
self.pending = b"+" # the prompt answers the knock, nothing else
|
||||
|
||||
def read_available(self, wait):
|
||||
self.reads += 1
|
||||
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet
|
||||
data, self.pending = self.pending, b""
|
||||
return data
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
self.exacts += 1
|
||||
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
|
||||
|
||||
|
||||
class StaleDTRPort:
|
||||
"""`--stay`, then a fresh invocation on a board that resets when its port
|
||||
opens. Three facts of that moment, all timed from the open: the previous
|
||||
session's final prompt is still in transit and lands only after the
|
||||
opening flush has already run; the reset holds the device off the line
|
||||
at first, eating anything written before it completes; and the fresh
|
||||
window is finite — once it expires the application boots and prints a
|
||||
banner whose bytes are what a pending identity read collects. A
|
||||
handshake that trusts the stale prompt spends the whole window waiting
|
||||
on a device that never heard its knock; one that drains the line first
|
||||
knocks into the real window and connects."""
|
||||
|
||||
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
|
||||
READY_AT = 0.05 # reset complete, activation window opens
|
||||
WINDOW = 1.0 # window length; expiry boots the application
|
||||
|
||||
def __init__(self):
|
||||
self.t0 = time.monotonic()
|
||||
# (visible-from, bytes): the line as a timed queue.
|
||||
self.queue = [(self.t0 + self.STALE_AT, b"+")]
|
||||
self.armed = False # a 'p' heard inside the window arms 'b'
|
||||
self.booted = False
|
||||
|
||||
def _boot_check(self):
|
||||
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
|
||||
self.booted = True
|
||||
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
|
||||
b"W r libavr tempmon\r\n"))
|
||||
|
||||
def _visible(self):
|
||||
self._boot_check()
|
||||
now = time.monotonic()
|
||||
return b"".join(d for t, d in self.queue if t <= now)
|
||||
|
||||
def _consume(self, n):
|
||||
now = time.monotonic()
|
||||
left = []
|
||||
for t, d in self.queue:
|
||||
if t <= now and n:
|
||||
take = min(n, len(d))
|
||||
d = d[take:]
|
||||
n -= take
|
||||
if d:
|
||||
left.append((t, d))
|
||||
self.queue = left
|
||||
|
||||
def flush_input(self):
|
||||
self._consume(len(self._visible()))
|
||||
|
||||
def write(self, data):
|
||||
self._boot_check()
|
||||
now = time.monotonic()
|
||||
if now < self.t0 + self.READY_AT or self.booted:
|
||||
return # still in reset, or the application owns the line
|
||||
if b"p" in data:
|
||||
self.armed = True
|
||||
self.queue.append((now + 0.01, b"+"))
|
||||
if b"b" in data and self.armed:
|
||||
# The slim identity (version 5 + m328p signature) and a prompt.
|
||||
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
|
||||
|
||||
def read_available(self, wait):
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
data = self._visible()
|
||||
if data:
|
||||
self._consume(len(data))
|
||||
return data
|
||||
if time.monotonic() >= deadline:
|
||||
return b""
|
||||
time.sleep(0.005)
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
data = b""
|
||||
while len(data) < count:
|
||||
visible = self._visible()
|
||||
if visible:
|
||||
take = visible[:count - len(data)]
|
||||
self._consume(len(take))
|
||||
data += take
|
||||
elif time.monotonic() >= deadline:
|
||||
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
|
||||
else:
|
||||
time.sleep(0.005)
|
||||
return data
|
||||
|
||||
|
||||
def terminates(port, wait, budget):
|
||||
"""Run connect_autobaud in a thread; True if it returns/raises within
|
||||
`budget` seconds rather than hanging."""
|
||||
@@ -97,6 +194,16 @@ def main():
|
||||
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
|
||||
check("well-behaved loader still connects (version 5)", info.version == 5)
|
||||
|
||||
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
|
||||
# fresh window — the pre-knock drain absorbs it and the first real knock
|
||||
# lands inside the window.
|
||||
try:
|
||||
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
|
||||
except pb.Error as failed:
|
||||
print(f" ({failed})")
|
||||
stale_ok = False
|
||||
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
|
||||
|
||||
print(f"\n {P} passed, {F} failed")
|
||||
return 1 if F else 0
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
|
||||
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
|
||||
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
|
||||
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
|
||||
# A chip's generated and reflect trees must answer with the same bytes
|
||||
# (the identity invariant), so the same target measuring two sizes means
|
||||
# a stale tree — or an identity breach. Either is a finding; picking one
|
||||
# silently is how a gate reports another build's numbers as today's.
|
||||
for chip, rows in found.items():
|
||||
seen: dict[str, tuple[int, str]] = {}
|
||||
for name, elf, _ in rows:
|
||||
if elf not in sizes:
|
||||
continue
|
||||
if name in seen and seen[name][0] != sizes[elf]:
|
||||
sys.exit(f"{chip} {name}: {seen[name][0]} B in {seen[name][1]} but "
|
||||
f"{sizes[elf]} B in {elf} — a stale tree (rebuild or remove it) "
|
||||
f"or a cross-mode identity breach")
|
||||
seen.setdefault(name, (sizes[elf], elf))
|
||||
measured = {
|
||||
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
|
||||
key=lambda row: -row[1])
|
||||
@@ -120,7 +134,9 @@ def cmd_max(args) -> int:
|
||||
|
||||
|
||||
def cmd_check_readme(args) -> int:
|
||||
"""The README's per-chip table, against the stock and autobaud builds."""
|
||||
"""The README's per-chip table, against the stock build and the worst
|
||||
autobaud configuration (OSCCAL baked, plus the USART-pin release where
|
||||
the chip has a USART) — the config the Autobaud column documents."""
|
||||
readme = (ROOT / "pureboot" / "README.md").read_text()
|
||||
measured = collect()
|
||||
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
|
||||
@@ -132,7 +148,9 @@ def cmd_check_readme(args) -> int:
|
||||
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
|
||||
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
|
||||
built = {name: text for name, text, _ in measured.get(chip, [])}
|
||||
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)):
|
||||
worst = ("pureboot_autobaud_osccal_on_usart0"
|
||||
if "pureboot_autobaud_osccal_on_usart0" in built else "pureboot_autobaud_osccal")
|
||||
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
|
||||
if target not in built:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
@@ -263,7 +263,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
|
||||
// byte and U2X0 need a store. The library still does the datasheet work.
|
||||
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
|
||||
// General-purpose registers are undefined at power-on (no crt zeroes them);
|
||||
// the direction latch must start "not receiving" so the first rx() enables
|
||||
|
||||
@@ -202,7 +202,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
{
|
||||
constexpr auto sol = avr::uart::solve_baud(dev::clock, 115200_Bd);
|
||||
static_assert(sol.u2x && sol.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
avr::hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(sol.ubrr));
|
||||
avr::hw::ubrr0::write(static_cast<std::uint8_t>(sol.ubrr));
|
||||
avr::hw::ucsr0a::write(avr::hw::ucsr0a::u2x0(1));
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ extern "C" [[noreturn]] void tsb_app(); // the application's reset vector: --def
|
||||
// 0, and rx()/tx() raise RXEN0/TXEN0 on first use — only the divisor low
|
||||
// byte and U2X0 need a store. The library still does the datasheet work.
|
||||
static_assert(baud.u2x && baud.ubrr < 256, "lean bring-up writes UBRR0L only, with U2X0");
|
||||
hw::reg<"UBRR0">::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ubrr0::write(static_cast<std::uint8_t>(baud.ubrr));
|
||||
hw::ucsr0a::write(hw::ucsr0a::u2x0(1));
|
||||
// General-purpose registers are undefined at power-on (no crt zeroes them);
|
||||
// the direction latch must start "not receiving" so the first rx() enables
|
||||
|
||||
Reference in New Issue
Block a user