Skip to main content

ESP32 Deep Sleep Secrets: Persisting Data with RTC_DATA_ATTR and RTC Memory

 You have designed a low-power sensor node. It wakes up, reads a sensor, and enters Deep Sleep to conserve battery. Ideally, you want to batch these readings and transmit them only once every ten wake cycles to save radio power.

However, every time your ESP32 wakes up, your variables reset. Your batch buffer is empty, and your counters are back to zero.

The immediate reaction is often to write state to the NVS (Non-Volatile Storage) flash or an SD card. This is a mistake for high-frequency data. Flash memory has limited write cycles (wear leveling becomes an issue), and SD cards consume significant power during initialization.

For transient state maintenance across sleep cycles, the ESP32 offers a dedicated hardware solution: RTC Memory.

The Architecture of Amnesia (The Root Cause)

To fix the problem, you must understand the ESP32's power domains. The ESP32 is not a single block of memory.

  1. SRAM (Static RAM): This is where your standard global variables, stack, and heap live. When the ESP32 enters Deep Sleep, the main CPU and the SRAM power domain are shut down to reduce current consumption to the ~10µA range. Data in SRAM is volatile; it evaporates when power is cut.
  2. RTC Slow Memory: This is an 8KB block of memory located within the RTC (Real-Time Clock) controller power domain. Unlike the main CPU, the RTC domain remains powered during Deep Sleep to maintain the internal timer and wake-up triggers.

The problem is that the compiler's linker script, by default, places all your variables in SRAM (.bss or .data sections). When the device sleeps, that memory is powered off. When it wakes, the bootloader re-initializes the runtime environment, effectively resetting your variables.

The Fix: The RTC_DATA_ATTR Macro

To persist data, we must instruct the linker to map specific variables to the RTC Slow Memory segment, not the general SRAM. Espressif provides a macro for this: RTC_DATA_ATTR.

The following solution demonstrates how to implement a persistent struct that survives deep sleep, allowing you to batch sensor readings and track boot cycles without touching Flash memory.

The Implementation

#include <Arduino.h>
#include <esp_sleep.h>

// Define a struct to hold our persistent state.
// We group variables to keep memory management clean.
struct SystemState {
    uint32_t bootCount;
    float sensorHistory[5]; // Batch up to 5 readings
    uint32_t lastActiveTime;
};

// CRITICAL: The RTC_DATA_ATTR macro maps this instance 
// to the RTC Slow Memory (SRAM) which stays powered during Deep Sleep.
// Initialized to 0 only on the very first cold boot (Power On Reset).
RTC_DATA_ATTR SystemState deviceState = {
    .bootCount = 0,
    .sensorHistory = {0.0},
    .lastActiveTime = 0
};

// Simulation of a sensor read
float readMockSensor() {
    // Generate a pseudo-random float for demonstration
    return 20.0 + ((float)random(0, 100) / 10.0); 
}

void printState() {
    Serial.printf("--- Wake Cycle: %d ---\n", deviceState.bootCount);
    Serial.print("History Buffer: [ ");
    for (int i = 0; i < 5; i++) {
        Serial.printf("%.2f ", deviceState.sensorHistory[i]);
    }
    Serial.println("]");
}

void setup() {
    Serial.begin(115200);
    delay(1000); // Allow Serial monitor to latch

    // 1. Identify Wake Up Reason
    esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause();

    if (wakeup_reason == ESP_SLEEP_WAKEUP_TIMER) {
        Serial.println("Woke from Deep Sleep via Timer.");
    } else {
        Serial.println("Cold Boot or Reset button.");
        // Optional: Explicitly reset state on cold boot if needed, 
        // though the CRT usually zeros .rtc.data on power-on.
        deviceState.bootCount = 0;
    }

    // 2. Perform Logic
    // Update the history buffer (circular buffer logic or linear fill)
    int historyIndex = deviceState.bootCount % 5;
    float currentReading = readMockSensor();
    
    deviceState.sensorHistory[historyIndex] = currentReading;
    deviceState.lastActiveTime = millis();
    
    printState();

    // 3. Prepare for next sleep
    deviceState.bootCount++;

    // logic: If we have filled the buffer, maybe transmit?
    if (deviceState.bootCount % 5 == 0) {
        Serial.println(">> Batch buffer full. Transmitting data (simulated)...");
        // Simulated network transmission delay
        delay(100); 
    }

    Serial.println("Entering Deep Sleep for 5 seconds...");
    Serial.flush(); 

    // 4. Configure Wakeup and Sleep
    // Sleep for 5 seconds (converted to microseconds)
    esp_sleep_enable_timer_wakeup(5 * 1000000ULL);
    
    // Start Deep Sleep
    esp_deep_sleep_start();
}

void loop() {
    // This section is never reached in a deep sleep cycle.
    // The device resets and runs setup() again upon waking.
}

How It Works: Under the Hood

When you compile this code, the RTC_DATA_ATTR attribute modifies the object file. It tells the linker to place deviceState into a specific memory section named .rtc.data.

Memory Mapping

Normally, variables go into DRAM (Data RAM).

  • Standard Int: 0x3FFBxxxx (Internal SRAM 1 - Powered off during sleep).
  • RTC_DATA_ATTR Int: 0x5000xxxx (RTC Slow Memory - Powered on during sleep).

Lifecycle Analysis

  1. Cold Boot (Battery Connect): The ESP32 hardware powers up. The bootloader runs. It sees the .rtc.data section and initializes it (usually setting it to the values defined in your code, or zero).
  2. Runtime: You modify deviceState. The CPU writes to the RTC Slow Memory address space via an internal bus bridge. This access is slightly slower than main SRAM, but negligible for simple state variables.
  3. Deep Sleep Entry: You call esp_deep_sleep_start(). The main CPU, peripherals (WiFi/BT), and main SRAM are powered down. However, the Power Management Unit (PMU) keeps voltage supplied to the RTC domain. The bits in deviceState are physically maintained.
  4. Wake Up: The timer triggers. The CPU boots up. It behaves like a reset except the memory in the RTC domain has not been wiped. The bootloader detects the wake cause and intentionally skips the initialization/zeroing of the .rtc.data section, preserving your data.

Important Limitations

While powerful, RTC memory has constraints you must engineer around:

  1. Capacity: The ESP32 typically has 8KB of RTC Slow Memory. This is plenty for structs, flags, and small buffers, but do not attempt to store large JSON strings or image buffers here.
  2. Power Loss: This is not Flash memory. If the battery is removed or voltage drops below the brownout threshold, RTC memory is lost. If your data is mission-critical (e.g., total water usage for billing), you must periodically commit it to NVS Flash, using RTC memory only as a high-frequency buffer.
  3. Bootloader Version: Ensure you are using a modern ESP32 core. Very old bootloaders had bugs regarding the initialization of this segment on cold boots, though this is rarely an issue in modern PlatformIO or Arduino IDE environments.

Conclusion

Using RTC_DATA_ATTR turns the ESP32's volatility into a strength. It allows you to maintain application state across sleep cycles with zero latency and zero flash-wear. For low-power IoT devices requiring data batching or state tracking, this is the architecturally correct approach.