Tips for embedded C developers

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

    • Compile-time selection of code blocks with
    • 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 addresseddifferently. In the rest of this section we cover various alternatives tousing the preprocessor.

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

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

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

    1. #![allow(unused_variables)]
    2. fn main() {
    3. /// In your top-level lib.rs
    4. #[cfg(feature="FIR")]
    5. pub mod fir;
    6. #[cfg(feature="IIR")]
    7. pub mod iir;
    8. }

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

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

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

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

    1. #![allow(unused_variables)]
    2. fn main() {
    3. const fn array_size() -> usize {
    4. #[cfg(feature="use_more_ram")]
    5. { 1024 }
    6. #[cfg(not(feature="use_more_ram"))]
    7. { 128 }
    8. }
    9. static BUF: [u32; array_size()] = [0u32; array_size()];
    10. }

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

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

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

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

    Build System

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

    Common use cases for build scripts include:

    • provide build-time information, for example statically embedding the builddate or Git commit hash into your executable
    • change the Cargo build configuration
    • add extra static libraries to link against

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

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

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

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

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

    Instead, use iterators:

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

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

    See the and Iterator documentation for more details.

    References vs Pointers

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

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

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

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

    In Rust, instead of marking a variable as volatile, we use specific methodsto perform volatile access: core::ptr::read_volatile and. 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:

    1. volatile bool signalled = false;
    2. void ISR() {
    3. // Signal that the interrupt has occurred
    4. signalled = true;
    5. }
    6. void driver() {
    7. while(true) {
    8. // Sleep until signalled
    9. while(!signalled) { WFI(); }
    10. // Reset signalled indicator
    11. signalled = false;
    12. // Perform some task that was waiting for the interrupt
    13. run_task();
    14. }
    15. }

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

    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 responsibilityto ensure safe use: see the methods' documentation for further details.

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

    Packed and Aligned Types

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

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

    1. struct Foo {
    2. x: u16,
    3. y: u8,
    4. z: u16,
    5. }
    6. fn main() {
    7. let v = Foo { x: 0, y: 0, z: 0 };
    8. println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
    9. }
    10. // 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2

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

    1. struct Foo {
    2. x: u16,
    3. y: u8,
    4. z: u16,
    5. }
    6. fn main() {
    7. let v = Foo { x: 0, y: 0, z: 0 };
    8. println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
    9. }
    10. // 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
    11. // Ordering is preserved and the layout will not change over time.
    12. // `z` is two-byte aligned so a byte of padding exists between `y` and `z`.

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

    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 isthe number of bytes to align to (and must be a power of two):

    1. #[repr(C)]
    2. #[repr(align(4096))]
    3. struct Foo {
    4. x: u16,
    5. y: u8,
    6. z: u16,
    7. }
    8. fn main() {
    9. let v = Foo { x: 0, y: 0, z: 0 };
    10. let u = Foo { x: 0, y: 0, z: 0 };
    11. println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
    12. println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
    13. }
    14. // 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
    15. // 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
    16. // The two instances `u` and `v` have been placed on 4096-byte alignments,
    17. // evidenced by the `000` at the end of their addresses.

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