Decompiler — Uf2

This is the magic trick. UF2 often includes a familyID. This tells the bootloader which chip to flash. For us, it tells the decompiler which disassembler backend to load.

If we see 0xe48bff56, we know we are dealing with ARM Thumb instructions. If we see 0x2BACD57F, we need an Xtensa disassembler (hello, Tensilica).

We don't need to write the disassembler from scratch. We use Capstone for ARM and llvm-mc or Xtensa plugins for the others.

We can’t decompile garbage. The first function in our tool is a validator and reassembler. We scan for the magic start 0x0A324655. If we find it, we know exactly where the payload sits.

A naive approach in Python:

def parse_uf2(file_path):
    blocks = []
    with open(file_path, 'rb') as f:
        while chunk := f.read(512):
            if chunk[0:4] != b'UF2\n':
                continue
            # Extract header
            flags = int.from_bytes(chunk[4:8], 'little')
            addr = int.from_bytes(chunk[8:12], 'little')
            size = int.from_bytes(chunk[12:16], 'little')
            # Extract payload
            payload = chunk[32:32+size]
            blocks.append((addr, payload))
    return blocks

Once we have the blocks, we sort them by address and dump the contiguous memory space into a raw .bin file. Congratulations. We just "decompiled" the container. But the firmware is still encrypted (by obscurity) and binary.

Below is a minimal but complete UF2 decompiler.

#!/usr/bin/env python3
# uf2_decompile.py

import struct import sys import os

UF2_MAGIC_START0 = 0x0A324655 UF2_MAGIC_START1 = 0x9E5D5157 UF2_MAGIC_END = 0x0AB16F30 uf2 decompiler

def parse_uf2(uf2_path): blocks = [] with open(uf2_path, 'rb') as f: while True: block = f.read(512) if len(block) < 512: break magic0, magic1 = struct.unpack('<II', block[0:8]) if magic0 != UF2_MAGIC_START0 or magic1 != UF2_MAGIC_START1: continue # skip invalid padding flags, addr, psize, block_no, num_blocks, family = struct.unpack('<IIIIII', block[8:32]) magic_end = struct.unpack('<I', block[508:512])[0] if magic_end != UF2_MAGIC_END: continue data = block[32:32+psize] blocks.append( 'addr': addr, 'data': data, 'block_no': block_no, 'num_blocks': num_blocks, 'family': family ) return blocks

def reassemble_binary(blocks): if not blocks: return b'' blocks.sort(key=lambda b: b['block_no']) # Determine min and max address min_addr = min(b['addr'] for b in blocks) max_addr = max(b['addr'] + len(b['data']) for b in blocks) bin_size = max_addr - min_addr firmware = bytearray(b'\xFF' * bin_size) for b in blocks: offset = b['addr'] - min_addr firmware[offset:offset+len(b['data'])] = b['data'] return firmware, min_addr

def main(): if len(sys.argv) < 2: print(f"Usage: sys.argv[0] firmware.uf2 [output.bin]") sys.exit(1) uf2_file = sys.argv[1] out_file = sys.argv[2] if len(sys.argv) > 2 else uf2_file.replace('.uf2', '.bin') blocks = parse_uf2(uf2_file) if not blocks: print("No valid UF2 blocks found.") sys.exit(1) print(f"Found len(blocks) blocks, family ID = 0xblocks[0]['family']:08X") firmware, base_addr = reassemble_binary(blocks) with open(out_file, 'wb') as f: f.write(firmware) print(f"Reassembled len(firmware) bytes -> out_file (base 0xbase_addr:08X)")

if name == 'main': main()

Despite the limitations, "decompiling" (technically, disassembling and decompiling) a UF2 file is immensely useful.

Search for “UF2 decompiler” on Google, and you’ll find forum posts or niche tools—but no magic software that converts .uf2 into readable C code. Why?

A decompiler works on a specific instruction set architecture (ISA) and assumes an executable format (e.g., ELF, PE, Mach-O) that includes section addresses and sometimes symbols. UF2 is just a transport. This is the magic trick

To decompile a UF2 file, you must:

Thus, the phrase “UF2 decompiler” is shorthand for the workflow of converting UF2 → Raw Binary → Disassembly → Decompiled C.