There are valid concerns with embedded devices, specifically IoT devices, ranging from the use of secure communication protocols, to memory safety issues. This makes rust a natural choice of language to program embedded devices.

Enter stm32, also known as Blue Pill. This is a very available and cheap (~2usd) developer board, powered with a 72Mhz ARM 32-bit CPU, the “same” architecture that powers most mobile devices. Rust currently provides Tier 2 support for ARM, making this a perfect choice of board.

Note: There is also a newer model, called Black Pill, which costs more or less the same and comes with better specifications and maintains most of the layout.

STM32

Setup

There are many embedded environmental sensors, in particular the sensors BME280, BMP280, and BME680 by Bosch have rust crates. I will use a BME280 sensor in this example with the following wiring.

STM32 BME280
3.3 VCC
G GND
B6 SCL
B7 SDA

In this example, I use a st-link v2 programmer for flashing and debugging, configured as follows.

STM32 ST-Link V2
+3.3V 3.3V
DIO SWDIO
DCLK SWCLK
GND GND

Important: Disconnect the blue-pill from any external source of power before connecting the debugger to your machine.

Rust and Cargo setup

Unless you are using an ARM machine, cross-compilation utilities have to be installed via

sudo dnf install arm-none-eabi-binutils-cs
sudo apt-get install binutils-arm-linux-gnueabi

depending on the host distribution, and add the target architecture of the stm32 to the rust toolchain.

rustup target add thumbv7m-none-eabi 

We start the project cargo init stm32_bme280 and add the following dependencies to Cargo.toml.

[dependencies]
bme280 = "0.2"
cortex-m-rt = "0.6"
cortex-m = "0.6"
stm32f1xx-hal = {version = "0.6", features = ["stm32f103", "rt"]}
panic-halt = "0.2"
rtt-target = { version = "0.2", features = ["cortex-m"] }

In order for the program to fit into memory we implement the following compile-time optimizations

# Cargo.toml
[profile.release]
opt-level = 's' # optimize for size ('z' would optimize even more)
lto = true # link with link time optimization (lto).

The cortex-m-rt crate requires a memory.x file at the project root specifying the memory layout of the board, luckily, the documentation contains an example for this specific board.

/* Linker script for the STM32F103C8T6 */
MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 64K
  RAM : ORIGIN = 0x20000000, LENGTH = 20K
}

To make life more convenient we add the following to .cargo/config, to be able to run cargo build without additional flags,

[build]
target = "thumbv7m-none-eabi"

rustflags = [
  "-C", "link-arg=-Tlink.x",
]

Computing the altitude

It is a known-fact that the altitude can be computed as a function of the pressure, a model for this is

\[h = h_0 \left( 1 - \left( \frac{p}{p_0}\right)^\alpha \right),\]

were \(p_0\) is the pressure at sea-level, \(p\) the pressure, \(h \sim 44330\,m\), and \(\alpha \sim \frac{1}{5.255}\). But there is a single problem, the implementation of the power function fmt::powf() requires intrinsic CPU instructions which are not available or implemented for this board. We can do the following approximation.

Let \(\varepsilon = 1- \frac{p}{p_0}\). Then, since the preassure at sea level is very similar to what can be measured at home, \(\lvert \varepsilon \rvert \ll 1\), and we can do the second order approximation

\[h \sim h_0 \left( \alpha \varepsilon - \frac{1}{2} \alpha (\alpha - 1) \varepsilon^2 \right).\]

Code

The program used is the following.

#![deny(unsafe_code)]
#![no_std]
#![no_main]

extern crate panic_halt;

use stm32f1xx_hal::{
    prelude::*,
    pac,
    delay::Delay,
    i2c,
};

use cortex_m_rt::entry;
use bme280::BME280;
use rtt_target::{rtt_init_print, rprintln};

#[entry]
fn main() -> ! {
    let cp = cortex_m::Peripherals::take().unwrap();
    let dp = pac::Peripherals::take().unwrap();
    let mut rcc = dp.RCC.constrain();

    // Set up the I2C bus
    let mut flash = dp.FLASH.constrain();
    let afio = dp.AFIO.constrain(&mut rcc.apb2);
    let mut gpiob = dp.GPIOB.split(&mut rcc.apb2);
    let scl = gpiob.pb6.into_alternate_open_drain(&mut gpiob.crl);
    let sda = gpiob.pb7.into_alternate_open_drain(&mut gpiob.crl);
    let mut mapr = afio.mapr;
    let mode = i2c::Mode::Standard { frequency: 40.hz() };
    let clocks = rcc.cfgr.freeze(&mut flash.acr);
    let mut apb = rcc.apb1;
    let start_timeout_us: u32 = 10000;
    let start_retries: u8 = 5;
    let addr_timeout_us: u32 = 10000;
    let data_timeout_us: u32 = 10000;

    // Creates a blocking I2C1 object on pins PB6 and PB7
    let i2c = i2c::BlockingI2c::i2c1(
        dp.I2C1,
        (scl, sda),
        &mut mapr,
        mode,
        clocks,
        &mut apb,
        start_timeout_us,
        start_retries,
        addr_timeout_us,
        data_timeout_us,
        );

    // Set up the BME280
    let delay = Delay::new(cp.SYST, clocks);
    let mut bme280 = BME280::new_primary(i2c, delay);
    bme280.init().expect("BME280 initialization failed.");

    rtt_init_print!();
    rprintln!("BME280 ready.");

    let delay_time = 1_000_000; // millis
    let sea_level: f32 = 1013.25; // hPa
    let h: f32 =  44330.0 // m;
    let alpha: f32 = 1.0/5.225;

    loop {
        cortex_m::asm::delay(delay_time);

        let measurements = bme280.measure().unwrap();
        let pressure = measurements.pressure / 100.0; // measured in hPa
        // Second order Taylor serie for (1- epsilon)^alpha
        let epsilon = 1.0 - pressure / sea_level;
        let altitude = h * ( alpha * epsilon - 0.5 * alpha * (alpha - 1.0) * epsilon* epsilon );

        rprintln!("Temperature: {}°C", measurements.temperature);
        rprintln!("Altitude: {}m", altitude);
        rprintln!("Humidty: {}%", measurements.humidity);
        rprintln!("Pressure: {}hPa", pressure);
    }
}

Some important things to note.

  • We have to implement #[panic_handler], the crate panic-halt, provides a lightweight implementation.
  • The bme280 crate does not implement the required traits to use i2c::i2c1, so we use its blocking cousin i2c::BlockingI2c::i2c1, see [2].
  • cargo-embed uses the Real-Time transfer protocol, provided by rtt-target, instead of the more common semi-hosting method.
  • The standard library is a no-go for most embedded applications, hence the use of #![no_std].

Flashing and testing

To run this program we are going to use cargo-embed from [probe-rs][probe.rs], which is a drop-in replacement for cargo run for embedded devices, but it can do much more, such as logging RTT output and opening a GDB server. It can be installed with,

cargo install cargo-embed

It is advisable to set add configuration file Embed.toml, with the following contents, at the root of the project.

[default.general]
chip = "STM32F103C8" # Use --list-chips to see more.

[default.rtt]
enabled = true

The default values can be seen at the default Embed.toml. Finally, to run the program we simply run

cargo embed --release

and we can see the output of our program,

9:01:27.862  BME280 ready.
9:02:36.586  Temperature: 16.995693°C
19:02:36.586 Altitude: 596.0528m 
19:02:36.586 Humidty: 69.90904%
19:02:36.586 Pressure: 943.97943hPa

In my measurements, the approximation works just fine. I compared the output with those from another BME280 sensor running on an Arduino board using Adafruit’s BME280 library and saw a difference in the measurement of altitude of about 0.15%.

Closing thoughts

Rust provides a rapidly moving environment for embedded devices, with a friendly community, and tools like cargo-embed which make it a more streamlined process, similar to the usual x86_64 experience. There are still a few rough edges, like the board auto-detection not working in some cases but overall rust is becoming a good language for IoT and embedded.

References

  1. Rust on STM32: Blinking an LED
  2. I2C and BME280, missing trait