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.pyimport 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.