diff --git a/CMakeLists.txt b/CMakeLists.txt index e3bafb7..a2768e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -289,6 +289,21 @@ if(PROJECT_IS_TOP_LEVEL) set_tests_properties(pureboot.dirty PROPERTIES TIMEOUT 180) endif() + # The reset walk region: an application that grew into the span reset + # crosses to reach the loader, refused by the ordinary --flash. One + # boot-sectioned mega carries it - the per-chip BOOTSZ ladder the + # refusal decodes is a planner test (test_planner.py). + if(LIBAVR_MCU STREQUAL "atmega328p") + add_test(NAME pureboot.walk + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwalk.py + ${PB_DEVICE} $ ${PUREBOOT_SIM_MCU} ${_pb_stock_hz} + ${PUREBOOT_BASE_HEX} ${PUREBOOT_PAGE} ${_pb_stock_baud} + $.bin + ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py + ${CMAKE_BINARY_DIR}/pbwalk-work) + set_tests_properties(pureboot.walk PROPERTIES TIMEOUT 300) + endif() + # Re-homing: a loader mistakenly programmed at address 0 (a raw .bin # handed to a programmer) or sitting in the staging slot must heal # into the canonical slot through the ordinary --update-loader flow. diff --git a/pureboot/README.md b/pureboot/README.md index 73ebe17..9c84fb1 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -587,8 +587,7 @@ one; a re-run on the resident's link finds nothing at all. The state file carrie the only bytes not recoverable from the device; losing it mid-update still completes the update, and the staging region comes back by reflashing the application. A boot-sectioned mega needs its fuses for the preflight — read -from the device, or supplied with `--assume-fuses` where reading is impossible -(simulators). +from the device, or supplied with `--assume-fuses` where reading is impossible. ## Host tool @@ -611,7 +610,11 @@ verify by read-back unless `--no-verify`, and a flash page that reads back wrong is rewritten up to three times before the run stops (see the fill above). `--verify-flash` only reports. Images are raw binary, or Intel HEX by extension. `--force` overrides the refusable safety checks — today, flashing -application data into a mega's reset walk region. +application data into a mega's reset walk region. That check reads the boot +fuses itself rather than waiting to be handed them, because a plain `--flash` +asks for none and is exactly the operation that must not skip it; fuses it +cannot read leave the region unknown, and unknown is refused rather than +assumed empty (`--assume-fuses` states them). `--autobaud` opens with the calibration pulse instead of the plain knock, for a loader built `SERIAL autobaud`; the rest of the session is identical, at @@ -750,6 +753,13 @@ Per chip preset, `ctest` runs: a bare verify must see the corruption and the repairing verify must fix it in one rewrite. Hardware forbids the state here, but simavr dispatches SPM from anywhere, which is what makes the path constructible; +- `pureboot.walk` (328P) — an application whose tail lands in the span reset + crosses to reach the loader, refused by a plain `--flash` and written only + under `--force`, with a lower image and an unprogrammed BOOTRST left alone. + The refusal is decided from a modeled fuse read: the diversion BLBSET arms + answers an LPM, which simavr executes straight out of flash with no hook, so + the runner lends those bytes the fuses for the cycles SELFPRGEN holds (`-f` + states them, unprogrammed elsewhere); - `pureboot.update` — the full `--update-loader` flow, then every power-fail phase: the device is killed mid-write, restarted from its flash dump, and a re-run must complete the update with the application intact; diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 012d071..f719cc9 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -1476,11 +1476,36 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non print(f"loader updated: pureboot {update.version}, {len(image)} B at {info.base:#06x}, staging region restored") +def walk_check_fuses(loader): + """The fuses the walk check needs, fetched where the check is rather than + by whichever operation happened to want them printed. An ordinary --flash + asks for no fuses of its own and is exactly the caller that must not skip + the check, so the dependency is fetched here and cannot be forgotten. + None where they cannot be had, which the check answers with its refusal.""" + if loader.info.patch_vector: + return None + try: + return read_fuse_bytes(loader) + except Error: + return None + + def check_walk_region(pages, info, fuse_bytes, force): """BOOTRST programmed below the loader means reset reaches it only by walking across erased flash; application data in that span would divert - reset into itself. Needs the fuses (--fuses or --assume-fuses).""" - if info.patch_vector or fuse_bytes is None: + reset into itself. The fuses are what name that span, so arriving without + them is a refusal and not a pass: the check that did not run answers + unknown, and unknown is not empty.""" + if info.patch_vector: + return + if fuse_bytes is None: + if not force: + raise Error( + f"the fuses could not be read, so the reset walk region below {info.base:#06x} " + "is unknown - an image writing into it while BOOTRST is programmed leaves " + "reset unable to reach the loader - --assume-fuses to state them, --force " + "to flash without the check" + ) return bootrst, bls_start = mega_boot(info, fuse_bytes) if not bootrst or bls_start >= info.base: @@ -1520,7 +1545,7 @@ def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False): image = load_image(path) verbose(f"{path}: {len(image)} B image") pages = plan_flash(image, loader.info) - check_walk_region(pages, loader.info, fuse_bytes, force) + check_walk_region(pages, loader.info, fuse_bytes or walk_check_fuses(loader), force) if erase: op_erase_flash(loader) order = covered(pages, loader.info, skip_blank=erase) @@ -1648,14 +1673,21 @@ def op_poke(loader, spec): print(f"poke: {len(data)} B at {int(address, 0):#06x}") -def op_fuses(loader): +def read_fuse_bytes(loader): + """The device's fuses in the order this tool indexes them everywhere: + low, lock, extended, high - BOOT_FUSE's order and --assume-fuses'.""" low, lock, extended, high = loader.read_fuses() + return bytes((low, lock, extended, high)) + + +def op_fuses(loader): + fuse_bytes = read_fuse_bytes(loader) + low, lock, extended, high = fuse_bytes print("fuses:") print(f" low 0x{low:02x}") print(f" high 0x{high:02x}") print(f" extended 0x{extended:02x}") print(f" lock 0x{lock:02x}") - fuse_bytes = bytes((low, lock, extended, high)) # On a boot-sectioned mega the BOOTSZ/BOOTRST decode is the fuse fact the # loader's whole deployment hangs on - say it in words. if not loader.info.patch_vector: diff --git a/test/pbsim.py b/test/pbsim.py index a5b68ee..692bfe5 100644 --- a/test/pbsim.py +++ b/test/pbsim.py @@ -9,12 +9,16 @@ import subprocess class Device: def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None, - window=False): + window=False, fuses=None): cmd = [binary] if link: cmd += ["-l", link] if window: cmd.append("-w") # report the first-transmit cycle, free-run idle + if fuses: + # What a fuse read answers, low,lock,extended,high. Unprogrammed + # otherwise, which is the profile every other test here runs. + cmd += ["-f", fuses] cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump] if reset_hex is not None or resume is not None: # Chips without a hardware boot section - the tinies and the diff --git a/test/pbwalk.py b/test/pbwalk.py new file mode 100644 index 0000000..c02579f --- /dev/null +++ b/test/pbwalk.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""The reset walk region, end to end - the brick this refusal exists for. + +BOOTRST programmed on a boot-sectioned mega enters reset at the boot section's +base, and the loader is reached only by walking up across erased flash. An +application that grew into that span is executed by the reset instead: it +happened to an ssd1306 board on a 328P with hfuse 0xdc, and with no reset edge +brought out, an ICE was the only way back. + +The plain --flash is the path under test. It asks for no fuses of its own, +which is exactly why it once skipped the check and wrote the image anyway. + +The runner still resets into the loader - the walk itself is not modeled. What +is modeled is the fuse read, so the refusal is decided from the bytes silicon +would have answered (-f, test/pureboot_device.cpp). + +Usage: pbwalk.py + +""" + +import os +import sys + + +def fail(message): + print(f"FAIL: {message}") + sys.exit(1) + + +def intel_hex(chunks): + """Intel HEX for {address: bytes}. An application that reaches the walk + region is sparse there - a .bin would have to carry every byte between.""" + lines = [] + for address, data in sorted(chunks.items()): + for at in range(0, len(data), 16): + row = data[at : at + 16] + here = address + at + record = bytes((len(row), here >> 8, here & 0xFF, 0)) + row + lines.append(":" + (record + bytes((-sum(record) & 0xFF,))).hex()) + return "\n".join(lines + [":00000001FF"]) + "\n" + + +def main(): + device_bin, elf, mcu, hz, base_hex, page, baud, app_bin, tool, workdir = sys.argv[1:] + page, baud = int(page), int(baud) + 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) + dump = os.path.join(workdir, "dump.bin") + # The ssd1306 board's own fuses, and the same boot section with reset left + # on the application - the one difference that decides the refusal. + bricking, harmless = "ffffffdc", "ffffffdd" + + def session(fuses): + return pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump, fuses=fuses) + + def connected(device): + port = pb.Port(device.pty, baud) + loader = pb.Loader(port) + loader.connect(25) + return port, loader + + device = session(bricking) + try: + port, loader = connected(device) + info = loader.info + bootrst, bls_start = pb.mega_boot(info, bytes.fromhex(bricking)) + if not bootrst or bls_start >= info.base: + fail(f"this profile cannot brick: BOOTRST {bootrst}, section at {bls_start:#06x}") + port.close() + + # An application whose tail lands in the walk region, which is what the + # bricked board's dump showed: live bytes at the section base. + overgrown = os.path.join(workdir, "overgrown.hex") + with open(overgrown, "w") as image: + image.write(intel_hex({0: open(app_bin, "rb").read(), bls_start: bytes(page)})) + + out = pbsim.run_tool(tool, device.pty, baud, "--fuses", "--stay") + if f"0x{bricking[6:]}" not in out: + fail(f"the device did not answer the fuses it was given ({bricking}):\n{out}") + + try: + pbsim.run_tool(tool, device.pty, baud, "--flash", overgrown, "--stay") + except RuntimeError as refused: + if "walk region" not in str(refused): + fail(f"--flash failed, but not on the walk region: {refused}") + else: + fail("a plain --flash wrote an application into the reset walk region") + + port, loader = connected(device) + if loader.read_flash(bls_start, page) != bytes((0xFF,)) * page: + fail(f"the refused image was written to {bls_start:#06x} anyway") + port.close() + + # The override is the escape hatch, not the default. + pbsim.run_tool(tool, device.pty, baud, "--flash", overgrown, "--erase-flash", "--force", "--stay") + port, loader = connected(device) + if loader.read_flash(bls_start, page) != bytes(page): + fail(f"--force did not write {bls_start:#06x}") + port.close() + finally: + device.stop() + + # An image that stays below the region flashes with no override at all, + # and so does the same overgrown image once reset boots the application. + for fuses, image in ((bricking, app_bin), (harmless, overgrown)): + device = session(fuses) + try: + pbsim.run_tool(tool, device.pty, baud, "--flash", image, "--erase-flash", "--stay") + finally: + device.stop() + + print("pbwalk: the walk region is refused by default, overridden by --force, and left alone otherwise") + + +main() diff --git a/test/pureboot_device.cpp b/test/pureboot_device.cpp index 85d4a8a..8ce0332 100644 --- a/test/pureboot_device.cpp +++ b/test/pureboot_device.cpp @@ -18,12 +18,13 @@ // is a silent no-op (the mega's boot section has one, avr_flash). The // missing module is supplied here: the SPM ioctl reads SPMCSR/Z/r1:r0 and // implements buffer fill, page erase, page write, and CTPB, completing -// instantly. RFLB's LPM diversion (fuse readout) stays unmodeled, so the -// 'F' command answers with flash bytes - the tests assert transport only. +// instantly. The fuse readout's LPM diversion is missing from every core and +// is supplied too, so a fuse read answers fuses (-f) and not flash bytes. // // On exit (or SIGTERM) the flash and EEPROM are dumped to files for a // ground-truth cross-check against what the host read back. #include +#include #include #include #include @@ -177,6 +178,58 @@ void fix_mega_flash_erase() std::println(stderr, "device: no flash module to fix - SPM page erases may misalign"); } +// --------------------------------------------------------------- fuses --- + +// The fuse and lock bytes answer an LPM, not an SPM: BLBSET|SELFPRGEN in +// SPMCSR diverts the next LPM to them, selected by Z (Atmel-8271 section +// 26.8.9). simavr executes LPM straight out of avr->flash and offers no hook +// on it, so the diversion is modeled where there is one - the SPMCSR write - +// by lending the four flash bytes Z can select to the fuses for as long as the +// hardware holds SELFPRGEN. Unprogrammed until -f says otherwise, as a part +// ships and as the erased flash above is. +auto fuses = std::to_array({0xFF, 0xFF, 0xFF, 0xFF}); // Z order: low, lock, extended, high +decltype(fuses) lent; + +int parse_fuses(std::string_view spec) +{ + if (spec.size() != 2 * fuses.size()) { + return -1; + } + for (std::size_t at = 0; at < fuses.size(); at++) { + const auto digits = spec.substr(2 * at, 2); + if (std::from_chars(digits.data(), digits.data() + digits.size(), fuses[at], 16).ec != std::errc{}) { + return -1; + } + } + return 0; +} + +avr_cycle_count_t end_fuse_read(avr_t *mcu, avr_cycle_count_t, void *) +{ + std::memcpy(mcu->flash, lent.data(), lent.size()); + return 0; +} + +void spmcsr_written(avr_t *mcu, avr_io_addr_t at, std::uint8_t value, void *) +{ + // A registered write handler *replaces* the store simavr would have done + // (sim_core.c), so performing it is this handler's job - on the tinies + // nothing else is watching the register, and swallowing the store would + // leave every SPM command unseen by the NVM model above. + avr_core_watch_write(mcu, at, value); + constexpr std::uint8_t read_fuse_command = 0x09; // BLBSET|SELFPRGEN + // The LPM must follow within three cycles of the arming store (Atmel-8271 + // section 26.8.9), and simavr runs its cycle timers between instructions - + // so the loan is returned after the LPM that took it, never during. + constexpr avr_cycle_count_t selfprgen_window = 3; + if ((value & 0x1F) != read_fuse_command) { + return; + } + std::memcpy(lent.data(), mcu->flash, lent.size()); + std::memcpy(mcu->flash, fuses.data(), fuses.size()); + avr_cycle_timer_register(mcu, selfprgen_window, end_fuse_read, nullptr); +} + void request_reset(int) { reset_requested = 1; @@ -184,6 +237,9 @@ void request_reset(int) // ------------------------------------------------------------- tiny NVM --- +// Where SPMCSR sits on the cores that carry no flash module to name it. +constexpr avr_io_addr_t tiny_spmcsr = 0x57; + struct tiny_nvm_t { avr_io_t io; std::array buffer; @@ -200,7 +256,7 @@ int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *) } auto *n = reinterpret_cast(io); avr_t *mcu = io->avr; - std::uint8_t command = mcu->data[0x57] & 0x1f; // SPMCSR, both tinies + std::uint8_t command = mcu->data[tiny_spmcsr] & 0x1f; auto z = static_cast(mcu->data[30] | (mcu->data[31] << 8)); std::uint32_t page_base = static_cast(z & ~(n->page - 1)) % (mcu->flashend + 1); if (command == 0x01) { // SPMEN alone: buffer fill from r1:r0 @@ -222,7 +278,7 @@ int nvm_ioctl(avr_io_t *io, std::uint32_t ctl, void *) std::memset(n->buffer.data(), 0xff, n->page); std::memset(n->used.data(), 0, n->page); } - mcu->data[0x57] &= static_cast(~0x1f); // the operation completes instantly + mcu->data[tiny_spmcsr] &= static_cast(~0x1f); // the operation completes instantly return 0; } @@ -494,11 +550,18 @@ void poll_pty() int main(int argc, char *argv[]) { bool link_given = false; - for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) { + for (int opt; (opt = getopt(argc, argv, "l:wf:")) != -1;) { if (opt == 'w') { window_report = true; continue; } + if (opt == 'f') { + if (parse_fuses(optarg) != 0) { + std::println(stderr, "device: -f takes 8 hex digits: low,lock,extended,high"); + return 2; + } + continue; + } if (opt != 'l' || parse_link(optarg) != 0) { std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)"); return 2; @@ -508,10 +571,12 @@ int main(int argc, char *argv[]) int args = argc - optind; if (args < 7 || args > 9) { std::print(stderr, - "usage: {} [-l link] [-w] " - " [reset_hex] [resume_flash]\n" + "usage: {} [-l link] [-w] [-f fuses] " + " [reset_hex] [resume_flash]\n" " -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n" " them); default: the chip's own\n" + " -f fuses: 8 hex digits, low,lock,extended,high - what a fuse read answers;\n" + " default: unprogrammed. reset_hex stays the reset target\n" " -w: print PB_WINDOW_TX at the first transmit activity and\n" " free-run idle time (window measurement mode)\n" " reset_hex: reset vector (default: base with a boot section, else 0)\n" @@ -599,6 +664,9 @@ int main(int argc, char *argv[]) nvm.io.ioctl = nvm_ioctl; avr_register_io(avr, &nvm.io); } + // Where the fuse read is armed: the mega's flash module names its SPMCSR, + // and the tinies keep theirs at the address both those cores share. + avr_register_io_write(avr, mega_flash ? mega_flash->r_spm : tiny_spmcsr, spmcsr_written, nullptr); if (!link_software) { // POLL_SLEEP paces an idle-polling loader in host real time (a diff --git a/test/test_planner.py b/test/test_planner.py index 912a64d..4c099d5 100644 --- a/test/test_planner.py +++ b/test/test_planner.py @@ -243,7 +243,14 @@ def main(): pb.check_walk_region(deep, mega, fuses(0xFA), True) pb.check_walk_region(deep, mega, fuses(0xFB), False) # BOOTRST unprogrammed pb.check_walk_region({0x7800: bytes((0xFF,)) * 128}, mega, fuses(0xFA), False) - pb.check_walk_region(deep, mega, None, False) # fuses unknown: no check + + # Fuses that could not be read leave the span unknown, and unknown is not + # empty: the check that did not run must refuse rather than pass, since the + # span it would have named is the one that costs the part. + expect_error("walk region without fuses", + lambda: pb.check_walk_region(deep, mega, None, False), "--assume-fuses", "--force") + pb.check_walk_region(deep, mega, None, True) + pb.check_walk_region(deep, tiny, None, False) # patched reset vector: no walk to guard # The repairing verify: a mismatched page is rewritten rather than raised, # bounded so a fault that is not self-clearing cannot spin.