Rust on STM32H7

Rust on STM32H7

or: CubeIDE Sucks, C is Bad, and We Can Do Better

A splash screen displaying "STM32CubeIDE" in mirrored and regular text, with a graphic of a 3D cube and a logo. The screen indicates the program is starting and shows the version number 1.13.2 at the bottom. There's also a smaller logo for STMicroelectronics in reverse at the bottom right.
It just... does this on my computer now. I can't fix it. 🤷‍♀️

For my EE capstone class, we've been working extensively with the STM32H7. Leading up to the class I was pretty excited, thinking "I'm finally going to learn how to work with serious microcontrollers – I can be a real engineer now!" I picked up the Nucleo before the first day and it looked pretty neat. One of the physically largest MCUs I've seen with a boatload of IO to boot, plus what looks like USB-OTG and Ethernet? Fancy!

Little did I know just behind of this cute little board was lurking a special hell of useless and slow tooling, poor (or nonexistent) documentation, and inscrutable buttons that sometimes only work just after you relaunch the editor.

Oh God, How Do They Work Like This?

Oh CubeIDE. Maybe there's people out there who love CubeIDE. Maybe it's perfectly fast with a chonky desktop. Maybe UI panels always render on the first try under Windows. Maybe there's a way to not get it to take 5 seconds to switch perspectives every time you want to do anything, and maybe there's some way to get the debugger to not fail with more than about 10 breakpoints [[1]] . I like to think that I'm pretty decent at computers, but it turns out I am not that person, and I wasn't able to figure it out.

CubeIDE, along with the general issues of C programming exacerbated by an embedded environment, have made this class probably, seriously, 3x harder than it needed to be. Introducing:

🦀 Rust (in A Paragraphish)

Rust is a memory safe language that has a borrow checker and high-level semantics.

Or, in English: It's a language that gives you the following:

  1. No null pointers or TypeErrors when the program is running, guaranteed
  2. The speed and efficiency of C
  3. Imports that work
  4. The features you'd expect from something modern like Python or Go
  5. Tooling that makes sense

Why's this actually matter? Well, debugging an embedded device, especially with CubeIDE, is often an absolute nightmare of inscrutable hard faults, visually inspecting call stacks, and print statements that just don't work. Rust moves all that work onto your laptop before it even gets to the device, making it incredibly rare for a Rust program to crash unexpectedly. Plus, unlike other high-level interpreted languages like Python or JavaScript, it runs at the same speed as C and unlike compiled but garbage collected languages like Java, C#, and Go, works on extremely memory constrained devices like our STM chips. In theory, it's the whole package for doing real work on devices like our good friend the H7!

Rust on Embedded

You may have caught the "in theory" up there. For the past few years, it's been a pretty load-bearing part of this pitch. I'd tried and failed to get an embedded rust project off the ground probably 3 times over the past 2 years. Not to say it's the year of linux on the desktop, but the winds of change are finally upon us.

In the past 6-12 months, we've seen leaps and bounds of improvements in the libraries and tools for embedded Rust. From amazing work by ferrous systems collaborating with industry-leading manufacturer Espressif on the ESP32-C3-DevKit-RUST-1 board, designed specifically to train and show engineers how much better things can be, to the arrival of embassy-rs and real asynchronous logic and networking on embedded microcontrollers without an RTOS, enabling multitasking like never before, and backed up by probe-rs and defmt-rtt bringing our tooling into the future with debug probes that just work and prints that are nearly free at runtime and automatically removed in release, there's never been a better time to get started.

Learning A Little

Now that we've learned a bit about the motivation for using Rust in projects like this, let's see a practical example so you'll have the tools so you can do this on your own!

We're going to go through a class project I wrote and used for one of our labs - a simple FFT tool that captures an input off analog pins, takes an FFT, and then returns the value back over the console. I'll be going through the code here, but if you'd like to follow along or try out changes, here's what you'll need:

  1. An STM-(I forget), though other nucleos may work with minor changes.
  2. Rust and it's build tool cargo, which comes with your package manager
  3. Probe Run [[2]], providing the ability to flash microcontrollers and get back optimized prints.
  4. This project's starter code

Now, without further ado, let's get learning and get into the code!

The Framework

Let's start with a look at some supporting files. Even if you're already familiar with Rust, this has a few things for embedded you might not have seen!

Unlike the .ioc files from STM Land, these are plain-text, short files you can just read. They're not graphical, but I personally prefer configuration this way to digging through menus.

We start with the most important file, Cargo.toml. This is where we specify the crates (read: libraries) we want to install, as well as some basic config.

In this specific example, we load in a few critical libraries. The most important are:

  1. cortex-m-*, which loads the basic platform features – how to get main to actually run, memory layout, some intrinsics, and activating the FPU
  2. stm32h7xx-hal [[3]] the Hardware Abstraction Layer, providing safe access to the hardware, including ADC, DMA, interrupts, and others.
  3. log - the same logging crate that's used on literally every Rust project, from supercomputer servers to the smallest microcontrollers. It's a real testimate to the flexibility of Rust!
  4. micromath - Optimized bit-twiddling float operations, faster than FPU at slightly reduced accuracy. When imported, it can replace the default float operations automatically thanks to the trait system[[4]]!
  5. microfft - an extremely optimized FFT library, just does one thing and does it well.

There's a few other critical things going on here:

memory.x specifies how the memory should be laid out - where different segments go and such. This is autogenerated based on hardware config documents, and can be found online for any microcontroller!

Embed.toml tells us which microcontroller exactly we're using and sets up print logging, plus random other tooling bits and bobs.

With that, we've looked at just about every important framework file, and we can get started with code.

The Code

We've got one main file to go through – main.rs – and a few supporting ones that bootstrap logging and the like. main.rs is on the long side, so let's go through it chunk by chunk and what's going on!

Imports and Headers

Starting out pretty good! Here, we see a bunch of Rust imports with the use statement, which look a lot more like Python than C. They specifically allow for one import statement per crate, and in my opinion are much better than the hell of individual module imports you may have seen at the top of a Java file.

One important thing is the #![no_std] declaration at the start. This tells Rust that it doesn't have access to an underlying OS and so it has a bit of a reduced feature set. Every embedded library, and many normal ones, are designed to work with this restriction so you won't find it being too bad, but you will see this around embedded rust frequently.

Opening

Here at the start of main we get a lot of admittedly weird looking code. Don't worry though! It's all doing important stuff that's usually hidden from you in CubeIDE, so let's dig in and learn what it's really hiding from you in that "generating code" phase.

  1. First, we setup peripherals with cortex_m::Peripherals::take().unwrap();. Peripherals include things like DMA, ADC, USB, SPI, and anythng else your chip may have deduicated hardware to handle. More importantly to us developers, it provides us with safe peripheral objects we can use to create individual peripherals down the line!
  2. Next, we do some kinda wild stuff to link in a buffer from .axisram. This is important for ADC reasons – some ADC pipes have restrictions on the memory regions of buffers they can work with – and helps to avoid stack memory waste, since this buffer will live for the whole program scope.
  3. After that, we finally setup a few other things, including the device power (ensuring the Nucleo will keep the chip on correctly) and whipping up clock and time objects, which configures system timers and clocks for you and is used later to configure items down the line.

With that, most of our configuration is ready and we can go setup the ADC and DMA bus!

Nice to adCee you

Here, we do some final setup and provision the ADC and DMA channel to write the analog values into our buffer we declared earlier! Again, this is something that the GUI handles for you in CubeIDE, but I personally find very interesting to see on this level. We can see use of the peripheral objects on the first few lines – dp.ADC1 – as well as configuration methods for the adc, dma, and some other components. It ends with starting an ADC session and then waiting for a result.

Math Time

Finally, it's time for the math! To me at least, this more resembles Python than C, especially in how we have NO i++ FOR LOOPS IN SIGHT! Rust always stores size with arrays, even when they're not vectors, meaning you can just do iter().sum() and never have to worry about accessing invalid memory.

Regarding the actual code here, there's a few things going on:

  1. We allocate a new f32 array and convert everything from ints to floats
  2. We normalize the slice (see later) to remove the DC offset
  3. Take the FFT in place
  4. Dump the magnitudes
  5. Go to sleep

For me, the simplicity of this part of the code is what really sells Rust for embedded applications. Setup can require more from you than pushing a few buttons in CubeIDE, but then you get to write code where you know it won't segfault or mess with undefined memory, and operations you write will just work as you wrote them. There's no pointer coercion issues and bit twiddling stuff can be done, it will just be safe.

Being Extremely Normal

Finally, here's that helper method I mentioned above. It:

  1. Sums up the slice. This probably could have just been .sum(), I'm not sure how I overlooked that optimization!
  2. Takes the mean. Note the explicit conversion to f32 - you can't compile without that, and it means you'll never have unintentional integer division breaking your logic again.
  3. Mutate each element in the slice in place, subtracting the mean to counteract the DC offset.
  4. Return the mean to be used later

You might think this high-level logic is slow or inefficient, however it's really not! When compiled with optimizations, it's actually only 20 instructions total for the whole function. These high-level calls, like iterators and folding, are compiled down to the same kind of code as your C array access index loops, but here you're never going to overrun your arrays and get a segfault on return!

Conclusions

I'm really happy with this project overall! I'm a big fan of the usability and safety of Rust and, as I mentioned above, I've been trying to develop the embedded rust dream for a few years now. This is the first project where the tooling and ecosystem has been ready for this to come together, so it's been great to see this come together. If you end up trying this on some project of yours, or if this helps you out, please let me know! I love to see what people are doing with this tech, and hopefully this can inspire you to work on your own projects!

[[1]]: Maybe this is all me showing my (lack of) age, growing up with JetBrains and VSCode instead of Eclipse and not having proper respect for how things used to be. Regardless, I've seen the light and we can expect more, especially from a real company's product.

[[2]]: cargo install probe-run

[[3]]: stm32h7xx-hal actually implements embedded_hal, the standard set of traits for everything in embedded rust! This means that if you have a library that reads from an ADC and was written for one board, it'll work for every board with an ADC, automatically. Isn't that neat?

[[4]]: Rust Traits are a bit of a whole thing that I don't have space for, but for now, think of them as Interfaces from C# or Abstract Base Classses in C++. They specify an interface for how an object should interact (or: some traits it has), and can be used in all sorts of places across the language.

Show Comments