pureboot 5: one command pair for every memory, and a clock-free backend
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>
This commit is contained in:
@@ -1,47 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Position-independence lint: the two link-time facts that let the identical
|
||||
image run from any slot, asserted from the built ELF.
|
||||
"""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
|
||||
grows out of relaxation range would break it silently.
|
||||
2. The info block within the image's first 256 bytes: 'b' rebuilds its
|
||||
address as (running slot high byte : link address low byte).
|
||||
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
|
||||
address anywhere in the image would move with the link and show up as a
|
||||
differing byte.
|
||||
|
||||
Usage: check_pi.py <objdump> <nm> <elf> <text_start_hex>
|
||||
Usage: check_pi.py <objdump> <objcopy> <cxx> <mcu> <elf> <object> <text_start_hex>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
objdump, nm, elf, text_start = sys.argv[1:]
|
||||
objdump, objcopy, cxx, mcu, elf, obj, text_start = sys.argv[1:]
|
||||
text_start = int(text_start, 0)
|
||||
|
||||
listing = subprocess.run([objdump, "-d", elf], capture_output=True, text=True, check=True).stdout
|
||||
absolute = [
|
||||
line
|
||||
for line in listing.splitlines()
|
||||
if re.search(r"\t(jmp|call)\t", line)
|
||||
]
|
||||
absolute = [line for line in listing.splitlines() if re.search(r"\t(jmp|call)\t", line)]
|
||||
if absolute:
|
||||
print("FAIL: absolute control flow in the image:")
|
||||
print("\n".join(absolute))
|
||||
sys.exit(1)
|
||||
fail("absolute control flow in the image:\n" + "\n".join(absolute))
|
||||
|
||||
symbols = subprocess.run([nm, "-C", elf], capture_output=True, text=True, check=True).stdout
|
||||
info = [line for line in symbols.splitlines() if "flash_table" in line and "::storage" in line]
|
||||
if len(info) != 1:
|
||||
print(f"FAIL: expected one info-block storage symbol, found {len(info)}")
|
||||
sys.exit(1)
|
||||
address = int(info[0].split()[0], 16)
|
||||
offset = address - text_start
|
||||
if not 0 <= offset < 256:
|
||||
print(f"FAIL: info block at image offset {offset:#x}, must sit in the first 256 bytes")
|
||||
sys.exit(1)
|
||||
# Allocated flash beyond .text would be data the running copy has to find.
|
||||
# Only ALLOC sections reach the device at all; .comment and the debug
|
||||
# sections ride along in the ELF container and are never flashed. objdump
|
||||
# prints each section's flags on the line following its header.
|
||||
headers = subprocess.run([objdump, "-h", elf], capture_output=True, text=True, check=True).stdout.splitlines()
|
||||
for index, line in enumerate(headers):
|
||||
fields = line.split()
|
||||
if len(fields) < 6 or not fields[0].isdigit():
|
||||
continue
|
||||
name, size = fields[1], int(fields[2], 16)
|
||||
flags = headers[index + 1] if index + 1 < len(headers) else ""
|
||||
if "ALLOC" not in flags or not size:
|
||||
continue
|
||||
if name not in (".text", ".noinit", ".bss"):
|
||||
fail(f"flash-resident section {name} ({size} bytes): the image must be .text alone")
|
||||
|
||||
print(f"PI lint: control flow PC-relative, info block at offset {offset:#x}")
|
||||
# Relink at a different base and compare the bytes.
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
elsewhere = text_start - 0x200 if text_start >= 0x200 else text_start + 0x200
|
||||
images = []
|
||||
for base, tag in ((text_start, "here"), (elsewhere, "there")):
|
||||
relinked = os.path.join(work, f"{tag}.elf")
|
||||
binary = os.path.join(work, f"{tag}.bin")
|
||||
subprocess.run(
|
||||
[cxx, f"-mmcu={mcu}", "-nostartfiles", f"-Wl,--section-start=.text={base:#x}",
|
||||
"-Wl,--defsym=pureboot_app=0", "-mrelax", obj, "-o", relinked],
|
||||
check=True, capture_output=True)
|
||||
subprocess.run([objcopy, "-O", "binary", relinked, binary], check=True)
|
||||
images.append(open(binary, "rb").read())
|
||||
if images[0] != images[1]:
|
||||
differing = [i for i, (a, b) in enumerate(zip(*images)) if a != b]
|
||||
fail(f"the image changes when linked at {elsewhere:#x} instead of {text_start:#x}: "
|
||||
f"{len(differing)} byte(s) differ, first at offset {differing[0]:#x}")
|
||||
|
||||
print(f"PI lint: control flow PC-relative, .text only, identical linked at {text_start:#x} and {elsewhere:#x}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -55,6 +55,12 @@ def main():
|
||||
# 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)
|
||||
@@ -73,12 +79,20 @@ def main():
|
||||
if needed not in out:
|
||||
fail(f"session 1 output lacks {needed!r}")
|
||||
|
||||
# Session 2: reconnect into the live session, verify, dump, hand over
|
||||
# is deferred — the pty must be reopened for the APP banner first.
|
||||
# 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, "--stay")
|
||||
"--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:
|
||||
@@ -110,12 +124,16 @@ def main():
|
||||
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 — it is drained and never programmed — and the
|
||||
# payload is erased-state bytes, so the probe can disturb neither
|
||||
# the image nor the page buffer it leaves behind.
|
||||
wire = wire_base + 1
|
||||
port.write(bytes((ord("W"), wire & 0xFF, wire >> 8)) + b"\xff" * page)
|
||||
# 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")
|
||||
|
||||
|
||||
@@ -182,9 +182,16 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par
|
||||
{
|
||||
(void)mcu;
|
||||
(void)param;
|
||||
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
if (tx_bit < 8) {
|
||||
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
|
||||
if (++tx_bit < 8)
|
||||
return when + bit_cycles;
|
||||
/* The byte is not delivered until its stop bit has passed. A real
|
||||
* receiver cannot answer sooner, and a host that did would put its
|
||||
* start bit on the wire while the device is still driving the stop
|
||||
* bit — which the device, transmitting, is not watching for. */
|
||||
return when + bit_cycles;
|
||||
}
|
||||
if (write(pty_master, &tx_shift, 1) != 1)
|
||||
fprintf(stderr, "device: pty write lost a byte\n");
|
||||
tx_active = 0;
|
||||
|
||||
@@ -30,8 +30,14 @@ def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_fla
|
||||
scale = 2 if word_flash else 1
|
||||
wire_base = base // scale
|
||||
flags = (1 if patch else 0) | (2 if word_flash else 0)
|
||||
# The EEPROM size comes from the signature, as it must: pureboot 5 derives
|
||||
# the whole geometry from the signature rather than sending it, so a
|
||||
# synthetic block that disagreed with its own signature would describe a
|
||||
# chip that cannot exist.
|
||||
eeprom = pb.CHIP_GEOMETRY[signature][2]
|
||||
raw = bytes((0x50, 0x42, pb.NEWEST_LOADER if version is None else version,
|
||||
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, 0, 2, flags))
|
||||
*signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8,
|
||||
eeprom & 0xFF, eeprom >> 8, flags))
|
||||
info = pb.Info(raw)
|
||||
if info.flash_size != flash:
|
||||
fail(f"info_of({base:#x}) decodes to {info.flash_size:#x} of flash, not {flash:#x}")
|
||||
@@ -162,11 +168,16 @@ def main():
|
||||
fail("mega staging content should be the bare image")
|
||||
expect_error("mega staging size", lambda: pb.staging_content(image + b"!", mega), "512")
|
||||
|
||||
# The embedded info block: found in a synthetic binary, absent in noise.
|
||||
binary = bytes((0xAA,)) * 10 + tiny.raw + bytes((0xBB,)) * 10
|
||||
# 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
|
||||
# of the same chip reports.
|
||||
stamp = bytes((0x50, 0x42, pb.NEWEST_LOADER)) + bytes(tiny.signature)
|
||||
binary = bytes((0xAA,)) * 10 + stamp + bytes((0xBB,)) * 10
|
||||
found = pb.image_info(binary)
|
||||
if found is None or found.raw != tiny.raw:
|
||||
fail("image_info misses the embedded block")
|
||||
fail(f"image_info misreads the v{pb.NEWEST_LOADER} stamp: "
|
||||
f"{found.raw.hex() if found else None} != {tiny.raw.hex()}")
|
||||
if pb.image_info(bytes((0xAA,)) * 40) is not None:
|
||||
fail("image_info invents a block")
|
||||
# An older loader's image stays readable, so a deployed build can be
|
||||
|
||||
Reference in New Issue
Block a user