Embedded C and Rust: Moving Fast and Never Looking Back | Forged.dev Blog
Forged.dev Blog

Embedded C and Rust: Moving Fast and Never Looking Back

By Ryan Summers

Yesterday, I had an interesting project come up to quickly update firmware before hardware was manufactured where we had a large legacy C code base. This article is going to talk about how I used Rust to quickly develop embedded firmware without needing completed hardware or needing to convert any legacy firmware.

I’ll walk through how you can enable C- and C++-based driver development using Rust to make development go a whole lot faster.

Background

I was recently working on a consulting project for a client that was building a new product with some pretty tight timelines. We already had quite a large legacy C codebase from an existing product and were going to leverage that for the new project. Unfortunately, we also needed to change most of the part numbers in the hardware design due to chips reaching end-of-life. This, importantly, included a visible light sensor that played a central role in the product. We needed to build both new hardware and update the existing firmware to support the new design.

AS7343 Light Sensor

The ambient light sensor that I was replacing.

The replacement sensor was a newer revision from the same manufacturer based on the same architecture, but they had shuffled around registers and changed the underlying design a bit. Unfortunately, the documentation for these chips has been less than impressive - large portions of the sensor don’t have any description as to how they work and not all registers and functions are documented in the datasheet or application notes.

Because of this, it was looking like the best path forward would be to start testing out code as quickly as possible and see how the chip responded. I bought a small development board from the vendor to start up work on updating our driver source code.

While the board was shipping, I began updating our C-based driver for the new chip based on whatever I could find in the datasheet and got everything ready for once the development kit arrived.

The Dev Kit

The board arrived a few days later, and to my surprise, the dev kit was a lot simpler than expected and came with a pretty beefy USB cable. There’s not much on the actual dev board beside the sensor, voltage regulation, and some EEPROM memory. It’s all connected up to an I2C bus that is broken out to the pin headers on the board.

The Dev kit

The dev board shipped with a FTDI C232HM cable, which is a USB <-> MPSSE (Multi-Protocol Synchronous Serial Engine) adapter. This is a pretty cool FTDI chip that lets you talk USB <-> other protocols such as SPI, UART, GPIO, or whatever you need.

Honestly, this made me pretty happy to see. Firmware is an often-seen failure point in systems, so being able to talk USB<->I2C directly has been incredibly advantageous on a number of projects that I’ve worked on, and it ultimately results in far simpler systems.

Writing and Testing a New Sensor Driver

I’ve been an embedded Rust developer for the last ~6 years at this point and prefer using it over C. Unfortunately, C is a bit of a necessary evil in the industry that I work in. I try to be a pragmatist and use whichever tool makes my job easiest on a given day - this involves lots of Python and shell scripting to support the embedded C that I find myself writing.

Now that I had this dev kit and cable in my hand, I wanted to start testing out the updated driver with the least amount of effort. I looked at my usual suspects in Python and C to try to interface with the FTDI chip in the cable, but these all seemed like a lot of work to configure the MPSSE engine, manage drivers, etc., and I still wasn’t sure how I’d test out my C code once I got it working.

After not finding any other great solutions, I took a look on crates.io and found a few libraries to talk to the FTDI chip. I set up a quick rust project, imported the crate, and was talking to the FTDI chip with a single line of code. Overall, it was incredibly painless and way quicker than I could have done with another language. That was a pretty promising first start, but I still needed to figure out how to bring in the C code.

Replacing C with Rust

It’s important to remember that this driver was a small part of a big, legacy C project. Thankfully, it was pretty isolated from the system and could be used stand-alone without too much effort. I started to think about how I could migrate the C code into a Rust-based stack so that I could exercise and update the driver without needing the rest of the C firmware.

Rust vs C Stackups

Roughly what I came up with when thinking about how to convert the application engineering stack from C to Rust for testing.

Ideally, I wanted to test the C code and focus my development there. I didn’t want very much Rust (since the final code is C anyways) and just wanted it to work. All I really needed to do was provide an I2C interface for the sensor driver code, compile it all together, and see if the driver was working properly.

Thankfully, I found the awesome ftdi-embedded-hal crate that exposed an I2C implementation from the FT232H with a simple API. It also happened to use the exact same pinout as my dev kit, which was awesome.

The process to realize the C to Rust infrastructure was pretty simple:

  1. Compile the sensor driver C code during the Rust project build process
  2. Generate bindings for the C code so that I can call it from within Rust and provide Rust implementations for I2C access.

There were a few tools that I needed to handle combining the Rust and C code:

Thankfully, this can also be done automatically within a Rust build script so that it happens whenever you build a rust project (via a build.rs file):

// build.rs contents
extern crate bindgen;

use std::env;
use std::path::PathBuf;

fn main() {
    // Rerun this process if any of the external sensor driver code changes
    println!("cargo::rerun-if-changed=../c_project/src/sensor_driver.c");
    println!("cargo::rerun-if-changed=../c_project/src/sensor_driver.h");

    // Generate bindings for the sensor driver C code so we can call it from Rust.
    let bindings = bindgen::Builder::default()
        .allowlist_file("sensor_driver.h")
        .generate()
        .expect("Unable to generate bindings");

    // Compile the C-based sensor driver code.
    cc::Build::new()
        .file("../c_project/src/sensor_driver.c")
        .cpp_link_stdlib("stdc")
        .compile("as7343");

    // Put the automatically generated Rust bindings for the C code into the Rust project.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

The main project file was also pretty simple, as I just wanted to verify I could perform measurements:

// main.rs file

// Include the bindings we generated using bindgen so we can call the `sensor_drvier_*` functions
// that are defined in C
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

use libftd2xx::Ftdi;

fn main() {
    let ft232h: Ft232h = Ftdi::new().unwrap().try_into().unwrap();

    // Open an I2C interface on the FT232H at 100KHz.
    let device = ftdi_embedded_hal::FtHal::init_freq(ft232h, 100_000).unwrap();
    let i2c_interface = device.i2c().unwrap();

    // Begin basic sensor testing. This is calling into the C API that bindgen is providing us
    // access to.
    unsafe {
        sensor_driver_init();
        sensor_driver_prepare_measurement();

        sensor_driver_start_measurement();
        while !sensor_driver_measurement_complete() {
            // Wait for the measurement to complete.
        }

        let mut result = [0u8; 16];
        sensor_driver_read_result(&mut result);
        println!("Measurement result: {result:?}");
    }
}

Trying to compile this without providing the necessary I2C implementation for the C code results in the compiler (justifiably) complaining about not being able to find definitions for the i2c functions:

// .... Attempting to run the above results in complaints about missing function references.

          C:\Users\rsummers\Documents\repositories\c_project\sensor-test\..\src\sensor_driver.c:23: undefined reference to `i2c_write'
          C:\Users\rsummers\Documents\repositories\c_project\sensor-test\..src\sensor_driver.c:92: undefined reference to `i2c_read'
          collect2.exe: error: ld returned 1 exit status

  = note: some `extern` functions couldn't be found; some native libraries may need to be installed or have their path specified
  = note: use the `-l` flag to specify native libraries to link
  = note: use the `cargo:rustc-link-lib` directive to specify the native libraries to link with Cargo (see https://doc.rust-lang.org/cargo/reference/build-scripts.html#rustc-link-lib)

error: could not compile `sensor-test` (bin "sensor-test") due to 1 previous error; 0 warnings emitted

The sensor driver code only used a few basic I2C functions to do everything, so there wasn’t much that needed to be implemented on the Rust side.

We can fix the compile errors by providing the necessary i2c functions in a new c_api.rs file. There’s a little bit of global-variable shenanigans here to make the single I2C instance shared across the various I2C functions, since Rust and C have different models around variable ownership. This is largely just boilerplate needed to get things to work in my case since I’m not doing anything multithreaded or complex:

use std::sync::{Mutex, OnceLock};

use crate::bindings::*;

use embedded_hal::i2c::*;
use libftd2xx::{Ft232h, Ftdi};

// Create global singletons of the I2C device so that we can access them via the i2c_read and
// i2c_write functions.
static _device: std::sync::OnceLock<ftdi_embedded_hal::FtHal<Ft232h>> = OnceLock::new();
static i2c: std::sync::OnceLock<Mutex<ftdi_embedded_hal::I2c<Ft232h>>> = OnceLock::new();

// Open the I2C interface and prepare it for use by the driver.
pub fn open_i2c() {
    let ft232h: Ft232h = Ftdi::new().unwrap().try_into().unwrap();
    let device = ftdi_embedded_hal::FtHal::init_freq(ft232h, 100_000).unwrap();
    let i2c_interface = device.i2c().unwrap();

    // Store the I2C and FTDI chips as global singletons so we can use them in other functions
    // later.
    i2c.set(Mutex::new(i2c_interface)).unwrap();
    _device.set(device).unwrap();
}

#[no_mangle]
pub extern "C" fn i2c_read(addr: u8, buf: *mut u8, count: usize) {
    let buf: &mut [u8] = unsafe { core::slice::from_raw_parts_mut(buf, count) };
    i2c.get()
        .unwrap()
        .lock()
        .unwrap()
        .read(addr, &mut buf[..])
        .unwrap();
}

#[no_mangle]
pub extern "C" fn i2c_write(addr: u8, buf: *const u8, count: usize) {
    let buf: &[u8] = unsafe { core::slice::from_raw_parts(buf, count) };
    i2c.get()
        .unwrap()
        .lock()
        .unwrap()
        .write(addr, &buf[..])
        .unwrap();
}

Now, if we include that in our main.rs file, we can successfully run it:

// Include the bindings we generated using bindgen so we can call the `sensor_drvier_*` functions
// that are defined in C
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

// Include the newly added C API for I2C.
mod c_api;

fn main() {
    // Open I2C so that it can be accessed by the C API.
    c_api::open_i2c();

    // Begin basic sensor testing of the C API that is imported in the bindgen bindings.
    unsafe {
        sensor_driver_init();
        sensor_driver_prepare_measurement();

        sensor_driver_start_measurement();
        while !sensor_driver_measurement_complete() {
            // Wait for the measurement to complete.
        }

        let mut result = [0u8; 16];
        sensor_driver_read_result(&mut result);
        println!("Measurement result: {result:?}");
    }
}

Running this code with cargo run successfully prints out sensor measurements! We can also probe the I2C lines of the dev board to see all of the I2C transfers taking place to confirm. We can even modify the sensor-driver.c file and see the results immediately when we re-run the Rust code.

This is now a solid development platform where I can update the sensor driver C code and immediately start testing it on my computer with Rust, and I’m no longer bottle-necked by the hardware that still needs to get built!

Conclusion

Rust is an awesome language because of the massive reusability that the crates.io ecosystem provides. Unfortunately, it’s often hard to incorporate into existing projects because of legacy C code. Thankfully, there’s a lot more uses for Rust outside of device firmware where it can help empower us to get our jobs done faster! I thought this was a super cool way to uncouple firmware development from hardware and get projects done faster faster.

The total time it took me to set things up was less than 60 minutes and that was largely because I’ve never used cc or bindgen before. This is going to give me a solid base to get our sensor driver code updated, and I hope it inspires you to to look for more ways that Rust can be used!