Embedded HandbookApps
All sections › XIII. Other languages and their build tools
STM32F103, STM32F407 · level intermediate · 9 min read
Open in the interactive handbook

114. Rust: Cargo, no_std, cortex-m-rt, embedded-hal

Rust promises the same as Ada, but by a different road: ownership and borrowing are checked by the compiler, and in safe code a dangling pointer or a data race simply does not build. In unsafe blocks (registers, data shared with an interrupt) the programmer is responsible for this again. In a debug build an overflow stops the program with a panic, while in a release build it silently wraps by default, like unsigned in C. To keep the check in the firmware too, you write overflow-checks = true in [profile.release] (it costs code), and where you need to wrap past zero, you write wrapping_add explicitly; where you need a flag, checked_add. Cargo is both the builder and the package manager; for firmware it installs dependencies from crates.io, and Cargo.lock pins their versions. This section builds the same CRC-32 and the same blink as sections 105 and 113, for F103 and F407, in the stm-rust:1 image — and compares the three languages byte by byte.

Ada is section 113, PlatformIO and CMake are section 115, the memory map is section 11.

Terms used in the documentation

Term Meaning
crate a Rust package
Cargo.toml, Cargo.lock the package description, the pinned dependency versions
target triple the build target: core, OS, ABI
no_std, no_main no OS standard library, no ordinary entry point
panic handler what to do on a panic
runtime crate (cortex-m-rt) the vector table and reset for Cortex-M
build script (build.rs) a program that runs before compilation
feature an optional package capability switched on at build time
ownership, borrow checker ownership, the borrow check
unsafe, volatile access an unsafe block, access without optimization
PAC, HAL the chip's register crate, the driver crate
embedded-hal common driver traits: pin output, I²C, SPI, delay

114.1 Cargo and build targets

The Rust compiler knows the Cortex-M targets out of the box: thumbv7m-none-eabi is Cortex-M3, that is F103, and thumbv7em-none-eabihf is Cortex-M4F with a hardware FPU, that is F407. In the image they are installed by rustup target add. The chip is chosen by a package feature:

[features]
f103 = []
f407 = []

[target.'cfg(target_os = "none")'.dependencies]
cortex-m-rt = "0.7"
panic-halt = "1"

[profile.release]
opt-level = "s"
lto = true

cargo build --release --target thumbv7m-none-eabi --features f103 --bin blink builds the firmware for F103. In the experiment it is rustc 1.98.1, and Cargo.lock pinned 8 packages: the project itself, cortex-m-rt 0.7.6 with its macros, panic-halt 1.0.0 and what the macros depend on.

Flashing and debugging are usually done through probe-rs: in .cargo/config.toml you write runner = "probe-rs run --chip STM32F103C8", and cargo run flashes the image by itself and shows the output. Without it, the image is converted to .bin (cargo objcopy -- -O binary fw.bin) and flashed with any programmer.


114.2 no_std and what cortex-m-rt provides

Three languages, one firmware: the vector table and the code in the F103 image

The firmware has no OS, and Rust builds it without the standard library and without the ordinary entry point:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;

The vector table, copying .data and zeroing .bss are taken care of by cortex-m-rt; #[entry] marks the function it will call after reset. It reads the memory map from memory.x, and that file is written by the build script — one per chip:

    let mem = if env::var_os("CARGO_FEATURE_F103").is_some() {

In the finished F103 image the vector table takes 1024 bytes from 0x08000000, the Reset handler sits at 0x08000400, the top of the stack is 0x20005000, and on F407 it is 0x20020000.


114.3 Registers: unsafe and volatile

The compiler cannot check a write to an arbitrary address, so it lives in an unsafe block, and next to it you write why it is safe:

    // SAFETY: адреса — регистры периферии этого камня, других владельцев у них нет
    unsafe {
        board::RCC_ENABLE.write_volatile(board::RCC_ENABLE.read_volatile() | board::ENABLE_BIT);
        let m = board::MODE.read_volatile();
        board::MODE.write_volatile((m & !board::MODE_CLEAR) | board::MODE_SET);
    }

write_volatile is the same as volatile in C: the write will not be removed or merged. In a real project the registers come from a PAC, a register crate generated from the chip's SVD file, and on top of it sits a HAL with embedded-hal traits: a sensor driver is written once for any chip that has a HAL.


114.4 Overflow and range

pub fn to_deci(raw_q4: i32) -> Option<i16> {
    if !(-160..=500).contains(&raw_q4) {
        return None;
    }
    i16::try_from(raw_q4 * 10 / 4).ok()
}

Where Ada raises Constraint_Error, Rust returns None, and the caller cannot mistake it for a number. .unwrap() lets it through, but on the device that is a panic, and with panic-halt it is a halt. cargo test on a PC ran 3 tests: CRC of "123456789" is 0xCBF43926, to_deci(100) gives 250, to_deci(-3) gives −7, to_deci(600) gives None, and for 255 of type u8 checked_add(1) gives None, wrapping_add(1) gives 0.

A number line with the allowed range of raw_q4 from minus 160 to 500: to_deci(100) gives Some(250), to_deci(−3) gives Some(−7), to_deci(600) is out of range and gives None; next to it a u8 at its limit of 255 — checked_add(1) returns None, wrapping_add(1) wraps to 0


114.5 Three languages, one firmware

One task — CRC-32 at startup and a register-level blink — built with three toolchains for F103:

Vector table Total in Flash
C, armclang and the Keil startup file 236 bytes 772 bytes
Ada, GNAT 16.1 and our own crt0.S 16 bytes 1468 bytes
Rust, rustc 1.98.1 and cortex-m-rt 1024 bytes 1256 bytes

The difference is not in the language but in what comes with it. The Keil startup file places exactly what the chip has: for F103 that is 59 words — the top of the stack, 15 core exceptions and 43 interrupts. cortex-m-rt without a chip crate reserves all 256 words, while the Rust code proper is 220 bytes. Our crt0.S for Ada holds only 4 words, but the Ada code carries range checks and the runtime. For F407 the picture is the same: 392, 16 and 1024 bytes of table.


114.6 Pitfalls

It built, but not for the chip. You forgot --target: Cargo built a program for the PC.

The linker cannot find memory.x. cortex-m-rt looks for it in the search paths; the build script must put it there.

The vector table is a kilobyte out of nowhere. There is no chip crate: without it cortex-m-rt does not know the number of interrupts and reserves them all.

The debug build does not fit in Flash. Without optimization the code bloats; for the device use --release with opt-level = "s".

In the release build a counter silently wrapped past zero. In the release profile the overflow check is off by default: 255u8 + 1 gives 0, while in the debug build it panics. If you need the check, use overflow-checks = true in [profile.release] or explicit checked_*.

It built differently on another machine. Cargo.lock and rust-toolchain.toml are not committed: the first pins the crate versions, the second the compiler version.

unsafe without an explanation. A block without a SAFETY comment cannot be checked in review.


114.7 Summary

Full sources

Cargo.toml — the crate description and build profiles.

# Раздел 114: Rust для Cortex-M через Cargo.
#   cargo test --lib                                              — проверки на компьютере
#   cargo build --release --target thumbv7m-none-eabi  --features f103 --bin blink
#   cargo build --release --target thumbv7em-none-eabihf --features f407 --bin blink
[package]
name = "rust114"
version = "0.1.0"
edition = "2021"

[lib]
path = "src/lib.rs"

[[bin]]
name = "blink"
path = "src/main.rs"
test = false
bench = false

[features]
f103 = []
f407 = []

[target.'cfg(target_os = "none")'.dependencies]
cortex-m-rt = "0.7"
panic-halt = "1"

[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
debug = false

lib.rs — CRC-32 and conversion of the code to tenths of a degree with a range check.

//! Раздел 114: то же, что CRC-32 из разделов 105 и 113, и перевод кода
//! в десятые доли градуса — с проверкой диапазона вместо молчаливого переполнения.
#![cfg_attr(not(test), no_std)]

/// CRC-32 кадра Ethernet: «123456789» → 0xCBF43926
pub fn crc32(data: &[u8]) -> u32 {
    let mut c: u32 = 0xFFFF_FFFF;
    for &b in data {
        c ^= b as u32;
        for _ in 0..8 {
            c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
        }
    }
    !c
}

/// код в четвертях градуса → десятые; вне диапазона датчика — None
pub fn to_deci(raw_q4: i32) -> Option<i16> {
    if !(-160..=500).contains(&raw_q4) {
        return None;
    }
    i16::try_from(raw_q4 * 10 / 4).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn crc_check_value() {
        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
    }

    #[test]
    fn deci_range() {
        assert_eq!(to_deci(100), Some(250));
        assert_eq!(to_deci(-3), Some(-7));
        assert_eq!(to_deci(600), None);
    }

    #[test]
    fn overflow_is_explicit() {
        let x: u8 = 255;
        assert_eq!(x.checked_add(1), None);
        assert_eq!(x.wrapping_add(1), 0);
    }
}

main.rs — a register-level blink without a HAL.

//! Раздел 114: мигание на регистрах без HAL — адреса те же, что в разделе 113.
#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use rust114::crc32;

#[cfg(feature = "f103")]
mod board {
    pub const RCC_ENABLE: *mut u32 = 0x4002_1018 as *mut u32; // RCC_APB2ENR
    pub const ENABLE_BIT: u32 = 0x10; // IOPCEN
    pub const MODE: *mut u32 = 0x4001_1004 as *mut u32; // GPIOC_CRH
    pub const MODE_CLEAR: u32 = 0x00F0_0000; // поле PC13
    pub const MODE_SET: u32 = 0x0020_0000; // выход 2 МГц, двухтактный
    pub const ODR: *mut u32 = 0x4001_100C as *mut u32; // GPIOC_ODR
    pub const PIN: u32 = 13;
}

#[cfg(feature = "f407")]
mod board {
    pub const RCC_ENABLE: *mut u32 = 0x4002_3830 as *mut u32; // RCC_AHB1ENR
    pub const ENABLE_BIT: u32 = 0x08; // GPIODEN
    pub const MODE: *mut u32 = 0x4002_0C00 as *mut u32; // GPIOD_MODER
    pub const MODE_CLEAR: u32 = 0x0300_0000; // поле PD12
    pub const MODE_SET: u32 = 0x0100_0000; // выход
    pub const ODR: *mut u32 = 0x4002_0C14 as *mut u32; // GPIOD_ODR
    pub const PIN: u32 = 12;
}

#[entry]
fn main() -> ! {
    if crc32(b"123456789") != 0xCBF4_3926 {
        loop {} // арифметика сломана: не мигать
    }
    // SAFETY: адреса — регистры периферии этого камня, других владельцев у них нет
    unsafe {
        board::RCC_ENABLE.write_volatile(board::RCC_ENABLE.read_volatile() | board::ENABLE_BIT);
        let m = board::MODE.read_volatile();
        board::MODE.write_volatile((m & !board::MODE_CLEAR) | board::MODE_SET);
    }
    loop {
        unsafe { board::ODR.write_volatile(board::ODR.read_volatile() ^ (1 << board::PIN)) };
        for _ in 0..400_000 {
            core::hint::spin_loop();
        }
    }
}

Next: section 115 "Build managers for firmware: PlatformIO, west (Zephyr), CMake presets" — who installs the toolchain and builds the project for you.

Open in the interactive handbook