Async on Embedded: Present & Future

Folkert
Systems software engineer

In our last post, we've seen that async can help reduce power consumption in embedded programs. The async machinery is much more fine-grained at switching to a different task than we reasonably could be. Embassy schedules the work intelligently, which means the work is completed faster and we race to sleep. Our application actually gets more readable because we programmers mostly don't need to worry about breaking up our functions into tasks and switching between them. Any await is a possible switching point.

Now, we want to actually start using async in our programs. Sadly there are currently some limitations. In this post, we'll look at the current workarounds, the tradeoffs, and how the limitations might be partially resolved in the near future.

Current limitations

At the moment, traits cannot contain async fn methods. By extension, async embedded programs cannot use the rich ecosystem of traits that exists for synchronous Embedded Rust development.

That may seem odd: async has been available in stable rust for years. There were however good technical reasons for why this feature was purposefully omitted from the initial async stabilization, set out here.

Luckily, good progress seems to be made on these issues recently, for instance Generic Associated Types (GATs) may become stable this year. This feature allows us to get close to having async fn methods, and stabilizations means we can publish packages that use this feature (published packages cannot use unstable features).

But we're impatient. The nightly version of Rust (a version where experimental unstable features are enabled) already implements the features we need. Let's see how close to async functions in traits we can get with nightly.

A glimpse of the future

The I²C protocol is one of the common ways to communicate with a sensor. Messages can be sent to the sensor, and can also be received by the host. In Rust, this behavior is captured by a trait. A simplified I2c trait from embassy looks like this:

pub trait I2c<A: AddressMode = SevenBitAddress> {
    /// Error type
    type Error;

    type ReadFuture<'a>: Future<Output = Result<(), Self::Error>> + 'a
    where
        Self: 'a;

    fn read<'a>(&'a mut self, addr: A, bs: &'a mut [u8]) -> Self::ReadFuture<'a>;
}

We have a read function (the other I2C primitives are omitted here) that returns a future. This future is explicitly named with an associated type ReadFuture and this type is generic over a lifetime 'a, hence we need Generic Associated Types, which are currently a nightly-only feature.

The read function is not an async fn at the moment, because the async keyword is not allowed in that position inside a trait definition. Not to worry though, an implementation can just use an async block like so:

fn read<'a>(&'a mut self, addr: u8, bs: &'a mut [u8]) -> Self::ReadFuture<'a> {
    async move {
        // implementation
    }
}

Inside the async block we can use async and .await just like in an async fn body.

In sum, for embedded needs, stabilizing GATs (and friends) as they currently exist on nightly would be sufficient to provide a basis for writing async driver crates, effectively giving the ecosystem a boost. Remaining rough edges can be polished over time.

Making a sensor package async

But even though we can make such async traits on nightly today, we can't publish them or implementations of them for concrete sensors. Therefore, we have to make our own async version of existing sync implementations. Let's look at what it takes today to make an async version of a sensor.

First of all, we need async communication with the sensor. When I first tried this, I was using a sensor that could only communicate over I2C. But there was no async implementation of the I2c trait for our hardware, so I made one. It was quite fun to work on this level of the stack, and the async version is mostly a copy of the sync version, with the crucial exception that it allows switching to another task while waiting for a response from the sensor.

Next, we have to turn normal functions into async ones. Usually, this is as simple as changing the very foundational IO functions (something like read_register and write_register) to be async fn and then follow the compiler messages from there.

The big exception is impl SomeTrait for MyType blocks, because trait methods cannot be turned into async fn, as we have seen. My solution so far has been to turn impl SomeTrait for MyType into a impl MyType. For example this impl Trait block:

impl<CORE> Accelerometer for Lis3dh<CORE>
where
    ...
{
    fn accel_norm(&mut self) -> Result<F32x3, AccelerometerError<Self::Error>> {
        ...
    }
    ...
}

Can be replaced with just a impl, allowing us to use async fn:

impl Lis3dh<CORE>
where
    ...
{
    async fn accel_norm(&mut self) -> Result<F32x3, AccelerometerError<Self::Error>> {
        ...
    }
    ...
}

This works fine if the consumer of the API was only using a concrete version of the sensor anyway. It can be quite laborious though.

Alternatively, I could have made an async version of the trait with the approach from the previous section, but that did not seem worth it so far.

Is it worth it

Perhaps.

The conversion process is laborious. So far, I've only done it for relatively simple sensors that implement one or two external traits.

For a more complex project, there are two major hurdles: first you have to translate all your code to use async, including (transitive) dependencies, and then, whenever you want to add another dependency, you need to translate that as well. The translation is usually not complex, but it's a huge time sink.

But, this will all change when GATs (and type alias impl trait) become stable. Things are looking good on that front, and the technical work may be done late this year or early next year. Then it is up to the community to create a rich ecosystem of async versions of the familiar traits. With that in place, async on embedded will be a very effective technique indeed!

In preparation, now might be an excellent moment to start playing around with async traits on nightly. Once GATs are stabilized, we can build the Rust Embedded async ecosystem together 🦀❤️🦀.


This is the 3rd article in a series on async embedded development with Rust:

Stay up-to-date

Stay up-to-date with our work and blog posts?

Related articles

Last September, at the start of my internship at Tweede Golf, my tutors gave me a LoRa-E5 Dev Board. My task was to do something that would make it easier to write applications for this device in Rust. Here's what I did.

It's time for another technical blog post about async Rust on embedded. This time we're going to pitch Embassy/Rust against FreeRTOS/C on an STM32F446 microcontroller.

Previously we talked about conserving energy using async. This time we'll take a look at performing power consumption measurements. Our goal is first to get a feel for how much power is consumed, and then to measure the difference between a standard synchronous and an async implementation of the same application.