How to modularise a multi-file embedded rust program?

Hi all, relatively new Rust user here. I am an embedded C developer evaluating Rust for use at my company. We mainly specialise MCUs (STM32, RP2040, ESP32, etc…) and specifically bare-metal firmware but also some RTOS occasionally.

So far I have been impressed by Rust with the quality of its code output as well as its safety guarantees – though I’m sure I don’t need to espouse Rust’s strong points to this forum since I’m sure you’re all already aware!

However, where I am falling over is trying to build Rust projects of a non-trivial complexity.

The embedded rust book (Introduction - The Embedded Rust Book) is great at explaining how Rust’s safety guarantees are readily applicable to embedded projects, however specific advice on how to organise large projects is lacking. This goes for a lot of other resources out there which are focussed on the hobbyist community and doesn’t go much beyond blinking LEDs.

So many example projects / tutorials out there just have all the firmware in one massive main file – which is not really tenable for a serious project.

For example, during initialisation of the program we create singleton objects to represent the various peripherals (GPIO, UART, etc…). We then call methods on these objects to interact with the peripherals. This is clean and I like this – however it falls apart as soon as you try to have code not in one massive main function.

In C I would create a GPIO module which would handle initialise the pins and provide wrapper functions for other modules to use. Any module which needed GPIO would then just include this GPIO module. My instinct is to pass the GPIO singleton into the GPIO module during initialisation, but this means it goes out of scope as soon as the GPIO initialisation function ends.

What I need therefore are static variables which can be initialised during runtime rather than at compile time, however Rust hates mutable static variables (which is understandable) and so this requires an awful lot of very ugly unsafe code (and having so much unsafe code kind of defeats the purpose of Rust’s safety guarantees in the first place).

Old threads on this topic seem to point to one of two places. OnceCell and LazyStatic. However OnceCell is now included in std, which means I can’t access it since I am running from a no_std context.

LazyStatic seems promising but for some reason I cannot get it to build in a no_std context for the life of me. There is a cargo feature “spin_no_std” to use spin lock mutexes and allow to build without std, however even with this the code fails to build complaining about not having access to std::sync::atomic. I think it worked in the past but seems to have since broken.

I have tried looking for example projects for how to do this sort of thing but have come up empty handed. This thread from a while ago had someone else with exactly the same question as me but it fizzled out without the issue really being solved.

So does anyone have any advice on how to organise a professional Rust project? I realise that I may be blinded by my experience in C and therefore could be failing to see the “Rust” way of doing this. Any help would be appreciated.

The static_cell crate might be what you are looking for here.

I am building a motor controller using embassy. The way I structure my project is based on functionality, and desired priority levels.

I have three main modules

  • Sensor code (High prio)
  • Motor Control Algorithms (Medium prio)
  • Serial Interface (Low prio)

Each of these modules runs in its own embassy async runtime. They communicate using message passing ( embassy_sync::Channel ). This allows me to more easily separate the code based on functionality, and to couple code based on a desired interface of the Channel's message type.

I am far from an expert, but this has worked well for me.

2 Likes

Hi @Donuts799 thanks for your reply. I will check out the static_cell crate to see if I can make use of it.

With your embassy project does your serial interface allow multiple modules to make use of it? For example if both sensor code and motor control algorithms wanted to send a serial message can they both import serial interface and pass it messages?

We use RTIC in our embedded Rust projects. It solves most of your problems (embassy would probably work too, but I have less experience there).

Especially the sharing of peripherals and data between interrupts and other parts of the program can be done without any unsafe with RTIC. I also allows for easier composability and gives you async support which simplifies code a lot (even without without alloc).
On top of that you get compile time guaranteed dead-lock free and race condition free code.

Your init function will still probably be very large, but that is difficult(/impossible) to work around in Rust, because the global peripherals singleton needs to be deconstructed in one function for the compiler to check safety.

It's definitely possible to write firmware without RTIC or embassy, but way more cumbersome and unsafe. Getting mutable global state correct in Rust is very non-trivial.

I know that as a C programmer it is very natural to use global variables to share state, but this should be avoided in Rust at all cost if possible IMO. Even if you don't use an RTOS it is often possible to use something like channels/queues instead to pass data around for example.

2 Likes

I am not sure what I am currently doing is recommended. Since embassy::Channel's are send+sync, I have a global Channel for the serial module, and I can send messages to it from any thread, and the channel uses a mutex (I use CriticalSectionRawMutex since I use interrupt based async runtimes) for synchronization.

I have heard great things about RTIC as Karsten mentioned.

In Rust you would normally pass a reference of the serial peripheral (or a wrapper) to any function that needs it. And with RTIC you can easily get a reference in every task by simply requiring it to be there. You can also make your function accept any T: embedded_io::Read/Write so that it works with any serial peripheral even across different MCUs which can be great for code reuse across projects.

To share serial data bbqueue is great. This can also be used with DMAs.

About your general question: I try to implement as much of the functionality as possible in platform independent crates and use the binary project mainly to wire everything up. I.e. initialization of the peripherals and calling the appropriate (external) functions from the interrupt handlers.

As far as I know embassy is a little bit more high-level than RTIC meaning you don't even need to handle interrupts yourself most of the time. That is already done for you by the HAL if you use the async functions. That probably reduces the need to have global a lot, too.

If you end up using RTIC, this should be interesting.

1 Like

Thanks @Karsten

After a little looking around it seems to me these RTOS frameworks (or at least RTOS-adjacent) are the potential solution. I think I will need to take a look at both to see which is more appropriate for what we need. My instinct is RTIC since as you say it seems a little closer to the metal which is important to us to be able to have control over what exactly our MCUs are doing.

It seems a bit overkill from my C developer perspective to resort to a multi-threaded RTOS for every problem especially considering most devices we use are only a single-core, but if these frameworks give you the tools required then so be it.

It seems a bit overkill from my C developer perspective to resort to a multi-threaded RTOS for every problem especially considering most devices we use are only a single-core, but if these frameworks give you the tools required then so be it.

Not sure if you meant RTIC. In any case, note that RTIC is not a thread-based, preemptable RTOS, like FreeRTOS, chibiOS, etc. It is much simpler than that. From RTIC's documentation

RTIC is a concurrency framework as there is no software kernel and that it relies on external HALs.

Although it does add a bit of overhead, it is rather minimal. I found it greatly simplifies managing the global state of MCU peripherals (basically, I agree with what @Karsten said).

1 Like