To read from an ESP32 flash partition in C using the ESP-IDF framework, you must use the esp_partition_find_first() and esp_partition_read() APIs. This requires defining a custom partition table in your project, compiling it into the binary, and handling the raw byte extraction via C pointers. Unlike file systems like LittleFS or SPIFFS, raw partition reading gives you direct memory access to specific flash offsets, which is critical for bootloaders, OTA staging, and proprietary data blobs.

Project Overview & Hardware Requirements

Difficulty: Intermediate | Time: 45 Minutes | Toolchain: ESP-IDF v5.1+

This guide targets the ESP32-WROOM-32E DevKit V1 (4MB Flash). While the API is identical across the ESP32-S3 and ESP32-C3 families, the 4MB WROOM-32E is the baseline for standard partition math. We are reading raw bytes from a custom data partition, bypassing the overhead of a POSIX file system.

Parts List

  • MCU: ESP32-WROOM-32E DevKit V1 (4MB SPI Flash)
  • Power/Data: High-quality USB-C to USB-A data cable (charge-only cables will cause serial port enumeration failures)
  • Indicator: 5mm Green LED + 330Ω through-hole resistor (for physical read-status feedback)
  • Wiring: 22 AWG solid core jumper wires, standard solderless breadboard

Understanding the ESP32 Partition Table

The ESP32 does not treat its SPI flash as a single monolithic drive. Instead, it uses a partition table mapped at offset 0x8000 in the flash. If you want to read a specific block of data, you must first tell the compiler to reserve space for it.

Create a file named partitions.csv in the root of your ESP-IDF project directory. Add a custom partition named sensor_log of type data and subtype undefined (0x06).

Custom partitions.csv Layout (4MB Flash)
NameTypeSubTypeOffsetSizeFlags
nvsdatanvs0x90000x6000
phy_initdataphy0xf0000x1000
factoryappfactory0x100001M
sensor_logdata0x060x1100001M
Critical Step: You must tell CMake to use this file. Run idf.py menuconfig, navigate to Partition Table → Custom partition table CSV, and type partitions.csv. If you skip this, the ESP32 will use the default single-factory layout and your C code will fail to find the sensor_log partition at runtime.

Pin Mapping & Debug UART Setup

Because the SPI flash is internal to the ESP32-WROOM-32E module (using GPIOs 6-11 internally), you do not wire any external SPI pins for this operation. However, robust embedded debugging requires physical feedback and serial logging. Below is the pin mapping for the debug UART and the external success/failure LED.

Hardware Pin Mapping for Debug & Feedback
FunctionESP32 GPIOWire ColorNotes
UART TX (Serial Out)GPIO 1N/A (Internal USB Bridge)Monitored via PC serial terminal at 115200 baud
UART RX (Serial In)GPIO 3N/A (Internal USB Bridge)Used for ESP-IDF monitor interaction
LED Anode (Success)GPIO 2Red (via 330Ω)Onboard LED on most DevKits; external LED recommended
LED CathodeGNDBlackShared ground with DevKit

Complete C Code: Reading a Custom Flash Partition

The following code is fully compilable within an ESP-IDF main.c file. It locates the sensor_log partition, reads the first 64 bytes into a RAM buffer, validates the read operation, and triggers physical LED feedback based on the hardware result.

#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "esp_partition.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

// --- PIN DEFINITIONS ---
#define LED_SUCCESS_GPIO GPIO_NUM_2   // Green LED Anode
#define UART_TX_PIN      GPIO_NUM_1   // Debug TX (Mapped internally on DevKit)
#define UART_RX_PIN      GPIO_NUM_3   // Debug RX (Mapped internally on DevKit)

static const char *TAG = "FLASH_READ";

// Configure GPIO for physical debug feedback
void configure_led_gpio(void) {
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << LED_SUCCESS_GPIO),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&io_conf);
    gpio_set_level(LED_SUCCESS_GPIO, 0); // Start OFF
}

void app_main(void) {
    configure_led_gpio();
    ESP_LOGI(TAG, "Initializing Flash Partition Read Sequence...");

    // 1. Locate the custom partition by type and name
    const esp_partition_t *partition = esp_partition_find_first(
        ESP_PARTITION_TYPE_DATA, 
        0x06,             // Custom subtype defined in partitions.csv
        "sensor_log"      // Exact name from CSV
    );

    if (partition == NULL) {
        ESP_LOGE(TAG, "Failed to find partition 'sensor_log'. Check menuconfig CSV path.");
        // Blink LED rapidly to indicate fatal config error
        for(int i=0; i<10; i++) {
            gpio_set_level(LED_SUCCESS_GPIO, i % 2);
            vTaskDelay(pdMS_TO_TICKS(100));
        }
        return;
    }

    ESP_LOGI(TAG, "Found partition: '%s', Address: 0x%lx, Size: 0x%lx",
             partition->label, partition->address, partition->size);

    // 2. Prepare RAM buffer and read parameters
    size_t read_size = 64; // Read first 64 bytes
    uint8_t *read_buffer = malloc(read_size);
    if (read_buffer == NULL) {
        ESP_LOGE(TAG, "Failed to allocate RAM for flash read buffer.");
        return;
    }
    memset(read_buffer, 0, read_size);

    // 3. Execute the raw flash read
    esp_err_t err = esp_partition_read(partition, 0, read_buffer, read_size);

    if (err == ESP_OK) {
        ESP_LOGI(TAG, "Successfully read %d bytes from flash.", read_size);
        ESP_LOG_BUFFER_HEXDUMP(TAG, read_buffer, read_size, ESP_LOG_INFO);
        
        // Solid LED ON indicates success
        gpio_set_level(LED_SUCCESS_GPIO, 1);
    } else {
        ESP_LOGE(TAG, "Flash read failed with error: %s", esp_err_to_name(err));
        // Two long blinks indicate hardware read failure
        for(int i=0; i<2; i++) {
            gpio_set_level(LED_SUCCESS_GPIO, 1);
            vTaskDelay(pdMS_TO_TICKS(500));
            gpio_set_level(LED_SUCCESS_GPIO, 0);
            vTaskDelay(pdMS_TO_TICKS(500));
        }
    }

    free(read_buffer);
}

Debugging: Exact Error Strings & Ranked Causes

When working directly with the SPI flash API, the ESP-IDF logger will throw specific esp_err_t codes. If your serial monitor halts or throws errors, cross-reference them with this ranked list.

The First 3 Things to Check When It Fails

  1. Menuconfig CSV Path: Did you actually type partitions.csv into idf.py menuconfig? If this field is blank, the compiler uses the default 2MB table, and your custom partition does not exist in the binary.
  2. Flash Erase State: Unwritten flash on the ESP32 reads as 0xFF. If your hex dump is entirely FF FF FF..., the read succeeded, but you never wrote data to that offset. The code is working; your data is just missing.
  3. Boundary Math: Ensure your offset + read_size does not exceed partition->size. Reading past the partition boundary triggers an immediate hardware fault protection block.

Ranked Error Causes

Exact Error StringMeaningMost Likely Cause & Fix
ESP_ERR_NOT_FOUND Partition iterator returned NULL. Cause: The CSV wasn't compiled into the binary, or the name/subtype in C doesn't exactly match the CSV.
Fix: Run idf.py partition-table to verify the compiled binary layout.
ESP_ERR_INVALID_ARG Invalid argument passed to read function. Cause: Your offset + size exceeds the partition boundaries, or the partition pointer is NULL.
Fix: Add an assert(offset + size <= partition->size); before the read call.
ESP_ERR_FLASH_OP_FAIL SPI flash hardware operation failed. Cause: Physical degradation of the flash chip, or an interrupt fired during the SPI transaction, corrupting the bus.
Fix: Run esptool.py erase_flash to clear bad sectors. If persistent, the WROOM module is bricked.
ESP_ERR_NO_MEM Memory allocation failed. Cause: You tried to malloc() a buffer larger than available contiguous DRAM.
Fix: Read in smaller chunks (e.g., 4KB pages) rather than allocating a massive 1MB buffer at once.

Extending and Simplifying the Build

To Simplify: If you only need to store simple key-value pairs (like WiFi credentials or device calibration offsets), abandon raw partition reads. Use the NVS (Non-Volatile Storage) API. NVS handles wear-leveling and boundary checking automatically, saving you from manual pointer arithmetic.

To Extend: If you are building a high-frequency data logger, raw partition reading is the correct path, but you must implement wear-leveling if you ever intend to write back to this partition. The ESP32 SPI flash has a limit of roughly 100,000 erase cycles per sector. To extend this build into a circular buffer logger, integrate the Wear Levelling API on top of your custom partition to distribute writes evenly across the 1MB sensor_log block.

Frequently Asked Questions

How do I read from an ESP32 flash partition in C without using SPIFFS or LittleFS?

You bypass file systems entirely by using the esp_partition API shown above. File systems like LittleFS add a POSIX translation layer that consumes RAM and CPU cycles to manage directories and file allocation tables. By defining a raw data partition in your CSV and using esp_partition_read(), you read raw memory addresses directly from the SPI flash chip into your C pointers, which is significantly faster and uses less heap memory.

Why does esp_partition_read return ESP_ERR_INVALID_ARG?

This specific error string almost always means your math is wrong. The ESP-IDF flash driver checks if offset + size > partition->size. If you attempt to read 100 bytes starting at an offset of 0x0FFFF0 in a 1MB partition, you will bleed into the adjacent partition or empty space. The hardware abstraction layer catches this and returns ESP_ERR_INVALID_ARG to prevent memory corruption. Always validate your boundaries dynamically before calling the read function.

Can I read and write to the same custom flash partition simultaneously in FreeRTOS?

No. The ESP32 utilizes a single SPI bus for its internal flash. While you can read and write to the same partition conceptually, you cannot do it simultaneously from different FreeRTOS tasks without a mutex. Furthermore, writing to flash requires erasing a 4KB sector first, which stalls the CPU and prevents XIP (Execute In Place) code execution if not handled in IRAM. If multiple tasks need flash access, wrap your esp_partition_read and esp_partition_write calls in a FreeRTOS Mutex (SemaphoreHandle_t) to prevent bus collisions and ESP_ERR_FLASH_OP_FAIL errors.