Instruction decoder and disassembler for the ARMv7-M (Thumb) architecture,
written in Python. The decoder itself is generated by
decoder-forge from a YAML
description of the instruction set; the disassembler prints UAL assembler text
that matches arm-none-eabi-objdump.
Python 3.12+ · GPL-3.0-only · 0.1.0
I have always wanted to build an emulator that simulates small microcontrollers correctly. This is the first step towards it: a disassembler for the ARMv7-M architecture.
Why Python? Why not. The whole decoder is auto-generated by decoder-forge anyway. For a private project Python is simply easy. I am aware that it is slow — but my goal is development speed and few subtleties to trip over.
- Python is very extensible
- There is nothing to compile
- The ecosystem is gigantic
Maybe there will be a C++ version of this disassembler one day. We will see.
The ARMv7-M instruction set is complete, as far as I can tell: 260 instructions in 369 encodings, taken from the Armv7-M Architecture Reference Manual (ARM DDI 0403E.e), including the floating-point extension. Every one of them has a disassembler formatter, so no instruction falls back to printing its raw fields.
I have not found a word that should decode and does not. That is a weaker claim than a proof against a 700-page manual, so read it as "nothing known missing" rather than "verified complete" — and please open an issue if you find a gap.
Decoding and disassembly only. Nothing here executes an instruction; the emulator is the next step, not this one.
Not published on PyPI yet, so install it from the repository:
uv add git+https://github.com/chgroeling/armv7m-decoderOr, for a checkout you want to work on:
git clone https://github.com/chgroeling/armv7m-decoder
cd armv7m-decoder
uv syncarmv7m-decoder decode firmware.bin --start-address 0x0Output is objdump's listing format — address, instruction bytes, assembler:
0: b510 push {r4, lr}
2: 2400 movs r4, #0
4: 2800 cmp r0, #0
6: bf08 it eq
8: 2401 moveq r4, #1
a: 6843 ldr r3, [r0, #4]
c: f20d 154f addw r5, sp, #335 @ 0x14f
10: b2da uxtb r2, r3
12: bd10 pop {r4, pc}
| Option | Meaning |
|---|---|
--start-address |
Offset into the file at which decoding starts (default 0x0) |
--out-file |
Write the listing to a file instead of stdout |
--max-instructions |
Stop after this many instructions |
Decoding one word takes two steps, because Thumb settles an instruction's width from its first halfword before anything is decoded:
from armv7m_decoder import Context, decode, disassemble, instr_size
ctx = Context()
size = instr_size(0x2401) # SIZE_16BIT
result = decode(0x2401, ctx, size)
# MOV_immediate(encoding=<Encoding.T1: 1>, sideeffects=0, d=4,
# setflags=True, imm32=1, carry=0)
disassemble(result, size) # 'movs\tr4, #1'instr holds exactly size bits: a bare halfword for a 16-bit instruction, a
full word (first halfword in the high half) for a 32-bit one. decode returns
the instruction alone — a dataclass per instruction, with an encoding member
saying which form matched, or NoMatch if none did.
IT makes up to four following instructions conditional, and neither their
condition nor their S bit is in their own encoding — both come from ITSTATE.
A stream is therefore decoded statefully: keep ITSTATE in Context.istate, hand
the same value to disassemble so it can spell moveq rather than mov, and
advance it after every instruction:
from armv7m_decoder import next_itstate
size = instr_size(hw1)
istate = ctx.istate
result = decode(instr, ctx, size)
asm = disassemble(result, size, offset, istate)
ctx.istate = next_itstate(istate, result)The size holds whether or not an encoding matched, which is what keeps the
stream in step where nothing does: a word answered NoMatch is still skipped
whole, rather than leaving its second halfword to be decoded as an instruction
of its own.
The architecture flags some words UNDEFINED, UNPREDICTABLE or SEE <other encoding>. Such a word is still decoded in full: every field is filled
in, and the flags arrive on the instruction's sideeffects member
(SIDEFFECT_UNDEFINED, SIDEFFECT_UNPREDICTABLE, SIDEFFECT_SEE,
SIDEFFECT_NONE). What to make of that is the caller's decision.
disassemble spells such a word anyway and puts a marker in front of it:
<SIDEFFECT: undefined> ldrb.w fp, [sp], #161
<SIDEFFECT: see, unpredictable> it al
One word can carry several flags, and each is named, in the order see,
undefined, unpredictable — strongest claim about the word first. A word
that matched no encoding at all is <no_match>; the CLI turns that into
objdump's @ <UNDEFINED> instruction: 0x… comment, being the place that still
has the word itself.
The disassembler is checked against arm-none-eabi-objdump, line for line, and
the listing above is byte-identical to what objdump prints for the same bytes.
One family differs on purpose. objdump prints coprocessor 1 and 2 accesses
using the mnemonics of the FPA — ARM's floating point accelerator from the
early 90s, which lived on those two coprocessor ports and encoded its
instructions as ordinary LDC/STC/CDP/MRC:
ecf0 0102 ldfe f0, [r0], #8 # objdump
ecf0 0102 ldcl 1, cr0, [r0], #8 # armv7m-decoder
Those are the same 32 bits, so no disassembler can tell them apart — the name
depends on what is wired to the port, which the word does not say. ARMv7-M has
no FPA, and objdump's own assembler rejects ldfe for a Cortex-M target, so
this package prints the LDC form the Armv7-M ARM defines. Every other
coprocessor number agrees character for character.
uv sync # Install dependencies
uv run pytest # Run the test suite
uv run ruff check # Lint
uv run ruff format # Format
uv run python -m armv7m_decoder._generate # Regenerate the decoderformats/armv7-m.yaml is the source of truth for the instruction set.
Regenerating rewrites src/armv7m_decoder/_decoder.py with fresh output from
decoder-forge. That file is committed and self-contained, so the package runs
without decoder-forge installed — only regeneration needs it.
AGENTS.md documents the layering and the decisions behind it in more detail.
GPL-3.0-only. See LICENSE.