Static variable in no_std (embassy)

Hey,
This might not necessarily be an embedded problem. But it came up for me in an embedded project using the Embassy framework.

I want to calculate and store the bias of my IMU during a startup sequence. It shouldn’t change at runtime for the time being, though it might change later on. Once the value has been calculated, I want to access it multiple times at runtime

What would be the idiomatic way to solve this problem? I started by experimenting with static_cell, but couldn't find a workable solution that way.

In C, I would define a static variable—possibly protected by a mutex—and then write a setter and getter function. The AI tools, which in my experience usually work quite well with Rust, have not yet provided me with a satisfactory solution to this problem.

Thank you very much in advance

in a hosted platform where std is available, the idiomatic way is LazyLock or OnceLock.

for embassy, you have a LazyLock too:

however, there's no OnceLock in embassy, you'll have to create your own OnceLock if LazyLock is not usable in your use case, e.g. when the static variable must be initialized with a closure that captures some runtime produced value.

it is easily implementable, e.g. a newtype wrapper on top of Mutex<Option<T>>.

I think lazy_lock isn't suitable for my situation, since you'd need access to the IMU within the closure. Unless I'm missing something? I'll include a snippet of the code so you can understand the dilemma.

use defmt::info;

use embassy_stm32::i2c::{I2c, Master};
use embassy_stm32::mode::Async;
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::signal::Signal;
use embassy_time::{Delay, Duration, Ticker, Timer};

use icm20948_async::{AccDlp, AccRange, AccUnit, GyrDlp, GyrRange, GyrUnit, IcmBuilder};

use static_cell::StaticCell;

use crate::config::{IMU_CALC_STATIC_BIAS_SAMPLES, IMU_READ_TICK_RATE};

#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ImuData<T> {
    pub acc: [T; 3],
    pub gyro: [T; 3],
    pub temp: T,
}

static IMU_STATIC_BIAS: StaticCell<ImuData<f32>> = StaticCell::new();

#[embassy_executor::task]
pub async fn imu_task(
    i2c: I2c<'static, Async, Master>,
    signal: &'static Signal<ThreadModeRawMutex, ImuData<f32>>,
) {
    info!("IMU START");
    let mut imu = IcmBuilder::new_i2c(i2c, Delay)
        .gyr_unit(GyrUnit::Rps) // rad/s → direct uom use
        .gyr_range(GyrRange::Dps250) // ±250 °/s — max resolution
        .gyr_dlp(GyrDlp::Hz51) // 51 Hz DLPF — 5.6 ms delay
        .gyr_odr(10_u8) // 1125 / (1+10) ≈ 102 Hz
        .acc_range(AccRange::Gs2) // ±2 g — max resolution
        .acc_unit(AccUnit::Mpss) // m/s² → direct uom use
        .acc_dlp(AccDlp::Hz50) // 50 Hz — closest to gyro DLPF
        .acc_odr(10_u16) // ≈ 102 Hz — match gyro
        .initialize_6dof()
        .await
        .expect("ICM20948 init failed");

    // Compute static bias
    let mut ticker = Ticker::every(IMU_READ_TICK_RATE);
    let mut sum: ImuData<f32> = Default::default();
    for _ in 0..IMU_CALC_STATIC_BIAS_SAMPLES {
        ticker.next().await;
        let data = imu.read_6dof().await.expect("ICM20948 read failed");
        sum.acc[0] += data.acc[0];
        sum.acc[1] += data.acc[1];
        sum.acc[2] += data.acc[2];
        sum.gyro[0] += data.gyr[0];
        sum.gyro[1] += data.gyr[1];
        sum.gyro[2] += data.gyr[2];
        sum.temp += data.tmp;
    }

    let n = IMU_CALC_STATIC_BIAS_SAMPLES as f32;
    IMU_STATIC_BIAS.init(ImuData {
        acc: [sum.acc[0] / n, sum.acc[1] / n, sum.acc[2] / n],
        gyro: [sum.gyro[0] / n, sum.gyro[1] / n, sum.gyro[2] / n],
        temp: sum.temp / n,
    });

    loop {
        let data_read = imu.read_6dof().await.expect("ICM20948 read failed");

        let data = ImuData {
            acc: data_read.acc,
            gyro: data_read.gyr,
            temp: data_read.tmp,
        };

        info!(
            "Accel -> X: {}, Y: {}, Z: {}",
            data.acc[0], data.acc[1], data.acc[2]
        );
        info!(
            "Gyro  -> X: {}, Y: {}, Z: {}",
            data.gyro[0], data.gyro[1], data.gyro[2]
        );

        info!("Tmp -> {}", data.temp);

        signal.signal(data);

        Timer::after(Duration::from_millis(500)).await;
    }
}

pub fn imu_get_static_bias() -> ImuData<f32> {
    if let Some(static_bias) = IMU_STATIC_BIAS.try_get() {
        static_bias
    } else {
        Default::default()
    }
}

problem static_cell has no get() function.

One solution, of course, would be to simply send the static_bias along with every read to the signal receivers.

yeah, that's what I mean you need a OnceCell if you need a stateful closure to initialize the static variable.

embassy does have a OnceCell, its main difference from the standard libarary is that the get() api is async:

this is often enough for embassy applications, but sometimes you want a blocking get() api, in which case it is not usable. (this is relatively rare for embedded targets, but these days dual-core microcontrollers are not uncommon anymore).

with your example:

// the static variable
static IMU_STATIC_BIAS: OnceCell<ImuData<f32>> = OnceCell::new();

// the task to initialize it
#[embassy_executor::task]
pub async fn imu_task(
    i2c: I2c<'static, Async, Master>,
    signal: &'static Signal<ThreadModeRawMutex, ImuData<f32>>,
) {
    //...
    IMU_STATIC_BIAS.init(...);
    //...
}

// the function to access the static value

// option 1: don't wait for it to be initialized, use fallbacks
// similar to your example
pub fn imu_get_static_bias1() -> ImuData<f32> { ... }

// opiton 2: `await` in case it is not initialized.
// downside is it cannot be used outside `async` context
pub async fn imu_get_static_bias2() -> ImuData<f32> {
    IMU_STATIC_BIAS.get().await.clone()
}

static_cell's api is designed with a different use case in mind, that is, when you want a &'static mut thing but you are not able to use Box::leak(). you can implement a OnceCell-like wrapper on top of it, but it's not the best use of it.