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>
80 lines
3.6 KiB
Python
80 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""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. 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> <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, 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)]
|
|
if absolute:
|
|
fail("absolute control flow in the image:\n" + "\n".join(absolute))
|
|
|
|
# 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")
|
|
|
|
# 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 len(images[0]) != len(images[1]):
|
|
fail(f"the image is {len(images[0])} B linked at {text_start:#x} and "
|
|
f"{len(images[1])} B at {elsewhere:#x} - relaxation followed the address")
|
|
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__":
|
|
main()
|