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.
Nothing to install, if you have uv - run it straight from the repository:
uvx git+https://github.com/chgroeling/armv7m-decoder firmware.binNot published on PyPI yet, so a project that wants it as a dependency takes it from the repository too:
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 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 |
decode_word decodes an instruction word you already have in hand:
from armv7m_decoder import Context, decode_word, disassemble
ctx = Context()
word = decode_word(0x2401, ctx)
word.instruction
# MOV_immediate(encoding=<Encoding.T1: 1>, sideeffects=0, d=4,
# setflags=True, imm32=1, carry=0)
disassemble(word.instruction, word.size) # 'movs\tr4, #1'Write the word the way an architecture manual spells the encoding - a bare
halfword for a 16-bit instruction, a full word with the first halfword in the
high half for a 32-bit one - and the size follows from the value, since nothing
below 0x10000 is a 32-bit encoding.
Back comes a DecodedWord: the size it was decoded at, the halfwords it holds,
and instruction - a dataclass per instruction, with an encoding member
saying which form matched, or NoMatch if none did. It also carries the offset
the word was read from, which for a word handed over directly is 0 - it came
from no buffer.
For a word still in memory, fetch_and_decode takes bytes and a position, and
answers None once what is left is not a whole instruction. Its size cannot
come from the value - four bytes and two bytes are the same value until you know
which - so it comes from the first halfword, before anything is decoded:
from armv7m_decoder import fetch_and_decode
offset = 0
while (word := fetch_and_decode(data, offset, ctx)) is not None:
asm = disassemble(word.instruction, word.size, offset)
offset += word.n_bytesThat rule 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.
DecodedWord also holds the word itself (word.word) and the halfwords it was
assembled from (word.halfwords), which is what a listing needs to print the
bytes alongside the mnemonic, and word.offset - where in the buffer this one
began, so a listing has its address without the loop passing it back in.
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 running state the loop above does not keep. Inside an IT block it therefore
spells mov where the listing should read moveq, and adds where the
architecture means add.
Carrying ITSTATE across a stream is not part of the public API. The command line does it, so its listings are right; a library caller writing its own loop gets everything except that.
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.