← Back to list

Designing a Memory-Mapped Virtual Device in QEMU for Early Software testing

In my recent work, I explored how to emulate a custom hardware device within QEMU to enable early stage software testing. The software…

Kavindu Shehan · 2026-04-22 19:43 · 1 claps · 11.5 min read
#qemu #embedded-systems #qemu-device #petalinux #zynq-ultrascale
Open on Medium ↗
Wiki topics: 🔓 · Open Source

Designing a Memory-Mapped Virtual Device in QEMU for Early Software testing

In my recent work, I explored how to emulate a custom hardware device within QEMU to enable early stage software testing. The software stack expected a memory-mapped interface with shared memory buffers for data exchange. By implementing a virtual Qemu-device that mimicked this behavior, I was able to run the software in a fully virtualized environment without requiring any physical hardware.

What is QEMU?

QEMU ( Quick Emulator) is an open-source system emulator that can replicate complete hardware platforms, including processors and peripheral devices. It provides mechanisms to define custom devices that expose memory-mapped interfaces, allowing software running inside the virtual machine to interact with them just like real hardware.

Why isn’t standard QEMU sufficient?

While QEMU can emulate most of the standard systems with different architectures ( x86, arm, aarch64 ….), it does not always model the specific custom hardware that a given software stack depends on. Suppose you are working on a software component of an embedded system that needs to read from and write to a set of memory-mapped registers exposed by a custom hardware block. The software may expect specific register offsets, control flags and status updates.

However, in a typical QEMU model for your embedded system architecture, those custom registers may not exist. As a result, register accesses may fail, return invalid data, or the software may not function at all.

This is where QEMU device modeling comes into play. Instead of waiting for the real hardware to be available, you can create a custom QEMU device that exposes the required registers and mimics the expected hardware behavior. This allows the software to interact with a virtual representation of the hardware, enabling development and testing in a fully simulated environment.

Practical Context

In my recent work, I was involved in developing a software application running on the application processing unit (A53 cores) of a Zynq UltraScale+ MPSoC platform. The application performed data processing on the processing system (PS) and subsequently wrote the processed data into a shared memory region in DDR. This shared memory acted as an interface for a custom hardware block implemented in the programmable logic (PL), which consumed the data for further processing.

The hardware block (PL) exposed a set of memory-mapped 32-bit configuration and status registers that needed to be accessed by the software running on the A53 cores. These registers were used to control the operation of the hardware (some parameters required for it) as well as to monitor its state during runtime.

To emulate the required hardware interface, I implemented a custom QEMU device that replicated both the memory-mapped 32-bit register set and the interaction with a shared DDR memory region. This allowed the software to access configuration and status registers, as well as exchange data through memory, in a manner consistent with the expected hardware behavior.

Since the target platform was based on the Zynq UltraScale+ architecture, I utilized the Xilinx-maintained QEMU distribution rather than the upstream (standard) QEMU. The Xilinx QEMU includes platform-specific support and device models tailored for ZynqMP systems, making it more suitable for this use case.

Building QEMU from Source

To integrate a custom device into QEMU, it is necessary to build QEMU from source, as this allows modification and extension of the device model framework. The Xilinx QEMU source code and detailed build instructions are publicly available, providing guidance on setting up the environment and compiling the emulator for the required target architectures.

Xilinx QEMU build from source —

(** https://xilinx-wiki.atlassian.net/wiki/spaces/A/pages/822312999/Building+and+Running+QEMU+from+Source+Code)**

Implementation

To explain the device-modeling approach, lets consider a simple example in which the custom hardware block (PL) behaves like a small arithmetic logic unit (ALU). In this example, the software running on the processor interacts with the hardware through a set of memory-mapped registers: it writes the operation type and input operands into dedicated registers, and then triggers execution through a control register. The hardware, in turn, places the computed result into a result register and updates a status register to indicate completion or error conditions.

To introduce a custom device into QEMU, the first step is to add a new source file for the device within the QEMU source tree. After cloning the xilinx Qemu source code, the main source directory contains a qemu folder. Since this device is implemented as a miscellaneous hardware component, the source file is added under,

qemu/hw/misc/simple-alu-device.c

This file contains the logic required to define and register the custom QEMU device.

There are some standard steps we need to add in our simple-alu-device.c.

  1. Adding required libraries.
#include "qemu/osdep.h"
#include "hw/sysbus.h"         // For system bus integration.
#include "hw/register.h"       // For memory mapped register implementation.
#include "qemu/log.h"          // For logging.
#include "qapi/error.h"        // For error management.
#include "qom/object.h"        // For Qemu object definition.
  1. Defining a QOM object.

Next, a QOM type is defined for the custom device.

#define TYPE_SIMPLE_ALU_DEVICE "amd,simple-alu-pl"

#define SIMPLE_ALU_DEVICE(obj) OBJECT_CHECK(SimpleAluState, (obj), TYPE_SIMPLE_ALU_DEVICE)

The QOM type string was chosen to align with the compatible string used in the device tree. This allows the virtual device model to correspond to the expected hardware node from the guest software’s perspective. The relationship between the device tree description and QEMU device instantiation is discussed in a later section.

  1. Adding Memory-mapped registers.

After defining the device type, the register map of the emulated hardware interface can be introduced. Following steps shows the implementation of 32-bit memory-mapped registers in the custom QEMU device. Add following snippet to the simple-alu-device.c file.

REG32(REG_CONTROL   ,  0x0000)
REG32(REG_OPERATION ,  0x0004)
REG32(REG_OPERAND_A ,  0x0008)
REG32(REG_OPERAND_B ,  0x000C)
REG32(REG_RESULT    ,  0x0010)
REG32(REG_STATUS    ,  0x0014)

Here 0x0000, 0x0004, 0x0008 and etc… are the address offsets of each register from the base address of this QEMU device. Later I will map this QEMU device at base address 0xA0000000, so these registers correspond to absolute addresses 0xA0000000, 0xA0000004, 0xA0000008, 0xA000000C, 0xA0000010and 0xA0000014 respectively.

Note:- Each register is 32 bit / 4 byte, so in a byte addressable memory ( Zynq UltraScale+) memory addresses of consecutive 32 bit register increments by 4.

REG32() macro is convenient because it defines both the address offset of each register and its index withing the internal register array of custom device. ( REG_CONTROL, REG_OPERATION, …. these are just some example names, can use any name for registers.) Also it generates two related definitions for each register.

  • A_REG_CONTROL → the byte offset of the register from the device base address. (0x0000 in this case)
  • R_REG_CONTROL → the index of the register within the internal register array (0 in this case)

The prefix A_ represents the register’s address offset and R_ represents its index in the internal register set of custom qemu device.

A_REG_CONTROL   = 0x0000,   R_REG_CONTROL   = 0
A_REG_OPERATION = 0x0004,   R_REG_OPERATION = 1
A_REG_OPERAND_A = 0x0008,   R_REG_OPERAND_A = 2
A_REG_OPERAND_B = 0x000C,   R_REG_OPERAND_B = 3
A_REG_RESULT    = 0x0010,   R_REG_RESULT    = 4
A_REG_STATUS    = 0x0014,   R_REG_STATUS    = 5
  1. Defining the device internal state struct.

The custom device maintains an internal state structure that represents the state of emulated hardware. Since this device only expose six 32-bit memory-mapped registers, the device state only needed to include the QEMU system bus parent object, the MMIO region definition and an internal register array to hold the emulated register values.

typedef struct SimpleAluState {
    SysBusDevice parent_obj;
    MemoryRegion iomem;

    /* Storage for emulated 32-bit registers */
    uint32_t regs[R_MAX];

    /* Register metadata used by QEMU */
    RegisterInfo regs_info[R_MAX];
} SimpleAluState;
  • SysBusDevice parent_obj — Lets the device integrate into QEMU’s system bus framework.
  • MemoryRegion iomem — Presents the memory-mapped I/O region exposed to the guest software.
  • uint32_t regs[R_MAX] — Stores the values of the emulated 32-bit registers.
  • RegisterInfo regs_info[R_MAX] — Holds metadata describing the registers when using QEMU’s register framework.

Here R_MAX is the size of the array required to hold the registers. If register address offsets are continuous, R_MAX is just the number of registers. (6 in this example). If register address offsets are not continuous, R_MAX should be calculated as follows.

R_MAX = R_<name of the last register> + 1

So for this example,

#define R_MAX (R_REG_STATUS + 1)
  1. Add RegisterAccessInfo with corresponding pre-write, post-write functions.

The register interface is modeled using QEMU’s register description framework. REG_CONTROLregister has pre-write and post-write callback functions.

/* Register descriptions */
static RegisterAccessInfo simple_alu_regs_info[] = {
    { .name = "REG_CONTROL",   .addr = A_REG_CONTROL,
      .pre_write = simple_alu_reg_control_pre_write,
      .post_write = simple_alu_reg_control_post_write },

    { .name = "REG_OPERATION", .addr = A_REG_OPERATION },
    { .name = "REG_OPERAND_A", .addr = A_REG_OPERAND_A },
    { .name = "REG_OPERAND_B", .addr = A_REG_OPERAND_B },
    { .name = "REG_RESULT",    .addr = A_REG_RESULT    },
    { .name = "REG_STATUS",    .addr = A_REG_STATUS    },
};
  • Pre-write callback A pre-write callback is executed before the new value is committed to the register. It is typically used to validate, filter, or modify the incoming value before it becomes part of the device state. This is useful when certain bits are reserved, when only a valid range of values should be accepted, or when a control operation such as a software reset needs to be interpreted before storing the value.
  • Post-write callback A post-write callback is executed after the new value has already been written to the register. It is generally used to trigger side effects based on the updated register value, such as updating status flags, recalculating internal state, or initiating a device action. In other words, the register write has already taken place, and the callback is used to react to it.

The pre-write callback of REG_CONTROL register does following things.

  • Accepts only START and RESET bits
  • Handles reset immediately
  • Rejects invalid execution requests such as division by zero
enum {
    CTRL_START = 1u << 0,
    CTRL_RESET = 1u << 1,
};
enum {
    STATUS_READY    = 1u << 0,
    STATUS_DONE     = 1u << 1,
    STATUS_ERROR    = 1u << 2,
    STATUS_DIV_ZERO = 1u << 3,
};
typedef enum {
    ALU_ADD = 0,
    ALU_SUB = 1,
    ALU_MUL = 2,
    ALU_DIV = 3,
} AluOperation;

static uint64_t simple_alu_reg_control_pre_write(RegisterInfo *reg, uint64_t val)
{
    SimpleAluState *s = SIMPLE_ALU_DEVICE(reg->opaque);
    uint32_t op = s->regs[R_REG_OPERATION];
    uint32_t b  = s->regs[R_REG_OPERAND_B];

    /* Only START and RESET bits are valid */
    /* bit0 -> start bit and bit0 -> reset bit */
    val &= (CTRL_START | CTRL_RESET);

    /* RESET clears the internal device-visible state */
    if (val & CTRL_RESET) {
        s->regs[R_REG_RESULT] = 0;
        s->regs[R_REG_STATUS] = STATUS_READY;
        return 0;
    }

    /* START request validation */
    if (val & CTRL_START) {
        /* Invalid opcode */
        if (op > ALU_DIV) {
            s->regs[R_REG_STATUS] = STATUS_ERROR;
            val &= ~CTRL_START;
        }

        /* Avoid division by zero */
        if (op == ALU_DIV && b == 0) {
            s->regs[R_REG_STATUS] = STATUS_ERROR | STATUS_DIV_ZERO;
            val &= ~CTRL_START;
        }
    }

    return val;
}

The post-write callback of REG_CONTROL register performs the arithmetic and update the REG_RESULT and REG_STATUS registers with the output.

static void simple_alu_reg_control_post_write(RegisterInfo *reg, uint64_t val)
{
    SimpleAluState *s = SIMPLE_ALU_DEVICE(reg->opaque);
    uint32_t op = s->regs[R_REG_OPERATION];
    uint32_t a  = s->regs[R_REG_OPERAND_A];
    uint32_t b  = s->regs[R_REG_OPERAND_B];
    uint32_t result = 0;

    if (!(val & CTRL_START)) {
        return;
    }

    /* Clear previous completion/error state before execution */
    s->regs[R_REG_STATUS] &= ~(STATUS_DONE | STATUS_ERROR | STATUS_DIV_ZERO);

    switch (op) {
    case ALU_ADD:
        result = a + b;
        break;
    case ALU_SUB:
        result = a - b;
        break;
    case ALU_MUL:
        result = a * b;
        break;
    case ALU_DIV:
        result = a / b;
        break;
    default:
        s->regs[R_REG_STATUS] = STATUS_ERROR;
        return;
    }

    s->regs[R_REG_RESULT] = result;
    s->regs[R_REG_STATUS] = STATUS_READY | STATUS_DONE;

    /* START behaves like a pulse, not a latched bit */
    s->regs[R_REG_CONTROL] &= ~CTRL_START;
}
  1. Add standard MMIO handler that delegate to the register API.
/* Standard MMIO handlers that delegate to the QEMU register API */
static const MemoryRegionOps simple_alu_ops = {
    .read  = register_read_memory,
    .write = register_write_memory,
    .endianness = DEVICE_LITTLE_ENDIAN,
    .valid = {
        .min_access_size = 4,
        .max_access_size = 4,
    },
};

This defines the memory-mapped I/O behavior of the device. Since all ALU registers are 32-bit, the valid access size is restricted to 4 bytes.

  1. Add Reset logic.
static void simple_alu_reset(DeviceState *dev)
{
    SimpleAluState *s = SIMPLE_ALU_DEVICE(dev);
    unsigned int i;

    for (i = 0; i < ARRAY_SIZE(s->regs_info); ++i) {
        register_reset(&s->regs_info[i]);
    }

    /* Set initial device state */
    s->regs[R_REG_RESULT] = 0;
    s->regs[R_REG_STATUS] = STATUS_READY;
}

This reset handler restores the device to a known initial state. In this example,

  • All registers are reset through QEMU’s register framework
  • REG_RESULT is cleared
  • REG_STATUS is initialized as READY

That makes the virtual ALU behave like a real hardware peripheral after power-up or reset.

  1. Add Initialization logic.

The initialization logic sets up the device’s MMIO region, binds the register definitions to it, and exposes the region to the guest through the system bus so that the guest software can access it through memory-mapped register operations.

static void simple_alu_init(Object *obj)
{
    SimpleAluState *s = SIMPLE_ALU_DEVICE(obj);
    SysBusDevice *sbd = SYS_BUS_DEVICE(obj);
    RegisterInfoArray *reg_array;
    const uint64_t region_size = 0x1000;
    const char *name = object_get_typename(obj);

    memory_region_init(&s->iomem, obj, name, region_size);

    reg_array = register_init_block32(DEVICE(obj),
                                      simple_alu_regs_info,
                                      ARRAY_SIZE(simple_alu_regs_info),
                                      s->regs_info,
                                      s->regs,
                                      &simple_alu_ops,
                                      0,
                                      region_size);

    /* Attach register block at offset 0 */
    memory_region_add_subregion(&s->iomem, 0x0, &reg_array->mem);

    /* Expose MMIO region through sysbus */
    sysbus_init_mmio(sbd, &s->iomem);
}
  1. Add Device class initialization.

The device class initialization step connects common device-level behavior, such as the reset handler, to the custom QEMU device. This allows QEMU to correctly invoke those operations during the device lifecycle.

static void simple_alu_class_init(ObjectClass *klass, void *data)
{
    DeviceClass *dc = DEVICE_CLASS(klass);
    dc->reset = simple_alu_reset;
}
  1. Add Type information.

The type information structure defines the identity and structure of the custom device, including its name, parent type, instance size, and initialization callbacks.

static const TypeInfo simple_alu_info = {
    .name          = TYPE_SIMPLE_ALU_DEVICE,
    .parent        = TYPE_SYS_BUS_DEVICE,
    .instance_size = sizeof(SimpleAluState),
    .instance_init = simple_alu_init,
    .class_init    = simple_alu_class_init,
};
  1. Register the custom type.

Finally, the custom type is registered with QEMU’s type system so that it becomes available during emulator initialization.

static void simple_alu_register_types(void)
{
    type_register_static(&simple_alu_info);
}

type_init(simple_alu_register_types)

Integrating the custom device into the QEMU build

After implementing the custom device, it must be integrated into QEMU’s build system so that it is compiled as part of the emulator. In the Xilinx QEMU source tree, this is done by adding the new device source file to the appropriate platform-specific section of hw/misc/meson.build. Once this step is completed, QEMU can be rebuilt with support for the newly added custom peripheral.

In hw/misc/meson.build file, add ‘simple-alu-device.c’ under CONFIG_XLNX_ZYNQMP section.

system_ss.add(when: 'CONFIG_XLNX_ZYNQMP', if_true: files(
  ...
  'simple-alu-device.c',
))

Now everything is done, rebuild the QEMU again.

Introducing the Custom device into Hardware DT of QEMU

After rebuilding the QEMU with the custom device, there is still one more important step: the device must be described in the hardware device tree used by QEMU.

QEMU does not automatically know about newly added peripherals just because their implementation exists in the source tree. In order to instantiate those peripherals as part of the virtual platform, QEMU relies on a hardware device tree ( hw-dtb) , which describes the hardware components present in the emulated system, including their address mappings, compatible strings and bus hierarchy.

In Zynq UltraScale_ MPSoC based workflows, a hardware DTB is often already available and is commonly passed to QEMU through the hw-dtb option. Since the default DTB ( ‘zynqmp-qemu-multiarch-arm.dtb’ in images/linux folder of petalinux projects.) only contains the standard peripherals already known for the target platform, device-tree sources must be modified and rebuilt.

Building QEMU device tree blobs from source —

( **https://xilinx-wiki.atlassian.net/wiki/spaces/A/pages/822312999/Building+and+Running+QEMU+from+Source+Code **)

Add following peripheral description under amba section in qemu-devicetrees/zynqmp-iou.dtsi file.

pl_simple_alu: simple_alu_device@a0000000 {
        compatible = "amd,simple-alu-pl";
        reg = <0x0 0xa0000000 0x0 0x1000>;
};

Here ‘compatible’ must exactly match with the string we used in the simple-alu-device.c ( #define TYPE_SIMPLE_ALU_DEVICE "amd,simple-alu-pl" ). This tells that a peripheral compatible with that string exists at the given MMIO address range. ( reg = <0x0 0xa0000000 0x0 0x1000 , here 0xa0000000 is the base address of the peripheral and 0x1000 is the allocated address range for that peripheral).

After adding this section, rebuild the device-trees and the generated hardware dtb file will be in qemu-devicetrees/LATEST/MULTI_ARCH folder. ( zcu102-arm.dtb can be used for zynqmp ultrascale+).

Verifying the functionality of custom qemu device.

I created a PetaLinux project and booted it on the custom-build QEMU. Inside the guest, I used a small shell script to write input values to the simple-alu device’s memory-mapped registers, trigger the operation and read back the result and status registers.

Test application execution inside petalinux (guest).

Test application execution inside petalinux (guest).

Full source code of simple-alu-device.c, test-alu.sh and the script for running petalinux on custom built QEMU can be found here —

[embed]GitHub - ShehanHMK/Custom-qemu-device Contribute to ShehanHMK/Custom-qemu-device development by creating an account on GitHub.github.com

Summary

Although the example discussed here focuses on a simple register-based ALU, custom QEMU device can be extended to implement interrupt signaling, direct access to guest DDR memory, timer-driven behavior, state-machine based control flow, FIFO or ring-buffer mechanisms, DMA-like transfers, and interactions with other emulated peripherals. As a result, QEMU provides a powerful framework not only for basic peripheral emulation, but also for modeling complex hardware-software interaction patterns in a fully virtualized environment.

REFERENCES


메타데이터
post_id
c2d8cd065ca7
slug
designing-a-memory-mapped-virtual-device-in-qemu-for-early-software-testing-c2d8cd065ca7
url
https://medium.com/@kavindushehan54/designing-a-memory-mapped-virtual-device-in-qemu-for-early-software-testing-c2d8cd065ca7
canonical_url
https://medium.com/@kavindushehan54/designing-a-memory-mapped-virtual-device-in-qemu-for-early-software-testing-c2d8cd065ca7
author_url
https://medium.com/@kavindushehan54
status
ok
fetched_at
2026-06-21 07:44:09