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:
@@ -24,7 +24,7 @@ else:
|
||||
import termios
|
||||
|
||||
PROMPT = b"+"
|
||||
VERSION = 3 # this tool's own version — free to drift from a loader's
|
||||
VERSION = 4 # this tool's own version — free to drift from a loader's
|
||||
# The loader versions this tool speaks. A pureboot version implies its wire
|
||||
# protocol, which carries no number of its own, so this window is where that
|
||||
# map lives: every version so far speaks the same protocol, and one that
|
||||
@@ -34,36 +34,38 @@ NEWEST_LOADER = 5
|
||||
SLOT = 512 # the loader slot, on every chip
|
||||
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
|
||||
# pureboot 5 replaced the four per-memory commands with one unified pair: 'G'
|
||||
# reads and 'g' writes, both taking a selector byte, a 16-bit address and a
|
||||
# count, over the spaces below. The loader carries one transfer loop instead of
|
||||
# four bodies, which is what paid for RAM access and for the calibration fix.
|
||||
# pureboot 5 replaced the four per-memory commands with one pair: 'G' reads and
|
||||
# 'g' writes, each taking a selector byte, a 16-bit address and a count, over
|
||||
# the spaces below. The loader carries one transfer loop instead of four bodies
|
||||
# — which is what buys the data space and the host-issued SPM operations.
|
||||
UNIFIED_LOADER = 5
|
||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||
|
||||
# A selector's high nibble is flash's third address byte, so a transfer names a
|
||||
# byte address within a 64 KiB bank and never has to speak word addresses. No
|
||||
# single transfer may cross a bank boundary — the host chunks to keep that true.
|
||||
# A selector's high nibble is the flash bank — the address bits above the 16-bit
|
||||
# wire address — so a transfer names a byte address within one 64 KiB bank and
|
||||
# no command has to speak word addresses. No single transfer may cross a bank
|
||||
# boundary; the host chunks to keep that true.
|
||||
def selector(space, address):
|
||||
return space | ((address >> 16) << 4)
|
||||
|
||||
|
||||
# The SPM operations pureboot 5 leaves to the host: a write to SP_SPM hands its
|
||||
# data byte to SPMCSR and fires the instruction at the selected flash address.
|
||||
# Every part pureboot targets agrees on these encodings.
|
||||
# byte to SPMCSR and fires the instruction at the selected flash address. Every
|
||||
# part pureboot targets agrees on these encodings.
|
||||
SPM_ERASE, SPM_WRITE, SPM_RWWSRE = 0x03, 0x05, 0x11
|
||||
|
||||
# Calibration byte for the autobaud loader: 0xC0 is a start bit plus six zero
|
||||
# Calibration byte for an autobaud loader: 0xC0 is a start bit plus six zero
|
||||
# data bits — one low pulse of seven bit-times, which the loader times into its
|
||||
# per-bit unit. Sent at whatever baud the host chose; the loader locks to it.
|
||||
CALIBRATE = 0xC0
|
||||
|
||||
# The autobaud loader slims its info block to the version and signature; the host
|
||||
# derives the rest of the geometry from the signature. flash, page, eeprom,
|
||||
# patch-vector per distinct signature, over every chip pureboot targets (the
|
||||
# loader computes the same from its chip database at build time). Die revisions
|
||||
# that share a signature share this row, as they share the silicon.
|
||||
AUTOBAUD_GEOMETRY = {
|
||||
# pureboot 5 answers 'b' with its version and the chip signature; the host
|
||||
# derives the rest of the geometry from the signature rather than reading a
|
||||
# table off the device. flash, page, eeprom, patch-vector per distinct
|
||||
# signature, over every chip pureboot targets (the loader computes the same
|
||||
# from its chip database at build time). Die revisions that share a signature
|
||||
# share this row, as they share the silicon.
|
||||
CHIP_GEOMETRY = {
|
||||
# signature : (flash, page, eeprom, patch_vector)
|
||||
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
|
||||
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
|
||||
@@ -359,18 +361,26 @@ class Info:
|
||||
"""The 12-byte info block."""
|
||||
|
||||
@classmethod
|
||||
def from_slim(cls, raw):
|
||||
"""The autobaud loader's slimmed reply — version and signature only —
|
||||
with the rest of the geometry looked up from the signature (the loader
|
||||
derived it from the same chip facts at build time). Reconstructs the full
|
||||
block so every derived attribute matches the fixed-baud path exactly."""
|
||||
def from_identity(cls, raw):
|
||||
"""pureboot 5's reply: the version and the chip signature. The rest of
|
||||
the geometry is looked up from the signature — the loader derived the
|
||||
same facts from its chip database at build time, so nothing is guessed,
|
||||
it is simply not sent. Reconstructs a block in the older layout, so
|
||||
every derived attribute below is shared with the loaders that do send
|
||||
one.
|
||||
|
||||
The base is where application flash ends, which is a property of the
|
||||
chip and not of the copy answering: a loader staged one slot lower
|
||||
reports the same geometry the resident one does, exactly as the loaders
|
||||
that send a block do. Which slot a copy runs in matters only to its own
|
||||
write guard, which is the loader's business."""
|
||||
if len(raw) != 4:
|
||||
raise Error(f"bad slim info block: {raw.hex()}")
|
||||
raise Error(f"bad identity reply: {raw.hex()}")
|
||||
version, signature = raw[0], tuple(raw[1:4])
|
||||
geometry = AUTOBAUD_GEOMETRY.get(signature)
|
||||
geometry = CHIP_GEOMETRY.get(signature)
|
||||
if geometry is None:
|
||||
sig = " ".join(f"{b:02x}" for b in signature)
|
||||
raise Error(f"unknown signature {sig} — this tool has no autobaud geometry for it")
|
||||
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
|
||||
flash, page, eeprom, patch = geometry
|
||||
base = flash - SLOT
|
||||
word_flash = flash > 0x10000
|
||||
@@ -393,8 +403,11 @@ class Info:
|
||||
self.signature = raw[3:6]
|
||||
self.page = raw[6] or 256 # the wire count convention: 0 means 256
|
||||
self.patch_vector = bool(raw[11] & 1)
|
||||
# Bit 1: flash addresses are words on the wire. Every address here
|
||||
# stays a byte address and converts at the wire.
|
||||
# Bit 1: the flash runs past what one 16-bit address covers. Through
|
||||
# pureboot 4 that made flash addresses words on the wire; pureboot 5
|
||||
# keeps them bytes and carries the bank in the selector instead. Every
|
||||
# address in this tool stays a byte address either way and converts at
|
||||
# the wire.
|
||||
self.word_flash = bool(raw[11] & 2)
|
||||
scale = 2 if self.word_flash else 1
|
||||
self.base = (raw[7] | (raw[8] << 8)) * scale
|
||||
@@ -424,7 +437,7 @@ class Info:
|
||||
f"version pureboot {self.version}",
|
||||
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
|
||||
f"flash {self.flash_size} B, {self.page} B pages"
|
||||
+ (", word-addressed wire" if self.word_flash else ""),
|
||||
+ (", past one 16-bit bank" if self.word_flash else ""),
|
||||
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
|
||||
f"loader {self.base:#06x} ({SLOT} B slot)",
|
||||
f"staging {self.stage:#06x}",
|
||||
@@ -441,70 +454,69 @@ class Loader:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.info = None
|
||||
# Set once a session is established over an autobaud link, so a
|
||||
# re-entry after 'J' repeats the handshake that worked.
|
||||
self.autobaud = False
|
||||
|
||||
def _read_identity(self):
|
||||
"""The 'b' reply, in either of the two layouts a loader may send.
|
||||
pureboot 5 answers with its version and the signature; older loaders
|
||||
answer with a 12-byte block. The version byte cannot be mistaken for
|
||||
the older block's 'P', so four bytes are enough to tell them apart."""
|
||||
head = self.port.read_exact(4, 2.0)
|
||||
if head[0:2] == b"PB":
|
||||
return Info(head + self.port.read_exact(8, 2.0))
|
||||
return Info.from_identity(head)
|
||||
|
||||
def _handshake(self, wait, knock, what):
|
||||
"""One activation, retried until the loader answers or the window
|
||||
closes. The identity reply is what proves the loader is listening — a
|
||||
prompt byte alone does not, since one left over from a previous session
|
||||
can still be in the pipeline while the port opening resets the device
|
||||
into a fresh window, where a command without its knock is discarded.
|
||||
Each attempt is therefore the whole handshake. This also converges into
|
||||
an already-live session: the knock bytes are ignored there and the
|
||||
drain absorbs whatever they produced."""
|
||||
deadline = time.monotonic() + wait
|
||||
knocks = 0
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(knock)
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
while self.port.read_available(0.3):
|
||||
pass
|
||||
self.port.write(b"b")
|
||||
try:
|
||||
# A version the tool cannot speak is the loader's own
|
||||
# answer, not a failed knock: Info reports it rather than
|
||||
# sending the tool round the loop again.
|
||||
self.info = self._read_identity()
|
||||
except Error as failed:
|
||||
if "pureboot" in str(failed):
|
||||
raise
|
||||
self.info = None
|
||||
if self.info is not None:
|
||||
self._expect_prompt()
|
||||
verbose(f"loader answered {what} {knocks}; identity read")
|
||||
return self.info
|
||||
if time.monotonic() > deadline:
|
||||
raise Error("no answer — reset the device within its activation window")
|
||||
|
||||
def connect(self, wait):
|
||||
"""Knock until the info block comes back. The block is what proves the
|
||||
loader is listening — a prompt byte alone does not, since one left over
|
||||
from a previous session can still be in the pipeline while the port
|
||||
opening resets the device into a fresh activation window, where a
|
||||
command without its knock is discarded. Each attempt is therefore the
|
||||
whole handshake, retried until it produces the block or the window
|
||||
closes. Also converges into a live session: the knock bytes are ignored
|
||||
there and the drain absorbs whatever they produced."""
|
||||
deadline = time.monotonic() + wait
|
||||
knocks = 0
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(b"pb")
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
while self.port.read_available(0.3):
|
||||
pass
|
||||
self.port.write(b"b")
|
||||
try:
|
||||
block = self.port.read_exact(12, 2.0)
|
||||
except Error:
|
||||
block = b""
|
||||
# A version the tool cannot speak is the loader's own answer,
|
||||
# not a failed knock: Info reports it rather than retrying.
|
||||
if block[0:2] == b"PB":
|
||||
self.info = Info(block)
|
||||
self._expect_prompt()
|
||||
verbose(f"loader answered knock {knocks}; info block read")
|
||||
return self.info
|
||||
if time.monotonic() > deadline:
|
||||
raise Error("no answer — reset the device within its activation window")
|
||||
"""Knock 'p' then 'b' and read the identity."""
|
||||
return self._handshake(wait, b"pb", "knock")
|
||||
|
||||
def connect_autobaud(self, wait):
|
||||
"""The autobaud handshake. Instead of the p+b knock, the host sends the
|
||||
0xC0 calibration pulse — a single seven-bit-time low pulse at the host's
|
||||
chosen baud — which the loader times into its per-bit unit, then a single
|
||||
'p' knock the loader decodes at the rate it just measured. As with
|
||||
connect(), each attempt is the whole handshake, retried until the slim
|
||||
info block comes back or the window closes: a lost pulse or a knock that
|
||||
lands while the loader is mid-frame simply fails to answer, and the
|
||||
loader's measurement loop is back waiting for the next pulse."""
|
||||
deadline = time.monotonic() + wait
|
||||
knocks = 0
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(bytes((CALIBRATE, ord("p"))))
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
while self.port.read_available(0.3):
|
||||
pass
|
||||
self.port.write(b"b")
|
||||
try:
|
||||
block = self.port.read_exact(4, 2.0)
|
||||
except Error:
|
||||
block = b""
|
||||
if len(block) == 4:
|
||||
self.info = Info.from_slim(block)
|
||||
self._expect_prompt()
|
||||
verbose(f"loader locked on knock {knocks}; slim info read")
|
||||
return self.info
|
||||
if time.monotonic() > deadline:
|
||||
raise Error("no answer — reset the device within its activation window")
|
||||
"""The autobaud handshake. In place of the p+b knock the host sends the
|
||||
calibration pulse — one seven-bit-time low pulse at the host's chosen
|
||||
baud, which the loader times into its per-bit unit — then a single 'p'
|
||||
the loader decodes at the rate it just measured. A lost pulse, or a
|
||||
knock landing while the loader is mid-frame, simply fails to answer and
|
||||
leaves the measurement loop waiting for the next pulse, so the retry in
|
||||
_handshake covers it."""
|
||||
self.autobaud = True
|
||||
return self._handshake(wait, bytes((CALIBRATE, ord("p"))), "calibration")
|
||||
|
||||
def _expect_prompt(self, timeout=2.0):
|
||||
byte = self.port.read_exact(1, timeout)
|
||||
@@ -650,8 +662,9 @@ class Loader:
|
||||
def enter_copy(self, byte_address, wait):
|
||||
"""Jump into the loader copy at `byte_address` and knock it — a slot
|
||||
base is that copy's entry stub, so it can only land there."""
|
||||
autobaud = self.autobaud
|
||||
self.jump(byte_address // 2)
|
||||
return self.connect(wait)
|
||||
return self.connect_autobaud(wait) if autobaud else self.connect(wait)
|
||||
|
||||
def run_application(self):
|
||||
self.jump(self.info.app_entry_word)
|
||||
@@ -818,12 +831,26 @@ def mega_boot(info, fuse_bytes):
|
||||
|
||||
|
||||
def image_info(image):
|
||||
"""The info block embedded in a pureboot binary, or None. Searched once
|
||||
per known version, so the magic stays three selective bytes rather than
|
||||
two that code could carry by chance."""
|
||||
"""What a pureboot binary says about itself, or None.
|
||||
|
||||
An update image is a bare slot: nothing about it names the chip it was
|
||||
built for, and installing a foreign one bricks the target — so every
|
||||
loader carries a stamp for this. Through pureboot 4 the stamp is the
|
||||
12-byte info block the device also serves; pureboot 5 serves its identity
|
||||
from immediates and carries a 6-byte stamp (magic, version, signature)
|
||||
that only this exists for, from which the geometry is looked up exactly as
|
||||
it is for a live device.
|
||||
|
||||
Searched once per known version, so the magic stays three selective bytes
|
||||
rather than two that code could carry by chance."""
|
||||
for version in range(OLDEST_LOADER, NEWEST_LOADER + 1):
|
||||
at = image.find(b"PB" + bytes((version,)))
|
||||
if 0 <= at <= len(image) - 12:
|
||||
if at < 0:
|
||||
continue
|
||||
if version >= UNIFIED_LOADER:
|
||||
if at <= len(image) - 6:
|
||||
return Info.from_identity(image[at + 2 : at + 6])
|
||||
elif at <= len(image) - 12:
|
||||
return Info(image[at : at + 12])
|
||||
return None
|
||||
|
||||
@@ -1013,7 +1040,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
|
||||
# it and matching byte for byte, and the slot unchanged since this update
|
||||
# began, so a half-written install takes the path below instead.
|
||||
current = loader.read_flash(info.stage, SLOT)
|
||||
staged_loader = image_info(current[:268])
|
||||
# The whole slot is searched: a loader's stamp sits wherever its image put
|
||||
# it, which is the end of the code on pureboot 5 and the front of it
|
||||
# before that.
|
||||
staged_loader = image_info(current)
|
||||
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
|
||||
print("staging slot already holds a loader — left in place")
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user