Description

August 22, 2026 · View on GitHub

A pure python ARM emulator, with two CPU cores:

  • ARMv6 (armulator.armv6) — AArch32, A32 and T32, integer only.
  • AArch64 (armulator.armv8) — modelled on the Cortex-A57, with all four exception levels, SIMD and floating point, two-stage address translation, a four-core cluster, a configurable memory model, and AArch32 execution at EL0. It runs GCC output at every optimisation level, in both execution states. See AARCH64.md.

Both sit on the same memory controller and drive the same peripheral models, so a board can be built around either.

Installation

Install the last released version using pip:

python3 -m pip install --user -U armulator

Or install the latest version from sources:

git clone git@github.com:matan1008/armulator.git
cd pyiosbackup
python3 -m pip install --user -U -e .

Usage

ARMv6

To create a processor object, you need to import it first:

from armulator.armv6.arm_v6 import ArmV6

Then you can just create it:

arm = ArmV6()

Getting familiar with the Memory controller concept is crucial for using the processor.
In short, there is one "hub" to which you can connect several controllers.
A "Memory Controller" can be a stick of RAM, Memory mapped LCD screen or whatever you wish.

For example, let's create a RAM controller:

from armulator.armv6.memory_types import RAM
from armulator.armv6.memory_controller_hub import MemoryController

mem = RAM(0x100)
mc = MemoryController(mem, 0xF0000000, 0xF0000100)
arm.mem.memories.append(mc)

Now, trying to access a memory between 0xF0000000 and 0xF0000100, will access the mem object.
You can also change the memory manually:

mem.write(0, 2, "\xfe\xe7")

Another useful feature is playing with the memory protection or management unit, for example cancelling memory protection will look like:

arm.registers.sctlr.m = 0
arm.take_reset()

Please note that after changing internal features it is recommended to reset the processor.

When running the armulator, you will probably want to start from a defined address, so:


arm.registers.branch_to(0x100)

The last thing we need to do is to really run the processor, which can be done with:

arm.emulate_cycle()

AArch64

The AArch64 core takes its memory map at construction and needs no global configuration:

from armulator.armv8.arm_v8 import ArmV8

cpu = ArmV8([{'mem_type': 'RAM', 'beginning': 0x0, 'end': 0x10000}])
cpu.take_reset()
cpu.registers.branch_to(0x1000)
cpu.emulate_cycle()

Registers differ from AArch32 in ways worth knowing before you start. X0X30 are flat, with no banking by mode. Register 31 is context dependent — zero in most instructions, SP in a few — so you say which you mean:

cpu.registers.get_x(31)          # always 0, this is XZR
cpu.registers.get_reg_or_sp(31)  # the stack pointer

A 32-bit write zeroes the upper half of its destination, and the same rule applies to the vector registers: writing D0 clears bits 127:64 of V0.

Reset leaves the core at EL1 with the MMU off and SIMD trapped. Firmware enables what it needs, as it would on hardware — see AARCH64.md for translation, exception levels, multi-core and the memory model.

Board and peripheral emulation

For testing GPIO and peripheral driver logic, armulator.boards provides prebuilt machines with peripherals mapped at the real SoC addresses:

from armulator.boards import RaspberryPi4
from armulator.boards.firmware import firmware

board = RaspberryPi4(trace=True)
board.load(board.CODE_BASE, firmware("""
    ldr r0, =0xFE200000
    mov r1, #1
    lsl r1, r1, #21        @ FSEL17 = output
    str r1, [r0, #0x04]    @ GPFSEL1
    mov r2, #1
    lsl r2, r2, #17
    str r2, [r0, #0x1C]    @ GPSET0
""", address=board.CODE_BASE))
board.start()
board.run()

assert board.gpio.level(17) is True
print(board.format_trace())     # gpio.GPFSEL1 <- 0x00200000 ...

Available boards: RaspberryPi3 (BCM2837, peripherals at 0x3F000000), RaspberryPi4 (BCM2711, 0xFE000000) and JetsonNano (Tegra X1, GPIO at 0x6000D000). Each has an A64 variant built around the AArch64 core — RaspberryPi3A64, RaspberryPi4A64, JetsonNanoA64 — and JetsonNanoA64Smp for the full four-core Cortex-A57 cluster.

Consoles differ by board: the Pis use a PL011 (Pl011Uart), the Jetson a 16550 with Tegra's 4-byte register spacing (TegraUart). They are unrelated designs, not variants, and board.uart gives whichever is right — see JETSON.md.

Peripherals expose a pin-level API so tests can act as the outside world:

board.gpio.drive_input(7, True)   # external device pulls pin 7 high
board.gpio.level(7)               # effective level
board.gpio.function(7)            # GpioFunction.INPUT / OUTPUT / ALT0 ...
board.gpio.pull(7)                # Pull.UP / DOWN / OFF
board.gpio.transitions(17)        # recorded output waveform
board.pending_irq()               # devices asserting their IRQ line

Writing peripherals of your own means subclassing MMIODevice and implementing two methods:

from armulator.peripherals import MMIODevice

class MyDevice(MMIODevice):
    REGISTERS = {0x00: 'CTRL', 0x04: 'STATUS'}

    def read_register(self, offset): ...
    def write_register(self, offset, value): ...

board.attach('mydev', MyDevice(0x1000), offset=0x100000)

Interrupt routing (GIC-400)

The Pi 4 and Jetson Nano boards include a GIC-400 interrupt controller, with device lines wired to their SoC SPI numbers. Interrupts only reach the CPU once firmware enables the distributor and CPU interface, as on real silicon:

from armulator.peripherals.gic400 import (
    GICC_CTLR, GICC_PMR, GICD_CTLR, GICD_ISENABLER, GICC_IAR, GICC_EOIR,
)

gic = board.gic
gic.write_register(GICD_CTLR, 1)          # enable distributor
gic.write_register(GICC_CTLR, 1)          # enable CPU interface
gic.write_register(GICC_PMR, 0xFF)        # unmask all priorities
gic.write_register(GICD_ISENABLER + 4, 1 << 17)

intid = gic.read_register(GICC_IAR)       # acknowledge
...                                        # service the device
gic.write_register(GICC_EOIR, intid)      # end of interrupt

Priority, edge/level configuration and the acknowledge/EOI handshake are all modelled, including the case that catches real drivers out: a level-triggered source that is still asserting at EOI immediately re-presents.

On the AArch64 core each core also has the architected generic timer, which is how bare-metal code gets a periodic tick without touching an SoC peripheral. It arrives as PPI 30 and its counter advances as instructions retire, so delay loops terminate deterministically:

timer = board.cpu.registers.generic_timer   # CNTPCT / CNTP_CTL / CNTP_TVAL

Its rate comes from the board, not from the architecture: TIMER_FREQUENCY is 19.2 MHz on the Jetson and the Pi 3 and 54 MHz on the Pi 4. On a cluster each core has its own timer and its own copy of PPI 30, because interrupt IDs below 32 are banked per core the way the architecture specifies.

Accesses that hit nothing

On the AArch64 boards, an access to an address no device claims raises a synchronous external abort rather than reading back zero, which is what a real bus does:

board.cpu.translate_address(0xDEAD0000, is_write=True, size=4)
# DataAbortException: external abort: no device at 0xDEAD0000   (status 0b010000)

Reading zero is convenient and hides firmware that walks off its own map -- the bug then only appears on hardware. This applies with the MMU off, where the virtual address is the physical one; with translation on, an address outside the page tables already faults as a translation fault, which is the more specific report. Set FAULT_ON_UNMAPPED = False on a board, or cpu.mem.fault_on_unmapped = False, to get the permissive behaviour back. The ARMv6 boards leave it off.

The Pi 3 has no GIC (the BCM2837 uses the legacy controller), so it falls back to polling device lines directly.

Motor control

armulator.peripherals models a motor HAT end to end — a PCA9685 PWM controller on I2C, driving H-bridges, driving DC motors or steppers:

from armulator.boards import RaspberryPi4
from armulator.peripherals import MotorHat

board = RaspberryPi4()
hat = MotorHat().attach_to(board)      # PCA9685 on the Pi's I2C bus at 0x60
motor = hat.attach_dc_motor(1)

# ... firmware runs and programs the controller ...

hat.advance(1.0)                       # one second of shaft time
assert motor.position > 0

Because the layers are separate, a test asserts on where the shaft ended up rather than on the register writes that were meant to move it. The full walkthrough, including the three PCA9685 behaviours that silently defeat a plausible-looking driver, is in MOTOR.md.

Serial buses

Bcm2835Spi and Bcm2835I2c are available on all boards as board.spi and board.i2c. Slaves are plain Python objects:

from armulator.peripherals.serial_bus import I2cSlaveDevice, SpiSlaveDevice

sensor = board.i2c.attach_slave(I2cSlaveDevice(address=0x48, registers={0: 0xDE}))
board.spi.attach_slave(SpiSlaveDevice(responses=b'\x99'), chip_select=0)

I2C models the NACK path — addressing a slave that isn't on the bus sets ERR in the status register.

SPI slave mode

Bcm2835SpiSlave models the Pi's SPI/BSC slave block (board.spi_slave), and it is deliberately faithful to a peripheral that behaves nothing like a conventional SPI slave:

  • transfers are half duplex octet "dialogues", not simultaneous shift-in/shift-out
  • each dialogue opens with an address/direction octet — upper 7 bits are the slave address, LSB 0 selects write and LSB 1 selects read
  • during a write dialogue MISO idles high and the TX FIFO is untouched
from armulator.peripherals.spi_slave import CR_EN, CR_RXE, CR_SPI, CR_TXE, address_octet

slave.spi_slave.write_register(0x08, 0x2A)                        # SLV
slave.spi_slave.write_register(0x0C, CR_EN | CR_SPI | CR_RXE | CR_TXE)

master.spi.write_register(0x00, 0x80)                             # CS: TA=1
master.spi.write_register(0x04, address_octet(0x2A, read=False))  # header
master.spi.write_register(0x04, 0x42)                             # payload
master.spi.write_register(0x00, 0x00)                             # CS: TA=0

assert slave.spi_slave.received == b'\x42'

Several hardware errata are modelled, since they are what catch driver authors out: CR.BRK does not actually clear the FIFOs (set brk_clears_fifos=True to get the behaviour the datasheet describes), TDR only peeks at the TX FIFO rather than draining it, and RX overrun is silent apart from RSR.OE. The datasheet's interrupt bit assignments for this block could not be confirmed against hardware — see the module docstring before relying on them.

example/spi_slave_errata.py walks through five mistakes that look correct in review and fail on silicon.

Emulating several boards together

armulator.boards.interconnect wires boards to each other, so a Pi and a Jetson can exchange signals the way they would on a bench:

from armulator.boards import JetsonNano, RaspberryPi4
from armulator.boards.interconnect import GpioLink, Machine, SpiBridge

machine = Machine()
pi = machine.add('pi', RaspberryPi4())
nano = machine.add('nano', JetsonNano())

machine.link(GpioLink(pi, 17, nano, 'PA0', name='DATA'))    # Pi drives
machine.link(GpioLink(nano, 'PA1', pi, 27, name='READY'))   # Jetson answers
SpiBridge(pi, pi3, chip_select=0)    # slave end needs a spi_slave block

machine.run_until(lambda: pi.cpu.registers.get(4) == 1)

Boards are stepped round-robin in instruction slices with all links settled between slices — deterministic and repeatable, but not cycle-accurate, since these are independent cores with independent clocks. Only pins configured as outputs drive a wire; an input pin releases it, and the receiver falls back to its own pull resistor.

See example/two_device_link.py for a full walkthrough including a two-way handshake that neither board can complete alone.

Assembling test firmware from source requires keystone-engine (pip install keystone-engine); pre-assembled bytes work without it.

See example/gpio_driver_test.py for a worked walkthrough.

Choosing a core

Every board takes an arch= argument, and the *A64 classes are the same boards built around the AArch64 core:

BoardCoreNotes
RaspberryPi3, RaspberryPi4, JetsonNanoARMv6A32/T32 firmware
RaspberryPi3A64, RaspberryPi4A64, JetsonNanoA64AArch64single core
JetsonNanoA64SmpAArch64the full quad-core A57 cluster

The ARMv6 boards remain the default. They are the older and better-travelled path, and the peripheral register interfaces — where GPIO driver logic actually lives — are identical either way, so A32 test firmware exercises the same sequences a production AArch64 driver performs.

Reach for the AArch64 boards when the code under test is AArch64: compiler output, code that uses the MMU or several cores, or anything where memory ordering matters.

from armulator.boards import JetsonNanoA64
from armulator.boards.firmware import firmware_a64

board = JetsonNanoA64()
board.load(board.CODE_BASE, firmware_a64('''
        movz x0, #0x6000, lsl #16
        movk x0, #0xD000
        movz w1, #1
        str  w1, [x0, #0x00]        // CNF port A -> GPIO
        str  w1, [x0, #0x10]        // OE  -> output
        str  w1, [x0, #0x20]        // OUT -> high
''', address=board.CODE_BASE))
board.start()
board.run(200)
assert board.gpio.level('PA0') is True

Neither core boots stock vendor kernels — the peripheral coverage is nowhere near a whole SoC. For booting real OS images, use QEMU's raspi3b / raspi4b machines instead.

Validating models against hardware

armulator.harness replays a captured driver trace against a peripheral model and asserts every read returns what the silicon returned. It is the strongest correctness check available without owning the board:

from armulator.boards import RaspberryPi4
from armulator.harness import load, replay_on_board

trace = load('capture.txt')          # ftrace rwmmio or canonical format
report = replay_on_board(RaspberryPi4(), 'gpio', trace,
                         captured_base=0xFFFF800008A00000)
print(report.format())
assert report.ok

Reports carry three things beyond pass/fail: provenance (a trace recorded from the model and replayed against it is circular — the report says so), coverage (a PASS on 3 of 33 registers is a weak claim, so untouched registers are listed), and volatile reads (counters that cannot match a capture are executed but not compared, and counted).

Capture recipe and the full workflow are in RASPI.md. example/replay_driver_trace.py walks through it.

traces/ holds baselines recorded from the models — regression guards only, not hardware validation. Regenerate with python3 tools/record_baselines.py.

Further reading

  • RASPI.md — Raspberry Pi register maps, the Pi 3 → Pi 4 pull up/down trap, GIC-400 acknowledge/EOI semantics, the SPI slave dialogue protocol and its errata, and the hardware capture recipe.
  • JETSON.md — Tegra GPIO structure and masked registers, the Tegra SPI controller's triggered-transfer model and its two off-by-one register traps, plus an explicit list of what is missing.
  • MOTOR.md — driving motors: a PCA9685 on I2C feeding H-bridges feeding DC motors and steppers, so a test can assert on shaft position rather than on register writes.
  • AARCH64.md — the AArch64 core: instruction coverage, exception levels and routing, two-stage translation, the multi-core cluster and PSCI bring-up, and the memory model that makes a missing barrier observable.

Running the tests

Running the tests can be done easily with pytest:

python3 -m pytest tests -vv

Acknowledgments

  • At first, I did it to learn the ARM architecture better. I guess I was carried away.
  • Feel free to report bugs.
  • Feel free to ask for more features.