Добрый день, уважаемые читатели! Практически любой проект автоматики требует применения настраиваемых во время работы программы параметров – ну например желаемая температура для термостата или пароль для подключения к сети WiFi. Получить эти данные с сервера или с панели управления не особо сложно, но сразу же возникает следующий вопрос – а что делать после перезагрузки или выключения и включения устройства? Нужно где-то хранить последнее установленное значение непосредственно на ESP.
Разработчики ESP32 и ESP-IDF позаботились об этом, и предусмотрели специальный раздел для хранения данных в виде пар “ключ-значение” – Nonvolatile Storage Library или кратко NVS. Этот механизм очень напоминает текстовые INI-файлы Windows и другие конфигурационные файлы.
Этот API можно легко использовать как из фреймворка ESP-IDF, так и из фреймворка Arduino ESP32. Наглядно это показано в другой статье: Arduino ESP32 шаг за шагом. Но для Arduino ESP32 имеется и другой способ – с помощью библиотеки-обертки Preferences.
В прошлой статье я рассказывал, как выделить место на flash-памяти под этот раздел, а в данной статье поговорим о том, как собственно работать с этим разделом.
Ключевые особенности NVS API
Библиотека NVS в своей работе использует две основные сущности: страницы и записи. Страница — это логическая структура, в которой хранится часть общего набора данных. Логическая страница соответствует одному физическому сектору флэш-памяти или 4096 байт. Страница состоит из трех частей: заголовка, карты записей и самих записей.
NVS работает с парами ключ-значение. Одна пара ключ-значение называется записью. Размер записи фиксирован и равен 32 байта. Но под непосредственно данные из них остается всего 8 байт, остальное место занимают служебные данные. А что если требуется хранить данные длиннее 8 байт? Тогда используется сразу несколько записей – см. иллюстрацию ниже.
Собственно данные могут иметь один из следующих типов:
- целые (int8_t – uint64_t)
- строка не длиннее 4000 байт
- двоичные данные (blob)
Все остальные типы данных (например float) должны быть приведены к одному из вышеперечисленных типов.
Как видно из схемы, длина ключа не должна превышать 15 символов (15 + завершающий ноль). Ключи должны быть уникальными. Говоря простыми словами ключ – это уникальное имя записи, или идентификатор, по которому API находит запрашиваемую запись.
Все ключи должны быть объединены в одном из пространств имен (но мне больше нравится слово группы). Длина имени группы также не должна превышать 15 символов. Иерархия групп (вложенность одной в другую) не предусмотрены, но если очень хочется, то можно использовать несколько разных разделов NVS с одинаковыми именами групп.
Можно использовать шифрование данных в разделах NVS, дополнительно защищая чувствительные данные.
Как устроена NVS
NVS хранит записи последовательно друг за другом, а новые записи добавляются в конец активной страницы. Это просто и ежу понятно. Как и при удалении записи – она помечается как удаленная.
Гораздо интереснее выглядит процесс изменения значения. Когда значение любого ранее сохраненного ключа необходимо обновить, в конце страницы добавляется новая пара ключ-значение, а старая запись помечается как удаленная. Как только вся страница будет заполнена, система открывает следующую страницу, и т.д.
Страницы, которые используются, имеют связанный с ними порядковый номер. Более высокие порядковые номера соответствуют страницам, которые были созданы позже. Отображение секторов флэш-памяти в логические страницы не имеет какого-либо определенного порядка. Библиотека проверит порядковые номера страниц, найденных в каждом секторе флэш-памяти, и организует страницы в список на основе этих номеров.
Когда все записи на странице будут помечены как удаленные, страница может быть “отформатирована” для повторного использования. Но пока есть свободные страницы, обычно просто используется следующая страница. Когда все страницы заполнены, NVS попробует “отформатировать” страницы, занятые только удаленными записями. При этом если на странице осталось мало действующих записей, NVS может принудительно переместить их на новое место для стирания данной страницы. После этого страницы можно использовать повторно.
Всё это позволяет значительно снизить физический износ микросхемы flash-памяти, так как при каждом новом изменении пары ключ-значение запись будет происходить по новому физическому адресу. Да и сам принцип хранения параметров в виде записей представляется для программиста гораздо более удобным, чем классическая библиотека EEPROM, к примеру.
В природе и технике не бывает идеальных сущностей, поэтому стоит упомянуть о недостатках этой модели:
- даже на самые “мелкие” данные типа int8 тратится целая запись размером 32 байта. Но с учетом солидного размера flash-памяти на ESP32, с этим можно смириться.
- на текущий момент нет возможности хранить данные типов double, extended и time_t, например. Но этот недостаток легко обходится с помощью blob или “приведения” к целочисленному типу того же физического размерчика.
- одного уровня группировки пространств имен лично мне не достаточно. Но обойти тоже можно, о чем я и расскажу в следующих статьях.
- для индексации записей используется оперативная память из кучи. Поэтому, при каждой новой записи количество свободных байт в куче будет немного уменьшаться. До тех пор, пока очередная страница не будет удалена полностью – тогда свободный остаток кучи скачкообразно увеличивается. С этим фактом приходится мирится.
Работа с NVS API из ESP-IDF или Arduino ESP32
Прежде чем начинать пользоваться разделом NVS, его нужно инициализировать. Проще говоря – отформатировать. Но не будем же мы форматировать раздел при каждом запуске микроконтроллера. Поэтому стоит поступить так:
esp_err_t err = nvs_flash_init(); if ((err == ESP_ERR_NVS_NO_FREE_PAGES) || (err == ESP_ERR_NVS_NEW_VERSION_FOUND)) { ESP_LOGW("NVS", "Erasing NVS partition..."); nvs_flash_erase(); err = nvs_flash_init(); }; if (err == ESP_OK) { ESP_LOGI("NVS", "NVS partition initilized"); } else { ESP_LOGE("NVS", "NVS partition initialization error: %d (%s)", err, esp_err_to_name(err)); };
Приведенный код пытается открыть NVS раздел с помощью nvs_flash_init()
. Если функция вернула состояния “нет свободных страниц” или “найдена новая версия”, то стираем раздел и пытаемся инициализировать его повторно. На практике мне еще не приходилось с этим сталкиваться, если не считать первое форматирование для нового чипа.
Затем можно уже начинать открывать пространство имен и работать с ним:
bool nvsOpen(const char* name_group, nvs_open_mode_t open_mode, nvs_handle_t *nvs_handle) { esp_err_t err = nvs_open(name_group, open_mode, nvs_handle); if (err != ESP_OK) { if (!((err == ESP_ERR_NVS_NOT_FOUND) && (open_mode == NVS_READONLY))) { ESP_LOGE("NVS", "Error opening NVS namespace \"%s\": %d (%s)!", name_group, err, esp_err_to_name(err)); }; return false; }; return true; }
Открыть пространство имен можно в режиме “только чтение” или “чтение и запись”. Если мы открываем пространство имен в режиме записи, то ошибка “namespace не найден” нам не интересна (он будет создан при записи автоматически), а вот в режиме “только чтение” – это уже может быть проблемой.
Ну и, наконец, уже можно читать и даже писать в NVS раздел, например так:
nvs_handle_t nvs_handle; if (nvsOpen("counters", NVS_READWRITE, &nvs_handle)) { nvs_set_u32(nvs_handle, "total", _counters.cntTotal); nvs_set_u32(nvs_handle, "today", _counters.cntToday); nvs_set_u32(nvs_handle, "yesterday", _counters.cntYesterday); nvs_close(nvs_handle); };
Так что же с float, double, time_t и прочими?
Разработчики почему-то не стали добавлять поддержку “по умолчанию” для данных типов данных, хотя и оговорились, что могут сделать это в будущем. Но если гора не идет к Магомету, то Магомет идет к горе. Сделаем это сами:
esp_err_t nvs_set_float(nvs_handle_t c_handle, const char* key, float in_value) { uint32_t buf = 0; memcpy(&buf, &in_value, sizeof(float)); return nvs_set_u32(c_handle, key, buf); } esp_err_t nvs_get_float(nvs_handle_t c_handle, const char* key, float* out_value) { uint32_t buf = 0; esp_err_t err = nvs_get_u32(c_handle, key, &buf); if (err == ESP_OK) { memcpy(out_value, &buf, sizeof(float)); } else { size_t _old_mode_size = sizeof(float); err = nvs_get_blob(c_handle, key, out_value, &_old_mode_size); }; return err; } esp_err_t nvs_set_double(nvs_handle_t c_handle, const char* key, double in_value) { uint64_t buf = 0; memcpy(&buf, &in_value, sizeof(double)); return nvs_set_u64(c_handle, key, buf); } esp_err_t nvs_get_double(nvs_handle_t c_handle, const char* key, double* out_value) { uint64_t buf = 0; esp_err_t err = nvs_get_u64(c_handle, key, &buf); if (err == ESP_OK) { memcpy(out_value, &buf, sizeof(double)); } else { size_t _old_mode_size = sizeof(double); err = nvs_get_blob(c_handle, key, out_value, &_old_mode_size); }; return err; } esp_err_t nvs_set_time(nvs_handle_t c_handle, const char* key, time_t in_value) { uint64_t buf = 0; memcpy(&buf, &in_value, sizeof(time_t)); return nvs_set_u64(c_handle, key, buf); } esp_err_t nvs_get_time(nvs_handle_t c_handle, const char* key, time_t* out_value) { uint64_t buf = 0; esp_err_t err = nvs_get_u64(c_handle, key, &buf); if (err == ESP_OK) { memcpy(out_value, &buf, sizeof(time_t)); } else { size_t _old_mode_size = sizeof(time_t); err = nvs_get_blob(c_handle, key, out_value, &_old_mode_size); }; return err; }
Раньше я использовал для этих целей nvs_get_blob()
, но потом просто стал приводить к целочисленным типам того же физического размера. Для обеспечения возможности корректного чтения при переходе на новую версию, пришлось усложнять функцию чтения. Вам же может пригодится, если вы захотите использовать nvs_get_blob()
всегда. Ну а я, как всегда, написал для своих проектов свою библиотечку-обертку, которую вы можете найти здесь: https://github.com/kotyara12/reNvs
Это все хорошо работает и достаточно просто. Но любой параметр внутри прошивки нужно еще как-то получить “извне”, например через подписку на топик mqtt, обработать и передать в логику проекта. А вот хорошо бы сделать так, чтобы один раз создал переменную, как-то её “оформил” в какой-то библиотеке, и она (эта библиотека) взяла бы на свои хрупкие плечи всю заботу об данной переменной в дальнейшем. И такую библиотеку я тоже создал: https://github.com/kotyara12/reParams, но об этом я расскажу как-нибудь в следующий раз.
Как видите, работа с NVS не представляет особой сложности. Конечно, NVS API немного сложнее, чем описано в статье, но Вы всегда можете прочитать официальные доки самостоятельно, если у Вас возникнет такая необходимость.
Работа с библиотекой Preferences из Arduino ESP32
Библиотека Preferences реализует работу с NVS (Non-Volatile Storage) и является рекомендуемой заменой устаревшей библиотеки EEPROM для платформы Arduino-ESP32, что обеспечивает удобный и надёжный способ работы с энергонезависимой памятью на всех чипах семейства ESP32, делая хранение настроек и параметров простым и безопасным процессом.
Подключаем библиотеку к скетчу:
#include <Preferences.h>
После этого можно объявить переменную – указатель на экземпляр класса Preferences
для пространства имен, с которым мы хотим работать. Для удобства одновременной работы рекомендуется придерживаться правила: одно пространство имен – одна переменная. Например так:
Preferences app_settings;
После этого необходимо открыть необходимое пространство имен с помощью метода begin()
:
// Открыть пространство имен bool begin(const char * name, bool readOnly=false, const char* partition_label=NULL); // Закрыть пространство имен void end();
Примечания:
- Имя пространства имен не может превышать 15 символов.
- Поскольку на Flash памяти может быть несколько разделов NVS, можно дополнительно указать нужный раздел в аргументе
partition_label
. Если у вас только один раздел NVS, можно оставить этот параметр пустым. - В случае успеха метод вернет true – теперь можно читать или записывать значения.
Открыть пространство имен удобнее всего в секции setup():
void setup() { Serial.begin(115200); Serial.println(); // Открываем пространство имен settings.begin("settings", false); ... };
Теперь уже можно записывать или считывать какие-то значения.
Для записи значений используйте любую из предложенных функций:
size_t putChar(const char* key, int8_t value); size_t putUChar(const char* key, uint8_t value); size_t putShort(const char* key, int16_t value); size_t putUShort(const char* key, uint16_t value); size_t putInt(const char* key, int32_t value); size_t putUInt(const char* key, uint32_t value); size_t putLong(const char* key, int32_t value); size_t putULong(const char* key, uint32_t value); size_t putLong64(const char* key, int64_t value); size_t putULong64(const char* key, uint64_t value); size_t putFloat(const char* key, float_t value); size_t putDouble(const char* key, double_t value); size_t putBool(const char* key, bool value); size_t putString(const char* key, const char* value); size_t putString(const char* key, String value); size_t putBytes(const char* key, const void* value, size_t len);
Для записи потребуется придумать ключ – уникальное имя записи, так же длиной не более 15 символов.
Для чтения значений имеются соответствующие им аналоги:
int8_t getChar(const char* key, int8_t defaultValue = 0); uint8_t getUChar(const char* key, uint8_t defaultValue = 0); int16_t getShort(const char* key, int16_t defaultValue = 0); uint16_t getUShort(const char* key, uint16_t defaultValue = 0); int32_t getInt(const char* key, int32_t defaultValue = 0); uint32_t getUInt(const char* key, uint32_t defaultValue = 0); int32_t getLong(const char* key, int32_t defaultValue = 0); uint32_t getULong(const char* key, uint32_t defaultValue = 0); int64_t getLong64(const char* key, int64_t defaultValue = 0); uint64_t getULong64(const char* key, uint64_t defaultValue = 0); float_t getFloat(const char* key, float_t defaultValue = NAN); double_t getDouble(const char* key, double_t defaultValue = NAN); bool getBool(const char* key, bool defaultValue = false); size_t getString(const char* key, char* value, size_t maxLen); String getString(const char* key, String defaultValue = String()); size_t getBytesLength(const char* key); size_t getBytes(const char* key, void * buf, size_t maxLen);
Если значение с заданным ключом не существует, то функция вернет значение по умолчанию.
Можно проверить, есть ли сохраненное значение с заданным ключом:
bool isKey(const char* key);
На этом пока всё, до встречи на сайте и на telegram-канале!
Статьи на данную тему:
💠 Настройка таблицы разделов flash-памяти для ESP32
Пожалуйста, оцените статью:
-= Каталог статей (по разделам) =- -= Архив статей (подряд) =-
Классная статья! Понятна даже такому начинающему котенку как я…