155. Storage on ESP32: the partition table, NVS, SPIFFS, FAT with wear levelling
On STM32 settings are put into a free flash memory page by hand
(section 11), and a file system is set up on an SD card (section 42). On
ESP32 all of this is already there: the flash memory on the module is split by the partition
table, settings are kept by NVS — a key-value store with
integrity checking, and files can be kept in SPIFFS or in FAT with
wear levelling, both visible through the ordinary fopen and
fread. The section shows how they differ and how much space a file really
takes. Everything is checked in QEMU, where the flash memory is emulated
faithfully (_verify/esp155/): three restarts in a row, formatting and
writing.
Terms used in the documentation
Term Meaning partition table (CSV) the flash memory partition table NVS (non-volatile storage) the key-value settings store namespace, key, entry a namespace, a key, an entry (32 bytes) SPIFFS a flat file system for flash memory LittleFS a file system for flash memory, the modern replacement for SPIFFS; in ESP-IDF a separate component FATFS, wear levelling FAT and the wear levelling layer VFS (virtual file system) the layer that gives fopen/freadto all of themallocation unit (cluster) a cluster: the smallest piece a file occupies LFN (long file name) long names; without them only 8.3 sector, page a flash memory sector of 4096 bytes, a SPIFFS page of 256 spiffsgen.py, nvs_partition_gen.py building partition images on a computer
155.1 Your own partition table
The default table from section 150 has settings, radio calibration and the
application. As soon as you need files, you write your own (partitions.csv and
CONFIG_PARTITION_TABLE_CUSTOM):
nvs, data, nvs, 0x9000, 24K
phy_init, data, phy, 0xf000, 4K
factory, app, factory, 0x10000, 1M
storage, data, spiffs, , 512K
fat, data, fat, , 1M
An empty offset means "right after the previous one", and the build computes it itself:
nvs,data,nvs,0x9000,24K,
phy_init,data,phy,0xf000,4K,
factory,app,factory,0x10000,1M,
storage,data,spiffs,0x110000,512K,
fat,data,fat,0x190000,1M,
The program sees the same table through esp_partition_find:
раздел nvs subtype 0x02 offset 0x009000 size 24576
раздел phy_init subtype 0x01 offset 0x00f000 size 4096
раздел storage subtype 0x82 offset 0x110000 size 524288
раздел fat subtype 0x81 offset 0x190000 size 1048576
The application partition must start at an address that is a multiple of 64 KB, any
partition at a 4096-byte sector (SPI_FLASH_SEC_SIZE). In total this
table takes 2.6 MB of 4 — the rest can be given to a second
application image for over-the-air updates — more on that later in this part.
155.2 NVS: settings that survive everything
NVS is not "EEPROM emulation" but a log-structured store: entries of 32 bytes, pages of one sector, checksums, and when a new value is written the old one is marked obsolete rather than erased in place. Working with it means opening a namespace, reading and writing by key, and committing:
nvs_handle_t h;
ESP_ERROR_CHECK(nvs_open("app", NVS_READWRITE, &h));
uint32_t count = 0;
e = nvs_get_u32(h, "boot", &count); /* при первом запуске ключа нет */
count++;
ESP_ERROR_CHECK(nvs_set_u32(h, "boot", count));
ESP_ERROR_CHECK(nvs_set_str(h, "name", "датчик-7"));
ESP_ERROR_CHECK(nvs_commit(h));
Three boots in a row (the program calls esp_restart itself):
загрузка 1: nvs_get_u32 → ESP_ERR_NVS_NOT_FOUND, boot = 1; записей занято 4, свободно 752, доступно 626, всего 756, пространств имён 1
загрузка 2: nvs_get_u32 → ESP_OK, boot = 2; записей занято 4, свободно 752, доступно 626, всего 756, пространств имён 1
загрузка 3: nvs_get_u32 → ESP_OK, boot = 3; записей занято 4, свободно 752, доступно 626, всего 756, пространств имён 1
The first run gives ESP_ERR_NVS_NOT_FOUND: the key does not exist yet, and this is not
an error but a normal answer, for which you write a default value.
After that the counter grows and survives a restart. Capacity: a 24 KB partition =
6 pages of 4096, a page holds 126
entries after the header and the map, 756 in total. But NVS always keeps one page empty:
during compaction the live entries are copied onto it. So the space
available for data is 126 entries fewer than the free ones — 626; this is the field available_entries, while
free_entries includes the reserve page.
Our four entries are the namespace, the number,
the string and its continuation; a number takes one entry, a string takes as many
as it needs for 32-byte chunks. The image file can also be built on
a computer from a CSV:
Created NVS binary: ===> /tmp/nvs.bin
nvs.bin: 24576 байт
страниц по 4096: 6 ; с записями: 1 ; с одним заголовком: 4
записей по 32 байта на странице после заголовка и карты: 126
This is how serial numbers and calibrations go to the factory: nvs_partition_gen.py
from a CSV table, esptool to the partition address.
155.3 SPIFFS: flat and cheap
SPIFFS is mounted with one structure, and if the partition is empty it formats it itself:
W (6151) SPIFFS: mount failed, -10025. formatting...
SPIFFS: всего 524288, доступно 474641, занято 0 после монтирования
Of the 512 KB partition, 474641 bytes are left for data — almost 50 KB, that is 9.5 %, goes to service pages. And even those are not filled to the end: SPIFFS reliably uses only about 75 % of the partition. When space runs out, garbage collection scans the whole file system over and over, and a single write can take several seconds or even fail. For 512 KB a reasonable limit is about 384 KB of data. Next, twenty files of 1000 bytes:
SPIFFS: записано файлов по 1000 байт: 20, занято стало 25100 (+25100), то есть 1255 байт на файл
SPIFFS: чтение f000.txt: «xxxx», каталогов нет — имена плоские
A 1000-byte file takes 1255: a SPIFFS page is 256 bytes, each has
its own header, plus an index page per file. So the overhead
is about a quarter for small files — the larger the file, the
smaller the share. SPIFFS has no directories at all: /spiffs/log/a.txt is
the whole file name, not a file in a directory, and the length of the name together with the path and
the terminating zero is limited to 32 bytes (CONFIG_SPIFFS_OBJ_NAME_LEN), that
is up to 31 bytes for the name itself (Cyrillic in UTF-8 takes two bytes per letter).
The image is also built on
a computer — spiffsgen.py from a directory:
spiffs.bin: 524288 байт из 100 файлов по 1000
This is how web pages and configurations are put into the firmware. A caveat: SPIFFS is declared deprecated, it has no protection against power loss in the middle of a write, and new projects use LittleFS — it is added as a component from the registry and behaves the same from the outside.
155.4 FAT: expensive, but the real thing
FAT is needed where files are read by a computer: a memory card, a log
that is taken out and looked at. In flash memory FAT lives on top of the
wear levelling layer (wear_levelling), because FAT itself would rewrite
the same table sectors over and over:
FAT: всего 1048576, том 995328, свободно 995328 после форматирования (сектор WL 4096)
FAT: записано файлов по 1000 байт в /fat/log: 20, свободно стало 909312 (−86016), то есть 4300 байт на файл
Of the megabyte partition the volume came out at 995328 bytes: 52 KB were taken by the wear
levelling layer. 86016 bytes are exactly 21 clusters: one for each of the
20 files and one more for the log directory itself. So each 1000-byte file
costs 4300 bytes: a whole 4096 cluster and a share of the directory cluster — FAT cannot
share a cluster between files. A thousand
small files in FAT is 4 MB that you do not have; for a log you write one
file and append to its end. On the other hand, the directories are real (mkdir works),
and the card can be inserted into a computer.
An important detail: by default long names are disabled in ESP-IDF
(CONFIG_FATFS_LFN_NONE=y), so names are 8.3 only, as in
section 42. LOG_2026.TXT will pass, sensor-log-2026-09-14.txt will not; long
names are enabled in menuconfig, and they cost memory for a buffer. Cyrillic letters
will not pass even in a short name: the default code page is 437
(CONFIG_FATFS_CODEPAGE_437). You need 866, or long names together
with CONFIG_FATFS_API_ENCODING_UTF_8.
155.5 What to choose
| What to store | With what |
|---|---|
| settings, calibrations, the serial number | NVS |
| the operating hours counter, the last state | NVS (log-structured writes, wear is spread) |
| web pages, firmware files, dictionaries | SPIFFS or LittleFS, an image from spiffsgen.py |
| a log that will be taken out and read on a computer | FAT + wear levelling, better still an SD card |
| many small records in a row | one file with appending, not a thousand files |
| data that must not be lost on power loss | LittleFS or NVS; SPIFFS does not promise it |
The endurance of the module's flash memory is on the order of 100000 sector erases, and that is why in ESP-IDF everything that writes goes either through a log (NVS), or through wear levelling (FAT), or through a file system that spreads writes itself (SPIFFS, LittleFS).
155.6 Pitfalls
| Symptom | Cause |
|---|---|
ESP_ERR_NVS_NOT_FOUND on the first read |
the key does not exist yet — this is normal, write a default value |
nvs_flash_init returned ESP_ERR_NVS_NO_FREE_PAGES |
the partition is full or the format is old: nvs_flash_erase and start over |
| settings disappeared after a firmware update | the NVS partition was erased together with the application; idf.py app-flash |
| SPIFFS: a 1000-byte file took 1255 | a 256-byte page plus an index per file |
mkdir("/spiffs/log") returns an error |
there are no directories: fopen("/spiffs/log/a.txt", "w") creates a file with the flat name log/a.txt, and mkdir is not supported; the name with the path is up to 31 bytes |
| FAT: 20 files ate 86 KB | a 4096-byte cluster per file |
fopen("/fat/sensor-log.txt") returns NULL |
long names are disabled, only 8.3 |
| the FAT volume is 52 KB smaller than the partition | the service area of wear levelling |
| data disappeared after power loss | SPIFFS does not survive an interrupted write — LittleFS or NVS |
| a partition does not fit into 4 MB | offsets are multiples of a sector, the application of 64 KB; work out the table in advance |
155.7 Summary
- Your own partition table: an empty offset is computed automatically; partitions are aligned to a 4096 sector, the application to 64 KB.
- NVS: 24 KB = 6 pages × 126 entries = 756, 626 for data (one page is a reserve for compaction); key-value, log-structured writes, survives a restart;
the first read gives
ESP_ERR_NVS_NOT_FOUND. - SPIFFS: of 512 KB, 474641 are available (9.5 % service), reliably fill about 75 % of the partition; a 1000-byte file costs 1255, no directories, a name with the path is up to 31 bytes.
- FAT + wear levelling: of 1 MB the volume is 995328, a 1000-byte file takes a 4096 cluster (20 files with a directory are 21 clusters, 4300 bytes per file), 8.3 names by default.
- Partition images are built on a computer:
nvs_partition_gen.py,spiffsgen.py. - SPIFFS is deprecated — use LittleFS in new projects.
Full sources
main.c — NVS across restarts, SPIFFS and FAT with wear levelling in their own partitions.
/* Раздел 155: хранение на ESP32 — NVS через перезапуски, SPIFFS и FAT с выравниванием износа
* в своих разделах: сколько влезает на самом деле. Три загрузки подряд через esp_restart(). */
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "esp_attr.h"
#include "esp_log.h"
#include "esp_partition.h"
#include "esp_spiffs.h"
#include "esp_system.h"
#include "esp_vfs_fat.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "wear_levelling.h"
static const char *TAG = "store155";
RTC_NOINIT_ATTR static int boots; /* номер загрузки переживает esp_restart */
static int fill(const char *dir, int chunk, int limit) /* пишет до limit файлов по chunk байт */
{
static char buf[1024];
memset(buf, 'x', sizeof buf);
int n = 0;
for (; n < limit; n++) {
char name[64];
snprintf(name, sizeof name, "%s/f%03d.txt", dir, n);
FILE *f = fopen(name, "wb");
if (!f) break;
int left = chunk, bad = 0;
while (left > 0) {
int part = left > (int)sizeof buf ? (int)sizeof buf : left;
if (fwrite(buf, 1, part, f) != (size_t)part) { bad = 1; break; }
left -= part;
}
if (fclose(f) != 0 || bad) { unlink(name); break; }
}
return n;
}
void app_main(void)
{
if (esp_reset_reason() == ESP_RST_POWERON) boots = 0;
boots++;
/* NVS: счётчик загрузок в настройках */
esp_err_t e = nvs_flash_init();
if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
e = nvs_flash_init();
}
ESP_ERROR_CHECK(e);
nvs_handle_t h;
ESP_ERROR_CHECK(nvs_open("app", NVS_READWRITE, &h));
uint32_t count = 0;
e = nvs_get_u32(h, "boot", &count); /* при первом запуске ключа нет */
count++;
ESP_ERROR_CHECK(nvs_set_u32(h, "boot", count));
ESP_ERROR_CHECK(nvs_set_str(h, "name", "датчик-7"));
ESP_ERROR_CHECK(nvs_commit(h));
nvs_stats_t st;
nvs_get_stats(NULL, &st);
printf("загрузка %d: nvs_get_u32 → %s, boot = %lu; записей занято %u, свободно %u, доступно %u, всего %u, пространств имён %u\n",
boots, esp_err_to_name(e), (unsigned long)count, (unsigned)st.used_entries, (unsigned)st.free_entries,
(unsigned)st.available_entries, (unsigned)st.total_entries, (unsigned)st.namespace_count);
nvs_close(h);
if (boots < 3) {
vTaskDelay(pdMS_TO_TICKS(100));
esp_restart();
}
/* таблица разделов, как её видит программа */
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, NULL);
for (; it; it = esp_partition_next(it)) {
const esp_partition_t *p = esp_partition_get(it);
printf("раздел %-9s subtype 0x%02x offset 0x%06lx size %6lu\n", p->label, p->subtype, (unsigned long)p->address, (unsigned long)p->size);
}
/* SPIFFS: плоская файловая система в разделе storage */
esp_vfs_spiffs_conf_t sc = { .base_path = "/spiffs", .partition_label = "storage", .max_files = 5, .format_if_mount_failed = true };
ESP_ERROR_CHECK(esp_vfs_spiffs_register(&sc));
size_t total = 0, used = 0;
esp_spiffs_info("storage", &total, &used);
printf("SPIFFS: всего 524288, доступно %u, занято %u после монтирования\n", (unsigned)total, (unsigned)used);
size_t used0 = used;
int nf = fill("/spiffs", 1000, 20);
esp_spiffs_info("storage", &total, &used);
printf("SPIFFS: записано файлов по 1000 байт: %d, занято стало %u (+%u), то есть %u байт на файл\n",
nf, (unsigned)used, (unsigned)(used - used0), (unsigned)((used - used0) / (nf ? nf : 1)));
FILE *f = fopen("/spiffs/f000.txt", "rb");
char probe[8] = { 0 };
fread(probe, 1, 4, f);
fclose(f);
printf("SPIFFS: чтение f000.txt: «%s», каталогов нет — имена плоские\n", probe);
esp_vfs_spiffs_unregister("storage");
/* FAT с выравниванием износа в разделе fat */
wl_handle_t wl = WL_INVALID_HANDLE;
esp_vfs_fat_mount_config_t fc = { .max_files = 5, .format_if_mount_failed = true, .allocation_unit_size = 4096 };
ESP_ERROR_CHECK(esp_vfs_fat_spiflash_mount_rw_wl("/fat", "fat", &fc, &wl));
uint64_t ft = 0, ff = 0;
esp_vfs_fat_info("/fat", &ft, &ff);
printf("FAT: всего 1048576, том %llu, свободно %llu после форматирования (сектор WL %u)\n", (unsigned long long)ft, (unsigned long long)ff, (unsigned)wl_sector_size(wl));
ESP_ERROR_CHECK(mkdir("/fat/log", 0777) == 0 ? ESP_OK : ESP_FAIL);
uint64_t ff0 = ff;
nf = fill("/fat/log", 1000, 20);
esp_vfs_fat_info("/fat", &ft, &ff);
printf("FAT: записано файлов по 1000 байт в /fat/log: %d, свободно стало %llu (−%llu), то есть %llu байт на файл\n",
nf, (unsigned long long)ff, (unsigned long long)(ff0 - ff), (unsigned long long)((ff0 - ff) / (nf ? nf : 1)));
esp_vfs_fat_spiflash_unmount_rw_wl("/fat", wl);
ESP_LOGI(TAG, "done");
}
Next: section 156 — networking: Ethernet and Wi-Fi, TCP/IP, an HTTP server, MQTT, SNTP, syslog.