Rust userspace application for QEMU custom peripherals

Rust userspace application for QEMU custom peripherals

After working with C, C++ and Python for more than 15 years, I got the opportunity to work with Rust at my current workplace (while still working primarily on Yocto and Linux).
It has been a pleasant ride (so far 😅), so I wanted to try a bit of Rust in my free time as well, both for Linux userspace applications and for baremetal applications (comming soon™).

The goal of this post is to demonstrate a Rust userspace application that can interact with all three custom QEMU peripherals presented so far on the MistraSolutions blog: Memory-mapped, I2C and SPI peripherals. The application replicates the functionality of the individual C++ userspace applications created in the respective posts, but it also handles data from all of the sensors at the same time.

The following things will be covered in this post

Rust userspace application

As mentioned in the introduction, the goal of this post is to have a Rust application that will interact with all three custom QEMU peripherals at the same time.

The task creation and handling will be done using async runtime in Rust.

Tip

I have some experience with tokio as async runtime, but I thought it would be interesting to try something new, so smol is used.

The application is organized as three separate tasks that interact with individual peripherals and submit measured data using a common channel to the fourth task that prints the received data to stdout.

Task organization

The implementation of the Rust application can be found in sens-apps-rs github repository.

smol async runtime

Smol async runtime is a collection of several crates providing various features. smol provides Executors that can be multi-threaded and are used to spawn individual tasks.
Some of the crates that were used will be described in this subsection.

There are examples in smol documentation on creating an Executor and spawning tasks onto it, but it has a lot of boilerplate code. However, as indicated in the smol documentation, smol-macros simplify creating a multi-threaded Executor and spawning tasks onto it to run concurrently.


Since the sensor tasks are independent and should run indefinitely, they are detach’ed instead of await’ed after creation (as suggested in the official documentation).

Communication between tasks is needed to pass data, so async-channel is used for communication between individual sensor (daemon) tasks and the main task.

Note

There was no need for bidirectional communication in this example so only one channel is used.

In order to make a more realistic example, Ctrl+C (SIGINT) handling is added, where the main signal handler will send a broadcast message (using async-broadcast) to each thread that interacts with a sensor, so they can gracefully stop the peripheral.

The tasks are implemented as infinite loops and are periodically woken up to execute, which also allows periodic check of the SIGINT reception.

Info

I could not find an equivalent to tokio::select which would work with futures with different return values, so I opted for this sequential approach. This means that Ctrl+C will be processed in individual tasks after the task is periodically woken up.
I expect this could have been handled by adding another layer of handler tasks, which would convert SIGINT reception and periodic wakeup into a variant of a message that can be sent over a channel, so a single await could be used to handle both events, but that was out of scope of this post.

Sensor trait

Looking at all three individual C++ applications, certain patterns are obvious:

  1. We want to initialize the peripheral by setting correct register values (init function), then
  2. perform reading in a loop (run function) with some delay (either HW or SW generated) and,
  3. once application should be closed, we want to deinitialize the peripheral (deinit function), in order to have it ready for another application start.

We can apply the same approach for the Rust application, but in this case we can create a Sensor trait, which will define all the functions an individual sensor struct will have to implement.

pub(super) trait Sensor {
    async fn spawn(
        ex: &smol_macros::Executor<'_>,
        sender: async_channel::Sender<UpdateMessage>,
        ctrl_c_receiver: async_broadcast::Receiver<()>,
    ) -> anyhow::Result<()>;
    async fn init(&mut self) -> anyhow::Result<()>;
    async fn deinit(&mut self);
    async fn run(self);
}

The spawn function was not mentioned in the previous analysis. The purpose of this function is to create and initialize the individual sensor structure, and then spawn and detach task.

Memory-mapped sensor polling

The Memory-mapped sensor implementation is different from the I2C and SPI sensors since it generates an interrupt every time a new sample is available. The data generation is periodic, so there is no need for a software timer to drive the reading and application can wait for the interrupt to arrive before doing anything.

The interrupt is exposed via sysfs interface and a file that has to be polled. In smol there is the polling crate which provides the interface towards the poll syscall.

Note

I couldn’t find an official example of polling a file descriptor, so the solution that is shown is made to the best of my abilities, and it works for my use case. There might be better ways to do this.

I had to experiment a bit, and the first solution that worked was enabling an Edge triggered source, without clearing it manually (even though it is suggested in the documentation).

...
  let poller = Poller::new().unwrap();
  let file = File::open(Path::new(MMSENS_PATH).join(INTERRUPT_ATTR))
    .await
    .expect("Could not open interrupt file");
  unsafe {
    poller
      .add_with_mode(&file, Event::readable(MMSENS_EV), polling::PollMode::Edge)
      .expect("Could not add file to poller")
  };
  let mut events = Events::new();
  loop {
    events.clear();
    poller
      .wait(&mut events, Some(std::time::Duration::from_millis(500)))
      .expect("Poll failed!");

    for ev in events.iter() {
      if ev.key == MMSENS_EV
            // read data and send it
      }
    }
  }
...

I2C and SPI interaction

For I2C and SPI it is easier, since there are crates for userspace I2C and SPI interaction. The crates enable interaction with I2C and SPI clients using the i2cdev and spidev interfaces, as was done in the C++ examples.

Full implementation

impl Sensor for I2CSens {
    async fn spawn(
        ex: &Executor<'_>,
        sender: Sender<UpdateMessage>,
        ctrl_c_receiver: Receiver<()>,
    ) -> anyhow::Result<()> {
        let i2c_device = LinuxI2CDevice::new(I2C_PATH, I2CSENS_ADDR)?;
        let mut i2csens = Self {
            sender,
            ctrl_c_receiver,
            i2c_device,
        };
        i2csens.init().await?;
        ex.spawn(i2csens.run()).detach();
        Ok(())
    }
}

impl I2CSens {
    async fn read_reg(&mut self, reg_nr: I2CReg) -> anyhow::Result<u8> {
        let mut read_data = [0; 1];
        let reg = [reg_nr as u8];
        let mut msgs = [
            LinuxI2CMessage::write(&reg),
            LinuxI2CMessage::read(&mut read_data),
        ];
        self.i2c_device
            .transfer(&mut msgs)
            .map(|_| read_data[0])
            .map_err(|e| e.into())
    }

    async fn write_reg(&mut self, reg_nr: I2CReg, value: u8) -> anyhow::Result<()> {
        let reg = [reg_nr as u8, value];
        let mut msgs = [LinuxI2CMessage::write(&reg)];
        self.i2c_device
            .transfer(&mut msgs)
            .map(|_| ())
            .map_err(|e| e.into())
    }
}

The periodic execution should be implemented using a software timer, so Timer from smol async-io is used.

QEMU custom peripherals

The QEMU is prepared for testing the Rust application by applying the patches from the old posts that introduced the custom peripherals.

For running on the QEMU 10.0.2 small changes are needed for the memory-mapped sensor component due to upstream updates, so main branch in the QEMU custom peripherals repository is updated. The other two patches (for custom I2C and SPI peripherals) can be applied without changes.

The order of applying patches should be: memory-mapped, I2C and finally SPI sensor.

Rust cross-compilation

We can try to cross-compile app using the armv7-unknown-linux-gnueabihf toolchain.

The toolchain can be installed using rustup

rustup target add armv7-unknown-linux-gnueabihf

Before it can be used, an arm-none-linux-gnueabihf toolchain needs to be made available so it can be used for linking object files.

Tip

For instructions on getting the toolchain and making it available follow Getting toolchain from the QEMU Board Emulation post series.

Also, a configuration file needs to be created in the project directory in .cargo/config.toml with the following contents to instruct cargo to use the arm-none-linux-guneabihf-gcc to be used as linker

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-none-linux-gnueabihf-gcc"

With all of the mentioned changes in place, the build can be performed using

cargo build --release --toolchain armv7-unknown-linux-gnueabihf

The output binary will be in target/armv7-mistra-linux-gnueabihf/release directory and it can be copied to the SD card image.

Yocto integration

Instead of using a pre-built toolchain, we can build a toolchain using Yocto, or let Yocto and bitbake build app for us.

The changes for both approaches are in the meta-mistra repository.

Rust SDK

Before our toolchain can be used for Rust cross-compilation, Rust support has to be added. It is done by adding the following lines to the mistra-image.bb recipe


TOOLCHAIN_HOST_TASK:append = "\
 nativesdk-rust \
 nativesdk-cargo \
"

SDK_TOOLCHAIN_LANGS:append = " rust"

The SDK is built using

bitbake mc::mistra-image -c populate_sdk

Once SDK is build, we can install it (same as it is done in the first custom peripheral userspace application post)

The cross-compilation is done by sourcing the environment and then running cargo build. The environment script takes care of setting the correct environment variables and settings.

. /opt/mistra-framebuffer/4.0/environment-setup-cortexa8t2hf-neon-mistra-linux-gnueabi
cargo build --release

After build command the output binary can be found in target/armv7-mistra-linux-gnueabihf/release directory and can be copied to the SD card image for testing.

Yocto Rust recipe

We can also let bitbake perform the build for us. All we need is a proper recipe.

The recipe can be auto-generated from the crate source using cargo bitbake.

Info

Make sure that description and repository fields in Cargo.toml are populated, otherwise it will fail.

cd sens-apps-rs
cargo bitbake
Wrote: sens-apps_1.0.0.bb

Tip

The resulting recipe lists all the dependencies, but it does not list the SHA256 checksum values for them. The easiest way to add those values is to let bitbake fail and print the list for you.

bitbake mc::mistra-swupdate
...
ERROR: sens-apps-1.0.0-r0 do_fetch: Missing SRC_URI checksum, please add those to the recipe:
SRC_URI[android-tzdata-0.1.1.sha256sum] = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
SRC_URI[android_system_properties-0.1.5.sha256sum] = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
SRC_URI[anyhow-1.0.98.sha256sum] = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
SRC_URI[async-broadcast-0.7.2.sha256sum] = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
SRC_URI[async-channel-2.3.1.sha256sum] = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
SRC_URI[async-executor-1.13.2.sha256sum] = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa"
...

This list can be copied straight to the sens-apps_1.0.0.bb

After the checksums for depending crates are added, the compilation will go through and the built executable will be placed in the /usr/bin/ directory of the target rootfs.

QEMU Testing

Once the app is built and deployed, the QEMU can be started.

There are no command-line options, so just running sens-apps will start printing the data.

Behind the scenes all peripherals will be configured on initialization and then data collected. This can be validated by running peripherals with tracing, so data is printed when configuration changes are made.

Summary

I am happy I was able to make something in Rust that integrates well with the rest of the blog and that works, and I will continue improving in that direction.

There are probably many ways this can be improved from the pure Rust perspective, but from the SW architect perspective I can see a few things that would be nice to have for a more complete example:

  • accepting input arguments for selection of peripherals to be used; not all three have to be used all the time,
  • periodic reporting from the main task instead of just pushing data as soon as it is received; this would require some kind of storage, perhaps also a database for being able to get some time series data,
  • nice terminal ui with handling key presses,
  • unit testing
Subscribe
If you would like to get information as soon as new content is published, please subscribe to the "MistraSolutions newsletter".