Embedded Systems Software [ESS], the modern approach

Rodrigo Peixoto
10 min readJan 5, 2023

--

It is common to see embedded software developers trying to replace C in their projects. C [ I love C] is an excellent language, simple, powerful, stable, consolidated, fast, etc. However, the main [current] complaint about C is security and the lack o language features to aid developers in dealing with high-complexity systems like Object Oriented Programming, better error handling, and other elementary [after 51 years of C’s first appearance] features are missing.

Several embedded software developers tried using C++ [I am one of them] to fill the gap. Sadly [maybe not], the C++ inventors decided to go out of the GCC and invented G++, which compiles C. So C++ compiles C but not the opposite. Unfortunately, the C++ code can use C libraries [for example]; however, the generated binary is incompatible with the C compiler. So C++ does not integrate with C. It only uses that; this is a problem for embedded systems.

[In my own experience] I have tried to use C++ with Zephyr RTOS. It was challenging at first because most of the parts of the system are C based, and some libraries did not work because they relied strongly on C macros, which C++ decided not to be compatible with. Some basic subsystems, like the shell, were not functional in C++, so I proposed a change to make that available to C++ at that time. After some attempts, C++ choices [like C macro incompatibility, struct fields sequence in initialization code, and other C++ design choices] made my life hard, and I gave up. Finally, the C++ support [on the project] got better, which is more likely to be used nowadays. I decided to go further instead of retrying that.

Zephyr Project and Rust working together.

After more years of working with embedded software, I could see some languages emerging to fill the gap. Rust was the one I liked more. The concept and way things work there make me think it can be a concrete alternative to C and C++ on embedded software. Thus I learned that and started to try using it. After some time, I could find great projects related to Rust and embedded systems. I can point out some here quickly:

  • Tock is a pure Rust real-time operating system;
  • RTIC is “a pseudo scheduler that relies on async/wait and makes thing looks like magic there”;
  • Embassy is a big project with a lot of resources and looks promising. It really can be a good choice in the future.

But looking at the projects, I could see that the mindset of some Rust developers usually is to rewrite everything [and “forget” the past]. For sure, it is not the majority of the cases, but it seems that it is possible to hear some [religious] Rust developer saying: “Rust is safe, fast, and pure we must not rely on C [impure code].” All the cited projects are trying to solve all the aspects [HAL, scheduler, interrupt service routine, etc.] from scratch. If you look, a few portions [maybe none] of the code from the projects are reused from mature projects. The rewriting [from scratch] is good, but it takes a long time to mature and apply to real-life projects. So I started to doubt the Rust adoption in the short term. Too much effort from the community and no perspective to be used in real-life projects.

[After the mindset discovery] I started to try another approach with Rust. Why not reuse mature code from stable projects and mix that with Rust without a heavy binding generation? I want to explore the excellent part of Rust and not reimplement everything in Rust. I want to make embedded software better rather than pure something. I am delighted with Zephyr’s infrastructure, maturity, and community. Zephyr is a great project [why “discard” all of that and start again?]. It supports several architectures and hundreds of boards. As the wise said: “start from they ended and not from they started.” I can build a system that uses Zephyr as the base, and on top of that, I could use Rust to make the complex application logic code portion; it would be immediate. So I started to dig.

There is a heavy binding project [with it, I mean all the available APIs are “translated” to Rust, usually in an automated way] proposition started at this repository. I suppose the heavy binding approach did not work because they stopped to maintain the code since version 2.5 of Zephyr. It makes people use all in Rust, and sometimes the generated bindings are odd [to C developers] and verbose [sometimes]. After all, the underlying code is C, so there is no safety and speed here because everything you use will call a C function. So I prefer to rewrite the code using the Rust way of doing things, but only the necessary parts. The rest I prefer to keep in C. I feel in this way, the migration to Rust would happen organically, and people would start implementing new APIs directly in Rust at some point, as it is going on with Android and Linux.

My main contribution to the Zephyr project was the zbus — a message bus that enables threads to talk to each other in a many-to-many simple, and unified way. I was figuring out if I could make the Rust portion speak with the rest of the system using the zbus, which makes it independent from the hardware code. Another goal is to use as few kernel APIs as possible to make the binding easy.

The sample

I started to rewrite a zbus sample that consists of a producer and consumer sending back ACKs to the producer using Rust. The code I implemented here is not the better or perfect code; some of that is illustrative only to prove it works. I started by looking at the David Brown video about non-C languages and Zephyr. There I could find a good starting point. I used his approach [code repository] of using the Rust code as an external project.

The image below illustrates the scenario [the green rectangles are Rust code, and the rest is C]. We have the producer [a Rust thread] and the consumer [a C thread]. Besides these threads, we have the three ACK channel observers: a C listener, a Rust listener, and a Rust subscriber [inside the producer].

Producer consumer sample using Rust + C in Zephyr RTOS.

The code for the sample is on this repository. The consumer thread is a regular Zephyr C thread that uses zbus to receive data and acknowledge the producer with a sequence number.

/*
* Copyright (c) 2022 Rodrigo Peixoto <rodrigopex@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
#include "messages.h"

#include <zephyr/logging/log.h>
#include <zephyr/zbus/zbus.h>
LOG_MODULE_DECLARE(zbus, CONFIG_ZBUS_LOG_LEVEL);

ZBUS_SUBSCRIBER_DEFINE(consumer_sub, 4);

ZBUS_CHAN_DEFINE(ack_chan, /* Name */
struct ack_msg, /* Message type */
NULL, /* Validator */
NULL, /* User data */
ZBUS_OBSERVERS(c_listener, rust_listener, rust_sub), /* observers */
ZBUS_MSG_INIT(.sequence = 0) /* Initial value */
);

static void consumer_subscriber_thread(void)
{
const struct zbus_channel *chan;
struct ack_msg ack = {.sequence = 0};
while (!zbus_sub_wait(&consumer_sub, &chan, K_FOREVER)) {
struct acc_msg acc;

zbus_chan_read(chan, &acc, K_MSEC(500));
LOG_INF(" --> Consuming data: Acc x=%d, y=%d, z=%d", acc.x, acc.y, acc.z);

++ack.sequence;
zbus_chan_pub(&ack_chan, &ack, K_MSEC(250));
}
}

K_THREAD_DEFINE(consumer_thread_id, 512, consumer_subscriber_thread, NULL, NULL, NULL, 3, 0, 0);

The producer thread is a Rust code portion that makes use of the minimal Zephyr wrapper I did to make the code work:

  • Zbus operations [implemented in a Rust way to work]: pub, read, sub, claim, finish, etc.;
  • Dynamic memory allocator based on Zephyr’s k_malloc and k_free. This feature makes the alloc and core crates available to use in Rust;
  • A Rust’s Duration API compatible with Zephyr k_timeout_t;
  • Sleep timeout function that wraps the k_msleep;
  • Logging wrapper, based on a zbus channel;
  • Printk wrapper.

The code below reveals the Rust producer implementation. The producer is also a subscriber to the ACK channel. When the producer receives the ACK channel notification, it prints the sequence number exchanged by the channels’ message and publishes the new data on the ACC channel again.

/*
* Copyright (c) 2022 Rodrigo Peixoto <rodrigopex@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
#![no_std]
#![feature(alloc_error_handler)]
#![feature(c_variadic)]
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

extern crate alloc;

use alloc::format;
use core::time::Duration;
use zephyr::*;

// This is generated by the bindgen based on the messages.h file.
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
mod zephyr;

zbus_static_channel_declare! {
name: acc_data_chan, // the same name as in C
msg_type: struct_acc_msg // Add the 'struct_' prefix from the name declared
}
zbus_static_channel_declare! {
name: ack_chan,
msg_type: struct_ack_msg
}
zbus_static_subscriber_declare! {
name: rust_sub
}

#[no_mangle]
pub extern "C" fn rust_thread() {
z_log_inf!("Rust thread started!");

let mut acc = struct_acc_msg { x: 1, y: 2, z: 3 };

loop {
match acc_data_chan.publish(&acc, Duration::from_secs(1)) {
Ok(_) => z_log_inf!("Rust producer: Message sent!"),
Err(e) => z_log_err!("Could not publish the message. Error code {e}"),
}
acc.x += 1;
acc.y += 2;
acc.z += 3;

// Wait for the ACK
match rust_sub.wait(Duration::MAX) {
Ok(_) => {
match ack_chan.read(Duration::from_secs(1)) {
Ok(struct_ack_msg { sequence }) => {
z_log_wrn!("Rust subscriber sequence: {sequence}")
}
Err(e) => z_log_err!("Could not publish the message. Error code {e}"),
};
}
Err(e) => z_log_err!("No notification arrived. Reason code {e}"),
}
sleep(Duration::from_secs(3));
}
}

Note that we are including the Rust bindings generated based on the messages.h in this sample code file. It enables the Rust code to use the same message structs defined in C to keep the size and alignment of the code. The code below is the bridge between the Rust code and Zephyr’s. It enables us to implement everything with few kernel APIs bindings.

/*
* Copyright (c) 2022 Rodrigo Peixoto <rodrigopex@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
#include <zephyr/kernel.h>
#include <zephyr/zbus/zbus.h>
#include <zephyr/logging/log.h>
#define LOG_LEVEL LOG_LEVEL_DBG
LOG_MODULE_REGISTER(rust_bridge);

#include "messages.h"

static struct version_msg acc_fw_version = {1, 3, 2089};

ZBUS_CHAN_DEFINE(acc_data_chan, /* Name */
struct acc_msg, /* Message type */
NULL, /* Validator */
&acc_fw_version, /* User data */
ZBUS_OBSERVERS(consumer_sub), /* observers */
ZBUS_MSG_INIT(.x = 0, .y = 0, .z = 0) /* Initial value */
);

/* External declaration for the Rust callback. */
void rust_function(const struct zbus_channel *chan);

/* External declaration for the Rust thread. */
void rust_thread(void);

void c_listener_callback(const struct zbus_channel *chan)
{
const struct ack_msg *msg = zbus_chan_const_msg(chan);
LOG_DBG("C listener sequence: %u", msg->sequence);
}

ZBUS_SUBSCRIBER_DEFINE(rust_sub, 4);

ZBUS_LISTENER_DEFINE(c_listener, c_listener_callback);

ZBUS_LISTENER_DEFINE(rust_listener, rust_function);

K_THREAD_DEFINE(rust_thread_id, 1024, rust_thread, NULL, NULL, NULL, 3, 0, 0);

Thanks to David Brown’s CMake configuration example, all the magic happens automatically. You “just” [it is not that easy at first] need to set the target correctly on the Rust side to be sure the project links compatible binaries. After that, it is only necessary to run west flash, and it is done!

Some parts of the code [mainly printk or log related] work in one architecture and do not on another. I saw several instances of that trying to use ARM and RISCV. The latter seems to be more stable and easy to use. For example, I could use the same code on the hifi1_revb and esp32c3_devkitm boards, which worked perfectly. However, the final sample code is still not working on ARM [I will keep trying].

Closing thoughts

Rust is an excellent match for this hybrid C plus something [modern] approach. I like Rust and I am using it on a complex project to evaluate the attempt. It may become a common practice shortly. Zbus [an event-driven architecture] makes the approach more feasible.

Unfortunately, using Rust on embedded systems is still a challenge. Cross-compiling, testing [the Rust part], picking the right target platform, dealing with bindgen [build.rs], mixing compilers [GCC and LLVM, for instance], tweaking the CMakeLists.txt to compile all of that together with Zephyr, and other tricky things. A lot to pay for the benefit of using that. Is it worth it? In some months, I will be able to reply to that with confidence.

Extra Zig

Another language I could see is Zig. That is a language like C; there are no classes, constructors, destructors, closures, interfaces [or something similar like Rust trait], and so on. So the language is a step further from C, which is interesting because it will be a small increment of features [will be easy to adopt]. Thus, it has impressive integration with C, making it possible to include C header files on the Zig code or even compile C. It could be an excellent match for hybrid embedded software as well. But, for now [personally], I will invest in Rust based on its popularity and features. I won’t be surprised if Zig gets official support on well-known RTOSes soon. The code below shows the power of including the C header [zephyr.h in this case] directly in the Zig code.

// SPDX-License-Identifier: Apache-2.0
// Bindings to things in Zephyr.

const std = @import("std");

pub const c = @cImport({
@cInclude("zephyr/zephyr.h");
});

// Raw are bindings to direct calls in C.
const raw = struct {
extern fn zig_log_message(level: c_int, msg: [*:0]const u8) void;
extern fn k_uptime_get() u64;
};

pub const k_uptime_get = raw.k_uptime_get;

// A single shared buffer for log messages. This will need to be
// locked if we become multi-threaded.
var buffer: [256]u8 = undefined;

// Use: `pub const log = zephyr.log;` in the root of the project to
// enable Zig logging to output to the console in Zephyr.
// `pub const log_level: std.log.Level = .info;` to set the logging
// level at compile time.
pub fn log(
comptime level: std.log.Level,
comptime scope: @TypeOf(.EnumLiteral),
comptime format: []const u8,
args: anytype,
) void {
_ = scope;
const msg = std.fmt.bufPrintZ(&buffer, format, args) catch return;
raw.zig_log_message(@enumToInt(level), msg);
}

I read a good text about Zig https://medium.com/swlh/zig-the-introduction-dcd173a86975; maybe you are interested in going further. I may try to do the same I did with Rust but in Zig. I will post it here if it happens.

--

--