An Introduction to PCIe Device Emulation With QEMU

Introduction

So you’ve made a software model of a device, great! Now, the next thing you need to do is test it, write drivers for it, and possibly figure out the firmware. For that, software models need to act as a device so that it can communicate with the OS, for which we have no medium in the host OS: we don’t physically have a device ready yet. This is where virtualization comes in. We then use QEMU to emulate an environment (an OS, essentially) which thinks that it has access to a real device, which, in reality, is a our software model. QEMU is an open-source framework which allows to create VMs and emulate devices in the VM. Explore the QEMU source here.

We have to integrate our model with QEMU to make it function like device. For the extent of this article, let’s assume we made a network device called crab. More or less all modern networking devices use PCIe technology to communicate with the rest of our hardware. Therefore, we need to emulate it as a PCIe device. To emulate a device, the things we need are in the hw directory in the QEMU source code. For example, network devices will be in hw/net. Create your device file here, for example crab.c. Here is a good repo on implementing a simple PCIe device.

The main thing you need to decide is how your device will communicate with the VM. You can emulate a device using shared memory or sockets. Before that, let’s see what the QEMU Object Model (QOM) is, and how it helps us easily integrate a device into QEMU.

QEMU Object Model (QOM)

Understanding QOM is fundamental to getting a good idea of how Shared Memory emulation works. QOM is QEMU’s own implementation of an object oriented programming model in C. We can basically make types, instantiate objects from them, support inheritance, and so on. Any given QEMU machine maintains a QOM tree which is a tree of all objects (instances of types) that make up the machine. We can use QEMU Monitor to further inspect that tree.

To make our own device, we basically need to create a new type that holds all the information about the device, and register it with QEMU so it can be initialized and added to the tree. We essentially use a TypeInfo struct to register the relevant details, and then simply register that struct with QEMU using their API. We will discuss its specific fields in the next sections.

Each device is associated with a class as well. So for any given number of similar devices, we will only have one class in memory, and multiple instances of the device in memory depending on how many we have emulated. So, the basic order is initializing the parents classes first, then the class, then the object

Class Init --> Object Init

Emulating With Shared Memory

Shared memory communication between QEMU and your device happens using the help of a hex file that both entities can access. You need to initialize pathways (also called buses) to and from the file for sharing data in the realize callback function. The explanations here are exactly the same for sockets, with the exception of the realize function, since we initialize files there. The same goes for MMIO read and write functions, they will use the files mainly.

Initializing A Device

The first thing we need to is initialize the device. We first need to know that QEMU usually uses Linked Lists for registering things. We need to add our device to that list. For that, we need to register our init function with QEMU like-so:

type_init(callback);
// Let's use crab_register as callback: crab_register(void)

When QEMU traverses its list, when it reaches our device, it finds this callback function and calls it to start the registration. In this function, we need to register a structure with QEMU that has the necessary details for QEMU to use it. We can register that struct using:

type_register_static(*struct TypeInfo);
// Lets call our struct crab_info

These are the things we need to populate inside our device’s struct, you can see the documentation in include/qom/object.h

struct TypeInfo 
{
    const char *name; // name of the type (required)
    const char *parent; // name of the parent type (required)

    size_t instance_size; // size of the object
    void (*instance_init)(Object *obj); // function called to initialize object
 
 // this function is called after all parent class initialization
 // has occurred to allow a class to set its default virtual method pointers
    void (*class_init)(ObjectClass *klass, const void *data);

 // list of interfaces associated with this type
    const InterfaceInfo *interfaces;
};

The one’s documented here should be enough to emulate a device. You’ll define the interface with an array:

.interfaces = (InterfaceInfo[]) {
 { INTERFACE_PCIE_DEVICE },
 { },
}

We should now talk about the registered functions in this TypeInfo crab_info struct. Clearly, once you’ve filled the struct, you can go ahead and call type_register_static and your device will be ready to go.

State & Instance Initialization

Our device has a state that can be updated (or accessed) every time we have a clock signal. It is essentially a structure that holds our required state variables. For our device, lets call that CrabState. Any functions that we register in the crab_info struct will therefore need to access this state struct. For example, we registered crab_instance_init in the .instance_init field. That function will look something like this:

static void crab_instance_init(Object *obj)
{
    CrabState *n = CRAB(obj);
    device_add_bootindex_property(obj, &n->conf.bootindex,
                                  "bootindex", "/ethernet-phy@0",
                                  DEVICE(n));
}

Over here, we first get the address of our allocated CrabState by using a method (CRAB) we have to define ourselves. You can see that the obj argument given to us by QEMU allows us to access this state somehow. Let’s talk about what this state and what does the CRAB function do to allow us to get the address of that state. We can then talk more about the instance initialize function.

The state is basically a struct that holds:

  1. Current register values
  2. Interrupt statuses
  3. BARs
  4. Pointers to backend

You can see qemu/hw/net/e1000.c for an example of how a state function looks like for an NIC. In our case, lets make a CrabState variable with two memory regions:

struct CrabState {
 PCIDevice pdev;
 // NICState *nic; if your device is a nic
 
 MemoryRegion mmio_bar0;
 MemoryRegion mmio_bar1;
 
 uint32_t bar0[16];
 uint8_t  bar1[4096];
}

You would probably have a reference manual that tells you how to make the state. The last thing that remains is the CRAB function. You can declare macro calls like this which give you the state and class casts respectively:

DECLARE_OBJ_CHECKERS(CrabState, CrabBaseClass,
                     CRAB, TYPE_CRAB_BASE)

I have no idea what device_add_bootindex_property does…

Class Initialization

Once the type is initialized, you need to initialize the class as well (remember the functions we registered in crab_info). To initialize the class, you need to register several functions, as we will see. Lets call this function crab_class_init, it looks like this:

void crab_class_init(ObjectClass *klass, void *data)
{
 // get device and parent device class
 DeviceClass *cls = DEVICE_CLASS(klass);
 PCIDeviceClass *pcls = PCI_DEVICE_CLASS(klass);
 
 // regsiter callbacks
 
 /* this inits memory so the OS can see it */
 pcls->realize = crab_realize;
 
 /* this de-inits the memory we previously registered */
 pcls->exit = crab_exit;
 
 // pcie & device information
 pcls->vendor_id = CRAB_VENDOR_ID;
 pcls->device_id = CRAB_DEVICE_ID;
 pcls->class_id = PCI_CLASS_NETWORK_ETHERNET; // for example
 
 // device class callbacks & info
 cls->desc = "Cool Crab Ethernet Device";
 
 /* runs after crab_exit */
 cls->reset = crab_reset;
 device_class_set_props(cls, mars_props);
}

You can explore the DeviceClass and PCIDeviceClass (if you need it) structs to see what information you can add. We should now look at each of these individual functions we just registered.

Realize

Remember, here we initialize memory so the OS can see it. You can also access the DeviceState and CrabState as we saw before. We should also enable MSI if we use them, or polling otherwise.

Therefore, you will do the following generally in this function:

void crab_realize(PCIDevice *pci_dev, Error **errp)
{
 DeviceState *dev = DEVICE(pci_dev);
 CrabState *cs = CRAB(pci_dev);
 
 // enable msi
 /*  Args:
  @device, @offset, @num_vectors, @msi64bit
  @msi_per_vector_mask, @errp
  Returns: 0 on success, -ERRNO on error 
 */
 msi_init(pci_dev, 0, 32, true, true, errp);
 
 // some file inits here for reading and writing to and from qemu
 
 // config write callback
 // called whenever kernel tries to write CSRs
 pci_dev->config_write = crab_write_config;
 
 // register BARs for the OS to see
 // we already have stack allocated memory in our state
 
 /* initialize an I/O memory region */
 memory_region_init_io(&cs->mmio_bar0, OBJECT(cs), &crab_bar0_ops, 
       cs, "crab_bar0", 16*4);
 
 /* once MMIO region is regitered, we need to mark it for PCIe */
 pci_register_bar(pci_dev, FLAGS, &cs->mmio_bar0);
 
 // endpoint capability
 pcie_endpoint_cap_init(pci_dev, 0x70);
 
 // initialize the nic if your device is a nic
 // cs->nic = qemu_new_nic(&net_crab_info, ...);
}

You can see that when init an I/O region, we need to pass in a struct that defines valid operations on the memory region, which is crab_bar0_ops in our case. This is a struct of type MemoryRegionOps, and some of its important fields are shown below. All addresses their are relative to the memory region:

struct MemoryRegionOps {
 uint64_t (*read) (void *opaque, hwaddr addr, unsigned size);
 void (*write) (void *opaque, hwaddr addr, uint64_t data, unsigned size);
 enum device_endian endianness;
 /* internal implementation constraints */
 struct {
  unsigned min_access_size;
  unsigned max_access_size;
 } impl;
}

You can register your functions here, for example: crab_bar0_mmio_read, etc. You can also pass in your desired flags while registering the BAR[1].

Memory Region Ops

Memory region operations are largely governed by your device’s specifications and behavior. However, the essential part is that you are more or less doing something with the state: reading from the BARs or writing to them for example, based on the address passed to the callback function. The examples below are from the repo I linked at the start.

Write: This allows us to write to the memory region. For example, here is simple write function that writes a the passed value to the BAR we have in our device’s state (for whatever reason you want to do that). This is a good opportunity to invoke any DMA or IRQ related functions based on the address.

static void crab_bar0_mmio_write(void *opaque, hwaddr addr, uint64_t val,
  unsigned size)
{
 CrabState *cs = opaque;
 
 *(uint64_t *) cs->bar0 = val;
}

Read: This allows us to read from the given memory region. Similar to the write function, we simply return a random value whenever we try to perform a read on the memory region:

static uint64_t crab_bar0_mmio_read(void *opaque, hwaddr addr, unsigned size)
{
 CrabState *cs = opaque;
 
 return rand();
}

You can obviously define behavior based on the address in the args.

Emulating With Sockets

In shared memory, we may binary files the mode of communicating with our device running on host, which constantly polled those files for new data. In emulating with sockets, we open a socket in the host kernel, to which QEMU and our device connect to exchange data. Basically, The kernel in QEMU passes data to our QEMU-emulated device (let’s say Crab is a PCIe device), and that device then passes data to the PCI Sim Glue Device. Finally, the data gets sent to the socket in the host kernel. Our actual device then gets data from that socket.


  1. There are three types of PCI memory: I/O, Prefetchable, and Non-prefetchable. I/O memory (PCI_BASE_ADDRESS_SPACE_IO) is used for port-based I/O operations, which are rarely used in modern technology. It has its own dedicated instructions. Non-port, or MMIO, memory can be of two types: prefetchable and non-prefetchable. Prefetchable memory (PCI_BASE_ADDRESS_MEM_PREFETCH) can be read in large bursts and be cached by the CPU. Reads don’t alter the device’s state. On the contrary, non-prefetchable memory (PCI_BASE_ADDRESS_SPACE_MEMORY) is not cached, read in lesser sizes, and device reads are stateful, meaning that reads may change the device’s state. This is good for control registers and changing data. ↩︎

Sadeem Sajid