155. Хранение на ESP32: таблица разделов, NVS, SPIFFS, FAT с выравниванием износа
На STM32 настройки кладут в свободную страницу флеш-памяти вручную
(раздел 11), а файловую систему заводят на SD-карте (раздел 42). У
ESP32 всё это уже есть: флеш-память на модуле разбита таблицей
разделов, настройки хранит NVS — хранилище «ключ-значение» с
контролем целостности, а файлы можно держать в SPIFFS или в FAT с
выравниванием износа, и то и другое видно через обычные fopen и
fread. Раздел показывает, чем они отличаются и сколько на самом деле
занимает файл. Всё проверено в QEMU, где флеш-память эмулируется
честно (_verify/esp155/): три перезапуска подряд, форматирование и
запись.
Как это называется в документации
Оригинал Перевод partition table (CSV) таблица разделов флеш-памяти NVS (non-volatile storage) хранилище настроек «ключ-значение» namespace, key, entry пространство имён, ключ, запись (32 байта) SPIFFS плоская файловая система для флеш-памяти LittleFS файловая система для флеш-памяти, современная замена SPIFFS; в ESP-IDF — отдельный компонент FATFS, wear levelling FAT и слой выравнивания износа VFS (virtual file system) слой, дающий fopen/freadдля всехallocation unit (cluster) кластер: наименьший кусок, который занимает файл LFN (long file name) длинные имена; без них — только 8.3 sector, page сектор флеш-памяти 4096 байт, страница SPIFFS 256 spiffsgen.py, nvs_partition_gen.py сборка образов разделов на компьютере
155.1 Своя таблица разделов
Таблица по умолчанию из раздела 150 — настройки, калибровка радио и
приложение. Как только нужны файлы, пишут свою (partitions.csv и
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
Пустое смещение означает «сразу за предыдущим», и сборка считает его сама:
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,
Программа видит ту же таблицу через 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
Раздел приложения обязан начинаться с адреса, кратного 64 КБ, любой
раздел — с сектора 4096 байт (SPI_FLASH_SEC_SIZE). Суммарно эта
таблица занимает 2,6 МБ из 4 — остальное можно отдать под второй
образ приложения для обновления по воздуху — о нём дальше в этой части.
155.2 NVS: настройки, которые переживают всё
NVS — это не «эмуляция EEPROM», а журнальное хранилище: записи по 32 байта, страницы по сектору, контрольные суммы, и при записи нового значения старое помечается устаревшим, а не стирается на месте. Работа с ним — открыть пространство имён, читать и писать по ключу, подтвердить:
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));
Три загрузки подряд (программа сама вызывает esp_restart):
загрузка 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
Первый запуск — ESP_ERR_NVS_NOT_FOUND: ключа ещё нет, и это не
ошибка, а нормальный ответ, под который пишут значение по умолчанию.
Дальше счётчик растёт и переживает перезапуск. Ёмкость: раздел 24 КБ =
6 страниц по 4096, на странице после заголовка и карты помещается 126
записей, итого 756. Но одну страницу NVS всегда держит пустой: при
уплотнении на неё переписываются живые записи. Поэтому под данные
доступно на 126 меньше — 626; это поле available_entries, а
free_entries резервную страницу включает.
Наши четыре записи — пространство имён, число,
строка и её продолжение; число занимает одну запись, строка — столько,
сколько нужно на 32-байтовые куски. Файл образа можно собрать и на
компьютере из CSV:
Created NVS binary: ===> /tmp/nvs.bin
nvs.bin: 24576 байт
страниц по 4096: 6 ; с записями: 1 ; с одним заголовком: 4
записей по 32 байта на странице после заголовка и карты: 126
Так на завод уезжают серийные номера и калибровки: nvs_partition_gen.py
из таблицы CSV, esptool — по адресу раздела.
155.3 SPIFFS: плоско и дёшево
SPIFFS монтируется одной структурой, и если раздел пуст — форматирует его сам:
W (6151) SPIFFS: mount failed, -10025. formatting...
SPIFFS: всего 524288, доступно 474641, занято 0 после монтирования
Из 512 КБ раздела под данные остаётся 474 641 байт — почти 50 КБ, то есть 9,5 %, уходит на служебные страницы. Но и их до конца не заполняют: SPIFFS надёжно использует лишь около 75 % раздела. Когда место кончается, сборка мусора раз за разом просматривает всю файловую систему, и одна запись может занять несколько секунд, а то и не пройти. Для 512 КБ разумный предел — около 384 КБ данных. Дальше двадцать файлов по 1000 байт:
SPIFFS: записано файлов по 1000 байт: 20, занято стало 25100 (+25100), то есть 1255 байт на файл
SPIFFS: чтение f000.txt: «xxxx», каталогов нет — имена плоские
Файл в 1000 байт занимает 1255: страница SPIFFS — 256 байт, у каждой
свой заголовок, плюс индексная страница на файл. То есть накладные
расходы около четверти для мелких файлов — чем крупнее файл, тем
меньше доля. Каталогов в SPIFFS нет вовсе: /spiffs/log/a.txt — это
имя файла целиком, а не файл в каталоге, и длина имени вместе с путём и
завершающим нулём ограничена 32 байтами (CONFIG_SPIFFS_OBJ_NAME_LEN), то
есть на само имя — до 31 байта (кириллица в UTF-8 — по два байта на букву).
Образ тоже собирается на
компьютере — spiffsgen.py из каталога:
spiffs.bin: 524288 байт из 100 файлов по 1000
Так в прошивку кладут веб-страницы и конфигурации. Оговорка: SPIFFS объявлен устаревшим, у него нет защиты от отключения питания посреди записи, и новые проекты берут LittleFS — он подключается компонентом из реестра и ведёт себя так же снаружи.
155.4 FAT: дорого, но по-настоящему
FAT нужен там, где файлы читает компьютер: карта памяти, журнал,
который вынимают и смотрят. Во флеш-памяти FAT живёт поверх слоя
выравнивания износа (wear_levelling), потому что сам FAT перезаписывал
бы одни и те же секторы таблицы:
FAT: всего 1048576, том 995328, свободно 995328 после форматирования (сектор WL 4096)
FAT: записано файлов по 1000 байт в /fat/log: 20, свободно стало 909312 (−86016), то есть 4300 байт на файл
Из мегабайта раздела том получился 995 328 байт: 52 КБ забрал слой
выравнивания. 86 016 байт — это ровно 21 кластер: по одному на каждый из
20 файлов и ещё один под сам каталог log. Так что каждый файл в 1000 байт
стоит 4300 байт: целый кластер 4096 и доля кластера каталога — делить
кластер между файлами FAT не умеет. Тысяча
мелких файлов в FAT — это 4 МБ, которых нет; для журнала пишут один
файл и дописывают в конец. Зато каталоги настоящие (mkdir работает),
и карту можно вставить в компьютер.
Важная мелочь: по умолчанию в ESP-IDF длинные имена выключены
(CONFIG_FATFS_LFN_NONE=y), то есть имена — только 8.3, как в
разделе 42. LOG_2026.TXT пройдёт, лог-2026-09-14.txt — нет; длинные
имена включают в menuconfig, они стоят памяти под буфер. Русские буквы
не пройдут даже в коротком имени: по умолчанию кодовая страница 437
(CONFIG_FATFS_CODEPAGE_437). Нужна 866 или длинные имена вместе
с CONFIG_FATFS_API_ENCODING_UTF_8.
155.5 Что выбрать
| Что хранить | Чем |
|---|---|
| настройки, калибровки, серийный номер | NVS |
| счётчик наработки, последнее состояние | NVS (журнальная запись, износ размазан) |
| веб-страницы, файлы прошивки, словари | SPIFFS или LittleFS, образ из spiffsgen.py |
| журнал, который вынут и прочитают на компьютере | FAT + выравнивание износа, ещё лучше — SD-карта |
| много мелких записей подряд | один файл с дописыванием, не тысяча файлов |
| данные, которые нельзя потерять при отключении питания | LittleFS или NVS; SPIFFS не обещает |
Ресурс флеш-памяти модуля — порядка 100 000 стираний сектора, и именно поэтому в ESP-IDF всё пишущее идёт либо через журнал (NVS), либо через выравнивание износа (FAT), либо через файловую систему, которая размазывает запись сама (SPIFFS, LittleFS).
155.6 Грабли
| Симптом | Причина |
|---|---|
ESP_ERR_NVS_NOT_FOUND при первом чтении |
ключа ещё нет — это норма, писать значение по умолчанию |
nvs_flash_init вернул ESP_ERR_NVS_NO_FREE_PAGES |
раздел заполнен или формат старый: nvs_flash_erase и заново |
| настройки пропали после обновления прошивки | стёрли раздел NVS вместе с приложением; idf.py app-flash |
| SPIFFS: файл 1000 байт занял 1255 | страница 256 байт плюс индекс на файл |
mkdir("/spiffs/log") возвращает ошибку |
каталогов нет: fopen("/spiffs/log/a.txt", "w") создаёт файл с плоским именем log/a.txt, а mkdir не поддерживается; имя с путём — до 31 байта |
| FAT: 20 файлов съели 86 КБ | кластер 4096 байт на файл |
fopen("/fat/лог.txt") возвращает NULL |
длинные имена выключены, только 8.3 |
| том FAT меньше раздела на 52 КБ | служебная область выравнивания износа |
| данные пропали после отключения питания | SPIFFS не переживает обрыв записи — LittleFS или NVS |
| раздел не влез в 4 МБ | смещения кратны сектору, приложение — 64 КБ; считать таблицу заранее |
155.7 Итоги
- Своя таблица разделов: пустое смещение считается само; разделы кратны сектору 4096, приложение — 64 КБ.
- NVS: 24 КБ = 6 страниц × 126 записей = 756, под данные — 626 (одна страница — запас на уплотнение); ключ-значение, журнальная запись, переживает перезапуск;
первое чтение —
ESP_ERR_NVS_NOT_FOUND. - SPIFFS: из 512 КБ доступно 474 641 (9,5 % служебных), надёжно заполняют около 75 % раздела; файл 1000 байт стоит 1255, каталогов нет, имя с путём до 31 байта.
- FAT + выравнивание износа: из 1 МБ том 995 328, файл 1000 байт занимает кластер 4096 (20 файлов с каталогом — 21 кластер, по 4300 байт на файл), имена 8.3 по умолчанию.
- Образы разделов собираются на компьютере:
nvs_partition_gen.py,spiffsgen.py. - SPIFFS устарел — в новых проектах LittleFS.
Полные исходники
main.c — NVS через перезапуски, SPIFFS и FAT с выравниванием износа в своих разделах.
/* Раздел 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");
}
Дальше: раздел 156 — сеть: Ethernet и Wi-Fi, TCP/IP, HTTP-сервер, MQTT, SNTP, syslog.