Most available RTOS are somewhat reluctant to play with Rust. The only RTOS that actually does claim to have first class citizen Rust support is VXWorks. Other offerings leave the integration up to the user as Rust is not their focus. Luckily almost all RTOS nowadays are written in C which at least makes the basic integration somewhat easy, i.e.: Using a C FFI we can easily call from Rust to C and vice versa. However, one of the larger challenges is, that common RTOS (such as FreeRTOS, embos or Zephyr) rely on the C Preprocessor to generat code at compile time. The respective macros are often part of the RTOS’ API, which makes using the RTOS in Rust cumbersome.
I’ve tackled “simple” RTOS like embos in the past, but Zephyr is another beast, in that it sports a completely integrated buildsystem based on CMake, a lot of codegeneration during configuration time and macros that are not only used to declare functions, but also to delcare loads of variables (most notably kernel objects such as threads and mutexes are declared by means of macros).
Note: There is a crate to integrate Rust and Zephyr (https://github.com/tylerwhall/zephyr-rust/tree/master/rust), however this create will only work with an older Zephyr version and doesn’t seem see much maintenance. The crate is a more complete solution to the problem.
Disclaimer: The code presented in this article is by no means production ready and should be considered an idea of what a possible integration of Rust into a Zephyr app could look like. That said most code was written in a very quick and dirty fashion.
What we need
For this article I used:
- Zephyr 3.14, installed as described in Zephyr’s documentation
- CMake 3.25
- Corrosion.rs
- Rust 1.70 with the thumbv7m-none-eabi target installed
- Bindgen
- My trusty nrfDK
- For Debugging we use Segger’s “Ozone” Debugger which can be used free of charge for educational purposes
Basic Setup
After the canonical setup of Zephyr you should have an installation directory that roughly looks like this:
.west
bootloader
modules
tools
zephyr
We’re building an out-of-tree app for Zephyr based on the generic “blinky” example. To do that first create a folder named “app” in the zephyr root folder. Copy the folder zephyr\samples\basic\blinky to the `app` folder. The folder should look like this now:
.west
app
bootloader
modules
tools
zephyr
We’ll have to create any bindings for the Zephyr kernel API ourselves. Since bindgen will attempt to process the C source tree and all headers, this will fail with our current state of the source tree, as Zephyr will generate a whole bunch of code in the CMake configuration step, and if that is missing using bindgen will fail with all kinds of weird errors. To prevent that, we have to first run the CMake configure step once for the board we’ll be using. For my nrfDK this boils down to:
west build -p always -b nrf52840dk_nrf52840 app/blinky
Since our basic application is already a fully functional, albeit limited, application this step should work as is.
The Rust Crate
Add a new Rust crate next to the blinky folder. For me the app folder looks as follows afterwards:
blinky
zephyrrustapp
In order for the Rust crate to be linkable to the Zephyr kernel we need to compile it as static library. To do so, open the `Cargo.toml` file and add the following
[lib]
crate-type=["staticlib"]
This will tell rustc to emit a .a file.
Corrosion
Corrosion is a CMake extension that can process Cargo.toml files and make the binaries defined in that files available as CMake targets. This allows us to nearly trivially add these binaries into an existing CMake build. Luckily, Zephyr comes with a CMakeBuild. Take a look at the file `CMakeLists.txt` in the `blinky` folder. It should look like this:
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(blinky)
target_sources(app PRIVATE src/main.c)
Adding Corrosion.rs is trivial. Just add the following after the last line:
# Lock target to thumbv7em, needs to be done before adding corrosion.
# if your MCU needs a different target, adjust accordingly
set(Rust_CARGO_TARGET thumbv7m-none-eabi)
include(FetchContent)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.3.5 # Optionally specify a commit hash, version tag or branch here
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/../zephyrrustapp/Cargo.toml)
target_link_libraries(app PRIVATE zephyrrustapp)
The above will bring our app’s build together with Zephyr’s build and we can already call into the crate, if we use Rust’s FFI to create exports without mangled names, i.e. if we define a function like so:
#[no_mangle]
extern "C" fn rust_function() { /*...*/ }
This function is callable from C:
extern void rust_function();
void main()
{
rust_function();
}
The elephant in the room
While the above is already pretty neat, we’ll fall on our face when we want to interact with the Zephyr kernel. We can’t really call kernel functions from Rust yet, nor can we actually interact with kernel objects, the reson being, that we don’t have access to the kernel’s C API. In order to do so we’ll use bindgen. Bindgen is a tool that, given a C header file, will produce an import module for Rust. In order to be able to use bindgen, add it as a build dependency to your Cargo.toml file:
[build-dependencies]
bindgen = "0.65.1"
Then add a build script to the crate (i.e. add a file called “build.rs” to the crate’s root, on the same level as the Cargo.toml file). In the build script add the following:
extern crate bindgen;
use std::env;
use std::path::{PathBuf, Path};
fn main() {
// Tell cargo to invalidate the built crate whenever the wrapper changes
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
// The input header we would like to generate
// bindings for.
.use_core()
.detect_include_paths(true)
/*
!
! Adjust paths in the following sections according to your directory layout!
!
*/
.detect_include_paths(true)
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/zephyr/include")
.clang_arg("-IE:/code/zephyrplayground/zephyr-sdk-0.16.1_windows-x86_64/zephyr-sdk-0.16.1/arm-zephyr-eabi/arm-zephyr-eabi/include")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/zephyr/include/zephyr")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/build/zephyr/include/generated")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/zephyr/soc/arm/nordic_nrf/nrf52")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/zephyr/soc/arm/nordic_nrf/common")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/zephyr/modules/hal_nordic/nrfx")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/modules/hal/nordic/nrfx")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/modules/hal/nordic/nrfx/mdk")
.clang_arg("-IE:/code/zephyrplayground/zephyrproject/modules/hal/cmsis/CMSIS/Core/Include")
.clang_arg("-DNRF52840_XXAA")
.header("wrapper.h")
// Tell cargo to invalidate the built crate whenever any of the
// included header files changed.
// Finish the builder and generate the bindings.
.generate()
// Unwrap the Result and panic on failure.
.expect("Unable to generate bindings");
// Write the bindings to the $OUT_DIR/bindings.rs file.
let out_path = PathBuf::from("./src/bindings.rs").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
The above will invoke bindgen and have it process a file called `wrapper.h`. All files included from there will also be processed, i.e. it behaves like a regular include file. All that is left to do is to write wrapper.h. Add the file to the crate’s root right next to the build script and insert the following:
#include "autoconf.h"
#include <zephyr/kernel.h>
If you now build the whole project using west your binding should show up in the crate’s src directory. It is also possible to build just the crate, however, if you wish to do so be advised, that:
- You’ll need to build using to correct rustc target for the board, otherwise the bindgen invocation will fail, usually with weird errormessages about the CPU architecture not being set or missing defines.
- You’ll have to execute Zephyr’s CMake configure step at least once beforehand. This is due to that step generating a bunch of glue code that bindgen will have to process. Not doing this will result in bindgen complaining about missing include files.
- If you change Zephyr’s kernel configuration be sure to rerun bindgen. Depending on your kernel options structs may change their layouts, members and sizes which will wreak havoc on the language boundary if it goes unnoticed.
That is nasty!
Looking at the generated binding you’ll notice that it is definitely not for the faint of heart, however it will give us access to a bunch of kernel functions easily. However you’ll also notice the lack of any functions regarding the creation of threads/tasks. While there are structures such as `_static_thread_data` there is a curious lack of an API for creating threads and other functionality. The reasons are twofold:
As mentioned earlier a lot of RTOS (and Zephyr is unfortunately no exception) rely heavily on the C preprocessor for parts of their API. Looking at Zephyr’s documentation we find the macro `K_THREAD_DEFINE`, that is used to statically allocate a thread and its stack. As this is a macro we can’t easily use it on the far side of the FFI.
The second curious thing is the lack of an obvious API to interact with kernel objects once they are created. While threads and – e.g. -mutexes ar created by means of the mentioned macros, the structs holding the data are obviously only part of the equation, we also need the API functions that work with these objects, and those seem to be missing as well, even though they are not implemented using macros. What is wrong here? Let’s look at Zephyrs `kernel.h` for the definition of k_mutex_lock:
__syscall int k_mutex_lock(struct k_mutex *mutex, k_timeout_t timeout);
As it turns out, Zephyr ships with its own flavour of GCC, that will process the `__syscall` intrinsic. Zephyr’s documentation states: The syscall attribute is very special. To the C compiler, it simply expands to ‘static inline’.
So, what happens is, that these function definitions are transformed to static inline functions, which will basically make them invisible unless the header is explicitly included into a C file (and then they are only visible in that C file!), thus causing behavior similar to a macro, when looked at from Rust’s perspective. A sensible approach would probably to use a C wrapper around the syscalls and call that from Rust. The bad: Performing a full-text search on Zephyr’s code base yields just a bit more than 500 occurrences of the __syscall intrinsic – we can’t really hope to do this by hand, especially since there a truckload of different struct involved in all calls, that are all potentially subject to change when the kernel configuration changes and/or when Zephyr is updated.
zephyr-rust uses bindgen to figure out the syscalls. While that is pretty neat there’s still a ton of heavy lifting involved to actually get the API to work. I do appreciate the effort made there, but the approach seems to suffer from a larger maintenance burden than necessary, given that they’re still stuck on a quite old Zephyr version. I’d favor a lighter approach, that will yield results faster.
Wrap-Up
Since this post got somewhat long I decided to split it up into several parts. In the next installment we’ll have a look at my solution for dealing with Zephyr’s thirst for macros and intrinsics.
Image Credit:
Hi, thanks for your post!
I also wanted to try out zephyr with rust, but I get some errors.
I think there is a typo in your build.rs; shouldn’t it be something like
let out_path = PathBuf::from(“./src/”);
bindings
.write_to_file(out_path.join(“bindings.rs”))
.expect(“Couldn’t write bindings!”);
Removing the “)” which seems to be too much, and changing out_path to only the directory, I get:
/home/user/zephyrproject/zephyr/include/zephyr/arch/arm/mpu/arm_mpu_v7m.h:10:10: fatal error: ‘cmsis_core.h’ file not found
thread ‘main’ panicked at build.rs:42:10:
Unable to generate bindings: ClangDiagnostic(“/usr/include/limits.h:145:5: error: function-like macro ‘__GLIBC_USE’ is not defined\n/usr/include/limits.h:184:5: error: function-like macro ‘__GLIBC_USE’ is not defined\n/home/user/zephyrproject/zephyr/include/zephyr/arch/arm/mpu/arm_mpu_v7m.h:10:10: fatal error: ‘cmsis_core.h’ file not found\n”)
Another question: I have a nRF52_DK (nRF52832). Do I have to change the -DNRF52_XXAA argument? If yes, what do I need to use?
Thanks again,
Konrad
Hi Konrad,
I can’t really do tech support here. The settings your build script needs to mimic are those, that are used to compile C code, so if NRF52_XXAA is not set, when you compile C for your board, you’ll not need it (but probably something else – again, have a look at what flags are passed to the C compiler). I’m also not sure if the Zephyr version you’re using the same I used when I wrote the article. For one, the include path “zephyrproject/zephyr/include/zephyr/arch/arm/mpu/arm_mpu_v7m.h” does not exist in my zephyr distribution (I do have zephyrproject\zephyr\include\zephyr\arch\arm\aarch32\mpu\arm_mpu_v7m.h, which does however not try to include “cmsis_core.h”). I guess you’ll have to extend the build script yourself and add the correct directory to the list of include directories then.
Regards
Philipp
Hi,
Thanks for the blog.
I have a very specific usecase where I need to call rust crate `postcard` from zephyr application so that I can use it serialize and add crc. I want to interact it with rust application on my computer. Is there a way I can do this. Most examples of interop are where you use your own package but I want to use `postcard`