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.

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).

If one does something like initializing OnceCell and then running tasks which need it it is possible to use OnceCell::try_get. Last time I needed OnceCell in a blocking API I did exactly that and unwrapped the returned value.

If initialization is guaranteed to be happening on another core just looping with try_get might work well enough too.

On a side node, there might be another reason to use blocking API on a single-core microcontroller: if you need to write your own interrupts. Specifically in my case embassy was for some reason missing ~5% of timer interrupts. Not sure whether it is because embassy-net-enc28j60 is just constantly polling in place of using interrupt pin of enc28j60[1] or my mix of OnePulse timers and low-level timer API was somehow wrong[2], but interrupts worked[3] and waiting for pulse start in a task missed pulses. However I could not possibly put await in an interrupt handler[4]. (Blocking get in this case would have either waited forever or instantly succeeded, so try_get().unwrap() is actually better.)


  1. Thus getting more resources than it deserves. ↩︎

  2. I have found no way for OnePulse timer to actually output anything and this timer only had two purposes: external interrupt replacement and turning 1us input pulses into 400us pulses. Existing OnePulse API only grants first. Mix was probably wrong, but I bet it was “has UB, but compiles ‘correctly’ for now” wrong, not “misses interrupts” wrong. ↩︎

  3. I also switched to only low-level timer API at the same time. ↩︎

  4. And also once_cell.try_get().unwrap() looks like executing less code so I just used it everywhere, not only in interrupt handler. ↩︎

On embedded, single core, I would not bother, and simply do something like this:

static mut IMU_STATIC_BIAS: ImuData = ImuData::new(); // const new(), ImuData must be Copy

pub fn get_imu_static_bias() -> ImuData {
    let p = &raw const IMU_STATIC_BIAS;
    unsafe { p.read() }
}


pub fn set_imu_static_bias(bias: ImuData) {
    let p = &raw mut IMU_STATIC_BIAS;
    unsafe { p.write(bias); }
}
  • Using interrupts? Consider disabling/enabling interrupts during read/write.
  • Multiple cores? Consider a spinlock.

Yes, technically, these functions are unsound. But in a fixed, controlled embedded environment?

“Unsound” in this case means “compiler can miscompile your code”, so “fixed, controlled embedded environment” generally means “I will have harder times finding where is the bug due to less tooling available”.

When sharing just between tasks on single-core you can just use embassy_sync::blocking_mutex::Mutex<embassy_sync::blocking_mutex::raw::NoopRawMutex, ImuData>[1] with a non-mut static and you get essentially same thing[2] while correctly explaining to the compiler access semantics, but with two extra benefits:

  1. Ensuring that semantics of the data access is understood by the compiler is not your responsibility. Embassy maintainers are more likely to find out and fix possible miscompilation problems.
  2. When it comes to switching to multi-core code or interrupts all you need to do is to replace embassy_sync::blocking_mutex::raw::NoopRawMutex with something more suitable. Though specifically spinlocks (except for the global spinlock used by CriticalSectionRawMutex on multi-core) are for some reason not provided[3] by embassy-sync, but by other packages like this.

  1. set* can use Mutex::lock_mut, its safety requirements are easily met by set* if get* and set* are only functions which interact with IMU_STATIC_BIAS. If you want to write no unsafe yourself you will have to also wrap ImuData in RefCell. ↩︎

  2. If you follow what code is actually doing then it is basically using UnsafeCell::get without any locks and should probably compile into nothing. ↩︎

  3. I mean that they do not provide interface like with CriticalSectionRawMutex while having implementation in their dependency, I do not expect implementation in embassy-sync itself. ↩︎

How will the compiler misscompile this code on a single core CPU, without any threads?

I also like this straightforward bare-metal operation in a single-core, single-threaded environment, so I implemented the local_static crate.
The efficiency of is the same as C's static, no need for extra lock operations, zero runtime overhead, and no need to write unsafe code.

How it can miscompile with threads? Almost all UB-enabled miscompilations I seen had a demonstrator which works on a single thread. There may be examples of incorrect memory access reordering which leads to problems specifically on multi-core, but I do not remember them.

I am not sure under which conditions specifically your code can miscompile, but one of the famous examples which includes statics is calling function which was never set: both of the following codes print HERE even though first should attempt to call garbage and second should attempt to call function at address 0. No threads anywhere.

First:

use core::mem::MaybeUninit;

static mut FUNC: MaybeUninit<fn()> = MaybeUninit::uninit();

pub fn func_impl() {
    println!("HERE");
}

#[unsafe(no_mangle)]
pub fn set_func() {
    let func = &raw mut FUNC;
    unsafe { func.write(MaybeUninit::new(func_impl)) };
}

fn get_func() -> fn() {
    let func = &raw const FUNC;
    unsafe { func.read().assume_init() }
}

pub fn main() {
    (get_func())()
}

Second (only differences):

static mut FUNC: *const fn() = core::ptr::null();

…

#[unsafe(no_mangle)]
pub fn set_func() {
    static FUNC_IMPL: fn() = func_impl;
    let func = &raw mut FUNC;
    unsafe { func.write(core::ptr::from_ref(&FUNC_IMPL)) };
}

fn get_func() -> fn() {
    let func = &raw const FUNC;
    unsafe { func.read().read() }
}

…

Both of your examples exhibit unconditional undefined behavior. There are only two possible values for FUNC and one is invalid so it is statically determined to be the other.

mroth's code is fully defined and sound as long as IMU_STATIC_BIAS isn't accessed at the same time from multiple threads or in a signal handler. There is no undefined behavior that can cause a miscompile, the only possible issue is a data race.

In the post with that code @mroth himself said “technically, these functions are unsound”. I was not analyzing whether it is true in my replies[1], in the second comment I was just showing that assuming functions are in fact unsound, absence of threads or single core does not protect from miscompilation.

More specifically, @mroth claiming that his functions are unsound and then claiming that they are OK without threads looked like he is operating under assumption that “absence of threads protects unsound functions from miscompilation”. I apologize if this is not true – I read “technically unsound” in that comment as “unsound where present now, but compile as wanted”, not “would become unsound if moved to other project”.


  1. I personally failed to find anything wrong with these functions under given conditions, but I am not sure I fully understand relevant compiler’s operational semantics. ↩︎

I hate to be the bearer of bad news, but the API offered in your crate makes it incredibly easy to create UB in safe code.

The two things I see are:

  1. new() initialises with MaybeUninit::zeroed(), but not all types have a valid representation of zero (NonZero... for example). Since get() and get_mut() can access this without proving or promising that the contents are initialised, you can trigger UB in safe code here.
  2. get_mut(&'static self) -> &'static mut Self permits the creation of multiple mut references

I do think that simply marking get and get_mut unsafe is enough though, as it moves the prover of the invariants to the user of the API.

BTW, one way that a global mutable like this can be implemented with as little footguns as possible is to omit a get_mut(...) -> &mut ... method, and instead only offer a with_mut<T>(&self, f: impl for<'a> FnOnce(&'a mut Self) -> T) -> T method. The existential lifetime prevents the user leaking it, so on a single core system the user must only promise not to call with_mut recursively, which is much easier to remember not to.

I use AtomicRefCell and AtomicOnceCell for this kind of stuff. Works nice

For atomic_once_cell the version on crates.io unconditionally depends on crossbeam, bun on GitHub it has a feature instead.