The reading pass over this repo found the tiers disagreeing with themselves,
and every fix here was measured.
**The turn-around guard is real code.** `tsb_asm` and `tsb_tricks` wrote
`for (std::uint8_t guard = 46; guard; --guard) ;` between taking the one-wire
line and the first UDR0 store, under a comment naming it a turn-around guard.
It has no side effect, so GCC deleted it - `sts UCSR0B` went straight to
`sts UDR0` - while the hand-written oracle spends six bytes on that wait and
libavr's own half-duplex spends them through `delay::cycles`. Two of four
tiers described a feature they did not have, which made the size gradient a
comparison between different loaders. `avr::delay::cycles<one bit time>()`
bottoms out in asm and cannot be deleted.
**The entry belongs to the library, and hand-rolling it was expensive.** Three
tiers wrote their own naked `.vectors` stub with `asm volatile("clr
__zero_reg__")` - which design.md fences to libavr and never a port, and which
`tsb_tricks` denied having in its own title line. `avr::startup::entry` also
keeps the body `noinline` for a stated reason: avr-ld must not shrink a
`.vectors` section, so a loader inlined into one forfeits call relaxation
everywhere. `tsb_pure` came out **836 -> 734** bytes for that alone.
`stack::hardware` - the reset value this part guarantees, with the write kept
where a part does not - saved another four, which is what let `tsb_asm` afford
the guard it had been four bytes short of. It fills its 512-byte section
exactly now, with the whole feature set.
**`tsb_pure` had no receive timeout.** Its `rx()` was `read_blocking()`, so a
silent host wedged the password gate and the command loop forever - the one
fix the oracle's own header lists by name, and one the other three tiers
implement. It is bounded now, and 0-on-silence falls through every compare as
theirs does.
Three gates could pass without proving anything. `sizes.py check-readme`
reported a match when every row's lookup missed; `check_size.cmake` used
`CMAKE_MATCH_1` without checking the match succeeded, which is the guard its
sibling `check_unit.cmake` has and it is the size gate; `check_pi.py` raised
IndexError instead of reporting a position-independence break that changed the
image's length. And `check.sh` spelled the 37-chip list a second time beside
make_presets.py, where a chip added to one and missed in the other is a
silently unbuilt chip - it reads the presets now, and produces the same 37 and
12.
tsbtest.py gains the scenario nothing covered: a wrong password byte must
neither activate the loader nor reach the emergency erase behind it. Red-green
on a tier with the refusal removed.
Smaller, all measured or checked: the signature is `hw::db.signature` in every
tier as the page size and EEPROM end beside it already were; `act_min` derives
from the clock; pureboot.py's `rjmp` helpers refuse a part past rjmp's
4096-word reach rather than silently folding an offset (unreachable today, the
ATtiny85 sits exactly on it); the host tool calls space 2 `data` as the wire
and the loader do; `.clangd` strips the fifth GCC-only flag the build passes;
pbrig's bitclock guard reads its own ladder; pbreloc's unexplained retry is
gone, the write being reliable on five runs without it; and the four tier
sizes live in oracle/README.md's table instead of four file headers and a
CMake comment.
`--poke` before `--peek` turned out to be right - pbtest.py round-trips a poke
through the peek behind it - so the parser order and README say so now.
Every chip green, the README size table matching every image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
3.6 KiB
Python
92 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""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.
|
|
|
|
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
|
|
<baud> <tool_py> <workdir>
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def fail(message):
|
|
print(f"FAIL: {message}")
|
|
sys.exit(1)
|
|
|
|
|
|
def main():
|
|
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
|
|
base, page, baud = int(base_hex, 0), int(page), int(baud)
|
|
stage = None # derived from the device's own info (slot-sized) below
|
|
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)
|
|
objcopy = os.environ.get("PB_OBJCOPY", "avr-objcopy")
|
|
image_path = os.path.join(workdir, "pureboot.bin")
|
|
subprocess.run([objcopy, "-O", "binary", elf, image_path], check=True)
|
|
image = open(image_path, "rb").read()
|
|
|
|
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, os.path.join(workdir, "dump.bin"))
|
|
try:
|
|
port = pb.Port(device.pty, baud)
|
|
loader = pb.Loader(port)
|
|
info = loader.connect(25)
|
|
if info.base != base:
|
|
fail(f"info reports base {info.base:#06x}")
|
|
resident_info = info.raw
|
|
|
|
# Install the staging copy exactly as the update flow would.
|
|
stage = info.stage
|
|
staged = pb.staging_content(image, info)
|
|
pb.write_differing(loader, stage, staged)
|
|
|
|
# Enter it; from here on, every command runs in the relocated copy.
|
|
staged_info = loader.enter_copy(stage, 25)
|
|
if staged_info.raw != resident_info:
|
|
fail(f"staged info {staged_info.raw.hex()} != resident info {resident_info.hex()}")
|
|
|
|
# 'R' from the staged copy already proved itself in the install
|
|
# verify; 'F' must answer 4 bytes (values are unmodeled in simavr).
|
|
if len(loader.read_fuses()) != 4:
|
|
fail("fuse read from the staged copy")
|
|
|
|
# EEPROM round-trip through the staged copy.
|
|
pattern = bytes(range(0x50, 0x60))
|
|
loader.write_eeprom(0, pattern)
|
|
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
|
|
# whole point of relocating. There is no running-slot guard to probe
|
|
# against: nothing here refuses an address, and a copy that erases the
|
|
# page it is executing from does not come back to report it.
|
|
# pbselfwrite.py gates that direction on a device it is allowed to
|
|
# destroy.
|
|
marker = bytes((i * 3) & 0xFF for i in range(page))
|
|
loader.write_page(base, marker)
|
|
if loader.read_flash(base, page) != marker:
|
|
fail("the staged copy could not write the resident slot")
|
|
|
|
# Restore the resident image through the staged copy, then 'J' back
|
|
# into it and prove it lives.
|
|
resident = image + b"\xff" * (pb.SLOT - len(image))
|
|
pb.write_differing(loader, base, resident)
|
|
back_info = loader.enter_copy(base, 25)
|
|
if back_info.raw != resident_info:
|
|
fail("the restored resident does not serve its info block")
|
|
port.close()
|
|
finally:
|
|
device.stop()
|
|
print("pbreloc: the relocated copy serves the full command set")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|