Oct 18, 2022

Custom I2C QEMU peripheral and userspace application

This is part 4 of the Linux device driver development post series.

After showing custom memory-mapped sensor and it's handing in previous posts, we will now take a look at I2C peripheral.

In this post we will cover the following things

Custom I2C QEMU peripheral

Emulating I2C peripheral under QEMU is not much harder then emulating a memory-mapped peripheral.

The peripheral needs to be connected to an existing I2C controller, assigned and I2C address and should provide responses when data is written to or read from I2C bus.

Peripheral description

In this example we will implement a simple I2C temperature sensor. Once enabled, it will return a random value in the range of 15.0 to 25.0 with 0.5 degrees (Celsius) step on every read.


The peripheral has following registers

offset name description
0x0 ID Unique ID register, always returning value 0x5A
0x1 CONFIG Configuration register, used to enable temperature temperature
0x2 TEMPERATURE Data register holding current temperature. The value is coded as Q5.1 to be able to hold 0.5 degree step

Bit values of CONFIG register are shown in the following table

name pos dflt description
Reserved 7:1 0 Reserved
EN 0 0 Enable device

Peripheral implementation

The implementation of the peripheral is available in github repository.

Functions of interest are the ones related to data handling. The global structure is defined in the following way

typedef struct I2CSensor {
  /*< private >*/
  I2CSlave i2c;
  /*< public >*/
  uint8_t regs[NR_REGS];  // peripheral registers
  uint8_t count;          // counter used for tx/rx
  uint8_t ptr;            // current register index
} I2CSensor;

Writing to the device

The only register that supports writing is the CTRL register.

In order to set the register that will be written, first I2C write must be the register address (that will update the ptr field), and second write must be the value that should be written.

static int i2c_sens_tx(I2CSlave *i2c, uint8_t data)
{
  I2CSensor *s = I2C_SENS(i2c);

  if (s->count == 0) {
    /* store register address */
    s->ptr = data;
    s->count++;
  } else {
    if (s->ptr == REG_CTRL_OFFSET) {
      s->regs[s->ptr++] = data;
    }
  }
  return 0;
}

Reading from device

Reading process is similar to writing: first one byte must be written to set the address of register to be read, and then the reading can proceed.

All registers support reading so it is just a matter of returning current register value.

/* Called when master requests read */
static uint8_t i2c_sens_rx(I2CSlave *i2c)
{
  I2CSensor *s = I2C_SENS(i2c);
  uint8_t ret = 0xff;

  if (s->ptr < NR_REGS) {
    ret = s->regs[s->ptr++];
  }

  return ret;
}

We also want each read to trigger loading of random value to the TEMPERATURE register. That is achieved by defining an event callback which modifies the s->regs[2] value when I2C_START_RECV event is received.

The register is modified only if device is currently enabled, otherwise a value of 0xff will be stored.

static int i2c_sens_event(I2CSlave *i2c, enum i2c_event event)
{
  I2CSensor *s = I2C_SENS(i2c);

  if (event == I2C_START_RECV) {
    if (s->ptr == REG_TEMPERATURE_OFFSET) {
      if (s->regs[REG_CTRL_OFFSET] & REG_CTRL_EN_MASK) {
        s->regs[REG_TEMPERATURE_OFFSET] = i2c_sens_get_temperature();
      } else {
        s->regs[REG_TEMPERATURE_OFFSET] = 0xff;
      }
    }
  }

  s->count = 0;

  return 0;
}

Integrating into QEMU

Similarly to the Memory-mapped peripheral, the new I2C peripheral must be integrated into the Versatile Express description. This time, instead of attaching to a place in memory map, it needs to be attached to an I2C controller at a selected I2C address.

We can choose either to use an already existing I2C controller, or to also add a new I2C controller in the memory map, and then attach this component to the I2C controller.

In this example we will add a new I2C controller at address 0x10010000 and attach our custom I2C component to that I2C controller. The I2C slave address is chosen as 0x36.

The excerpt from initialization of emulated Versatile Express board is

  dev = sysbus_create_simple(TYPE_VERSATILE_I2C, map[VE_I2C_SENSOR], NULL);
  i2c = (I2CBus *)qdev_get_child_bus(dev, "i2c");
  i2c_slave_create_simple(i2c, "mistra.i2csens", 0x36);

I2C Userspace handling

With the device created in QEMU we can turn to making a userspace application to access the I2C device.

The program flow will be similar to the one used for memory-mapped device

  1. Device should be initialized
  2. After device is initialized, we should periodically read the temperature data

The full userspace application can be found in github.

Initialization

The device initialization now involves setting up the I2C userspace communication. Everything is done using the dev file for the selected I2C controller. In our case it is /dev/i2c-2.

The process can be summarized as

  1. Opening the I2C controller device file
    fd = open("/dev/i2c-2", O_RDWR);
    
  2. Configuring I2C slave address
    ioctl(fd, I2C_SLAVE, 0x36);
    
  3. Enabling device by writing 1 to CTRL register. Since this write should be performed in two steps, an array consisting of CTRL register offset (0x01) and value to be written (1) is passed to the write function
    uint8_t buffer[2] = { 0x01, 0x01 };
    write(fd, buffer, 2);
    

Periodic execution

After the device is initialized, we can use a separate thread to periodically initiate reads from I2C device. Unlike the memory-mapped device, this peripheral has no interrupt generation possibilities, so we need to make periodic execution in some other way.

We will use std::this_thread::sleep_until to enable periodic readouts from the I2C peripheral.

The read process also consists of two steps: writing TEMPERATURE register offset and then reading the TEMPERATURE register value.

while (m_running)
{
  // Read current time so we know when to timeout
  current_time = std::chrono::system_clock::now();

  // read I2C device and print
  // first write address
  uint8_t buffer[1] = { 0x02 };
  write(fd, buffer, 1);
  // then read value
  read(fd, buffer, 1);

  std::cout << "Measured " << (buffer[0] / 2.) << std::endl;

  // sleep_until
  std::this_thread::sleep_until(current_time + std::chrono::seconds(1));
}

Testing in QEMU

i2c-tools

Before testing the userspace application, we need to test that device is integrated properly inside QEMU. For that we can use i2c-tools package (add it Yocto or install in Ubuntu rootfs).

Once i2c-tools are installed, we can use i2cdetect to verify that a peripheral at 0x36 address exists using

$ i2cdetect -y 2
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:          -- -- -- -- -- -- -- -- -- -- -- -- -- 
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
30: -- -- -- -- -- -- 36 -- -- -- -- -- -- -- -- -- 
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
70: -- -- -- -- -- -- -- -- 

The i2cget can be used to read a value and i2cset to set certain value.

We can check that ID register returns 0x5A

$ i2cget -y 2 0x36 0
0x5a

The last test would be to turn on the device and check that TEMPERATURE register returns different values every time

$ i2cset -y 2 0x36 1 1
$ i2cget -y 2 0x36 2
0x22
$ i2cget -y 2 0x36 2
0x25
$ i2cget -y 2 0x36 2
0x2c

Userspace application

Userspace application can be compiled in the same manner as the memory-mapped application, via external toolchain or using a Yocto SDK generated toolchain:

$ mkdir build && cd build
$ cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/toolchain.cmake ..
$ make -j

or

$ source /opt/mistra-framebuffer/3.1/environment-setup-armv7at2hf-neon-mistra-linux-gnueabi
$ mkdir build && cd build
$ cmake ..
$ make -j

Alternatively, application can be added to the Yocto build system with a recipe shown in github.


After the application has been built and copied to the rootfs, it can be started. The application will initialize the I2C peripheral and start printing read temperature value every second.

Summary

In this blog post userspace application development for custom QEMU I2C sensor is presented.

The application encapsulates the I2C userspace access and implements periodic reading of data.

It can be improved with unit tests, as well as more interactive behavior, but it is left for some other time.

Oct 8, 2022

Userspace application for custom QEMU memory-mapped peripheral

This is part 3 of the Linux device driver development post series.

In the previous post structure of a Linux device driver for the custom memory-mapped peripheral in QEMU was shown.

In this post we will cover the following things

Developing userspace application for designed memory-mapped device

The driver that was developed in the previous post provides interface to our custom memory-mapped peripheral. What remains is to make an application that will interact with the hardware using that driver.

The application should be able to initialize the device and then block until interrupts arrive, when it should capture the value from the data register and print it.

Device initialization

In order to initialize the device the application needs to:

  1. turn it on via EN bit;
  2. select desired frequency via FREQ bit;
  3. enable interrupt generation via IEN bit.

Since all of these bits are accessible via implemented sysfs attributes, the application should access this files and perform appropriate writes.

After device is initialized, the application will go into an infinite loop waiting for interrupt to happen.

Interrupt handling (poll)

The title of this subsection might be misleading, since interrupt handling is done in the kernel driver code. However, the device driver only acknowledges the interrupt and clears interrupt flag, so interrupt processing is left to the application code. The notification from device driver to user space is implemented via sysfs_notify function on interrupt sysfs attribute.

In order for user space application to be able to be woken up when interrupt happens we will use poll syscall. It will make application thread to block while waiting for notification from device driver. Once interrupt is received, the application will be woken up and continue processing.

As for the processing, the application should read the data register (the processed value provided by the sysfs attribute) and print the value that was read.

Coding ideas

In this case I opted for using C++/CMake implementation presented in github repository.

The low-level access to sysfs attribute files and poll syscall is implemented in the Poller class. This class handles initialization and interrupt notification so the higher level code can only worry about handling of read data.

The Poller class will provide method to do the device initialization. It will also provide a method to start a thread where it uses poll syscall to wait for notification from sysfs attribute interrupt. Once notification is received, Poller will read the data sysfs attribute and print read value.

After Ctrl+c is pressed the application will call method from Poller to disable interrupt generation and turn off the device.

Building application manually

Using arm-none-linux-gnueabihf

CMake is chosen as the build system. Since we are cross-compiling for ARM architecture, we need to provide the toolchain.cmake file, which describes everything related to cross-compilation toolchain.

An example for arm-none-linux-gnueabihf toolchain is in github repository.

When setting up the build system, we will pass that toolchain file to cmake using -DCMAKE_TOOLCHAIN_PATH. The rest of the process is the standard CMake flow.

$ mkdir build && cd build
$ cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/toolchain.cmake ..
$ make -j

The resulting mmsens-app binary file will be in the build directory.

Using Yocto SDK

Yocto can be used to create and SDK which includes all required development libraries and build tools.

In order to build the SDK, process similar to building image (presented in part 4 of QEMU Board emulation) with additinal parameter of -c populate_sdk.

$ source setup-environment build_framebuffer
$ DISTRO=mistra-framebuffer MACHINE=vexpress-qemu bitbake core-image-test -c populate_sdk

The resulting toolchain file will be in build_framebuffer/tmp/deploy/sdk/, the mistra-framebuffer-glibc-x86_64-core-image-test-armv7at2hf-neon-vexpress-qemu-toolchain-3.1.sh.

Toolchain and SDK is installed using

$ ./mistra-framebuffer-glibc-x86_64-core-image-test-armv7at2hf-neon-vexpress-qemu-toolchain-3.1.sh
Mistra FrameBuffer SDK installer version 3.1
============================================
Enter target directory for SDK (default: /opt/mistra-framebuffer/3.1):
You are about to install the SDK to "/opt/mistra-framebuffer/3.1". Proceed [Y/n]?
Extracting SDK...........................................done
Setting it up...done
SDK has been successfully set up and is ready to be used.
Each time you wish to use the SDK in a new shell session, you need to source the environment setup script e.g.
 $ . /opt/mistra-framebuffer/3.1/environment-setup-armv7at2hf-neon-mistra-linux-gnueabi

From our mmsens-app we can initiate the build of the application in the following way

$ source /opt/mistra-framebuffer/3.1/environment-setup-armv7at2hf-neon-mistra-linux-gnueabi
$ mkdir build && cd build
$ cmake ..
$ make -j

There is no need to pass the toolchain file since sourcing environment script already does that for us.

Adding application to Yocto

Before we go to testing, we shall look into writing recipe for adding this application to the Yocto build.

The recipe should inherit cmake which will indicate to bitbake that CMake is used for building.

Summary of the recipe is presented here and full recipe can be found in meta-vexpress-apps.

DESCRIPTION = "Memory-mapped sensor userspace application"

SRC_URI = "git://github.com/straxy/mmsens-app.git;protocol=https;branch=main"

S = "${WORKDIR}/git"

inherit cmake

Only other thing needed is to add mmsens-app to IMAGE_INSTALL variable in the image recipe, and the application will be included in the final output image.

After recipe is added, the image can be built and loaded by following instructions from part 4 of QEMU Board emulation.

Testing application

If application is built manually, it needs to be copied to the rootfs of the SD card image following part 2 of QEMU Board emulation.

If application is build as part of the Yocto image, it will be included in the rootfs and located in /usr/bin.

Once application is in rootfs, it just needs to be executed using ./mmsens-app (if not in $PATH) or mmsens-app (if in $PATH).

The application will print the read value from the data register, and can be gracefully stopped by pressing Ctrl+c.

$ ./mmsens-app /sys/class/mmsens/mmsens0
Hello World!
[   51.670787] Interrupt received
Initial 0001
[   52.667802] Interrupt received
0002
[   53.667079] Interrupt received
0003
[   54.667250] Interrupt received
0004
[   55.666935] Interrupt received
0005
[   56.667187] Interrupt received
0006
^C[   57.666989] Interrupt received
0007
$

Running same application from Yocto image inside QEMU

Summary

In this blog post userspace application development for custom QEMU memory mapped sensor is presented.

The application encapsulates the sysfs files access and can block until interrupt is received so it can process it.

It can be improved with unit tests, as well as more interactive behavior, but it is left for some other time.