Embedding Rust Into Zephyr Firmware Using C-bindgen

Jared Wolff · 2021.3.29 · 12 Minute Read · rust · zephyr · nrf9160 feather

Rust in Zephyr

I’m a big fan of the Rust programming language. I’ve used it to build servers, develop test firmware, build CLI tools, and more. One of my goals has been to get some type of Rust code running on the nRF9160 Feather. There are a few ways to go around it but one of the easiest methods to get started is to generate a library that can be utilized by the C-code-based infrastructure that already exists.

In this post, I’ll go into how I developed a brief CBOR serialization library using serde and cbindgen. I’ll run through the details of writing the Rust code and then also how I utilized it within my Zephyr-based C-code. So, let’s get to it!

Side note: if you’re unfamiliar with Zephyr it’s an open-source RTOS spearheaded by the Linux Foundation. Highly recommend you check it out if it’s new to you.

Inter-op with C

While no_std Rust code can be compiled down into binary code, Rust is still in the early stages of development. That means Rust based hardware libraries may be missing functionality or simply not exist alogether!

Thus, to take advantage of already existing hardware drivers, RTOS, and more we can convert our Rust code to C or even C++. That’s where cbindgen comes in.

cbindgen is a tool spearheaded by Ryan Hunt. He and 85 other contributors the Rust community has built to make it easier to interoperate between your Rust and C code. For example, take a Rust struct that looks like this:

pub struct EnvironmentData {
    pub temperature: u16,
    pub humidity: u16,
}

An then generate a corresponding C struct like this:

typedef struct EnvironmentData {
  uint16_t temperature;
  uint16_t humidity;
} EnvironmentData;

Not only that but, it allows you to import no_std libraries like serde_cbor so you don’t have to generate the serialization/deserialization logic yourself. If you’ve ever written this code in C you’ll know that not only do you have to write the serialization, deserialization functions but also test it to make sure that the receiving party can process it correctly!

Side note: I’ve previously talked about developing a C-based and Rust-based CBOR codec in this article.

Writing the Rust

While the Rust std library is great, it doesn’t exist on some platforms and especially on embedded. Therefore writing the Rust code will differ slightly from writing regular ol' std Rust code. Most importantly, dynamic data structures like Vec are not supported by cbindgen so it’s important to use known static types while creating your data structures.

Speaking of data structures, let’s use the one in the previous section as a starting point.

pub struct EnvironmentData {
    pub temperature: u16,
    pub humidity: u16,
}

We’re getting data from a temperature + humidity sensor. Next, we want to encode it. In our case, we’re going to use serde and serde_cbor to do all the heavy lifting. Simply add

#[repr(C)]
#[derive(Debug, Serialize, Deserialize, Clone)]

over the EnvironmentData struct. We’ll end up with something like this:

#[repr(C)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvironmentData {
    pub temperature: u16,
    pub humidity: u16,
}

This will do two things:

  1. Allow you to serialize and deserialize that struct.
  2. It will also generate the c-bindings when #[repr(C)] is included.

This is a common theme about how cbindgen works. Anything that you’ll want to create a c-binding for will need an attribute added to it.

For adding functions we use the no_mangle attribute along with prefixing the function with pub extern "C":

#[no_mangle]
pub extern "C" fn encode_environment_data(data: &EnvironmentData) -> Encoded {

This combination is picked up by cbindgen and is used to generate a corresponding call in your generated C-library.

Every argument has a purpose. For example the no_mangle attribute, according to the Rust documentation, “.. turns off Rust’s name mangling, so that it is easier to link to.” i.e. It doesn’t add any extra cruft to the function name so you can easily call it from your C code!

Think with your C brain

When creating functions, like the above, you need to use your C coding brain to think about how you intend for the functions, structs, and enums to look like. Remember you’re limited to the standard C-types that are provided by the compiler you’re using. (In most cases arm-gcc)

For example, my encode function returned an Encoded struct. This struct not only had the encoded data, if valid, but it also included a return code indicating whether or not the operation was successful:

#[repr(C)]
pub struct Encoded {
    data: [u8; 96],
    size: usize,
    resp: CodecResponse,
}

As you can see in the above, I set the array size to the largest static size I anticipated necessary for the EnvironmentData struct encoding. (It was overkill, but overkill is better than a buffer overflow/fault!)

While in Rust you can use a Result or Option type, it’s not supported in cbindgen. Thus CodecResponse:

/// Struct that handles error codes
#[repr(C)]
pub enum CodecResponse {
    Ok = 0,
    EncodeError = -1,
    DecodeError = -2,
}

This allows you to return the Encoded struct from an encoding operation which then you can use to send data however which way you want! Speaking of encoding, let’s get to that in the next step.

Creating a function

Great so we have an Encoded response. Now let’s create a function that will generate it. That’s where defining encode_environment_data comes in. Since we’re coding this all in no_std using statically allocated structures, it’s more complicated versus the standard serde_cbor::to_vec!

Here’s what a full no_std encode looks like:

pub extern "C" fn encode_environment_data(data: &EnvironmentData) -> Encoded {
    // Encode
    let mut encoded = Encoded {
        data: [0; 96],
        size: 0,
        resp: CodecResponse::Ok,
    };

    // Create the writer
    let writer = SliceWrite::new(&mut encoded.data);

    // Creating Serializer with the "packed" format option. Saving critical bytes!
    let mut ser = Serializer::new(writer).packed_format();

    // Encode the data
    match data.serialize(&mut ser) {
        Ok(_) => {
            // Get the number of bytes written..
            let writer = ser.into_inner();
            encoded.size = writer.bytes_written();
        }
        Err(_) => encoded.resp = CodecResponse::EncodeError,
    };

    // Return the encoded data
    encoded
}

SliceWrite allows us to use a static array to create a serializer and then serialize the EnvironmentData into those bytes. It’s not one call, but it works, right?

Side note: this does get a little easier if you happened to wire up alloc to allow the allocation of dynamic memory. This is all system-dependent though and requires you to play nice with the heap allocation mechanism in your RTOS of choice.

In the end, we’ve created an encode function and supporting structs and enums that we’ll be able to use in our C-code shortly. But first, we’ll need to compile it into something useful!

Generating the C

There are some important components to generated C libraries using cbindgen. One of which is is a compiler. Let’s get that set up first:

It’s compile-time

For the most part, arm-gcc is the standard compiler for embedded work. For folks who run Mac they can use brew to install:

$ brew tap osx-cross/arm
$ brew install arm-gcc-bin

Alternatively, copies of nRF Connect SDK already come with a version of arm-gcc. For example on Mac the full path to arm-none-eabi-gcc is /opt/nordic/ncs/v1.5.0/toolchain/bin. If you are using a chip like the nRF9160 or the nRF5340 then you’ll likely want to keep using the same compiler.

You will, however, need to ensure that GNUARMEMB_TOOLCHAIN_PATH is then pointing to your toolchain directory. As an example:

export GNUARMEMB_TOOLCHAIN_PATH=/opt/nordic/ncs/v1.5.0/toolchain/

That will be important for the coming steps!

Install cbindgen

This should be a straight forward step as long as you have Rust installed:

cargo install cbindgen

Generate headers with cbindgen

Next, we’ll create the .h file that you’ll be using within your C-code. This is an example from the lib-codec-example in the Pyrinas Server repository.

$ cd lib-codec-example
$ cbindgen --config cbindgen.toml --crate pyrinas-codec-example --output generated/libpyrinas_codec_example.h --lang c

This will generate and copy libpyrinas_codec_example.h to a folder called generated.

Manage dependencies

Taking a look at the Cargo.toml file there are some important bits to be noticed. First, you’ll need to use crate-type to declare the output type. This is needed to produce the .a. file that we expect:

[lib]
crate-type = ["staticlib", "lib"] # C

Dependencies need to be included but with default-features turned off.

[dependencies]
serde = { version = "1.0.123", default-features = false, features = ["derive"] }
serde_cbor = { version = "0.11.1", default-features = false }

This removes any of the std features that are enabled by default.

Finally, we’ll need the panic-halt library which has some default hooks for any operation that may cause a panic during the execution of the code.

Without all of the above, you’ll likely run into compilation issues. Now to put it all together.

Compile the library

The last step here before we get into integrating is compiling everything together as a library file that can be imported into Zephyr.

cargo build --package pyrinas-codec-example --target thumbv8m.main-none-eabihf --release --no-default-features

You’ll notice that I’ve specified a --target option. thumbv8m.main-none-eabihf is the target for the nRF9160. Depending on your target processor, you. may have to alter it to thumbv7 or even thumbv6.

In the version in the repository, I’ve also made it compatible with std operations for the server-side. Using --no-default-features is important, again, to disable std features.

As long as things compile ok, you’ll get the result in /target/thumbv8m.main-none-eabihf/release as libpyrinas_codec_example.a

Now, let’s get this installed into Zephyr!

Installing and Using on Zephyr

So by this point, you have a header file (libpyrinas_codec_example.h) and a library file (libpyrinas_codec_example.a) so now we have to find a place to put them!

Directory Structure

Taking a look at the example code directory structure, it should look familiar to any Zephyr veterans out there.

❯ lsd --tree --icon never 
.
├── boards
│  └── circuitdojo_feather_nrf9160ns.overlay
├── CMakeLists.txt
├── Kconfig
├── lib
│  ├── include
│  │  └── libpyrinas_codec_example.h
│  └── libpyrinas_codec_example.a
├── Makefile
├── manifest.json
├── prj.conf
└── src
   ├── app.c
   └── version.c

The important thing is placing the library and header within the lib folder. You can change it up as you like just make sure to make the changes to your CMakeLists.txt accordingly.

Updating CMakelists.txt

CMakeLists.txt is the way to get your library installed into your Zephyr-based project. Everything after the line with # Add external Rust lib directory is related to adding the library and headers so it’s accessible to the C compiler.

# Name the project
project(pyrinas_cloud_sample)

# Get the source
FILE(GLOB app_sources src/*.c)
FILE(GLOB app_weak_sources ${PYRINAS_DIR}/lib/app/*.c)

target_sources(app PRIVATE ${app_sources} ${app_weak_sources})

# Add external Rust lib directory
set(pyrinas_codec_example_dir   ${CMAKE_CURRENT_SOURCE_DIR}/lib)
set(pyrinas_codec_example_include_dir   ${CMAKE_CURRENT_SOURCE_DIR}/lib/include)

# Add the library
add_library(pyrinas_codec_example_lib STATIC IMPORTED GLOBAL)

# Set the paths
set_target_properties(pyrinas_codec_example_lib PROPERTIES IMPORTED_LOCATION             ${pyrinas_codec_example_dir}/libpyrinas_codec_example.a)
set_target_properties(pyrinas_codec_example_lib PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${pyrinas_codec_example_include_dir})

# Link them!
target_link_libraries(app PUBLIC pyrinas_codec_example_lib -Wl,--allow-multiple-definition)

You’ll notice the creation of some compile-time variables, adding a library, setting some properties for the library, and finally linking the library. I did manage to get some warnings about multiple definitions which were mitigated by using the --allow-multiple-definition compiler flag.

Using it!

With everything installed, let’s import it into main.c and make some magic happen!

First, make sure it’s included:

#include <libpyrinas_codec_example.h>

Then let’s create some (bogus) data as if we were publishing it.

  /* Create request */
  EnvironmentData data = {
      .temperature = 1000,
      .humidity = 3000,
  };

  /* Encode the data with our sample library */
  Encoded encoded = encode_environment_data(&data);

  /* If we have valid encoded data, publish it.*/
  if (encoded.resp == Ok)
  {
    /* Do something! */
  }

As you can see we’re using the encode_environment_data function defined earlier in this post. You’ll also notice I’m avoiding the use of decimal points by multiplying the data by 100. This does save some bytes when during transmission.

In my case I chose to publish to a test instance of Pyrinas Server using the provide call:

/* Request config */
pyrinas_cloud_publish("env", encoded.data, encoded.size);

You can publish however you’d like to depend on the IoT library/protocol you’re using.

That’s it!

By now we’ve created some Rust code, created C bindings/library for that Rust code, and imported it into a Zephyr project. Now, for a siesta. 😴

Drawbacks to watch out for

As you may have guessed things get hairy especially when you start dealing with direct pointers to data. This turns your safe Rust code into unsafe Rust code pretty quick! Since getting this code working, I got excited and wanted to redo all of Pyrinas server’s data structures but ran into some show stoppers.

Large arrays (Strings) are troublesome

Since in Rust things need to be known at compile-time, it makes it difficult to provide constructs for variable-length data. Not only that but it’s also hard to use data, like a Git version string, since it’s typically beyond the limit of 32 across the Rust ecosystem. Arrays larger than that were simply not supported!

Since the release of Const Generics (Stable Release) this problem should be easier to wrangle. I’ve yet to play with this but I’m hopeful it provides a solution here.

Rust types like Option

According to the cbindgen documentation it does support Option types but I’m not exactly sure how it translates into C code. While it would be great to have, there are workarounds like creating your own struct which handles return data and whether or not it’s valid. (See the Encoded struct above)

Example code

This example code is both available in the Pyrinas Server repository and the Pyrinas Client repository.

More reading & final notes

If you’d like to read more about exporting Rust libraries for use in C code check out this great article on the Interrupt Blog. Also for more info on cbindgen, you can check out this article and its' documentation. After searching I also found this one which discusses conditionally making a crate std/no_std. That way it can be used both in an std context and no_std for embedded!

Additionally, a big thank you to all the folks involved in creating the projects mentioned in this post.

Finally, if you haven’t already seen it, I’m proud to announce the nRF9160 Feather Peach Cobbler Edition! It’s compatible with all the work I’ve laid out in this post and is an excellent jumping-off point for Cellular + GPS deployments.

nRF9160 Feather Peach Cobbler Edition

They’re on sale for a limited time, I’m offering 10% off with the checkout code CBINDGEN. 👉 Click here to grab one while they’re fresh!

Last Modified: 2021.5.18

Subscribe here!

You may also like

Testing Hardware Using Rust

Rust has grown on me over the past year. I consumed The Book while on a plane ride in February of last year right before Covid hit. It has basically been all downhill since. 😅…

CBOR for Embedded C and Rust

When sending data theres a few ways you can go about it. In the embedded world, it’s not uncommon to serialize data so it can be efficiently sent through the ether. On the other…