My friend G has an interest in seabirds, to put it mildly; the seabird species we care about for this post nest in burrows. A few weeks ago, G and I were in a seabird colony talking about ways to detect whether a burrow has a bird in it. He raised the idea of measuring CO2 concentration, when I got home I ordered a Sensirion SCD41, and the burrowmeter concept was born…

After some tinkering, I’ve got a little battery powered thing that periodically logs a few measurements including CO2 concentration to a micro SD card. Sometime in the next week or two, we’ll leave it in a burrow that is also being monitored via a trail camera. Reading from the sensor and writing to the SD card were both fairly straightforward, but it took me a while to land on a good solution for storing state when the ESP32s3 microcontroller goes in to deep sleep.

Deep sleep is necessary for decent battery life; when the microcontroller is awake, the board draws something like 90mA (under a day of running time with the battery pack I’m using), but in deep sleep it’s more like 2.4mA (over a month). So, in this application, the microcontroller is only awake for long enough to read the sensor and write to the SD card, then it goes back to deep sleep.

When the ESP32s3 goes in to deep sleep, most of the chip including the main RAM is powered off, so waking up works very much like a fresh boot. This creates a challenge for the CO2 logger firmware, because it needs to keep track of which file on the SD card to record the measurements in to - I want it to start a new file when the logger is initially powers up, then go to sleep, wake up again and record to that same file - not create a new file for each measurement.

There is a small amount of RAM in the ESP32’s RTC peripheral that remains powered in deep sleep; we can use this to maintain some state, but doing so is nuanced. One of the rules of Rust is basically that you cannot use an uninitialised variable, normally MaybeUninit is used to deal with situations like this - it tells the Rust compiler when a chunk of memory has been initialised in some way it couldn’t otherwise know about. Another complication is that, basically, one shouldn’t use mut references to static variables since it’s very hard to do without triggering Undefined Behaviour. To address these concerns, we can use the ram macro to put the state in the RTC RAM (assuming an esp-hal context), raw pointers and UnsafeCell to avoid the referencing issues, and MaybeUninit to prevent the state from being overwritten by initialisation on wakeup from sleep.

This approach has a remaining issue though: any corruption due to a reset while the state is being modified could result in the subsequent next wake() triggering undefined behaviour. The corruption could be caused by a reset or power failure, and the undefined behaviour could be caused by memory used for the enum not containing a valid discriminant. To detect corruption, this approach carefully uses the crc crate - note the use of &raw const to avoid creating a reference. The CRC approach is not 100% bulletproof, as there’s some small chance that the data would be corrupted in a way that leaves a valid CRC, but in practice I’ve not seen a single instance of corruption in my prototyping and the 16-bit CRC should catch the overwhelming majority of whatever corruption does occur.

#[derive(Format, Debug, PartialEq)]
enum State {
    /// Device has just powered up without persistent state
    ColdBoot,
    /// The persistent state appears to have been corrupted
    ChecksumMismatch,
    Sleeping {
        file_index: u32,
        sample_number: u32,
    },
}

impl State {
    /// Reads the state out of a global static kept in the RTC RAM
    fn wake() -> State {
        match reset_reason().unwrap() {
            SocResetReason::CoreDeepSleep => {
                let crc = Crc::<u16>::new(&crc::CRC_16_IBM_SDLC);

                // Read from RTC memory, set to `Some()` iff checksum matches
                let checksummed = unsafe {
                    let questionable = UnsafeCell::raw_get(&raw const RTC_STATE)
                        .read()
                        .assume_init();
                    let questionable_bytes = slice_from_raw_parts(
                        &raw const questionable.data as *const u8,
                        size_of::<Self>(),
                    );

                    if questionable.checksum == crc.checksum(&*questionable_bytes) {
                        Some(questionable.data)
                    } else {
                        // Ensure the Self doesn't get dropped, as that could
                        // trigger undefined behaviour
                        forget(questionable);
                        None
                    }
                };

                checksummed.unwrap_or(State::ChecksumMismatch)
            }
            SocResetReason::ChipPowerOn => State::ColdBoot,
            reason => {
                warn!("Unhandled reason {}", reason as usize);
                State::ColdBoot
            }
        }
    }

    /// Stores the state
    fn store(self) {
        let crc = Crc::<u16>::new(&crc::CRC_16_IBM_SDLC);
        unsafe {
            let bytes = slice_from_raw_parts(&self as *const _ as *const u8, size_of::<Self>());

            let checksum = crc.checksum(&*bytes);

            RTC_STATE = UnsafeCell::new(MaybeUninit::new(Checksummed {
                data: self,
                checksum,
            }));
        }
    }
}

struct Checksummed<T> {
    data: T,
    checksum: u16,
}

type StoredState = UnsafeCell<MaybeUninit<Checksummed<State>>>;

#[ram(rtc_fast)]
static mut RTC_STATE: StoredState = UnsafeCell::new(MaybeUninit::uninit());

fn main() {
    let state = State::wake();

    // ...do stuff...

    State::Recording{file_index: 3, sample_number: 42}.store();

    start_deep_sleep();
}