Persistent State in Rust Firmware
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();
}