Written by Jared Wolff, Hardware and firmware enthusiast and proud father of the nRF9160 Feather.
This blog originally ran on Jared’s blog. For more content like this, visit https://www.jaredwolff.com/blog/.
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:
- Allow you to serialize and deserialize that struct.
- 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.