In my previous post, I was setting up the environment to build and debug embedded Rust applications using STM32F0 discovery kit and vscode. In this post, I continue learning Rust in more detail and try to understand some of the concepts I was already using related to embedded development with Rust.
Microcontroller manufacturer usually provides a System View Description (SVD) file which describes the microcontroller memory-mapped register contents. svd2rust tool helps you to create a Peripheral Access Crate (PAC) from that SVD file for your microcontroller. Here is an example showing how to create the PAC for the STM32F072RB76 microcontroller.
After you have your PAC ready, you should continue abstracting your hardware on top of that with a Hardware Abstraction Layer (HAL). Rust has an embedded-hal project which defines a set of traits to be implemented, i.e. common interface to the hardware. You might want to implement traits for Serial, LEDs or whatever you need, the stm32f0xx-hal crate in this case. Fortunately, it has already been implemented so I will use that one here. Anyway, it is often good to search for what is already available in crates.io.
In this example, take method guarantees that we have the only reference to the peripheral, ie. singleton with an exclusive access to the hardware. Using this, we first configure the system clock to 8 MHz and then call a split method to get a structure with individual pins. After this, we can control PC6 pin as output and continue writing LED on and off within the loop.
This attribute is used with embedded systems to link application against core crate instead of std crate. The core crate is a subset from std crate and doesn't make any assumptions on the system it is used on. This means you won't have dynamic memory allocation with heap (collections), math functions, stack overflow protection nor initialization code before the main function.
This attribute can be used to indicate that we are not using common main function as an application entry point because it makes some assumptions about an execution environment. Instead, we define ENTRY(function) in a linker script to indicate the symbol we want to use as an entry point. For example, we could implement Reset function and map that to the reset vector in vector table as an entry point for the application. There we can do all necessary initialization and then finally call the main function. Please see an example in linker chapter. Note that this attribute has no effect on library crates.
This attribute can be used with structure and union types to control how compiler lays out variables in memory. Rust default representation doesn't provide any guarantees of the memory layout so it shouldn't be used with the code interfacing directly with the hardware or with the C code.
Tip:
This attribute is used to make sure the layout of a type in memory is interoperable with C. Attribute can be combined with the #[repr(align(n))] or with #[repr(packed)].
To specify a specific memory alignment, use this attribute. Note that n must be power of two.
This attribute can be used to prevent padding between structure members. It will also set the alignment of type to 1. You might need to use this when implementing protocols etc.
This attribute does the same as inlining in C. However, you should avoid using this attribute as rustc compiler uses internal heuristics to automatically inline functions when necessary.
Panicking is like responding to an exception, for example, when out of bounds indexing is attempted. When using core crate, panicking behavior is undefined. You need to define it by declaring #[panic_handler] function by yourself or by choosing from the crates for panicking. For example, to use panic-halt crate:
You can also implement the same by declaring a divergent function (i.e. function that never returns) to handle panicking by yourself:
Check Debugging chapter for information how to use panic crates for debugging.
Creating a block of unsafe code will disable some of the Rust memory safety guarantees so extra care should be taken when used. The reason why you would use the unsafe code could be one of the following.
Raw pointers are often used with memory-mapped registers to interface directly with the embedded hardware. Here is an example (from The Embedded Rust Book) how to access memory-mapped SysTick register at address 0xE000E010:
Note that because Rust doesn't have volatile keyword, one need to use specific methods core::ptr::read_volatile and core::ptr::write_volatile with raw pointers to perform volatile read or write. Here is an example (from The Embedded Rust Book) where Interrupt Service Routine (ISR) will set SIGNALLED variable to true and driver function waits until that happens:
Read more about raw pointers.
Other libraries might have unsafe functions which you need to wrap inside the unsafe block when called. You might also want to use Foreign Function Interface (FFI) to implement an interface to an existing C library. All these function calls must be within unsafe block.
If you are not using Real Time Operating System (RTOS), you probably doesn't have other threads running causing race conditions and the previous example with interrupts is something you might face. But if you would have other threads and mutable static variables, it is unsafe to access those and you should definitely use some form of concurrency. Here is an example from The Rust Programming Language book how to access this kind of variables with unsafe code:
You would probably need to use unions if interfacing with the C code. Here is an example how to declare an union, to initialize and access union member (from The Rust Reference):
There are more superpowers which you may read from here.
Rust doesn't have a preprocessor but it offers similar concepts you may use instead. Here are some of them.
In Rust, one can use Cargo features to select code blocks at compile time. You could select the code based on architecture or enable only some parts from an external library. You can enable the features you would like to use in your Cargo.toml file. For example, when we added HAL crate to our project, we specified which microcontroller to use. This way we don't need to build code for all microcontroller variants supported by that library.
Read here for more about features.
Tip: use the following command to list enabled features for your project:
Rust has const fn functions which are evaluated at compile time. You may use these functions to return the size of an array. You may also combine this concept with the Cargo features to select the size of the array based on configuration or something else. This example is directly from the Embedded Rust book to select the buffer size based on enabled features:
Rust has a powerful macro system which operates at higher level compared to the C preprocessor. Rust has declarative macros and three kinds of procedural macros. Macros are a way of metaprogramming to write code that creates a new code. If you want simpler code, you should consider using functions. If you want to reduce your code size and are not afraid of a bit more complex definitions, macros are your choice. For example, The Embedonomicon used macro to make sure main is type safe:
See also how macros are used in RTT debugging. Please read here for more about macros.
In Rust, you will often use Cargo to build your crates. Cargo is a package manager, tool that allows Rust packages to declare dependencies and makes sure you build is repeatable every time. It also makes cross compiling easy because you only need to define --target option and Cargo will then build crate for the correct target device. Of course, you need to first add cross compilation support for your target with rustup target add command: Cargo also helps with the code reuse as it is easy to bring new crates to your build. You may find 3rd party crates from the crates.io.
If you need to customize the build process, this can be done with the build scripts. It is Rust code that is executed on the build machine. You can use it to provide build time information, for example, to embed git hash to firmware version number. Or you might want to build external C library from the sources during the build of your Rust application. Build script can be executed every time file in package has changed and before actually compiling anything. Here is one example how to get the git hash to your build.
Tip: Check out also vergen crate.
As usual, optimizations provide a way to balance between the size and the efficiency of the compiled binary code. No optimizations are used by default when building dev profile. This helps with the debugging. When building release profile, by default it will be optimized for the speed (opt-level = 3). There are options to select optimization level or which crates to optimize in your Cargo.toml file. Here are couple of examples:
You may control symbol names and memory section placement using the following attributes:
Here is an example to specify function name as a symbol name and then link that symbol to the reset vector in the vector table:
After installing cargo-binutils, you may use it to inspect compiled binary file contents. Here are couple of examples how to use these tools. For more examples, read cargo-binutils
This is the output from our PAC test app:
Use this tool to get the size of the linker sections in the compiled binary:
Use this tool to disassemble the compiled binary:
When working with the embedded systems, debugging is one important aspect. It is very annoying if you cannot figure out or doesn't have any means to debug what is wrong with your code. Here are couple of ways to debug your device.
cortex-m-semihosting crate enables to do semihosting on the host. Embedded devices are usually lacking interfaces for debugging, semihosting will enable ARM devices to do I/O on the host running a debugger.
If you use cargo generate to generate new project from https://github.com/rust-embedded/cortex-m-quickstart, it will create a "hello world" application for the semihosting. Wether you are using QEMU or OpenOCD, you need to first enable semihosting:
There is also panic-semihosting crate if you want to log your panic messages with the semihosting.
ITM is faster than semihosting but Cortex-M0 doesn't support it. You also need to have Single Wire Output (SWO) pin connected to the Single Wire Debug (SWD) interface. When you have GDB connected, you need to issue the following commands to send captured ITM to the file itm.fifo. These commands can be found from the openocd.gdb file when using cortex-m-quickstart project as a template:
Here is the ITM example from the cortex-m-quickstart project.
After running this example, there should be itm.fifo file which you can parse with itm-decode tool.
There is also panic-itm crate if you want to log panic messages with ITM.
Rust has some crates which implement support for the RTT debugging, rtt-target for example. You can use the following code to print with the RTT, note that messages with debug_* macros are not visible in the release builds:
If you need to implement a viewer on the host side also with Rust, there are crates like probe-rs-rtt to display messages on the host.
On some cases, you might want to integrate your existing C code to your Rust projects. To do this, you will first need to define an interface to the C code (data types and function signatures) and then compile it to the shared library either with an external build system or with Rust cc crate.
There is a bindgen tool to generate these bindings automatically. For example, here is my c_code "library" I want to use in Rust and the generated bindings for it:
To build the C code, you need to add the following dependencies to the Cargo.toml file:
The following code must be added to the build.rs file to build C sources with the cc crate:
Now, in your Rust source file, you can create a structure and then call the C function. Note that calling external function must be within unsafe block.
If yuu current projects are written with C, you might want to trial and implement a part of it using Rust. You can do this by first creating a Rust library with the code you like to export and then specifying crate-type = ["staticlib"] in the Cargo.toml. Building this Rust library creates a shared library which you can then link with your code and generate the C headers with cbindgen tool.
When using the latest stable Rust, inline assembly can be used to write assembly code directly inside your Rust code.
With older versions, you need to do it manually as described here. The idea is the same as with C code, you implement assembly code to .s file and then build it with the cc crate. Here is an example from The Embedonomicon:
In Rust, one should use iterators for an array access instead of indexing because every indexed access needs to be bound checked and that is slow and may prevent other compiler optimizations. Here is a simple example how to iterate array items:
When you are using core crate, you are not having math functions. The Embedded Rust Book lists couple of alternative crates you may use instead.
If you need to use dynamic data structures, compiler is shipped with alloc crate which enables dynamic memory allocation when using core crate. Another way is to use fixed capacity collections which are provided by the heapless crate. But with this, you need to define beforehand how much memory to reserve and check failures more often. But as usual, there are trade-offs when using these. Please find the examples from here.
I tried to cover several different topics in this post. The most of these should help you to get started with Rust and Cortex-M based boards. You should also check the linked documentation for more detailed information.
While I was testing the codes used here, I noticed that one of the biggest difference between Rust and C is the cargo package manager. It feels so easy to reuse code with crates found from the crates.io.