< View all posts

Embedded Rust with C HAL libraries

Posted on , by Samuel Leeuwenburg

Rust is a great language for embedded software development, its got safety and low level speed while still giving you access to higher level programming. But one of the barriers of entry for embedded is not only lower level access to memory but also board support, sure Rust can compile to ARM but this does not mean you can run your Rust code on any ARM chip out of the box and expect to have a good time.

The embedded industry relies on solutions provided by manufacturers, these come in many shapes and forms ranging from Hardware Abstraction Layer (HAL) libraries to fully integrated code generating IDEs where all you need to do is press the play button and your code will run. One thing however, is universal: the use of C. Everywhere you go you will find C.

The Rust community has done amazing things to solve this problem. Using svd2rust you are able to generate Rust code using the manufacturer supplied .svd file, this results in a Peripheral Access Crate (PAC) these are then used to create the HAL libraries in Rust itself. The community uses shared traits defined in the embedded_hal crate to share hardware abstractions across crates providing a uniform and very thought out development experience.

For example for the STM32F4 there is the PAC and the HAL crates which are well maintained, documented and provide plenty of examples to get you started using nothing but Rust.

But now imagine, you have a chip and find out that the Rust HAL isn’t quite there yet and you don't have the time on your hands to implement a HAL yourself or to contribute. You do have this nice C HAL supplied by the manufacturer, and sure it is not as safe as any Rust implementation will be, but you trust it enough. What if we could just build our entire application in Rust but call the C functions provided by the manufacturer?

With bindgen we can!

The hardware

stm32f439 nucleo-f439zi dev board

Meet the Nucleo-F439ZI devboard, it has a STM32F439 M4 microcontroller running at 180 Mhz with 2Mb flash memory and 256Kb SRAM.

Now in all fairness this is a poor example because there is actually really good Rust support for this board, but as an example it will do.

"Nothing better than C"

I will spare you the full details on getting the C HAL code from STM, but just to point you in the right direction: STM has a tool called STM32CubeMX which is a graphical user interface where you can search for the development board you have and it will provide you with a scaffold project. In case you just want to take a look at it or download it directly I've got it in the example project on Github.

Let’s browse around the generated files, we can ignore the Core/Src/main.c and focus on the included files instead which is what we're really after. A good place to start is the Makefile. If you search for the .elf file target you should see something like this:

$(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) Makefile
	$(CC) $(OBJECTS) $(LDFLAGS) -o $@
	$(SZ) $@

As you can see it requires the OBJECTS and Makefile itself, and runs the compiler (CC) with the objects as arguments and the linker flags (LDFLAGS). We are interested in the sources and the header files. if you do a bit more digging you will find two variables C_SOURCES and C_INCLUDES. We will use these to build a static library and generate rust bindings to it.

A HAL of our own

Create a new Rust library, move the CubeMX content to a subfolder ./c and create a build.rs file.

$ cargo new stmlib --lib
$ mv CubeMXGeneratedStuff ./stmlib/c
$ cd stmlib
$ touch build.rs

Inside build.rs we will generate our static library using the contents we peeked from the Makefile

extern crate cc;

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

fn main() {
    let dst = PathBuf::from(env::var_os("OUT_DIR").unwrap());

    let mut cfg = cc::Build::new();

    // disable warnings produced by the lib
    cfg.warnings(false);

    cfg.define("STM32F439xx", None)
        .define("USE_HAL_DRIVER", None)
        .include("c/Core/Inc")
        .include("c/Drivers/STM32F4xx_HAL_Driver/Inc")
        .include("c/Drivers/STM32F4xx_HAL_Driver/Inc/Legacy")
        .include("c/Drivers/CMSIS/Device/ST/STM32F4xx/Include")
        .include("c/Drivers/CMSIS/Include")
        .file("c/Core/Src/stm32f4xx_it.c")
        .file("c/Core/Src/stm32f4xx_hal_msp.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_flash.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_flash_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_flash_ramfunc.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_dma_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_dma.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_pwr.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_pwr_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_cortex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_exti.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_eth.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_tim.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_tim_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_uart.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_pcd.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_pcd_ex.c")
        .file("c/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_ll_usb.c")
        .file("c/Core/Src/system_stm32f4xx.c")
        .out_dir(dst.join("lib"))
        .compile("stm32_hal");
}

Now build the project

$ cargo build

There should be a stm32_hal.a file in the ./target/ folder.

$ find ./target | grep stm32_hal
./target/thumbv7em-none-eabihf/debug/build/stmlib-9dfbc58f9ffa9c10/out/lib/libstm32_hal.a

Let's inspect the contents a little before we move on:

$ nm ./target/thumbv7em-none-eabihf/debug/build/stmlib-9dfbc58f9ffa9c10/out/lib/libstm32_hal.a

As you can see it compiled all the .c files into object files and bundled them into our library, now we can use bindgen to generate our Rust code.

First we need to create a header file to tell bindgen for what functions we want to generate bindings.

// wrapper.h
#define STM32F439xx

#include "stm32f439xx.h"
#include "stm32f4xx.h"
#include "system_stm32f4xx.h"

#include "cmsis_compiler.h"
#include "cmsis_gcc.h"
#include "cmsis_version.h"
#include "core_cm4.h"
#include "mpu_armv7.h"

#include "stm32f4xx_hal_conf.h"
#include "stm32f4xx_it.h"

#include "Legacy/stm32_hal_legacy.h"
#include "stm32f4xx_hal.h"
#include "stm32f4xx_hal_adc.h"
#include "stm32f4xx_hal_adc_ex.h"
#include "stm32f4xx_hal_cortex.h"
#include "stm32f4xx_hal_def.h"
#include "stm32f4xx_hal_dma.h"
#include "stm32f4xx_hal_dma_ex.h"
#include "stm32f4xx_hal_exti.h"
#include "stm32f4xx_hal_flash.h"
#include "stm32f4xx_hal_flash_ex.h"
#include "stm32f4xx_hal_flash_ramfunc.h"
#include "stm32f4xx_hal_gpio.h"
#include "stm32f4xx_hal_gpio_ex.h"
#include "stm32f4xx_hal_pwr.h"
#include "stm32f4xx_hal_pwr_ex.h"
#include "stm32f4xx_hal_rcc.h"
#include "stm32f4xx_hal_rcc_ex.h"
#include "stm32f4xx_hal_uart.h"

And then in our build.rs we need to combine our generated lib with our header file to output our bindings.rs

    // ... 
    println!("cargo:rustc-link-search={}", dst.display());
    println!("cargo:rustc-link-lib=static=stm32_hal");
    println!("cargo:rerun-if-changed=wrapper.h");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .clang_arg("-I./c/Core/Inc")
        .clang_arg("-I./c/Drivers/STM32F4xx_HAL_Driver/Inc")
        .clang_arg("-I./c/Drivers/STM32F4xx_HAL_Driver/Inc/Legacy")
        .clang_arg("-I./c/Drivers/CMSIS/Device/ST/STM32F4xx/Include")
        .clang_arg("-I./c/Drivers/CMSIS/Include")
        .use_core()
        .ctypes_prefix("cty")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .generate()
        .expect("Unable to generate bindings");

    bindings
        .write_to_file(dst.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

Build the project again, this time inspect the generated rust bindings:

$ cargo build
$ find ./target | grep bindings.rs
./target/thumbv7em-none-eabihf/debug/build/stmlib-9dfbc58f9ffa9c10/out/bindings.rs

There we go! Over 33k lines of Rust containing consts, functions, structs and even some documentation. Let's put it to use and create a wrapper function for the HAL_GPIO_TogglePin function so we can create a blinky demo.

To import our bindings into our project we need to use some macro magic to tell Rust where to look for our file. Add the following line in lib.rs

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

Once that is done we can add our library function:

pub fn gpio_toggle_pin(gpio: usize, pin: u16) {
    unsafe {
        HAL_GPIO_TogglePin(gpio as mut GPIO_TypeDef, pin);
    }
}

And there we go, we now have a semi-safe way of wrapping C libraries for our embedded targets. I've added an example project on github so you can try it out yourself. You can also skip the library generation and import the bindings directly into your project, whatever suits your needs.

In action

I have added some extra library functions to help setup the pin configuration and required clocks, you can see the final lib.rs code here and our application code now looks like this:

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use panic_halt as _;

// import our homebrew C HAL wrapper crate
use stmlib::{clock_enable, hal_init, led_init, toggle_pin};

// GPIO B
const GPIOB: usize = 0x40020400;

// user led LD1
const LED: usize = 1;

#[entry]
fn main() -> ! {
    hal_init();

    clock_enable();

    led_init(GPIOB, LED);

    loop {
        toggle_pin(GPIOB, LED);
    }
}

Let's spin up the 'ol GDB and take a look at what the final binary is actually doing.

$ cargo build
$ arm-none-eabi-gdb -x ./openocd.gdb -q ./target/thumbv7em-none-eabihf/debug/stmtest

gdb debug screen

Lets run continue (c) to jump to the main entry function and then set a breakpoint at line 25 (b 25).

code halting at main

As you can see we are in our Rust code, lets jump to the breakpoint

code halting at our breakpoint

And then step (s) into the toggle_pin function.

code halting in our rust lib

This takes us to our lib.rs function which calls the C binding generated by bindgen. Lets step again

code halting in the stm hal lib

As you can see in the top we are now in the STM32F4xx_hal_gpio.c file from STM. To me this is amazing, having the power of gdb for both Rust and C and how easy they interop with eachother.

Let’s step one more time and enjoy the result

LD1 is on

Final thoughts

I would argue that generating your own bindings like this is a worse alternative to using a crate like stm32f4xx-halor rp-hal, but at times it will definitely be a helpful solution to quickly generate FFI bindings to any C library you might need for some specific problem. Rust really shines here with its flexibility, letting you fine tune things at almost every step along the way.

It should also not be understated that it is really impressive to have this level of tooling as an alternative to C in the embedded scene, Rust has a unique chance to compete and you can already see it being used more and more by companies in this space.