build: the libavr pin advances past phase 6, at byte parity everywhere

The pin crosses libavr's phase 6 - the renamed system surface, the named
serial configs, the receiver-tolerance table, the paged SPM receipts -
and every loader image comes out size-identical: the full matrix on six
representative chips (the exhaustive cross product on three of them),
the stock and autobaud columns untouched, the four tsb tiers back on
their recorded floors at 510/526/638/836.

Byte parity was not free, and the two libavr defects it surfaced were
fixed there rather than absorbed here. The EEPROM write procedure's
step 2 - the SPMEN spin - had landed unconditionally and cost every
build six bytes for a wait a polled loader can never take; it is scoped
now, and the loaders state the datasheet's own omission clause
(spm_interlock::omitted, DS40002061B 8.6.3). The blocking page
erase/write grew an internal wait the tiers' settle() already provides,
so the tiers issue the command form and pureboot keeps its host-driven
sp_spm path.

What the port states rather than inherits: the stock 115200 at 16 MHz
sits +2.1 % past the receiver-tolerance table libavr now holds rates
to, so the hardware links say .allow_baud_error = true - the same
2.5 % envelope pureboot_baud_feasible() has always enforced, proven on
silicon across the fleet. rx_ready() reads readable() now.

Alongside the pin: rule 33's ASCII sweep over every source (docs keep
their typography), rule 34's InsertBraces in .clang-format with the
tree reformatted, std::array over the simavr runners' raw buffers, and
the stale Studio size in ide/README.md replaced by the claim its
check-flags gate actually holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:43:44 +02:00
parent 0cb83ff36f
commit e4390d2ba8
38 changed files with 791 additions and 625 deletions

View File

@@ -2,12 +2,12 @@
"""Position-independence lint: the property that lets the identical image run
from any slot, asserted from the built ELF and its object.
1. No absolute jmp/call -mrelax normally guarantees it, but a branch that
1. No absolute jmp/call - -mrelax normally guarantees it, but a branch that
grows out of relaxation range would break it silently.
2. Nothing flash-resident to address: the image is .text alone, so there is
no table whose runtime address has to be reconstructed.
3. The image is byte-identical when linked at a different base. This is
position independence itself rather than a proxy for it an absolute
position independence itself rather than a proxy for it - an absolute
address anywhere in the image would move with the link and show up as a
differing byte.

View File

@@ -1,5 +1,5 @@
# 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
# 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
@@ -16,21 +16,21 @@ 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} "
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}")
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?")
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")
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

@@ -7,6 +7,7 @@
// SPM genuinely writes avr->flash on the mega cores, so on exit (or SIGTERM)
// we dump the flash image to a file for a ground-truth cross-check against
// what the client read back through the bootloader.
#include <array>
#include <csignal>
#include <cstdint>
#include <cstdio>
@@ -17,7 +18,7 @@
#include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers the block covers both harmlessly.
// unlike simavr's core headers - the block covers both harmlessly.
extern "C" {
#include "avr_uart.h"
#include "sim_avr.h"
@@ -75,11 +76,11 @@ int main(int argc, char *argv[])
return 1;
}
// An image that runs past flash end cannot execute on hardware, and a
// naive copy of it would smash the heap beyond avr->flash after which
// naive copy of it would smash the heap beyond avr->flash - after which
// the simulation misbehaves in ways that point everywhere but here.
// Refuse it loudly instead.
if (boot_base + fw.flashsize > avr->flashend + 1) {
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} image does not fit its slot",
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} - image does not fit its slot",
fw.flashsize, boot_base, avr->flashend);
return 1;
}
@@ -94,13 +95,13 @@ int main(int argc, char *argv[])
if (cfg) {
std::uint32_t app_end = boot_base - 128; // config page sits directly below the boot code
for (int i = 0; cfg[i] && cfg[i + 1]; i += 2) {
char b[3] = {cfg[i], cfg[i + 1], 0};
avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(b, nullptr, 16));
const std::array<char, 3> pair = {cfg[i], cfg[i + 1], 0};
avr->flash[app_end + i / 2] = static_cast<std::uint8_t>(std::strtoul(pair.data(), nullptr, 16));
}
}
// POLL_SLEEP makes simavr usleep(1) on every status-register read while the
// UART is idle a host-CPU-saving hack that models no hardware and paces a
// UART is idle - a host-CPU-saving hack that models no hardware and paces a
// tight-polling loader (one that releases TX between bytes, as one-wire does)
// in real time, distorting protocol timing. Clear it so the loader runs at
// true cycle speed.
@@ -119,8 +120,9 @@ int main(int argc, char *argv[])
for (;;) {
int state = avr_run(avr);
if (state == cpu_Done || state == cpu_Crashed)
if (state == cpu_Done || state == cpu_Crashed) {
break;
}
}
finish(0);
}

View File

@@ -1,18 +1,18 @@
// Test-fixture application for the pureboot protocol tests: prints "APP" on
// the chip's serial link (the same link the loader uses) the proof that
// the chip's serial link (the same link the loader uses) - the proof that
// the loader's hand-over, and on the tinies the host's reset-vector
// surgery, actually launched it. Linked normally (crt, vectors at 0); on
// the tinies its reset vector is the rjmp the host re-homes.
//
// On the hardware-USART link it then listens, and an 'L' makes it jump into
// the resident loader the application-owned loader entry a
// the resident loader - the application-owned loader entry a
// BOOTRST-unprogrammed mega relies on (reset always boots the application
// there), exercised by the self-update tests. The software link idles:
// reset reaches those loaders through the patched vector (or the runner
// models BOOTRST), so the application owes them nothing.
//
// PUREBOOT_HANDOVER drops the listening and jumps straight in, leaving the
// USART enabled behind it the hand-over state a loader bit-banging on that
// USART enabled behind it - the hand-over state a loader bit-banging on that
// USART's own pins has to survive.
//
// The fixture speaks the deployment its loader was built for: the same
@@ -30,10 +30,12 @@ consteval avr::hertz_t clock()
return avr::hertz_t{PUREBOOT_CLOCK_HZ};
#else
auto name = std::string_view{avr::hw::db.name};
if (name.starts_with("ATtiny13"))
if (name.starts_with("ATtiny13")) {
return 9.6_MHz;
if (name.starts_with("ATtiny"))
}
if (name.starts_with("ATtiny")) {
return 8_MHz;
}
return 16_MHz;
#endif
}
@@ -66,7 +68,10 @@ struct link {
#else
static constexpr avr::baud_t baud{115200};
#endif
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .max_baud_error = 2.5_pct}>;
// The fixture speaks whatever rate the loader was built for, stock
// 115200 at 16 MHz included, which sits past the receiver-tolerance
// table's bound - the same deployment envelope the loader itself states.
using tx_t = avr::uart::usart<PUREBOOT_USART, C, {.baud = baud, .allow_baud_error = true}>;
static void init()
{
avr::init<tx_t>();
@@ -75,7 +80,7 @@ struct link {
{
tx_t::write(static_cast<std::uint8_t>(c));
}
// The loader sits in the top slot 512 bytes on every chip. The jump
// The loader sits in the top slot - 512 bytes on every chip. The jump
// takes a word address, which is what makes the >64 KiB chips' entry
// reachable through a 16-bit pointer at all.
static void enter_loader()
@@ -87,7 +92,7 @@ struct link {
[[noreturn]] static void idle()
{
#if defined(PUREBOOT_HANDOVER)
// Hand back at once, with this USART still enabled the state that
// Hand back at once, with this USART still enabled - the state that
// leaves a bit-banged loader on its pins mute unless the loader
// releases it. Unconditional because there is no command wire to
// wait on: that loader's link is the pins, not this peripheral.
@@ -96,13 +101,20 @@ struct link {
#else
for (;;) {
auto command = tx_t::read_blocking();
if (command == 'L')
if (command == 'L') {
enter_loader();
}
// 'D' leaves every word of the SPM page buffer dirty, so that a
// following 'L' enters the loader with the buffer it never clears.
// Hardware refuses application-section SPM on a boot-sectioned
// part; simavr dispatches it anyway, which is the whole reason the
// state is constructible - the stated section is the compilable
// fiction that matches what the simulator runs.
if (command == 'D') {
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2)
avr::spm::fill(at, 0xdead);
const auto open = avr::spm::page::begin<avr::spm::from::boot_section>(0);
for (std::uint16_t at = 0; at < avr::spm::page_bytes; at += 2) {
avr::spm::fill(open, at, 0xdead);
}
tx('D');
}
}
@@ -119,18 +131,19 @@ struct link<C, false> {
#endif
// A shared-pin deployment (RX == TX) banners as a guest on its own line:
// the pull-up input is the released line, the transmitter takes the pin
// for exactly one frame per byte the shape a real one-wire application
// for exactly one frame per byte - the shape a real one-wire application
// beside this loader uses.
static constexpr bool one_wire = avr::PUREBOOT_RX == avr::PUREBOOT_TX;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, baud, one_wire>;
static void init()
{
// The guest transmitter configures no pin; the released line the
// pull-up input a receiver would own is established here.
if constexpr (one_wire)
// The guest transmitter configures no pin; the released line - the
// pull-up input a receiver would own - is established here.
if constexpr (one_wire) {
avr::init<avr::io::input<avr::PUREBOOT_TX, avr::io::pull::up>, tx_t>();
else
} else {
avr::init<tx_t>();
}
}
static void tx(char c)
{
@@ -143,7 +156,7 @@ struct link<C, false> {
// cycles-per-bit transmitter: `tools/pbrig.py rate` sweeps the host rate
// against it to find the part's true bit rate, and from that the clock
// its RC oscillator is really running at. Only the *bit* timing carries
// the measurement the delay merely spaces the lines out, so its own
// the measurement - the delay merely spaces the lines out, so its own
// error does not matter. Software link only: the hardware-link idle owes
// the self-update tests a command loop, and a crystal deployment has
// nothing to measure.
@@ -173,7 +186,7 @@ int main()
link<dev::clock>::tx('P');
#endif
// The hand-over fixture stays silent: nothing is listening on the USART it
// brings up the loader it hands to speaks those pins directly so its
// brings up - the loader it hands to speaks those pins directly - so its
// banner would be a write into a peer that does not exist.
link<dev::clock>::idle();
}

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""End-to-end autobaud test: drive an autobaud loader in simavr through the
calibration handshake and a flash + EEPROM + fuse round-trip, cross-checked
against the simulator's ground-truth memory then repeat at a second F_CPU with
against the simulator's ground-truth memory - then repeat at a second F_CPU with
the *same* loader binary, which is the property autobaud exists for: one
clock-agnostic image that locks onto whatever rate the host sends.
Usage: pbautobaud.py <device_bin> <loader_elf> <mcu> <base_hex> <page>
<app_bin> <app_hz> <app_baud> <tool_py> <workdir> [link]
The loader is a software-serial build, driven over the GPIOpty bridge; the
optional link overrides the default -l sw:B0,B1 RX == TX in it is the
The loader is a software-serial build, driven over the GPIO<->pty bridge; the
optional link overrides the default -l sw:B0,B1 - RX == TX in it is the
one-wire deployment, and every session then runs with the host's echo
discard on. The app fixture is built for (app_hz, app_baud); the hand-over
is checked at that point, and a second point at half the clock proves the
@@ -45,7 +45,7 @@ def main():
open(ee_path, "wb").write(ee_image)
# The geometry the surgery planner needs, from the chip class the runner is
# told the same derivation pbtest.py makes: the boot-sectioned megas need
# told - the same derivation pbtest.py makes: the boot-sectioned megas need
# no vector surgery, the tinies and the boot-section-less m48s do, and the
# large chips speak word addresses.
mega = mcu.startswith("atmega")
@@ -74,7 +74,7 @@ def main():
# 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
# 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:
@@ -96,8 +96,8 @@ def main():
if hand_over:
# Regression: a calibration pulse with no knock behind it must
# not wedge the loader. The knock's edge wait used to be
# unbudgeted, so one stray low pulse EMI, or a host that opens
# the port and never knocks held the loader forever and the
# unbudgeted, so one stray low pulse - EMI, or a host that opens
# the port and never knocks - held the loader forever and the
# application never ran. The whole activation is bounded now, so
# the window closes and the app boots; the banner is the proof.
# (The pause lets the loader reach its measurement loop, so the
@@ -111,13 +111,13 @@ def main():
port.write(bytes((pb.CALIBRATE,)))
# Accumulate rather than match exactly: the reset leaves the
# idle line a framing artefact ahead of the banner, which is
# noise here the question is only whether the app ran.
# noise here - the question is only whether the app ran.
seen = b""
deadline = time.monotonic() + 180.0
while b"APP" not in seen and time.monotonic() < deadline:
seen += port.read_available(1.0)
if b"APP" not in seen:
fail(f"{label}: lone calibration pulse wedged the loader app never bannered, saw {seen!r}")
fail(f"{label}: lone calibration pulse wedged the loader - app never bannered, saw {seen!r}")
print(f" {label}: lone calibration pulse does not wedge the loader")
finally:
port.close()
@@ -165,7 +165,7 @@ def main():
+ (", hand-over ok" if hand_over else ""))
def must_lock(hz, baud, label):
"""The calibration alone, at a tight bit period. Nothing is programmed
"""The calibration alone, at a tight bit period. Nothing is programmed -
the question is only whether the loader can still measure the pulse."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump,
@@ -186,7 +186,7 @@ def main():
# The app fixture is built for one clock; the hand-over banners there. A
# second point at double that clock, same loader binary, proves the lock is
# measured, not baked in the whole point of autobaud. (Doubling keeps the
# measured, not baked in - the whole point of autobaud. (Doubling keeps the
# bit period healthy; halving would drop it below the software UART's floor.)
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
@@ -194,7 +194,7 @@ def main():
# Both points above sit near 100 cycles a bit, which is comfortable. The
# calibration's real floor is far tighter, and it is worth a gate: measured
# here, the lock is solid down to ~36 cycles a bit and fails outright by ~31
# a sharp edge, not a fraying one. This pins the tightest standard rate the
# - a sharp edge, not a fraying one. This pins the tightest standard rate the
# fixture's clock reaches, so a change that raises the floor is caught.
#
# It does *not* bound what a real deployment can use. On silicon the

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Dirty-page-buffer acceptance test: with no discard in the loader, a page
filled over words an earlier writer left takes those instead. The whole
contract is asserted a bare verify sees the corruption, the repairing
contract is asserted - a bare verify sees the corruption, the repairing
verify fixes it in one rewrite, and it stays fixed.
The state is reached the one way the loader cannot prevent: an application
dirties the buffer and jumps in with no reset between. Boot-sectioned megas
forbid that outright (SPM runs only from the boot section, Atmel-8271 §26.2),
forbid that outright (SPM runs only from the boot section, Atmel-8271 section 26.2),
but simavr dispatches SPM from anywhere, which is what makes it constructible.
Usage: pbdirty.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
@@ -66,7 +66,7 @@ def main():
fail(f"the read-back failed, but not at verify: {error}")
else:
# Either the fixture no longer dirties the buffer, or the loader
# clears it again in which case this test's premise is gone.
# clears it again - in which case this test's premise is gone.
fail("programming over a dirty page buffer came back clean")
# What the programming path uses: one rewrite settles it, and it stays

View File

@@ -3,8 +3,8 @@
that exists for it.
The board this was written for loses and mangles bytes on its own serial path,
and the failure that made it matter a page-fill byte lost, the stream one
byte out, a page-address byte arriving where an SPMCSR value belongs is not
and the failure that made it matter - a page-fill byte lost, the stream one
byte out, a page-address byte arriving where an SPMCSR value belongs - is not
reachable by asking a healthy link nicely. So the damage is injected here, at
a named byte index rather than a probability: a failing case is a case that
fails again.
@@ -13,7 +13,7 @@ Every check is a pair. The same bit flipped in the same field is applied on
one side of the seal and then the other: *after* the host seals the header,
which is a mangled command and must be refused, and *before*, which is a
well-formed command for something else and must be obeyed. Only the pair
proves anything a test that showed the refusal alone would pass against a
proves anything - a test that showed the refusal alone would pass against a
loader that had simply stopped doing SPM, and one that showed the corruption
alone would not say what caught it.
@@ -35,7 +35,7 @@ def frame(pb, op, space, address, count, damage=None, before_seal=False):
`damage` is (index, mask). Applied before the seal is computed it produces
a valid command for whatever the damaged fields now say; applied after, a
command whose seal no longer matches its own body which is the shape a
command whose seal no longer matches its own body - which is the shape a
link fault actually has."""
head = bytearray((op, pb.selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, count & 0xFF))
@@ -76,7 +76,7 @@ def main():
fail("the marker page did not survive an undamaged write")
# Every field of the header, one bit each. A damaged seal must be
# refused, the loader must re-prompt, and the page must be untouched
# refused, the loader must re-prompt, and the page must be untouched -
# and it is the erase being aimed at it, so a single escape is visible.
for index in range(6):
bad = frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE, damage=(index, 0x01))
@@ -87,13 +87,13 @@ def main():
if port.read_exact(1, 5.0) != pb.PROMPT:
fail(f"no prompt after refusing a header damaged in byte {index}")
if loader.read_flash(0, page) != marker:
fail(f"damage in byte {index} reached flash the marker page changed")
fail(f"damage in byte {index} reached flash - the marker page changed")
# The fill, whose payload is the protocol's one unacked burst: a
# refused fill must be refused *before* the page is sent, or the host
# is left pushing 128 bytes into a loader reading commands. Nothing is
# sent after the verdict here, and the very next command must be
# understood that is the whole claim.
# understood - that is the whole claim.
bad = frame(pb, pb.OP_FILL, pb.SP_FLASH, 0, page, damage=(3, 0x80))
port.write(bad)
if port.read_exact(1, 5.0) != pb.NAK:
@@ -105,7 +105,7 @@ def main():
# The pair's other half. The identical flip, applied before the seal:
# a well-formed erase of the page one bit away from the one intended.
# It must be obeyed otherwise the refusals above prove nothing about
# It must be obeyed - otherwise the refusals above prove nothing about
# the seal and only that this loader stopped erasing.
port.write(frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE,
damage=(2, 0x01), before_seal=True))
@@ -114,7 +114,7 @@ def main():
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("no prompt after a correctly sealed erase")
if loader.read_flash(0, page) != b"\xff" * page:
fail("the sealed erase did not reach flash the marker page is intact")
fail("the sealed erase did not reach flash - the marker page is intact")
port.close()
finally:
device.stop()

View File

@@ -6,7 +6,7 @@ application hands over with that USART still enabled: TXEN keeps the USART
owning the pin, so the bit-banged transmitter's port writes go nowhere and the
loader receives and obeys while answering nothing. The link's init releases it.
The state is reached the way silicon reaches it an application that sets up
The state is reached the way silicon reaches it - an application that sets up
its USART and jumps in with no reset between, so nothing clears UCSRnB for it.
The pin ownership itself is modelled by the device runner: simavr wires a
USART through IRQs alone and never takes the pin from the port, so without
@@ -35,7 +35,7 @@ def main():
import pureboot as pb
if "@" not in link:
fail(f"the link {link} names no owning USART nothing would be under test")
fail(f"the link {link} names no owning USART - nothing would be under test")
# A shared line (RX == TX) echoes the host's own bytes; discard them the
# way the shipped --one-wire mode does.
one_wire = re.fullmatch(r"sw:([A-H][0-7]),\1@[01]", link) is not None
@@ -63,7 +63,7 @@ def main():
try:
loader.connect(25)
except pb.Error as error:
fail(f"the loader never answered after the hand-over the USART still owns its TX pin ({error})")
fail(f"the loader never answered after the hand-over - the USART still owns its TX pin ({error})")
if loader.info.version != resident:
fail(f"identity changed across the hand-over: {resident} then {loader.info.version}")

View File

@@ -1,7 +1,7 @@
#!/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
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

View File

@@ -4,7 +4,7 @@ canonical slot must still be a working loader, and the ordinary
--update-loader flow must put a build into the top slot from there.
Two positions. Address 0, a raw .bin handed to a programmer. And the staging
slot itself, where a loader already sitting there IS the staging copy
slot itself, where a loader already sitting there IS the staging copy -
recognized by its embedded block and left in place, then streaming the new
resident like any staged copy.
@@ -35,7 +35,7 @@ def rehome_from(pbsim, pb, device_bin, elf, place_hex, update_bin, base, page, b
loader = pb.Loader(port)
info = loader.connect(25)
if info.base != base:
fail(f"the misplaced copy reports base {info.base:#06x} the info block must stay canonical")
fail(f"the misplaced copy reports base {info.base:#06x} - the info block must stay canonical")
# The ordinary update flow puts the build into the top slot.
pb.op_update_loader(loader, 25, update_bin, state, None)
@@ -72,7 +72,7 @@ def main():
print("re-home from address 0: converged")
# The staging slot: erased flash with the loader sitting exactly where
# a staging copy would the tool must leave it in place and let it
# a staging copy would - the tool must leave it in place and let it
# stream the (different) update build into the resident slot.
stage = base - pb.SLOT
rehome_from(pbsim, pb, device_bin, elf, hex(stage), update_bin, base, page, baud, app_bin, workdir,

View File

@@ -2,7 +2,7 @@
"""Position-independence acceptance test: the identical binary, flashed one
slot below the resident, must serve the complete command set from there. The
info block must come back byte-identical, and the staged copy must be able to
rewrite the resident verbatim which is the whole of what relocation is for.
rewrite the resident verbatim - which is the whole of what relocation is for.
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
@@ -63,7 +63,7 @@ def main():
if loader.read_eeprom(0, len(pattern)) != pattern:
fail("EEPROM round-trip through the staged copy")
# The resident slot, written from the copy standing beside it the
# The resident slot, written from the copy standing beside it - the
# whole point of relocating. pureboot 9 dropped the running-slot guard
# that used to sit behind this, so the probe that used to accompany it
# (aim a write at the copy's *own* slot and watch it be refused) is

View File

@@ -3,7 +3,7 @@
loader is executing from.
pureboot 9 dropped the running-slot write guard, so this command is now
permitted that is what lets a resident copy plant something in its own slot,
permitted - that is what lets a resident copy plant something in its own slot,
which on a chip whose boot section *is* the loader slot is the only route a
self-update has. Permitted means the loader must actually do it, and the only
honest proof is the flash afterwards.
@@ -72,12 +72,12 @@ def main():
fail("the loader did not re-prompt after refusing the erase")
alive = loader.read_flash(base, 8)
if alive == b"\xff" * 8:
fail("the refused erase happened anyway the running page reads erased")
fail("the refused erase happened anyway - the running page reads erased")
# Green: the identical command, correctly sealed. Nothing is required
# of the link from here on. The verdict is *issued* before the SPM, but
# the erase takes the code that would have finished saying it, and how
# much of it survives is the chip's business an erase removes one page
# much of it survives is the chip's business - an erase removes one page
# and nothing else, so a loader whose command loop lives past the page
# erased will prompt as usual where one with 128-byte pages goes with
# the stub. None of that is the claim. The claim is that the erase
@@ -95,7 +95,7 @@ def main():
# Ground truth: the simulator's flash, not the loader's opinion of it.
flash = open(dump, "rb").read()
if flash[base : base + page] != b"\xff" * page:
fail("the sealed erase did not reach flash the running page is intact")
fail("the sealed erase did not reach flash - the running page is intact")
print("pbselfwrite: the running slot is refused unsealed and erased sealed")

View File

@@ -17,8 +17,8 @@ class Device:
cmd.append("-w") # report the first-transmit cycle, free-run idle
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None:
# Chips without a hardware boot section the tinies and the
# m48s reset to address 0 like silicon; the boot-sectioned
# Chips without a hardware boot section - the tinies and the
# m48s - reset to address 0 like silicon; the boot-sectioned
# megas re-vector to the loader base (BOOTRST).
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
cmd.append(reset_hex if reset_hex is not None else ("0" if patch else base_hex))
@@ -44,7 +44,7 @@ class Device:
self.proc.send_signal(signal.SIGUSR1)
def power_fail(self):
"""SIGTERM: the runner dumps its flash and exits the image a
"""SIGTERM: the runner dumps its flash and exits - the image a
restart resumes from."""
self.stop()
return self.dump

View File

@@ -21,7 +21,7 @@ def fail(message):
def rjmp_decode(word, at, flash_words):
"""Where an rjmp word at word-address `at` lands deliberately written
"""Where an rjmp word at word-address `at` lands - deliberately written
against the instruction-set definition (12-bit signed offset), not with
the host tool's encoder, so an encoding bug cannot verify itself."""
if word & 0xF000 != 0xC000:
@@ -86,7 +86,7 @@ def main():
fail(f"session 1 output lacks {needed!r}")
# Session 2: reconnect into the live session, verify, dump, exercise
# the data space; hand over is deferred the pty must be reopened for
# the data space; hand over is deferred - the pty must be reopened for
# the APP banner first.
probe = "c0ffee"
out = pbsim.run_tool(tool, device.pty, baud, *extra, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
@@ -123,7 +123,7 @@ def main():
loader = pb.Loader(port)
live = loader.connect(15)
# The loader built from this tree must report a version the tool
# beside it speaks a bump the tool was never told about is a
# beside it speaks - a bump the tool was never told about is a
# loader it would refuse to talk to. Not equality with the newest:
# the tool now spans two loader generations, the fixed-baud one
# here and the unified autobaud loader that follows it.
@@ -139,7 +139,7 @@ def main():
# rather than through write_page(), which would follow the fill
# with its erase and write; the point here is that the fill alone
# consumes exactly one page whatever the address's low bits say.
# Hand-sealed too a protocol probe that borrowed the tool's own
# Hand-sealed too - a protocol probe that borrowed the tool's own
# frame builder could not tell a wrong frame from a wrong loader.
wire = base + 1
head = bytes((pb.OP_FILL, pb.selector(pb.SP_FLASH, wire), wire & 0xFF,
@@ -156,8 +156,8 @@ def main():
# And the seal itself, red: one wrong bit in the address of that
# same frame must be refused outright. The verdict has to arrive
# *before* the page would have been sent that ordering is what
# keeps a refusal from turning into a desync so the probe sends
# *before* the page would have been sent - that ordering is what
# keeps a refusal from turning into a desync - so the probe sends
# no payload at all and expects the loader straight back at the
# command level.
broken = bytearray(head + bytes((seal,)))
@@ -186,7 +186,7 @@ def main():
# The surgery, decoded independently: the patched vector must land on the
# loader, the trampoline on the application's own entry (patched-vector
# chips only a boot-sectioned mega's word 0 stays the application's).
# chips only - a boot-sectioned mega's word 0 stays the application's).
if patch:
flash_words = (base + pb.SLOT) // 2
app = open(app_bin, "rb").read()

View File

@@ -4,8 +4,8 @@ itself with a re-timed build, and every power-fail phase is rehearsed by
killing the device mid-write, restarting it from its flash dump, and letting
a re-run complete the update.
The boot-sectioned megas run the BOOTRST-unprogrammed profile reset boots
the application, whose 'L' is the application-owned loader entry with
The boot-sectioned megas run the BOOTRST-unprogrammed profile - reset boots
the application, whose 'L' is the application-owned loader entry - with
--assume-fuses standing in for the fuse read simavr cannot model.
Usage: pbupdate.py <device_bin> <pureboot_elf> <update_elf> <mcu> <hz>
@@ -39,8 +39,8 @@ class PowerFail(Exception):
def assumed_fuses(pb, image):
"""Synthetic 'F' bytes for --assume-fuses: the smallest boot section
covering both the resident and the staging slot (two slots what a
self-update needs), BOOTRST unprogrammed the per-chip BOOTSZ ladder
covering both the resident and the staging slot (two slots - what a
self-update needs), BOOTRST unprogrammed - the per-chip BOOTSZ ladder
and fuse byte come from the tool's own table, keyed by the update
image's embedded signature."""
info = pb.image_info(image)
@@ -138,7 +138,7 @@ def main():
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=reset_hex)
final = "v0"
try:
# The application first its planner output is the restore truth.
# The application first - its planner output is the restore truth.
pbsim.run_tool(tool, device.pty, baud, "--flash", app_bin, "--stay")
port, loader = connect(device)
app_pages = pb.plan_flash(open(app_bin, "rb").read(), loader.info)
@@ -167,7 +167,7 @@ def main():
# direction so the flash is never already at its target. The mega's
# mid-resident-rewrite loss is exercised as a host crash instead:
# with BOOTRST unprogrammed and the resident mid-erase, a power loss
# there has no reset path into the staging copy the documented
# there has no reset path into the staging copy - the documented
# cost of that profile (README).
for kill_region, kill_hits, kill_device in (
("stage", 2, True),

View File

@@ -2,18 +2,18 @@
"""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
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
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 × 9 / f_cpu, the
(--autobaud-polls); the seconds it amounts to are budget x 9 / f_cpu, the
measured cost of the calibrate() wait loop this gate pins.
"""
import argparse
@@ -26,7 +26,7 @@ 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
# 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
@@ -43,7 +43,7 @@ def load_tool(path):
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
(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")
@@ -129,7 +129,7 @@ def main():
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%)")
f"{expected:.3f} s ({error:+.1%}, gate +/-2%)")
return 0 if ok else 1

View File

@@ -11,18 +11,19 @@
// The link follows the chip's natural default (USART0 on the megas, the
// software UART on PB0/PB1 elsewhere) unless -l overrides it: `-l usart1`
// for the second instance, `-l sw:B5,B1` for a software build's RX,TX pins,
// and `-l sw:D0,D1@0` where those pins are a USART's own see the pin
// and `-l sw:D0,D1@0` where those pins are a USART's own - see the pin
// ownership the bridge models below.
//
// simavr's tiny cores decode the SPM opcode but attach no NVM module SPM
// simavr's tiny cores decode the SPM opcode but attach no NVM module - SPM
// is a silent no-op (the mega's boot section has one, avr_flash). The
// missing module is supplied here: the SPM ioctl reads SPMCSR/Z/r1:r0 and
// implements buffer fill, page erase, page write, and CTPB, completing
// instantly. RFLB's LPM diversion (fuse readout) stays unmodeled, so the
// 'F' command answers with flash bytes the tests assert transport only.
// 'F' command answers with flash bytes - the tests assert transport only.
//
// On exit (or SIGTERM) the flash and EEPROM are dumped to files for a
// ground-truth cross-check against what the host read back.
#include <array>
#include <csignal>
#include <cstdint>
#include <cstdio>
@@ -37,7 +38,7 @@
#include <unistd.h>
// The parts headers (uart_pty.h) carry no C++ linkage guards of their own,
// unlike simavr's core headers the block covers both harmlessly.
// unlike simavr's core headers - the block covers both harmlessly.
extern "C" {
#include "avr_eeprom.h"
#include "avr_flash.h"
@@ -64,7 +65,7 @@ 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
// 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
@@ -74,8 +75,9 @@ bool window_tx_seen;
void window_first_tx()
{
if (!window_report || window_tx_seen)
if (!window_report || window_tx_seen) {
return;
}
window_tx_seen = true;
std::println("PB_WINDOW_TX {}", avr->cycle);
std::fflush(stdout);
@@ -91,7 +93,7 @@ void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
// direction the way the real wiring does: it drives only while the
// firmware's DDR bit reads input, decodes transitions as the firmware's
// transmit only while the firmware owns the line, ignores its own raises
// coming back through the shared irq and echoes every byte it drives back
// coming back through the shared irq - and echoes every byte it drives back
// to the pty, which is what the host-side FTDI tie does and what the host
// tool's --one-wire mode reads back and discards.
bool link_one_wire;
@@ -107,8 +109,9 @@ int parse_link(std::string_view spec)
}
if (spec.starts_with("sw")) {
link_software = true;
if (spec.size() == 2)
if (spec.size() == 2) {
return 0;
}
char owner = 0;
int fields =
std::sscanf(spec.data() + 2, ":%c%d,%c%d@%c", &sw_rx_port, &sw_rx_bit, &sw_tx_port, &sw_tx_bit, &owner);
@@ -122,16 +125,16 @@ int parse_link(std::string_view spec)
}
// simavr 1.6's avr_flash PGERS handler erases spm_pagesize bytes starting at
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly)
// hardware ignores the in-page bits (§26.8.1), so an erase issued with Z
// Z & ~1 instead of the page containing Z (its PGWRT path masks correctly) -
// hardware ignores the in-page bits (section 26.8.1), so an erase issued with Z
// anywhere inside the page wipes half the neighbouring page in simulation
// only. Wrap the mega's registered flash ioctl and re-dispatch page erases
// with Z forced to the page boundary; everything else passes through.
//
// A second gap on the boot-section-less m48s: their RWWSRE bit is the
// temporary-buffer discard (Atmel-8271 §26.2/§26.3.1), but the stock model
// gates its RWWSRE branch on AVR_SELFPROG_HAVE_RWW absent on the m48
// core so the discard store falls through into the buffer-fill branch and
// temporary-buffer discard (Atmel-8271 section 26.2/section 26.3.1), but the stock model
// gates its RWWSRE branch on AVR_SELFPROG_HAVE_RWW - absent on the m48
// core - so the discard store falls through into the buffer-fill branch and
// plants whatever Z/R1:R0 happen to hold. Perform the silicon's discard
// here instead.
avr_flash_t *mega_flash;
@@ -171,7 +174,7 @@ void fix_mega_flash_erase()
return;
}
}
std::println(stderr, "device: no flash module to fix SPM page erases may misalign");
std::println(stderr, "device: no flash module to fix - SPM page erases may misalign");
}
void request_reset(int)
@@ -183,8 +186,8 @@ void request_reset(int)
struct tiny_nvm_t {
avr_io_t io;
std::uint8_t buffer[128];
std::uint8_t used[128]; // a buffer word loads once until erased like silicon
std::array<std::uint8_t, 128> buffer;
std::array<std::uint8_t, 128> used; // a buffer word loads once until erased - like silicon
unsigned page;
};
@@ -192,8 +195,9 @@ tiny_nvm_t nvm;
int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
{
if (ctl != AVR_IOCTL_FLASH_SPM)
if (ctl != AVR_IOCTL_FLASH_SPM) {
return -1;
}
auto *n = reinterpret_cast<tiny_nvm_t *>(io);
avr_t *mcu = io->avr;
std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies
@@ -209,13 +213,14 @@ int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *)
} else if (command == 0x03) { // PGERS
std::memset(mcu->flash + page_base, 0xff, n->page);
} else if (command == 0x05) { // PGWRT: programming only clears bits
for (unsigned i = 0; i < n->page; i++)
for (unsigned i = 0; i < n->page; i++) {
mcu->flash[page_base + i] &= n->buffer[i];
std::memset(n->buffer, 0xff, n->page);
std::memset(n->used, 0, n->page);
}
std::memset(n->buffer.data(), 0xff, n->page);
std::memset(n->used.data(), 0, n->page);
} else if (command == 0x11) { // CTPB
std::memset(n->buffer, 0xff, n->page);
std::memset(n->used, 0, n->page);
std::memset(n->buffer.data(), 0xff, n->page);
std::memset(n->used.data(), 0, n->page);
}
mcu->data[0x57] &= static_cast<std::uint8_t>(~0x1f); // the operation completes instantly
return 0;
@@ -234,9 +239,9 @@ 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
// 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
// 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;
@@ -248,23 +253,25 @@ avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
}
if (tx_bit < 8) {
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8)
if (++tx_bit < 8) {
return when + bit_cycles;
}
// The byte is delivered at the stop bit's sampling point (9.5 bit
// times), where a hardware receiver raises its RXC not sooner: a
// times), where a hardware receiver raises its RXC - not sooner: a
// host answering before the stop bit would put its start bit on the
// wire while the device is still driving, which the device,
// transmitting, is not watching for.
return when + bit_cycles;
}
if (write(pty_master, &tx_shift, 1) != 1)
if (write(pty_master, &tx_shift, 1) != 1) {
std::println(stderr, "device: pty write lost a byte");
}
tx_active = 0;
return 0;
}
// A USART owns its TxD pin whenever its transmitter is enabled, and the port
// register cannot drive it (§20.2 / Atmel-8271 §19.2) which is why a
// register cannot drive it (section 20.2 / Atmel-8271 section 19.2) - which is why a
// bit-banged link deployed on those pins is mute until it clears UCSRnB.
// simavr wires a USART entirely through IRQs and never touches the port pin
// model, so the ownership does not exist there and the mute cannot happen:
@@ -274,36 +281,40 @@ avr_uart_t *tx_owner;
bool tx_pin_taken()
{
if (!tx_owner)
if (!tx_owner) {
return false;
if (avr_regbit_get(avr, tx_owner->txen))
}
if (avr_regbit_get(avr, tx_owner->txen)) {
return true;
}
// One-wire on the USART's RXD: RXEN forces the shared pin's direction to
// input (§20.7.3), so the firmware's drive goes nowhere until the
// release the receive-side twin of the TXD hold.
// input (section 20.7.3), so the firmware's drive goes nowhere until the
// release - the receive-side twin of the TXD hold.
return link_one_wire && avr_regbit_get(avr, tx_owner->rxen);
}
// simavr leaves TXEN set in UCSRnB out of reset, where silicon clears the
// whole register (§20.11.3) which would hand the pin to a USART no code has
// whole register (section 20.11.3) - which would hand the pin to a USART no code has
// enabled, making a freshly reset chip mute for reasons hardware does not
// have. Reset it the way the datasheet does, so the ownership starts from
// nobody's and only an application that really enables the USART takes it.
void reset_tx_owner()
{
if (tx_owner)
if (tx_owner) {
avr_regbit_clear(avr, tx_owner->txen);
}
}
void find_tx_owner()
{
for (avr_io_t *io = avr->io_port; io; io = io->next)
for (avr_io_t *io = avr->io_port; io; io = io->next) {
if (io->kind && std::string_view{io->kind} == "uart" &&
reinterpret_cast<avr_uart_t *>(io)->name == sw_tx_owner) {
tx_owner = reinterpret_cast<avr_uart_t *>(io);
reset_tx_owner();
return;
}
}
std::println(stderr, "device: no USART{} to own the software link's TX pin", sw_tx_owner);
}
@@ -311,7 +322,7 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
{
if (link_one_wire && (self_drive || !mcu_owns_line)) {
// The bridge's own drive coming back through the shared irq, or a
// transition while the line is the bridge's either way not the
// transition while the line is the bridge's - either way not the
// firmware talking: the decoder sees an idle line.
tx_level = 1;
return;
@@ -329,7 +340,7 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
tx_level = level;
}
std::uint8_t rx_queue[8192];
std::array<std::uint8_t, 8192> rx_queue;
unsigned rx_head, rx_tail; // ring: head = next to send
int rx_active, rx_bit;
std::uint8_t rx_byte;
@@ -355,10 +366,11 @@ avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
if (rx_bit == 8) { // stop bit, plus one idle bit of margin
bridge_drive(1);
// The host-side tie: an FTDI adapter on a one-wire line reads every
// byte it transmits supply that echo, which the host tool's
// byte it transmits - supply that echo, which the host tool's
// --one-wire mode consumes as its wiring check.
if (link_one_wire && write(pty_master, &rx_byte, 1) != 1)
if (link_one_wire && write(pty_master, &rx_byte, 1) != 1) {
std::println(stderr, "device: pty echo lost a byte");
}
rx_bit++;
return when + 2 * bit_cycles;
}
@@ -369,15 +381,17 @@ avr_cycle_count_t rx_step(avr_t *, avr_cycle_count_t when, void *)
void rx_start_next()
{
if (rx_active || rx_head == rx_tail)
if (rx_active || rx_head == rx_tail) {
return;
// The firmware is answering on the shared line: hold the byte — a real
}
// The firmware is answering on the shared line: hold the byte - a real
// host's transmission waits out the reply on the wire too. The next
// poll_pty tick retries once the line is handed back.
if (link_one_wire && mcu_owns_line)
if (link_one_wire && mcu_owns_line) {
return;
}
rx_byte = rx_queue[rx_head];
rx_head = (rx_head + 1) % sizeof(rx_queue);
rx_head = (rx_head + 1) % rx_queue.size();
rx_active = 1;
rx_bit = 0;
bridge_drive(0); // start bit
@@ -389,11 +403,12 @@ void rx_start_next()
void on_ddr(avr_irq_t *, std::uint32_t value, void *)
{
const bool owns = (value >> sw_rx_bit) & 1;
if (mcu_owns_line && !owns)
if (mcu_owns_line && !owns) {
bridge_drive(1); // hand-back: a turn-based host idles here, and the cache stays truthful
}
mcu_owns_line = owns;
// A byte held back while the firmware answered starts from the next
// poll_pty tick, never from inside the DDR write itself the port
// poll_pty tick, never from inside the DDR write itself - the port
// model's own pull-up re-derivation runs right after this notify and
// would erase a start edge raised here.
}
@@ -416,7 +431,7 @@ void bridge_reset()
mcu_owns_line = false; // avr_reset zeroed DDR: every pin reads input again
// Re-drive the idle line through a forced transition: ioport pin irqs are
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
// keeps its pre-reset cached value so a plain raise(1) against a cached
// 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.
@@ -428,12 +443,13 @@ void bridge_reset()
void poll_pty()
{
std::uint8_t chunk[256];
ssize_t got = read(pty_master, chunk, sizeof(chunk));
std::array<std::uint8_t, 256> chunk;
ssize_t got = read(pty_master, chunk.data(), chunk.size());
for (ssize_t i = 0; i < got; i++) {
unsigned next = (rx_tail + 1) % sizeof(rx_queue);
if (next == rx_head)
unsigned next = (rx_tail + 1) % rx_queue.size();
if (next == rx_head) {
break; // full: the host will retry on timeout
}
rx_queue[rx_tail] = chunk[i];
rx_tail = next;
}
@@ -452,19 +468,24 @@ void poll_pty()
std::fwrite(avr->flash, 1, avr->flashend + 1, f);
std::fclose(f);
}
avr_eeprom_desc_t ee = {.ee = nullptr, .offset = 0, .size = 0};
avr_eeprom_desc_t ee = {
.ee = nullptr,
.offset = 0,
.size = 0,
};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &ee) == 0 && ee.ee && ee.size) {
char path[512];
std::snprintf(path, sizeof(path), "%s.eeprom", dump_path);
f = std::fopen(path, "wb");
std::array<char, 512> path;
std::snprintf(path.data(), path.size(), "%s.eeprom", dump_path);
f = std::fopen(path.data(), "wb");
if (f) {
std::fwrite(ee.ee, 1, ee.size, f);
std::fclose(f);
}
}
}
if (!link_software)
if (!link_software) {
uart_pty_stop(&uart_pty);
}
_exit(0);
}
@@ -494,7 +515,7 @@ int main(int argc, char *argv[])
" -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"
" resume_flash: raw full-flash image loaded instead of the ELF - a prior\n"
" run's dump, for power-fail resume tests\n",
argv[0]);
return 2;
@@ -506,8 +527,9 @@ int main(int argc, char *argv[])
auto baud = static_cast<unsigned>(std::atoi(argv[6]));
dump_path = argv[7];
const bool is_mega = mcu_name.starts_with("atmega");
if (!link_given)
if (!link_given) {
link_software = !is_mega; // the chips' natural links: USART0, or PB0/PB1
}
avr = avr_make_mcu_by_name(mcu_name.data());
if (!avr) {
@@ -534,17 +556,17 @@ int main(int argc, char *argv[])
}
// An image past flash end would smash the simulator's heap and turn
// into phantom peripheral behavior (lessons: believe the size gate
// first) refuse it loudly instead.
// first) - refuse it loudly instead.
if (base + fw.flashsize > avr->flashend + 1) {
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} image does not fit its slot",
std::println(stderr, "device: {} B at {:#x} runs past flash end {:#x} - image does not fit its slot",
fw.flashsize, base, avr->flashend);
return 1;
}
std::memcpy(avr->flash + base, fw.flash, fw.flashsize);
}
// The boot-sectioned megas enter the loader in hardware (BOOTRST, not
// modeled the argument picks the modeled fuse's target); the tinies
// and the boot-section-less m48s reset to word 0 like silicon erased
// modeled - the argument picks the modeled fuse's target); the tinies
// and the boot-section-less m48s reset to word 0 like silicon - erased
// flash walks up into the loader, and after the host's surgery the
// patched vector routes there.
const bool boot_section = is_mega && !mcu_name.starts_with("atmega48");
@@ -553,11 +575,15 @@ int main(int argc, char *argv[])
avr->codeend = avr->flashend;
// Erased EEPROM, as hardware powers up (simavr zeroes it).
std::uint8_t blank[1024];
std::memset(blank, 0xff, sizeof(blank));
avr_eeprom_desc_t seed = {.ee = blank, .offset = 0, .size = 0};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= sizeof(blank)) {
seed.ee = blank;
std::array<std::uint8_t, 1024> blank;
std::memset(blank.data(), 0xff, blank.size());
avr_eeprom_desc_t seed = {
.ee = blank.data(),
.offset = 0,
.size = 0,
};
if (avr_ioctl(avr, AVR_IOCTL_EEPROM_GET, &seed) == 0 && seed.size <= blank.size()) {
seed.ee = blank.data();
avr_ioctl(avr, AVR_IOCTL_EEPROM_SET, &seed);
}
@@ -568,7 +594,7 @@ int main(int argc, char *argv[])
fix_mega_flash_erase();
} else {
nvm.page = page;
std::memset(nvm.buffer, 0xff, sizeof(nvm.buffer));
std::memset(nvm.buffer.data(), 0xff, nvm.buffer.size());
nvm.io.kind = "tiny_nvm";
nvm.io.ioctl = nvm_ioctl;
avr_register_io(avr, &nvm.io);
@@ -582,35 +608,41 @@ int main(int argc, char *argv[])
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
// simavr leaves TXEN set out of reset where silicon clears the whole
// UCSR#B (§20.11.3). Harmless to a loader that enables TXEN itself
// UCSR#B (section 20.11.3). Harmless to a loader that enables TXEN itself -
// but a half-duplex build's receiver-only init then *drops* TXEN,
// and this uart model clears UDRE on that edge and never re-raises
// it on a later enable: the first transmitter after the hand-over
// waits UDRE forever, a wedge silicon does not have. Start from the
// datasheet's zero, as the software bridge's tx-owner model does.
for (avr_io_t *io = avr->io_port; io; io = io->next)
for (avr_io_t *io = avr->io_port; io; io = io->next) {
if (io->kind && std::string_view{io->kind} == "uart" &&
reinterpret_cast<avr_uart_t *>(io)->name == uart_digit)
reinterpret_cast<avr_uart_t *>(io)->name == uart_digit) {
hw_uart = reinterpret_cast<avr_uart_t *>(io);
if (hw_uart)
}
}
if (hw_uart) {
avr_regbit_clear(avr, hw_uart->txen);
}
uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit);
if (window_report)
if (window_report) {
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_UART_GETIRQ(uart_digit), UART_IRQ_OUTPUT),
window_uart_hook, nullptr);
}
std::println("PB_PTY {}", uart_pty.pty.slavename);
} else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
if (sw_tx_owner)
if (sw_tx_owner) {
find_tx_owner();
}
rx_pin = avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), static_cast<unsigned>(sw_rx_bit));
avr_irq_register_notify(
avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_tx_port), static_cast<unsigned>(sw_tx_bit)), tx_hook,
nullptr);
if (link_one_wire)
if (link_one_wire) {
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_IOPORT_GETIRQ(sw_rx_port), IOPORT_IRQ_DIRECTION_ALL),
on_ddr, nullptr);
}
bridge_drive(1); // idle line
int slave;
@@ -632,8 +664,9 @@ int main(int argc, char *argv[])
long since_poll = 0;
for (;;) {
int state = avr_run(avr);
if (state == cpu_Done || state == cpu_Crashed)
if (state == cpu_Done || state == cpu_Crashed) {
break;
}
if (reset_requested) {
reset_requested = 0;
avr_reset(avr);
@@ -643,8 +676,9 @@ int main(int argc, char *argv[])
avr_ioctl(avr, AVR_IOCTL_UART_GET_FLAGS(uart_digit), &flags);
flags &= ~AVR_UART_FLAG_POLL_SLEEP;
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
if (hw_uart) // and simavr's bogus reset TXEN (§20.11.3: zero)
if (hw_uart) { // and simavr's bogus reset TXEN (section 20.11.3: zero)
avr_regbit_clear(avr, hw_uart->txen);
}
} else {
bridge_reset();
reset_tx_owner();
@@ -655,13 +689,14 @@ int main(int argc, char *argv[])
poll_pty();
// An unthrottled idle simulation runs the activation window out
// from under the host's real-time knock cadence: a 1 MHz build's
// 8 s window is 8 M cycles tens of wall milliseconds so a
// 8 s window is 8 M cycles - tens of wall milliseconds - so a
// first knock lost to an in-flight reset misses the window
// entirely. Pace the simulation only while the bridge is fully
// quiet (nothing decoding, nothing queued); transfers keep full
// speed, and a quiet window stretches toward real time.
if (!window_report && !rx_active && !tx_active && rx_head == rx_tail)
if (!window_report && !rx_active && !tx_active && rx_head == rx_tail) {
usleep(200);
}
}
}
finish(0);

View File

@@ -3,15 +3,15 @@
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
trailing bytes before it asks for the identity. That drain must be bounded: a
target that never falls quiet a board stuck in a reset loop presents exactly
target that never falls quiet - a board stuck in a reset loop presents exactly
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
prompt otherwise spins the tool forever. Regression for that hang, plus a
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
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.
@@ -85,7 +85,7 @@ class StaleDTRPort:
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
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
@@ -195,7 +195,7 @@ def main():
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
# 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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Host-tool unit tests the planning and policy logic, no simulator:
"""Host-tool unit tests - the planning and policy logic, no simulator:
programming orders and their recovery properties, the reset-vector surgery,
the staging composition, the boot-fuse decode, and the update preflight over
fuse combinations simavr cannot model.
@@ -76,7 +76,7 @@ def main():
"newer tool",
)
# mega_boot: BOOTSZ words and the BOOTRST sense per chip the fuse byte
# mega_boot: BOOTSZ words and the BOOTRST sense per chip - the fuse byte
# index (EXTENDED on the x8 line except the m328s' HIGH, HIGH elsewhere)
# and the per-family ladders (Atmel-2486/2466/2503/2545/8271/DS40002065/
# 8272/8011/2593/42719). Synthetic 'F' replies: only the boot byte
@@ -114,15 +114,15 @@ def main():
fail(f"mega_boot {signature[1]:02x}{signature[2]:02b} unprogrammed: {prog} {at:#07x}")
# Word-addressed info decode: the 1284P's base and page ride the wire
# scaled a 17-bit base halved into the block's two bytes, a 256-byte page
# spelled 0 and its slot is the same 512 bytes as everywhere else, so its
# scaled - a 17-bit base halved into the block's two bytes, a 256-byte page
# spelled 0 - and its slot is the same 512 bytes as everywhere else, so its
# staging slot lands inside the 1 KiB minimum boot section.
big = info_of(pb, 0x1FE00, 0, False, 0x20000, signature=(0x1E, 0x97, 0x05), word_flash=True)
if big.page != 256 or big.base != 0x1FE00 or big.stage != 0x1FC00:
fail(f"word-addressed info decode: page {big.page}, base {big.base:#x}, stage {big.stage:#x}")
# Surgery: word 0 lands on the loader, the trampoline on the original
# entry checked with an independent decoder.
# entry - checked with an independent decoder.
app = bytes((0xC0 | 0x00, 0xC0)) + bytes((0x12,)) * 300 # rjmp .+0x00C0... entry word 0xC0C0
entry = rjmp_decode(app[0] | (app[1] << 8), 0, tiny.flash_size // 2)
pages = pb.plan_flash(app, tiny)
@@ -158,7 +158,7 @@ def main():
# Staging content: the identical image plus the through-word on a
# patched-vector chip; hard size clamps either way.
image = bytes(range(256)) * 2 # 512 B too big for a tiny slot
image = bytes(range(256)) * 2 # 512 B - too big for a tiny slot
expect_error("tiny staging size", lambda: pb.staging_content(image, tiny), "510")
staged = pb.staging_content(image[:508], tiny)
through = staged[510] | (staged[511] << 8)
@@ -170,7 +170,7 @@ def main():
# The image stamp: found in a synthetic binary, absent in noise. pureboot
# 5 stamps the magic, its version and the signature, and the geometry is
# looked up from there so what comes back must equal what a live device
# looked up from there - so what comes back must equal what a live device
# of the same chip reports.
stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature)
binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10
@@ -228,7 +228,7 @@ def main():
# The 1284s' smallest boot section (512 words) is exactly the resident
# slot plus its staging slot, so self-update is possible at the minimum
# BOOTSZ no fuse step up, the 644's geometry. That holds only while a
# BOOTSZ - no fuse step up, the 644's geometry. That holds only while a
# slot is 512 B: at 1 KiB the staging slot would fall outside the section
# and the preflight would refuse.
notes = pb.update_preflight(bytes((0xAA,)) * 8 + big.raw, big, fuses(0xFE))
@@ -291,7 +291,7 @@ def main():
if device.writes != pb.RETRIES + 1:
fail(f"unrepairable page took {device.writes} writes, expected {pb.RETRIES + 1}")
# The knock handshake against a device that is not listening yet the
# The knock handshake against a device that is not listening yet - the
# state a port open leaves behind: it resets the chip into a fresh
# activation window while the previous session's prompt is still in
# flight, so the first knock is lost and a prompt arrives anyway.

View File

@@ -1,7 +1,7 @@
#!/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
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>
@@ -47,7 +47,7 @@ def main():
fail(f"the absolute clock must scale with the found ratio:\n{report}")
# The walk's rates mostly have no termios B-constant, so the POSIX port
# must set them through termios2 probed on a pty, which accepts the
# must set them through termios2 - probed on a pty, which accepts the
# ioctl without caring about the speed. Without this every off-nominal
# probe would abort the walk on the platform --scan matters most on.
if os.name == "posix":

View File

@@ -3,14 +3,14 @@
`--update-loader` installs the new image in the staging slot and then *enters
it* to have it rewrite the resident. That copy is the new image, so it speaks the
new image's baud and backend but the host was talking to the *resident*. Where
new image's baud and backend - but the host was talking to the *resident*. Where
the two differ, the host kept knocking at the old rate in the old mode, the
staging copy never answered, and the update stranded: staging installed, resident
untouched, and on a 1 KiB tiny the application region (which *is* the staging
slot there) already gone.
The wire cannot be probed for this 512 bytes of position-independent code carry
no header saying what rate they were built for so the operator declares it, and
The wire cannot be probed for this - 512 bytes of position-independent code carry
no header saying what rate they were built for - so the operator declares it, and
a mismatch with nothing declared has to say so instead of reporting a bare
timeout.
@@ -32,7 +32,7 @@ P = F = 0
def check(name, ok, detail=""):
global P, F
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" - {detail}" if detail else ""))
class TwoLinkPort:

View File

@@ -144,7 +144,7 @@ class Host:
self._expect(CONFIRM, "C end")
return echo
# Activation when the config page carries a password: 3×'@' then the
# Activation when the config page carries a password: 3x'@' then the
# password bytes, then the info block + mainloop '!'.
def activate_password(self, password):
self.s.reset_input_buffer()
@@ -181,7 +181,7 @@ PW_BYTES = bytes([0x50, 0x57])
def scenario_roundtrip(host):
"""Activation + info block + flash/EEPROM/config read-write round-trips, on
a device with a blank (erased) config page the usual no-password case."""
a device with a blank (erased) config page - the usual no-password case."""
info = host.activate()
check(info[0:3] == b"TSB", f"magic 'TSB' (got {info[0:3]!r})")
check(info[6:9] == bytes([0x1E, 0x95, 0x0F]), f"signature 1E 95 0F (got {info[6:9].hex()})")