Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

71 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

armv7m-decoder

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

Why this exists

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.

Coverage

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.

Install

Not published on PyPI yet, so install it from the repository:

uv add git+https://github.com/chgroeling/armv7m-decoder

Or, for a checkout you want to work on:

git clone https://github.com/chgroeling/armv7m-decoder
cd armv7m-decoder
uv sync

Command line

armv7m-decoder decode firmware.bin --start-address 0x0

Output 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

Library

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.

Walking a stream

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.

Side effects

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.

Compared against objdump

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.

Development

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 decoder

formats/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.

License

GPL-3.0-only. See LICENSE.

About

ARMv7-M (Thumb) instruction decoder and disassembler in Python - full instruction set, objdump-compatible output, generated from a YAML spec.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages