R/r/w/F collapse into G and g over a selector byte naming the space — flash,
EEPROM, data, fuse, SPM — with the flash bank in its high nibble. Four command
bodies, four transfer loops and four argument decodes become one of each, and
W joins the same decode instead of keeping an address form of its own. The
loader shrinks while gaining everything below: on the 1284P the stock build
goes 480 -> 432 B and the software one 496 -> 450.
What the freed space buys:
- Data space. On AVR one pointer spans SRAM, the register file and the whole
I/O space, so G over space 2 reads all three. pureboot keeps zero static
RAM and pushes no register, so at loader entry an application's SRAM is
still what the application left there — this is a post-mortem, not just a
poke hole. As its own command it needed a dispatch arm and a loop; as one
more space it is a single ld/st.
- Host-issued SPM. W fills the page buffer and stops; erase, write and RWW
re-enable are writes to space 4, which reach the same fused store-and-SPM
pair through the transfer's own address and data. Any SPM operation, lock
bits included, is now reachable and the loader carries no page-commit logic.
The four-cycle SPMCSR-to-SPM window is why that primitive stays fused: no
host can hit it across a serial link, and that — not the byte count — is
the floor on how low-level a bootloader's primitives can go.
- Byte addresses everywhere. The bank in the selector retires the
word-addressed wire the >64 KiB parts needed, so the 1284s stop being the
outlier.
SERIAL autobaud is a third backend on the same loader, over libavr's
software_autobaud: no clock, no baud, one binary per chip for every F_CPU and
every rate. Activation counts poll iterations rather than seconds and bounds
every wait, so a stray pulse cannot hold an unattended device.
b answers with the version and signature only; the host derives geometry from
the signature, which is what an autobaud build requires anyway. An update image
is a bare slot with no device to ask, so every image carries a six-byte stamp —
the same bytes b answers with, and the source of both — that the loader never
reads from flash and the host refuses to install a mismatch against. The
running-slot write guard moved onto the SPM commit, which covers erase and
write both where guarding W covered neither directly.
The position-independence lint now proves the property instead of a proxy for
it: the image must come out byte-identical linked at a different base.
-fno-move-loop-invariants left the tuned flag set — it was fitted to a command
loop carrying four transfer bodies and costs bytes now that it carries one.
Verified: the exhaustive matrix on all 37 chips (every clock x every baud x
every backend, non-standard rates included, plus the autobaud build) —
8174 size checks, no failures, tightest fit the 1284s' autobaud at 510 of 512.
Behavioral suites green on every chip class: t13a 10/10, t85 11/11, m8 13/13,
m16a 13/13, m48pa 13/13, 328P 23/23, 644A 17/17, 1284P 17/17. Data-space
round trip through --peek/--poke and the autobaud handshake are both red-green
proven.
The two prototype sources and their findings file go; the README carries the
protocol and dev/done.md in libavr carries the reasoning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
8.5 KiB
Python
180 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""End-to-end protocol test: drive the simavr device with the real host tool
|
|
over its pty through flash, EEPROM, fuse and hand-over scenarios, and
|
|
cross-check the tool's view against the simulator's ground-truth dumps.
|
|
|
|
Usage: pbtest.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
|
<baud> <eeprom_size> <app_bin> <tool_py> <workdir> [link]
|
|
|
|
The optional link is the runner's -l spec (usart1, sw:B5,B1, ...), for a
|
|
loader built off the chip's natural serial default.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
|
|
def fail(message):
|
|
print(f"FAIL: {message}")
|
|
sys.exit(1)
|
|
|
|
|
|
def rjmp_decode(word, at, flash_words):
|
|
"""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:
|
|
fail(f"word at {at * 2:#06x} is {word:#06x}, not an rjmp")
|
|
offset = word & 0x0FFF
|
|
if offset >= 0x800:
|
|
offset -= 0x1000
|
|
return (at + 1 + offset) % flash_words
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
link = args.pop() if len(args) == 12 else None
|
|
(device_bin, elf, mcu, hz, base_hex, page, baud, eeprom_size, app_bin, tool, workdir) = args
|
|
base, page, baud, eeprom_size = int(base_hex, 0), int(page), int(baud), int(eeprom_size)
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import pbsim
|
|
import pureboot as pb
|
|
|
|
os.makedirs(workdir, exist_ok=True)
|
|
ee_image = bytes(range(0xA0, 0xB0))
|
|
ee_path = os.path.join(workdir, "ee.bin")
|
|
open(ee_path, "wb").write(ee_image)
|
|
dump = os.path.join(workdir, "flash_dump.bin")
|
|
read_flash = os.path.join(workdir, "readback_flash.bin")
|
|
read_eeprom = os.path.join(workdir, "readback_eeprom.bin")
|
|
|
|
# The geometry the host will discover, for computing the expected image:
|
|
# the boot-sectioned megas need no vector surgery (the tinies and the
|
|
# boot-section-less m48s do), the large chips speak word addresses, and
|
|
# the page byte is the wire's 0-means-256.
|
|
mega = mcu.startswith("atmega")
|
|
patch = not mega or mcu.startswith("atmega48")
|
|
# Where SRAM begins: the x8 and x4 megas push it past their extended I/O
|
|
# space, everything else starts right after the plain I/O registers. The
|
|
# loader keeps no statics and its stack sits at RAMEND, so the first SRAM
|
|
# byte is free for the data-space probe below.
|
|
classic = mcu in ("atmega8", "atmega8a", "atmega16", "atmega16a", "atmega32", "atmega32a")
|
|
ram_base = 0x0100 if mega and not classic else 0x0060
|
|
word_flash = base + pb.SLOT > 0x10000
|
|
wire_base = base // 2 if word_flash else base
|
|
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
|
info = pb.Info(
|
|
bytes([ord("P"), ord("B"), pb.NEWEST_LOADER, 0, 0, 0, page & 0xFF])
|
|
+ bytes([wire_base & 0xFF, wire_base >> 8, eeprom_size & 0xFF, eeprom_size >> 8])
|
|
+ bytes([flags])
|
|
)
|
|
|
|
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, link=link)
|
|
try:
|
|
# Session 1: knock from reset, identify, program everything, stay.
|
|
out = pbsim.run_tool(tool, device.pty, baud, "--info", "--fuses", "--flash", app_bin,
|
|
"--eeprom", ee_path, "--stay")
|
|
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
|
if needed not in out:
|
|
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 APP banner first.
|
|
probe = "c0ffee"
|
|
out = pbsim.run_tool(tool, device.pty, baud, "--verify-flash", app_bin, "--verify-eeprom", ee_path,
|
|
"--read-flash", read_flash, "--read-eeprom", read_eeprom,
|
|
"--poke", f"{ram_base:#x}:{probe}", "--peek", f"{ram_base:#x}:3", "--stay")
|
|
if out.count("verify:") != 2:
|
|
fail("session 2 did not verify both memories")
|
|
# What went into SRAM must come back out of it: the data space is one
|
|
# more selector on the same transfer as flash and EEPROM, so a wrong
|
|
# selector decode would show up here and nowhere else.
|
|
if probe not in out.replace(" ", ""):
|
|
fail(f"data-space round trip at {ram_base:#x} did not read back {probe}\n{out}")
|
|
|
|
eeprom_back = open(read_eeprom, "rb").read()
|
|
if eeprom_back[: len(ee_image)] != ee_image:
|
|
fail("EEPROM read-back mismatch")
|
|
|
|
# The expected post-surgery flash, straight from the tool's planner.
|
|
pages = pb.plan_flash(open(app_bin, "rb").read(), info)
|
|
flash_back = open(read_flash, "rb").read()
|
|
for address, data in pages.items():
|
|
if flash_back[address : address + page] != data:
|
|
fail(f"flash read-back mismatch in page {address:#06x}")
|
|
|
|
# An external reset re-enters through the patched word 0 (tinies; the
|
|
# runner resets them to address 0 like silicon) or BOOTRST (mega).
|
|
# The loader must answer a fresh knock, and the 'J' hand-over must
|
|
# land in the application, which banners on the same link.
|
|
device.reset()
|
|
port = pb.Port(device.pty, baud)
|
|
try:
|
|
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
|
|
# 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.
|
|
if not pb.OLDEST_LOADER <= live.version <= pb.NEWEST_LOADER:
|
|
fail(f"loader reports pureboot {live.version}, the tool speaks "
|
|
f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}")
|
|
|
|
# A W addressed inside a page rather than at its base must still
|
|
# consume exactly one page and prompt. The loader's own slot is the
|
|
# target — the guard refuses to commit it — and the payload is
|
|
# erased-state bytes, so the probe can disturb neither the image nor
|
|
# the page buffer it leaves behind. Hand-built rather than through
|
|
# write_page(), which would follow the fill with its erase and
|
|
# write; the point here is that the fill alone consumes exactly one
|
|
# page whatever the address's low bits say.
|
|
wire = base + 1
|
|
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
|
|
+ b"\xff" * page)
|
|
if port.read_exact(1, 5.0) != pb.PROMPT:
|
|
fail("unaligned W did not return to the prompt")
|
|
|
|
loader.run_application()
|
|
banner = port.read_exact(3, 5.0)
|
|
if banner != b"APP":
|
|
fail(f"application banner was {banner!r}")
|
|
finally:
|
|
port.close()
|
|
finally:
|
|
device.stop()
|
|
|
|
# Ground truth: the simulator's own memories, against the host's view.
|
|
flash_true = open(dump, "rb").read()
|
|
if flash_true[:base] != flash_back:
|
|
fail("host flash read-back differs from the simulator's flash")
|
|
if flash_true[base] == 0xFF and flash_true[base + 1] == 0xFF:
|
|
fail("loader region looks erased in the ground-truth dump")
|
|
|
|
# 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).
|
|
if patch:
|
|
flash_words = (base + pb.SLOT) // 2
|
|
app = open(app_bin, "rb").read()
|
|
word0 = flash_true[0] | (flash_true[1] << 8)
|
|
if rjmp_decode(word0, 0, flash_words) != base // 2:
|
|
fail("patched reset vector does not land on the loader base")
|
|
trampoline = flash_true[base - 2] | (flash_true[base - 1] << 8)
|
|
original = app[0] | (app[1] << 8)
|
|
if rjmp_decode(trampoline, (base - 2) // 2, flash_words) != rjmp_decode(original, 0, flash_words):
|
|
fail("trampoline does not land on the application's own entry")
|
|
ee_true_path = dump + ".eeprom"
|
|
if os.path.exists(ee_true_path):
|
|
ee_true = open(ee_true_path, "rb").read()
|
|
if ee_true[: len(ee_image)] != ee_image:
|
|
fail("ground-truth EEPROM does not match what was programmed")
|
|
|
|
print("pbtest: all scenarios pass")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|