Writing a custom device for QEMU

In this article, i want to stay straightforward and just give an example on how to use QEMU to add a simple custom device.

To do so, i choose to emulate a ROM memory mapped on the QEMU RISC-V virtual board.

Introduction

Imagine you are working with a prototype device, and you need to start the development of your Linux device driver, RTOS driver, baremetal application or whatever which require to access this device.

But the board is still not ready yet, or you have one, two boards, and you have a bunch of developers waiting for using this device to validate their features.

You can create a mock that will return pre-defined values to simulate some parts of your device behavior, and i personally like CMocka for that.

But if want to validate the full device behavior, with its IRQs, registers access and a full simulation of the real device, then QEMU is a fantastic tool for that as you can totally emulate a full board.

Getting start with QEMU

Building QEMU for your Board Architecture

The first step, will be to get the QEMU source and build it for your Board Architecture.

I choose the RISC-V 64 ISA as i’m discovering RISC-V right now, but it doesn’t matter in the end as the device we want to emulate is simple enough to work on any architectures.

At the date of this post, QEMU v6.0.0 is the most recent revision.

$ git clone https://github.com/qemu/qemu.git
$ git checkout v6.0.0
$ cd qemu
$ ./configure --target-list=riscv64-softmmu
$ make

After the make completion, you should have your built QEMU in the build/ folder.

Testing your host OS in QEMU – The Buildroot and Linux case

Here it obviously depends on your OS, in my case i’m using a Linux kernel with a minimal rootfs built using Buildroot.

To reproduce my setup you can either use directly the qemu_riscv64_virt_defconfig from Buildroot:

$ git clone https://github.com/buildroot/buildroot.git
$ cd buildroot/
$ make qemu_riscv64_virt_defconfig
$ make

Or use my out-of-tree Buildroot Kernel Labs, which just add debug symbols and facilities like GDB scripts, and built an Hello World Linux module.

This step is absolutely not necessary here, as hopefully this example will work without having to use GDB (promise :D).

But if you want to use my Kernel Labs for testing purpose:

$ git clone https://github.com/buildroot/buildroot
$ git clone https://github.com/sbourdelin/br2-external-kernel-dev-lab
$ cd buildroot/
$ make BR2_EXTERNAL=../br2-external-kernel-dev-lab
$ make qemu_riscv64_virt_debug_defconfig
$ make

In any case, after the make completion, you should have your kernel and rootfs ready in the output/images/ sub-folder:

Note that you have a start-qemu.sh script ready to be used (yes Buildroot is great!).

However, we need to modify this script to use our own built QEMU.

We can keep all the arguments and just point to our built qemu-system-riscv64 binary:

$ exec <path_to_your_qemu>/build/qemu-system-riscv64 -M virt -bios fw_jump.elf -kernel Image -append "rootwait root=/dev/vda ro" -drive file=rootfs.ext2,format=raw,id=hd0 -device virtio-blk-device,drive=hd0 -netdev user,id=net0 -device virtio-net-device,netdev=net0 -nographic

Which should boot the Kernel with our minimal Rootfs and give you the Buildroot prompt:

(the default login is root).

Writing a custom device in QEMU

The Banana ROM

The new device we want to add is a simple ROM, which i called the banana_rom (it’s actually a bad name as it exists the banana_board and there is absolutely no link with my virtual rom).

Below an overview of our block diagram:

The QEMU Object Model (QOM)

In QEMU, all devices are represented as an object using the QEMU Object Model.

« The QEMU Object Model provides a framework for registering user creatable types and instantiating objects from those types. »

As described in the API

This Model follow the Object Oriented Programming approach where a device is an object inheriting from a parent object.

In our case, and from the QEMU Object Model point of view, our banana_rom is a child object from the system bus.

Registering a new Device

The first step is to filled the TypeInfo structure, which will be used to register the device (in our case as a static type, there is multiple types available in the QEMU API), and initialize it:

/* create a new type to define the info related to our device */
static const TypeInfo banana_rom_info = {
	.name = TYPE_BANANA_ROM,
	.parent = TYPE_SYS_BUS_DEVICE,
	.instance_size = sizeof(BananaRomState),
	.instance_init = banana_rom_instance_init,
};

static void banana_rom_register_types(void)
{
    type_register_static(&banana_rom_info);
}

type_init(banana_rom_register_types)

We inherit from the system bus, and fill the instance_size and instance_init callback with respectively the state of our device, and a function to instance it.

Device State

The device state structure is used to keep track of the status of the device in our board.

I tend to see it as an equivalent to the kobject in Linux with kset file operations associated, but it’s my interpretation which might not be correct at all.

struct BananaRomState {
	SysBusDevice parent_obj;
	MemoryRegion iomem;
	uint64_t chip_id;
};

Here i’m defining a MemoryRegion associated to the device state, which will be our memory mapped space and a chip_id.

For a more complex device we could imagine having specific registers and irqs defined here.

Instance Initialization

#define CHIP_ID	0xBA000001

static void banana_rom_instance_init(Object *obj)
{
	BananaRomState *s = BANANA_ROM(obj);

	/* allocate memory map region */
	memory_region_init_io(&s->iomem, obj, &banana_rom_ops, s, TYPE_BANANA_ROM, 0x100);
	sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem);

	s->chip_id = CHIP_ID;
}

There is a bit of magical macro but the idea here is to initialize our device state by allocating its memory, the file operations associated to it and its size using the memory_region_init_io function.

The size of the memory we are allocating 0x100 is not random, i’ll explain it later when we will add our device to our board.

Then we let the system bus know that this memory region is handle by our device by using the sysbus_init_mmio function.

Finally we add others required initialization like the CHIP_ID in my case.

Device file operations

As our ROM is Read-Only by definition, the file operations associated to the device are minimalist.

static const MemoryRegionOps banana_rom_ops = {
	.read = banana_rom_read,
	.endianness = DEVICE_NATIVE_ENDIAN,
};

We just need to define a read callback and the endianness associated to the MemoryRegion.

The read callback is as simple as:

#define REG_ID 	0x0

static uint64_t banana_rom_read(void *opaque, hwaddr addr, unsigned int size)
{
	BananaRomState *s = opaque;
	
	switch (addr) {
	case REG_ID:
		return s->chip_id;
		break;
	
	default:
		return 0xDEADBEEF;
		break;
	}

	return 0;
}

The first bytes return the chip_id and the rest of the ROM is filled with 0xDEADBEEF.

Exporting the Device

Finally as i don’t want to have my device tied to a specific board and i’m not planning to use the Device Tree.

I’m just providing a function to create my new device which can be called from the board initialisation.

DeviceState *banana_rom_create(hwaddr addr)
{
	DeviceState *dev = qdev_new(TYPE_BANANA_ROM);
	sysbus_realize_and_unref(SYS_BUS_DEVICE(dev), &error_fatal);
	sysbus_mmio_map(SYS_BUS_DEVICE(dev), 0, addr);
	return dev;
}

And we are done for the device, now we just need to add it to an existing board which in our case is the RISC-V virtual board.

Adding the Banana ROM to the QEMU RISC-V virtual board

As i choose to target the QEMU RISC-V virtual board (remember the qemu_riscv64_virt_defconfig from Buildroot), we will need to add our device to this board in QEMU.

Kconfig and Meson in QEMU

QEMU uses Kconfig and Meson to select and built the dependencies.

First, we add the BANANA_ROM config dependencies in the RISCV_VIRT config:

[qemu/hw/riscv/Kconfig]

@@ -34,6 +34,7 @@ config RISCV_VIRT
    select SIFIVE_TEST
    select VIRTIO_MMIO
    select FW_CFG_DMA
+   select BANANA_ROM

config SIFIVE_E
    bool

And we let Meson know what to build when this symbol is selected:

[hw/misc/meson.build]

@@ -1,4 +1,5 @@
  softmmu_ss.add(when: 'CONFIG_APPLESMC', if_true: files('applesmc.c'))
+ softmmu_ss.add(when: 'CONFIG_BANANA_ROM', if_true: files('banana_rom.c'))
  softmmu_ss.add(when: 'CONFIG_EDU', if_true: files('edu.c'))
  softmmu_ss.add(when: 'CONFIG_FW_CFG_DMA', if_true: files('vmcoreinfo.c'))
  softmmu_ss.add(when: 'CONFIG_ISA_DEBUG', if_true: files('debugexit.c'))

Adding a device in the QEMU RISC-V virtual board Memory Map

As our device is io/mapped, we need to find some space in our board memory map that can be assigned to it.

Remember the 0x100 space allocated during the Instance Initialization, below this is why:

[hw/riscv/virt.c]

@@ -51,6 +52,7 @@ static const MemMapEntry virt_memmap[] = {
    [VIRT_RTC] =         {   0x101000,        0x1000 },
    [VIRT_CLINT] =       {  0x2000000,       0x10000 },
    [VIRT_PCIE_PIO] =    {  0x3000000,       0x10000 },
+   [VIRT_BANANA_ROM] =  {  0x4000000,       0x100 },
    [VIRT_PLIC] =        {  0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) },
    [VIRT_UART0] =       { 0x10000000,         0x100 },
    [VIRT_VIRTIO] =      { 0x10001000,        0x1000 },

Create our device during the board init

Finally, the last think to do is to create the banana_rom device during the RISC-V board init:

@@ -731,6 +733,9 @@ static void virt_machine_init(MachineState *machine)
    /* SiFive Test MMIO device */
    sifive_test_create(memmap[VIRT_TEST].base);

+   /* Banana device */
+   banana_rom_create(memmap[VIRT_BANANA_ROM].base);
+
    /* VirtIO MMIO devices */
    for (i = 0; i < VIRTIO_COUNT; i++) {
        sysbus_create_simple("virtio-mmio",

And that’s it!

Testing our new QEMU device in Linux

Let’s boot our previously built Linux in our new QEMU.

$ exec <path_to_your_qemu>/build/qemu-system-riscv64 -M virt -bios fw_jump.elf -kernel Image -append "rootwait root=/dev/vda ro" -drive file=rootfs.ext2,format=raw,id=hd0 -device virtio-blk-device,drive=hd0 -netdev user,id=net0 -device virtio-net-device,netdev=net0 -nographic

Then we should be able to access the ROM by reading at the address 0x4000000 using /dev/mem

Woot Woot!

Hope you had fun 🙂

Code Source

The source from this example can be found on my github.