Add a helper script that converts RP2040 UF2 payloads into raw flash images. 
This mirrors the machine's UF2 loading path and is useful when preparing 
persistent flash-file inputs outside QEMU.

Signed-off-by: Gilles Grimaud <[email protected]>
---
 scripts/uf2-to-flash.py | 462 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 462 insertions(+)
 create mode 100755 scripts/uf2-to-flash.py

diff --git a/scripts/uf2-to-flash.py b/scripts/uf2-to-flash.py
new file mode 100755
index 0000000000..f1c0fcbbbb
--- /dev/null
+++ b/scripts/uf2-to-flash.py
@@ -0,0 +1,462 @@
+#!/usr/bin/env python3
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+"""
+uf2-to-flash.py
+
+Convert a Raspberry Pi Pico UF2 file into a raw flash image.
+
+This tool implements the simple and practical conversion model used for
+QEMU-style emulation:
+
+    UF2 target address 0x10000000 + offset  ->  flash image offset
+
+The output file is not a FAT image. It is a raw representation of the
+external QSPI flash contents after UF2 programming.
+
+Supported input model:
+    - RP2040 / Pico 1 UF2 files, family ID 0xE48BFF56.
+    - RP2350 / Pico 2 UF2 files are detected and converted linearly,
+      but RP2350 partition remapping is not implemented.
+
+Usage:
+    scripts/uf2-to-flash.py firmware.uf2 flash.img
+    scripts/uf2-to-flash.py flash.img firmware.uf2
+
+If the two arguments are provided in reverse order, the .uf2 extension is
+used to detect the input file.
+"""
+
+import os
+import sys
+import struct
+
+
+UF2_MAGIC_START0 = 0x0A324655
+UF2_MAGIC_START1 = 0x9E5D5157
+UF2_MAGIC_END = 0x0AB16F30
+
+UF2_BLOCK_SIZE = 512
+UF2_HEADER_SIZE = 32
+
+FLAG_NOT_MAIN_FLASH = 0x00000001
+FLAG_FAMILY_ID_PRESENT = 0x00002000
+
+RP2040_FAMILY_ID = 0xE48BFF56
+
+# Usual RP2350 / Pico 2 family IDs.
+# They are detected for diagnostics. This script does not implement
+# RP2350 partition table remapping.
+RP2350_FAMILY_IDS = {
+    0xE48BFF58: "rp2350-arm-secure",
+    0xE48BFF59: "rp2350-arm-nonsecure",
+    0xE48BFF5A: "rp2350-riscv",
+    0xE48BFF5B: "data-or-absolute",
+}
+
+XIP_BASE = 0x10000000
+
+# RP2040 UF2 flash window: 0x10000000 .. 0x11000000
+PICO1_FLASH_WINDOW_SIZE = 16 * 1024 * 1024
+
+# RP2350 UF2 flash window: 0x10000000 .. 0x12000000
+PICO2_FLASH_WINDOW_SIZE = 32 * 1024 * 1024
+
+
+def print_help() -> None:
+    """Print a minimal usage message."""
+
+    program_name = os.path.basename(sys.argv[0])
+
+    print("Usage:")
+    print(f"  {program_name} firmware.uf2 flash.img")
+    print()
+    print("Convert a Raspberry Pi Pico UF2 file into a raw flash image.")
+    print("Exactly one argument must have the .uf2 extension, the other")
+    print("is used as the raw flash output file.")
+    print()
+    print("Examples:")
+    print(f"  {program_name} blink.uf2 flash.img")
+    print(f"  {program_name} flash.raw blink.uf2")
+
+
+def get_extension(path: str) -> str:
+    """Return the lowercase extension of a path."""
+
+    return os.path.splitext(path)[1].lower()
+
+
+def detect_input_output(arguments: list[str]) -> tuple[str, str]:
+    """
+    Detect the UF2 input file and the raw image output file.
+
+    The arguments may be passed in either order, provided exactly one of them
+    has the .uf2 extension.
+    """
+
+    if len(arguments) != 2:
+        print_help()
+        sys.exit(2)
+
+    first_path, second_path = arguments
+    first_ext = get_extension(first_path)
+    second_ext = get_extension(second_path)
+
+    if first_ext == ".uf2" and second_ext != ".uf2":
+        return first_path, second_path
+
+    if second_ext == ".uf2" and first_ext != ".uf2":
+        print("Reversed arguments detected: using the .uf2 file as input.")
+        return second_path, first_path
+
+    print("Error: unable to determine which file is the UF2 input.")
+    print("Exactly one argument must have the .uf2 extension.")
+    print()
+    print_help()
+    sys.exit(2)
+
+
+def read_uf2_blocks(path: str):
+    """
+    Yield all 512-byte blocks from a UF2 file.
+
+    Raises ValueError if the file size is not a multiple of 512 bytes.
+    """
+
+    file_size = os.path.getsize(path)
+
+    if file_size == 0:
+        raise ValueError("empty file")
+
+    if file_size % UF2_BLOCK_SIZE != 0:
+        raise ValueError(
+            f"invalid UF2 file size: {file_size} bytes; "
+            f"expected a multiple of {UF2_BLOCK_SIZE}"
+        )
+
+    with open(path, "rb") as input_file:
+        physical_block_index = 0
+
+        while True:
+            block = input_file.read(UF2_BLOCK_SIZE)
+
+            if not block:
+                break
+
+            physical_block_index += 1
+
+            if len(block) != UF2_BLOCK_SIZE:
+                raise ValueError(
+                    f"incomplete block at physical index 
{physical_block_index}"
+                )
+
+            yield physical_block_index, block
+
+
+def analyze_uf2(uf2_path: str) -> dict:
+    """
+    Analyze a UF2 file and extract main-flash pages.
+
+    Returns a dictionary containing:
+        - pages: mapping from flash offset to payload bytes
+        - family_ids: detected family IDs
+        - warnings: non-fatal problems
+        - errors: fatal format problems
+        - min_offset / max_end_offset: occupied flash range
+    """
+
+    pages: dict[int, bytes] = {}
+    warnings: list[str] = []
+    errors: list[str] = []
+
+    family_ids: set[int] = set()
+    valid_block_count = 0
+    non_uf2_block_count = 0
+    ignored_not_main_flash_count = 0
+
+    min_offset = None
+    max_end_offset = 0
+
+    announced_num_blocks = None
+    seen_block_numbers: set[int] = set()
+
+    for physical_index, block in read_uf2_blocks(uf2_path):
+        (
+            magic_start0,
+            magic_start1,
+            flags,
+            target_address,
+            payload_size,
+            block_number,
+            num_blocks,
+            family_or_file_size,
+        ) = struct.unpack_from("<IIIIIIII", block, 0)
+
+        (magic_end,) = struct.unpack_from("<I", block, UF2_BLOCK_SIZE - 4)
+
+        if (
+            magic_start0 != UF2_MAGIC_START0
+            or magic_start1 != UF2_MAGIC_START1
+            or magic_end != UF2_MAGIC_END
+        ):
+            non_uf2_block_count += 1
+            errors.append(
+                f"block {physical_index}: invalid UF2 magic values"
+            )
+            continue
+
+        valid_block_count += 1
+
+        if announced_num_blocks is None:
+            announced_num_blocks = num_blocks
+        elif num_blocks != announced_num_blocks:
+            warnings.append(
+                f"block {physical_index}: inconsistent numBlocks "
+                f"({num_blocks}, expected {announced_num_blocks})"
+            )
+
+        if block_number in seen_block_numbers:
+            warnings.append(
+                f"block {physical_index}: duplicated blockNo {block_number}"
+            )
+
+        seen_block_numbers.add(block_number)
+
+        if flags & FLAG_NOT_MAIN_FLASH:
+            ignored_not_main_flash_count += 1
+            continue
+
+        if not (flags & FLAG_FAMILY_ID_PRESENT):
+            errors.append(
+                f"block {physical_index}: missing family ID; Pico boot ROM "
+                f"expects a family ID"
+            )
+            continue
+
+        family_id = family_or_file_size
+        family_ids.add(family_id)
+
+        if payload_size != 256:
+            errors.append(
+                f"block {physical_index}: payloadSize={payload_size}; "
+                f"expected 256 for Pico UF2 files"
+            )
+            continue
+
+        if target_address % 256 != 0:
+            errors.append(
+                f"block {physical_index}: target address is not "
+                f"256-byte aligned: 0x{target_address:08x}"
+            )
+            continue
+
+        pico2_window_end = XIP_BASE + PICO2_FLASH_WINDOW_SIZE
+        if not (XIP_BASE <= target_address < pico2_window_end):
+            errors.append(
+                f"block {physical_index}: target address outside Pico "
+                f"flash window: 0x{target_address:08x}"
+            )
+            continue
+
+        flash_offset = target_address - XIP_BASE
+
+        if flash_offset + payload_size > PICO2_FLASH_WINDOW_SIZE:
+            errors.append(
+                f"block {physical_index}: page exceeds Pico 2 flash window: "
+                f"offset=0x{flash_offset:x}, size={payload_size}"
+            )
+            continue
+
+        if flash_offset in pages:
+            warnings.append(
+                f"block {physical_index}: duplicated flash page at offset "
+                f"0x{flash_offset:x}; last occurrence wins"
+            )
+
+        payload = block[UF2_HEADER_SIZE:UF2_HEADER_SIZE + payload_size]
+        pages[flash_offset] = payload
+
+        if min_offset is None or flash_offset < min_offset:
+            min_offset = flash_offset
+
+        if flash_offset + payload_size > max_end_offset:
+            max_end_offset = flash_offset + payload_size
+
+    if valid_block_count == 0:
+        errors.append("no valid UF2 block found")
+
+    if (announced_num_blocks is not None
+            and len(seen_block_numbers) != announced_num_blocks):
+        warnings.append(
+            f"observed {len(seen_block_numbers)} distinct blockNo values, "
+            f"but numBlocks announces {announced_num_blocks}"
+        )
+
+    return {
+        "pages": pages,
+        "family_ids": family_ids,
+        "warnings": warnings,
+        "errors": errors,
+        "valid_block_count": valid_block_count,
+        "non_uf2_block_count": non_uf2_block_count,
+        "ignored_not_main_flash_count": ignored_not_main_flash_count,
+        "min_offset": min_offset,
+        "max_end_offset": max_end_offset,
+    }
+
+
+def diagnose_family_ids(
+    family_ids: set[int],
+    warnings: list[str],
+    errors: list[str],
+) -> None:
+    """
+    Diagnose UF2 family IDs with respect to Pico 1 and Pico 2.
+
+    RP2040 is accepted as the strict Pico 1 case.
+    RP2350 IDs are accepted with a warning because partition remapping is not
+    implemented.
+    """
+
+    if not family_ids:
+        errors.append("no usable family ID found")
+        return
+
+    if len(family_ids) > 1:
+        family_list = ", ".join(
+            f"0x{value:08x}" for value in sorted(family_ids)
+        )
+        warnings.append(f"multiple family IDs found: {family_list}")
+
+    for family_id in sorted(family_ids):
+        if family_id == RP2040_FAMILY_ID:
+            continue
+
+        if family_id in RP2350_FAMILY_IDS:
+            warnings.append(
+                f"family ID 0x{family_id:08x} detected "
+                f"({RP2350_FAMILY_IDS[family_id]}): this is probably a "
+                f"Pico 2 / RP2350 UF2 file. Linear conversion is performed, "
+                f"but RP2350 partition remapping is not implemented."
+            )
+        else:
+            warnings.append(
+                f"unknown or non-Pico family ID: 0x{family_id:08x}"
+            )
+
+    if RP2040_FAMILY_ID not in family_ids:
+        warnings.append(
+            "no RP2040 / Pico 1 family ID found (0xe48bff56); "
+            "the output may not be a strict Pico 1 flash image."
+        )
+
+
+def build_flash_image(pages: dict[int, bytes], occupied_size: int) -> 
bytearray:
+    """
+    Build a raw flash image initialized with erased flash bytes, 0xff.
+
+    Only the occupied part is emitted:
+        output size = highest written offset + payload size
+    """
+
+    image = bytearray([0xFF] * occupied_size)
+
+    for flash_offset, payload in pages.items():
+        image[flash_offset:flash_offset + len(payload)] = payload
+
+    return image
+
+
+def human_size(byte_count: int) -> str:
+    """Return a human-readable size string."""
+
+    if byte_count < 1024:
+        return f"{byte_count} B"
+
+    if byte_count < 1024 * 1024:
+        return f"{byte_count / 1024:.1f} KiB"
+
+    return f"{byte_count / (1024 * 1024):.2f} MiB"
+
+
+def main() -> None:
+    """Program entry point."""
+
+    uf2_path, image_path = detect_input_output(sys.argv[1:])
+
+    try:
+        analysis = analyze_uf2(uf2_path)
+    except OSError as error:
+        print(f"UF2 file access error: {error}")
+        sys.exit(1)
+    except ValueError as error:
+        print(f"UF2 format error: {error}")
+        sys.exit(1)
+
+    pages = analysis["pages"]
+    family_ids = analysis["family_ids"]
+    warnings = analysis["warnings"]
+    errors = analysis["errors"]
+
+    diagnose_family_ids(family_ids, warnings, errors)
+
+    if not pages:
+        errors.append("no main-flash page to write")
+
+    if analysis["max_end_offset"] > PICO1_FLASH_WINDOW_SIZE:
+        warnings.append(
+            "image exceeds the Pico 1 flash window "
+            "0x10000000..0x11000000; it is not strict Pico 1 compatible"
+        )
+
+    if errors:
+        print("Conversion refused: UF2 format problems were found.")
+        for error in errors:
+            print(f"ERROR: {error}")
+        for warning in warnings:
+            print(f"WARNING: {warning}")
+        sys.exit(1)
+
+    occupied_size = analysis["max_end_offset"]
+    image = build_flash_image(pages, occupied_size)
+
+    try:
+        with open(image_path, "wb") as output_file:
+            output_file.write(image)
+    except OSError as error:
+        print(f"Output image write error: {error}")
+        sys.exit(1)
+
+    programmed_size = sum(len(payload) for payload in pages.values())
+
+    print(f"UF2 input: {uf2_path}")
+    print(f"Raw output: {image_path}")
+    print(f"Valid UF2 blocks: {analysis['valid_block_count']}")
+    print("Ignored NOT_MAIN_FLASH blocks: "
+          f"{analysis['ignored_not_main_flash_count']}")
+    print(f"Written flash pages: {len(pages)}")
+    print(
+        f"Actually programmed bytes: {programmed_size} "
+        f"({human_size(programmed_size)})"
+    )
+    print(
+        f"Occupied flash size: {occupied_size} "
+        f"({human_size(occupied_size)})"
+    )
+
+    if family_ids:
+        family_id_list = ", ".join(
+            f"0x{value:08x}" for value in sorted(family_ids)
+        )
+        print(f"Family ID(s): {family_id_list}")
+
+    if warnings:
+        print()
+        print("Warnings:")
+        for warning in warnings:
+            print(f"- {warning}")
+
+
+if __name__ == "__main__":
+    main()
-- 
2.53.0


Reply via email to