ESP32 thread safety mastered

How we solved concurrent ESP32/FreeRTOS access and NVS stability issues using a patented Nested Locking counter. This strategy guarantees a robust, single-session state across all logical operations, ensuring agnostic business logic reliability and NVS session stability at the SDK level.

Fixing NVS crashes with Nested Locking

If you're building complex firmware on the ESP32 using FreeRTOS, you've probably encountered strange, intermittent crashes or deadlocks when multiple tasks try to access the Non-Volatile Storage (NVS) simultaneously. The common approach to fixing this usually fails.

Here’s why your code breaks, and how we engineered a bulletproof solution using Nested Locking in our PreferencesManager wrapper.

 

The core problem: the Preferences library isn’t thread-safe.

The Preferences library, widely used for saving configurations on the ESP32, is designed for single-threaded Arduino sketches. When you introduce FreeRTOS, where multiple tasks (running on Core 0 and Core 1) hit the storage at the same time, it becomes inherently unstable.


Why the simple fix approach fails 

The most common "fix" is to wrap every NVS read/write operation with a FreeRTOS Mutex.

// The seemingly safe, but ultimately failing pattern
void setSetting(const char* key, int value) {
    xSemaphoreTake(nvsMutex, portMAX_DELAY); // ? Lock the gate
    preferences.begin("app", false);
    preferences.putInt(key, value);
    preferences.end(); // ? BOOM!
    xSemaphoreGive(nvsMutex); // ? Unlock
}

This serializes the access, which is good. But it introduces a fatal flaw when you have nested function calls.

 

The catastrophe of nested calls

Imagine a function like loadAllSettings() calls getSetting(key1) and getSetting(key2).

  1. loadAllSettings calls getSetting(key1).
  2. getSetting(key1) locks the Mutex and calls preferences.end()This prematurely closes the NVS session.
  3. The next call ( getSetting(key2) ) finds the NVS in a corrupt or uninitialized state, leading to a system crash, often the dreaded Guru Meditation Error: LoadProhibited - which is really bad!

 

// ❌ THE DANGEROUS NESTED CALLS

// This function needs multiple settings
void loadAllSettings() {
    Serial.println("Loading API key...");
    getSetting("api_key"); 

    Serial.println("Loading refresh rate...");
    getSetting("refresh_rate"); 
    
    // The second call is where the crash happens!
}


// This function is protected, but its 'end()' call is premature
void getSetting(const char* key) {
    xSemaphoreTake(nvsMutex, portMAX_DELAY);
    preferences.begin("app", true);

    // ... read data ...

    preferences.end(); // ? BOOM! This breaks the session for the next call!
    xSemaphoreGive(nvsMutex);
}


We need a system that ensures the NVS session (preferences.begin() and preferences.end()) is managed only once for an entire logical block of operations, even if that block contains nested calls to protected functions.

 

The Solution: Nested Locking with Resource Counting

To achieve true, enterprise-grade stability, we implemented a Nested Locking mechanism within our PreferencesManager wrapper. This pattern makes the NVS access point fully thread-safe and re-entrant without relying on complex, unreliable OS features.


1. The components

We use three core components:

  • nvsMutex (The Bouncer): The standard FreeRTOS Mutex to ensure only one task can enter the NVS critical section at any moment.
  • batchDepth (The Check-In Counter): An integer that tracks how many nested functions have requested the NVS access.
  • Single Session Logic: The Mutex and the Counter are used to gate the preferences.begin() and preferences.end() calls.


2. The logic: a smart gatekeeper

Our wrapper functions (begin() and end()) now execute smart logic:


A. The entry gate: begin(readOnly)
void PreferencesManager::begin(bool readOnly) {
    xSemaphoreTake(nvsMutex, portMAX_DELAY); // 1. ? ALWAYS lock first

    // 2. This is the Nested Locking logic
    if (batchDepth == 0) {
        // Only the first, outermost call opens the NVS session.
        preferences.begin(space, readOnly);
    }
    batchDepth++; // 3. Increment the counter for the current thread.
}

 

B. The exit gate: end()
void PreferencesManager::end() {
    batchDepth--; // 1. Decrement the counter

    // 2. Only the last, outermost call closes the NVS session.
    if (batchDepth == 0) {
        preferences.end(); // Commits data and closes the session.
        xSemaphoreGive(nvsMutex); // 3. ? Release the Mutex last.
    }
    // If batchDepth > 0, the session and lock remain active.
}

 


3. The result: stability assured

With this strategy, when a function calls begin() multiple times in a nested fashion:

  • The Mutex is acquired by the outer function, successfully blocking all other tasks.
  • The inner functions simply increment batchDepth without opening or closing the NVS session.
  • The NVS session remains open and valid until the final, outermost call to end() decreases batchDepth to zero, at which point the session is properly closed and the lock is released.

This guarantees absolute integrity of the NVS session state, eliminating the source of our toughest concurrency bugs.

 


Takeaway for your projects

If you encounter instability with Preferences in a multi-tasking environment, stop relying on simple Mutex wraps. Implement Nested Locking using a resource counter (our batchDepth above) to manage the lifespan of your preferences.begin() and preferences.end() calls. Your firmware stability will thank you for it!