Tips for embedded C developers

This chapter collects a variety of tips that might be useful to experienced embedded C developers looking to start writing Rust. It will especially highlight how things you might already be used to in C are different in Rust.

Preprocessor

In embedded C it is very common to use the preprocessor for a variety of purposes, such as:

  • Compile-time selection of code blocks with #ifdef
  • Compile-time array sizes and computations
  • Macros to simplify common patterns (to avoid function call overhead)

In Rust there is no preprocessor, and so many of these use cases are addressed differently. In the rest of this section we cover various alternatives to using the preprocessor.

Compile-Time Code Selection

The closest match to #ifdef ... #endif in Rust are Cargo features. These are a little more formal than the C preprocessor: all possible features are explicitly listed per crate, and can only be either on or off. Features are turned on when you list a crate as a dependency, and are additive: if any crate in your dependency tree enables a feature for another crate, that feature will be enabled for all users of that crate.

For example, you might have a crate which provides a library of signal processing primitives. Each one might take some extra time to compile or declare some large table of constants which you'd like to avoid. You could declare a Cargo feature for each component in your Cargo.toml:

[features]
FIR = []
IIR = []

Then, in your code, use #[cfg(feature="FIR")] to control what is included.

#![allow(unused)]
fn main() {
/// In your top-level lib.rs

#[cfg(feature="FIR")]
pub mod fir;

#[cfg(feature="IIR")]
pub mod iir;
}

You can similarly include code blocks only if a feature is not enabled, or if any combination of features are or are not enabled.

Additionally, Rust provides a number of automatically-set conditions you can use, such as target_arch to select different code based on architecture. For full details of the conditional compilation support, refer to the conditional compilation chapter of the Rust reference.

The conditional compilation will only apply to the next statement or block. If a block can not be used in the current scope then the cfg attribute will need to be used multiple times. It's worth noting that most of the time it is better to simply include all the code and allow the compiler to remove dead code when optimising: it's simpler for you and your users, and in general the compiler will do a good job of removing unused code.

Compile-Time Sizes and Computation

Rust supports const fn, functions which are guaranteed to be evaluable at compile-time and can therefore be used where constants are required, such as in the size of arrays. This can be used alongside features mentioned above, for example:

#![allow(unused)]
fn main() {
const fn array_size() -> usize {
    #[cfg(feature="use_more_ram")]
    { 1024 }
    #[cfg(not(feature="use_more_ram"))]
    { 128 }
}

static BUF: [u32; array_size()] = [0u32; array_size()];
}

These are new to stable Rust as of 1.31, so documentation is still sparse. The functionality available to const fn is also very limited at the time of writing; in future Rust releases it is expected to expand on what is permitted in a const fn.

Macros

Rust provides an extremely powerful macro system. While the C preprocessor operates almost directly on the text of your source code, the Rust macro system operates at a higher level. There are two varieties of Rust macro: macros by example and procedural macros. The former are simpler and most common; they look like function calls and can expand to a complete expression, statement, item, or pattern. Procedural macros are more complex but permit extremely powerful additions to the Rust language: they can transform arbitrary Rust syntax into new Rust syntax.

In general, where you might have used a C preprocessor macro, you probably want to see if a macro-by-example can do the job instead. They can be defined in your crate and easily used by your own crate or exported for other users. Be aware that since they must expand to complete expressions, statements, items, or patterns, some use cases of C preprocessor macros will not work, for example a macro that expands to part of a variable name or an incomplete set of items in a list.

As with Cargo features, it is worth considering if you even need the macro. In many cases a regular function is easier to understand and will be inlined to the same code as a macro. The #[inline] and #[inline(always)] attributes give you further control over this process, although care should be taken here as well — the compiler will automatically inline functions from the same crate where appropriate, so forcing it to do so inappropriately might actually lead to decreased performance.

Explaining the entire Rust macro system is out of scope for this tips page, so you are encouraged to consult the Rust documentation for full details.

Build System

Most Rust crates are built using Cargo (although it is not required). This takes care of many difficult problems with traditional build systems. However, you may wish to customise the build process. Cargo provides build.rs scripts for this purpose. They are Rust scripts which can interact with the Cargo build system as required.

Common use cases for build scripts include:

  • provide build-time information, for example statically embedding the build date or Git commit hash into your executable
  • generate linker scripts at build time depending on selected features or other logic
  • change the Cargo build configuration
  • add extra static libraries to link against

At present there is no support for post-build scripts, which you might traditionally have used for tasks like automatic generation of binaries from the build objects or printing build information.

Cross-Compiling

Using Cargo for your build system also simplifies cross-compiling. In most cases it suffices to tell Cargo --target thumbv6m-none-eabi and find a suitable executable in target/thumbv6m-none-eabi/debug/myapp.

For platforms not natively supported by Rust, you will need to build libcore for that target yourself. On such platforms, Xargo can be used as a stand-in for Cargo which automatically builds libcore for you.

Iterators vs Array Access

In C you are probably used to accessing arrays directly by their index:

int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
    process(arr[i]);
}

In Rust this is an anti-pattern: indexed access can be slower (as it needs to be bounds checked) and may prevent various compiler optimisations. This is an important distinction and worth repeating: Rust will check for out-of-bounds access on manual array indexing to guarantee memory safety, while C will happily index outside the array.

Instead, use iterators:

let arr = [0u16; 16];
for element in arr.iter() {
    process(*element);
}

Iterators provide a powerful array of functionality you would have to implement manually in C, such as chaining, zipping, enumerating, finding the min or max, summing, and more. Iterator methods can also be chained, giving very readable data processing code.

See the Iterators in the Book and Iterator documentation for more details.

References vs Pointers

In Rust, pointers (called raw pointers) exist but are only used in specific circumstances, as dereferencing them is always considered unsafe -- Rust cannot provide its usual guarantees about what might be behind the pointer.

In most cases, we instead use references, indicated by the & symbol, or mutable references, indicated by &mut. References behave similarly to pointers, in that they can be dereferenced to access the underlying values, but they are a key part of Rust's ownership system: Rust will strictly enforce that you may only have one mutable reference or multiple non-mutable references to the same value at any given time.

In practice this means you have to be more careful about whether you need mutable access to data: where in C the default is mutable and you must be explicit about const, in Rust the opposite is true.

One situation where you might still use raw pointers is interacting directly with hardware (for example, writing a pointer to a buffer into a DMA peripheral register), and they are also used under the hood for all peripheral access crates to allow you to read and write memory-mapped registers.

Volatile Access

In C, individual variables may be marked volatile, indicating to the compiler that the value in the variable may change between accesses. Volatile variables are commonly used in an embedded context for memory-mapped registers.

In Rust, instead of marking a variable as volatile, we use specific methods to perform volatile access: core::ptr::read_volatile and core::ptr::write_volatile. These methods take a *const T or a *mut T (raw pointers, as discussed above) and perform a volatile read or write.

For example, in C you might write:

volatile bool signalled = false;

void ISR() {
    // Signal that the interrupt has occurred
    signalled = true;
}

void driver() {
    while(true) {
        // Sleep until signalled
        while(!signalled) { WFI(); }
        // Reset signalled indicator
        signalled = false;
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

The equivalent in Rust would use volatile methods on each access:

static mut SIGNALLED: bool = false;

#[interrupt]
fn ISR() {
    // Signal that the interrupt has occurred
    // (In real code, you should consider a higher level primitive,
    //  such as an atomic type).
    unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}

fn driver() {
    loop {
        // Sleep until signalled
        while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
        // Reset signalled indicator
        unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

A few things are worth noting in the code sample:

  • We can pass &mut SIGNALLED into the function requiring *mut T, since &mut T automatically converts to a *mut T (and the same for *const T)
  • We need unsafe blocks for the read_volatile/write_volatile methods, since they are unsafe functions. It is the programmer's responsibility to ensure safe use: see the methods' documentation for further details.

It is rare to require these functions directly in your code, as they will usually be taken care of for you by higher-level libraries. For memory mapped peripherals, the peripheral access crates will implement volatile access automatically, while for concurrency primitives there are better abstractions available (see the Concurrency chapter).

Packed and Aligned Types

In embedded C it is common to tell the compiler a variable must have a certain alignment or a struct must be packed rather than aligned, usually to meet specific hardware or protocol requirements.

In Rust this is controlled by the repr attribute on a struct or union. The default representation provides no guarantees of layout, so should not be used for code that interoperates with hardware or C. The compiler may re-order struct members or insert padding and the behaviour may change with future versions of Rust.

struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}

// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// Note ordering has been changed to x, z, y to improve packing.

To ensure layouts that are interoperable with C, use repr(C):

#[repr(C)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}

// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// Ordering is preserved and the layout will not change over time.
// `z` is two-byte aligned so a byte of padding exists between `y` and `z`.

To ensure a packed representation, use repr(packed):

#[repr(packed)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    // References must always be aligned, so to check the addresses of the
    // struct's fields, we use `std::ptr::addr_of!()` to get a raw pointer
    // instead of just printing `&v.x`.
    let px = std::ptr::addr_of!(v.x);
    let py = std::ptr::addr_of!(v.y);
    let pz = std::ptr::addr_of!(v.z);
    println!("{:p} {:p} {:p}", px, py, pz);
}

// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// No padding has been inserted between `y` and `z`, so now `z` is unaligned.

Note that using repr(packed) also sets the alignment of the type to 1.

Finally, to specify a specific alignment, use repr(align(n)), where n is the number of bytes to align to (and must be a power of two):

#[repr(C)]
#[repr(align(4096))]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    let u = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
    println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}

// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// The two instances `u` and `v` have been placed on 4096-byte alignments,
// evidenced by the `000` at the end of their addresses.

Note we can combine repr(C) with repr(align(n)) to obtain an aligned and C-compatible layout. It is not permissible to combine repr(align(n)) with repr(packed), since repr(packed) sets the alignment to 1. It is also not permissible for a repr(packed) type to contain a repr(align(n)) type.

For further details on type layouts, refer to the type layout chapter of the Rust Reference.

Other Resources