Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

83 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

Nothing to install, if you have uv - run it straight from the repository:

uvx git+https://github.com/chgroeling/armv7m-decoder firmware.bin

Not 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-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 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

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.

Walking a stream

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_bytes

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

One thing this loop does not do

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.

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