diff --git a/pureboot/README.md b/pureboot/README.md index 3b462b8..bb5f4e9 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -170,7 +170,7 @@ The info block (`b`): | Offset | Content | |---|---| -| 0–2 | `'P'`, `'B'`, protocol version (1) | +| 0–2 | `'P'`, `'B'`, pureboot version (2) | | 3–5 | device signature | | 6 | SPM page size in bytes (0 means 256) | | 7–8 | loader base — application flash ends here (a word address when bit 1 is set) | @@ -180,6 +180,21 @@ The info block (`b`): Composites are the host's job: verify = read back and compare, erase = write `0xff` (per page for flash, per byte for EEPROM). +## Version + +The third byte of the info block is the **pureboot version** — the loader's +one identity number, and the only way to tell what a deployed loader is. +Nothing else is numbered: the wire protocol has no version of its own, a +pureboot version implies its protocol, and the host tool is what holds that +map. It states the window of loader versions it speaks +(`OLDEST_LOADER`/`NEWEST_LOADER` in `pureboot.py`); a version that changes +the protocol becomes the new floor there. So far none has: pureboot 1 and 2 +speak the identical session, and a loader newer than the tool is refused by +name rather than decoded on the assumption that nothing moved. + +The tool carries its own version, free to drift from the loader's: +`--version` prints both it and the window. + ## Deployment The build leaves three artifacts per chip. The ELF is a container for the @@ -245,7 +260,7 @@ BOOTRST does not exist there. ## Updating the loader `pureboot.py --update-loader new_pureboot.bin` replaces the resident loader -with any pureboot build — a re-timed window, a newer protocol — using the +with any pureboot build — a re-timed window, a newer version — using the loader itself as its own staging loader. The image is the loader's own 512 bytes as a raw binary, or the Intel HEX the build emits beside it, which links the loader at its base inside an otherwise blank flash image: @@ -312,11 +327,12 @@ Intel HEX by extension. `--force` overrides the refusable safety checks (today: application data into a mega's reset walk region). Readouts come one fact per line: `--info` prints the decoded info block -field by field, `--fuses` each fuse byte on its own line — plus, on a -boot-sectioned mega, the decoded meaning (where the BOOTSZ section starts, -what BOOTRST does to reset). Transfers that take wire time — programming, -reading, erasing, verifying, the update phases — draw a transient progress -bar on stderr when it is a tty; logs and pipes see only the summary lines. +field by field, the loader's version first; `--fuses` each fuse byte on its +own line — plus, on a boot-sectioned mega, the decoded meaning (where the +BOOTSZ section starts, what BOOTRST does to reset). Transfers that take +wire time — programming, reading, erasing, verifying, the update phases — +draw a transient progress bar on stderr when it is a tty; logs and pipes +see only the summary lines. `-v`/`--verbose` adds the decisions as they happen: knock counts, the programming plan (vector-surgery targets, skipped blank pages), update state handling and per-phase page counts. diff --git a/pureboot/pureboot.cpp b/pureboot/pureboot.cpp index ff5d287..f573b51 100644 --- a/pureboot/pureboot.cpp +++ b/pureboot/pureboot.cpp @@ -92,6 +92,12 @@ constexpr std::uint16_t wire_page_mask = word_flash ? (page / 2 - 1) : (page - 1 #endif constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; +// The pureboot version: the loader's one identity number, carried in the info +// block so a host can tell a deployed loader apart from another. The wire +// protocol has no number of its own — a version implies its protocol, and the +// host tool is what holds that map (README.md). +constexpr std::uint8_t version = 2; + // The 12-byte info block the host reads with the 'b' command, flash-resident // through flash_table (there is no crt to copy a .data image, and its storage // carries the word alignment 'b' needs to halve the address on the large @@ -99,7 +105,7 @@ constexpr std::uint8_t timeout_seconds = PUREBOOT_TIMEOUT; inline constexpr avr::flash_table{ 'P', 'B', - 1, // magic, protocol version + version, // magic, then the loader's version avr::hw::db.signature[0], avr::hw::db.signature[1], avr::hw::db.signature[2], diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 4c62c32..0a35a46 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -35,7 +35,14 @@ else: import termios PROMPT = b"+" -PROTOCOL_VERSION = 1 +VERSION = 2 # this tool's own version — free to drift from a loader's +# The loader versions this tool speaks to. A pureboot version implies its wire +# protocol — the protocol carries no number of its own — so knowing which +# versions speak what is the tool's job, and this window is where it says so: +# every pureboot so far speaks this protocol, and a version that changes it +# becomes the new floor here. +OLDEST_LOADER = 1 +NEWEST_LOADER = 2 SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops @@ -313,8 +320,12 @@ class Info: def __init__(self, raw): if len(raw) != 12 or raw[0:2] != b"PB": raise Error(f"bad info block: {raw.hex()}") - if raw[2] != PROTOCOL_VERSION: - raise Error(f"protocol version {raw[2]}, tool speaks {PROTOCOL_VERSION}") + self.version = raw[2] + if not OLDEST_LOADER <= self.version <= NEWEST_LOADER: + raise Error( + f"pureboot {self.version}: this tool (version {VERSION}) speaks pureboot " + f"{OLDEST_LOADER}..{NEWEST_LOADER} — a newer loader needs a newer tool" + ) self.raw = bytes(raw) self.signature = raw[3:6] self.page = raw[6] or 256 # the wire count convention: 0 means 256 @@ -349,6 +360,7 @@ class Info: else: hand_over = "hardware boot section, jump to word 0" return ( + 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 ""), @@ -638,9 +650,15 @@ def mega_boot(info, fuse_bytes): def image_info(image): - """The info block embedded in a pureboot binary, or None.""" - at = image.find(b"PB" + bytes((PROTOCOL_VERSION,))) - return Info(image[at : at + 12]) if 0 <= at <= len(image) - 12 else None + """The info block embedded in a pureboot binary, or None. Searched per + known loader version, so the magic stays three selective bytes rather than + two that code could carry by chance — and a binary this tool does not know + the version of reads as no block at all, which is what it is to the tool.""" + for version in range(OLDEST_LOADER, NEWEST_LOADER + 1): + at = image.find(b"PB" + bytes((version,))) + if 0 <= at <= len(image) - 12: + return Info(image[at : at + 12]) + return None def loader_image(path): @@ -677,7 +695,10 @@ def update_preflight(image, info, fuse_bytes): """Errors and warnings before any flash is touched. Returns warnings.""" embedded = image_info(image) if embedded is None: - raise Error("no pureboot info block in the update image — not a pureboot binary?") + raise Error( + "no pureboot info block in the update image — not a pureboot binary, " + f"or a version this tool ({VERSION}) does not know" + ) if embedded.raw[3:] != info.raw[3:]: raise Error( f"update image is for another target: it declares " @@ -811,6 +832,8 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes): image = loader_image(path) for warning in update_preflight(image, info, fuse_bytes): print(f"note: {warning}") + update = image_info(image) # the preflight proved it is there + verbose(f"installing pureboot {update.version} over pureboot {info.version}") staged = staging_content(image, info) resident = bytes(image) + bytes([0xFF] * (info.slot - len(image))) page = info.page @@ -877,7 +900,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes): write_differing(loader, info.stage, state.staging, order, label="staging restore") state.discard() - print(f"loader updated: {len(image)} B at {info.base:#06x}, staging region restored") + print(f"loader updated: pureboot {update.version}, {len(image)} B at {info.base:#06x}, staging region restored") def check_walk_region(pages, info, fuse_bytes, force): @@ -1052,6 +1075,8 @@ def main(): parser = argparse.ArgumentParser( description="pureboot host tool", epilog="operations run in the order listed above" ) + parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION} " + f"(speaks pureboot {OLDEST_LOADER}..{NEWEST_LOADER})") parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty") parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies") parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking") diff --git a/test/pbtest.py b/test/pbtest.py index 3d850ac..9a77888 100644 --- a/test/pbtest.py +++ b/test/pbtest.py @@ -61,7 +61,7 @@ def main(): 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"), 1, 0, 0, 0, page & 0xFF]) + 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]) ) @@ -71,7 +71,7 @@ def main(): # 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 ("signature", "fuses", "verify:", "stays"): + for needed in ("version", "signature", "fuses", "verify:", "stays"): if needed not in out: fail(f"session 1 output lacks {needed!r}") @@ -101,7 +101,12 @@ def main(): port = pb.Port(device.pty, baud) try: loader = pb.Loader(port) - loader.connect(15) + live = loader.connect(15) + # The loader built from this tree and the tool beside it must + # agree on where the version numbering stands: a bump the tool + # was never told about is a loader it would refuse to speak to. + if live.version != pb.NEWEST_LOADER: + fail(f"loader reports pureboot {live.version}, the tool's newest is {pb.NEWEST_LOADER}") loader.run_application() banner = port.read_exact(3, 5.0) if banner != b"APP": diff --git a/test/test_planner.py b/test/test_planner.py index 9e979de..1087f5a 100644 --- a/test/test_planner.py +++ b/test/test_planner.py @@ -27,12 +27,12 @@ def expect_error(what, fn, *needles): fail(f"{what}: no error raised") -def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_flash=False): +def info_of(pb, base, page, patch, flash, signature=(0x1E, 0x93, 0x0B), word_flash=False, version=None): scale = 2 if word_flash else 1 wire_base = base // scale flags = (1 if patch else 0) | (2 if word_flash else 0) - raw = bytes((0x50, 0x42, 1, *signature, page & 0xFF, wire_base & 0xFF, wire_base >> 8, - 0, 2, flags)) + 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)) info = pb.Info(raw) assert info.flash_size == flash return info @@ -55,6 +55,21 @@ def main(): tiny = info_of(pb, 0x1E00, 64, True, 0x2000) mega = info_of(pb, 0x7E00, 128, False, 0x8000, signature=(0x1E, 0x95, 0x0F)) + # Versioning: the block's third byte is the loader's version, and the tool + # speaks a window of them. Every version in the window decodes, so an older + # deployed loader stays usable; one above the window is refused by name, + # since which version changed the protocol is knowledge only the tool + # holds, and it holds none about a version it has never heard of. + for version in range(pb.OLDEST_LOADER, pb.NEWEST_LOADER + 1): + if info_of(pb, 0x1E00, 64, True, 0x2000, version=version).version != version: + fail(f"pureboot {version} does not decode") + expect_error( + "unknown loader version", + lambda: info_of(pb, 0x1E00, 64, True, 0x2000, version=pb.NEWEST_LOADER + 1), + f"pureboot {pb.NEWEST_LOADER + 1}", + "newer tool", + ) + # mega_boot: BOOTSZ words and the BOOTRST sense per chip — the fuse byte # index (EXTENDED on the x8 line except the m328s' HIGH, HIGH elsewhere) # and the per-family ladders (Atmel-2486/2466/2503/2545/8271/DS40002065/ @@ -154,6 +169,12 @@ def main(): fail("image_info misses the embedded block") 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 + # identified and installed like any other. + old = info_of(pb, 0x1E00, 64, True, 0x2000, version=pb.OLDEST_LOADER) + found_old = pb.image_info(bytes((0xAA,)) * 10 + old.raw) + if found_old is None or found_old.version != pb.OLDEST_LOADER: + fail("image_info misses an older loader's block") # loader_image must peel a padded image down to the slot content: a raw # .bin padded from address 0 (or a whole-flash read-back with the loader