Сохраняем данные в EEPROM на Arduino транзакционно

?v=1

Наличие EEPROM дает разработчикам удобный инструмент для сохранения конфигурационных параметров или медленно меняющегося состояния, которое должно переживать выключение питания. В этой статье мы рассмотрим, как это делать максимально безопасно и удобно, чтобы ничего не забывать и не вспоминать того, чего не было.
Предположим, у нас есть переменная, и мы хотим сохранить ее в EEPROM. Казалось бы, все инструменты для этого у нас в руках:

#include 

int my_var = DEFAULT_VALUE;

EEPROM.get(MY_VAR_ADDR, my_var);
my_var = NEW_VALUE;
EEPROM.put(MY_VAR_ADDR, my_var);


Однако при ближайшем рассмотрении оказывается, что такой подход создает больше проблем, чем их решает. Обсудим их по порядку.

1. Как убедиться в том, что мы прочитали именно то, что записали (гарантировать целостность)? Представим себе следующую картину. Мы пишем письмо самому себе на случай своей внезапной кончины от потери питания или сигнала сброса и кладем его в ящик стола. В следующей жизни мы открываем ящик стола, достаем листок бумаги, читаем сообщение и продолжаем свою миссию. Проблема в том, что в ящике всегда есть листы бумаги, исписанные случайным текстом. Значит, нам нужен способ отличить правильное сообщение от случайного. Можно было бы заверить его у нотариуса, но в простейшем случае хватит и своей подписи, если у нас есть способ проверить ее правильность. Например, мы можем в качестве подписи использовать результат математического выражения, зависящего от текста, так, чтобы вероятность случайного совпадения была достаточно мала. В простейшем случае это CRC или контрольная сумма. Она защитит нас не только от чтения того, что мы не писали, но и от чтения поврежденного сообщения. Текст ведь со временем выцветает, а электроны в изолированном затворе еще менее долговечны, — прилетит частица из космоса с достаточной энергией, и бит изменится. А есть ведь еще один способ получить поврежденное сообщение — это не дописать его до конца. Он не такой уж и экзотический, ведь в момент записи ток потребления резко возрастает, что может спровоцировать преждевременную кончину писателя.

2. Допустим, мы убедились в правильности сообщения, но как мне убедиться в том, что его написал именно я (гарантировать подлинность). Как говориться, я бывают разные. Вдруг за этим столом до моей реинкарнации сидел кто то другой, а у него была другая миссия, и с какой стати я теперь буду руководствоваться его сообщениями? Если бы мы снабдили свои записи неким ярлыком, нам было бы легче отличать свои от чужих. Например, таким ярлыком могло бы служить имя переменной, которую мы сохраняем. Проблема только в том, что в EEPROM не так много места, чтобы класть туда еще и имена переменных, да и делать это неудобно, они ведь разной длины. Но к счастью есть способ проще, — можно посчитать контрольную сумму от имени переменной и использовать ее в качестве ярлыка. Заодно в эту контрольную сумму полезно добавить размер переменной в байтах, чтобы случайно не прочитать неправильное их количество. Ну и для полноты картины добавим туда еще один численный идентификатор, чтобы гарантированно отличить нашу переменную от чьей то еще, даже если они называются одинаково. Назовем это число идентификатором экземпляра (навеяно ООП, если имя переменной рассматривать как поле объекта). Если мы когда нибудь обновим нашу миссию до радикально новой версии, так что это обновление сделает бессмысленным все, что сохранила старая, то нам будет достаточно изменить идентификатор экземпляра, чтобы сделать недействительным все, сохраненное старой версией.

3. Как сделать так, чтобы незавершенная операция записи оставляла старое сохраненное значение неизменным? То есть, операция сохранения должна либо успешно завершиться, либо не должна иметь какого либо наблюдаемого эффекта вообще. Иначе говоря, она должна быть атомарной или транзакционной, если мы говорим о транзакции, которая сводится к безусловному обновлению одного единственного значения. Очевидно, что мы не можем обеспечить атомарность записи, переписывая предыдущее значение, мы должны писать на новое место, чтобы старое сохраненное значение осталось нетронутым по крайней мере вплоть до завершения записи нового. Эта техника часто называется 'копирование при записи', если обновляется лишь часть сохраненного значения, но часть, оставшаяся неизменной, все равно копируется и записывается на новое место. Развивая нашу аналогию, мы будем писать письма самому себе, оставляя старые нетронутыми, но снабжая каждое письмо увеличивающимся порядковым номером, чтобы в следующей жизни у нас была возможность найти последнее написанное письмо. При этом, однако, возникает новая проблема — место в ящике, куда мы кладем письма, рано или поздно закончится, если мы не будем выбрасывать старые письма, ставшие неактуальными. Нетрудно понять, что достаточно хранить всего 2 письма — одно старое и одно новое, оно может быть в процессе написания. Соответственно, для номера письма тоже не нужно много бит.

Как это ни странно, автору не удалось найти ни одной реализации, которая бы позволяла организовать сохранение данных в EEPROM, обеспечивая при этом целостность, подлинность и атомарность. Пришлось написать самому github.com/olegv142/NvTx

Для сохранения каждой переменной в EEPROM используются 2 последовательные области — ячейки, имеющие идентичную структуру. В первые 2 байта записывается идентификатор переменной, вычисленный на основе ее размера, текстового ярлыка и идентификатора экземпляра. Далее записываются данные, за которыми следуют 2 байта контрольной суммы. В самом первом байте два бита имеют специальное назначение. Старший бит — флаг корректности, при записи всегда устанавливается в единицу. Младший бит используется как однобитный номер эпохи, он нужен, чтобы найти последнее сообщение. Запись производится в ячейки 'по кругу'. Номер эпохи меняется каждый раз, когда производится запись в первую ячейку. Отсюда алгоритм определения последней записанной ячейки: если эпохи ячеек одинаковые, то вторая записана последней, если разные — то первая.

Бит корректности кажется избыточным, но у него есть важная функция. Первым делом мы читаем сохраненные данные и проверяем корректность обеих ячеек. Если ячейка не проходит проверку на правильность идентификатора или контрольной суммы, мы сбрасываем бит корректности. Последующие операции записи могут не проверять корректность ячеек, а полагаться на этот флаг, что уменьшает накладные расходы примерно в 2 раза.

Желающие вникнуть в детали реализации могут посмотреть картинки и код в репозитории. Я же, дабы не утомлять читателя, перейду к использованию. Сами функции записи / чтения данных получают по 5 параметров, так что удобством их использования пожертвовано в пользу гибкости. Зато это щедро скомпенсировано двумя наборами макросов, которые делают использование библиотеки таким же простым, как и в случае с EEPROM.get/put. Первый набор макросов используется, если вы просто хотите сохранить переменную по заданному адресу:

#include 

int my_var = DEFAULT_VALUE;

bool have_my_var = NvTxGetAt(my_var, MY_VAR_ADDR);
my_var = NEW_VALUE;
NvTxPutAt(my_var, MY_VAR_ADDR);


Если сохраняемых переменных несколько, то придется каждой определить адрес и при этом корректно учесть размер, дабы области памяти, где сохраняются переменные, не пересекались. Чтобы упростить задачу, второй набор макросов реализует автоматическое распределение адресов, причем делает это во время компиляции. К примеру, библиотека Arduino-EEPROMEx умеет распределять память во время исполнения, при этом она хранит адрес в оперативной памяти для каждой сохраняемой переменной. Библиотека NvTx распределяет место в EEPROM не добавляя ничего ни к исполняемому коду, ни к содержимому оперативной памяти.

#include 

int my_var = DEFAULT_VALUE;
char my_string[16] = "";

NvPlace(my_var, MY_START_ADDR, MY_INST_ID);
NvAfter(my_string, my_var);

bool have_my_var = NvTxGet(my_var);
my_var = NEW_VALUE;
NvTxPut(my_var);


Макрос NvPlace задает начальный адрес области EEPROM, где мы будем сохранять переменные, и идентификатор экземпляра. Макрос NvAfter резервирует область памяти для сохранения своего первого аргумента сразу после области памяти, отведенной для второго. При распределении памяти также проверяется, что мы не вышли за пределы доступного размера EEPROM, а также то, что мы не зарезервировали пересекающиеся области памяти (это может произойти, если два макроса NvAfter имеют совпадающий второй аргумент). В случае нарушения любого из двух указанных условий программа просто не скомпилируется. Желающие разобраться с механизмом распределения памяти найдут его в заголовочном файле NvTx.h. Все, что делают макросы NvPlace и NvAfter, — определяют перечисления, формируя их имена на основе имен переменных, а также используют весьма полезную идиоматическую конструкцию валидатор времени компиляции (compile time assert).

Надеюсь, библиотека NvTx поможет читателям в написании надежного кода промышленного качества.

© Habrahabr.ru