Работа с параметрами во внешнем EEPROM
Введение
Привет, Хабр. Наконец-то у меня появилось свободное время и я могу еще немного поделиться своим опытом, возможно кому-то это будет полезно, и поможет в его работе, и я этому буду безусловно рад. Ну что же,....
Смотря на то, как студенты делают свои курсовые, я стараюсь замечать моменты, которые вызывают у них затруднения. Одним из таких моментов является работа с внешним EEPROM. Это то место, где хранятся пользовательские настройки и другая полезная информация, которая не должна быть уничтожена после выключения питания. Самый простой пример - изменение единиц измерения. Пользователь жмет на кнопку и меняет единицы измерения. Ну или записывает коэффициенты калибровки через какой-нибудь внешний протокол, типа Модбаса.
Всякий раз, когда студент решает что-то сохранить в EEPROM, это выливается во множество багов, связанных как с неверно выбранной архитектурой, так и просто человеческим фактором. Собственно обычно студент лезет в интернет и находит что-то типа этого:
int address = 0;
float val1 = 123.456f;
byte val2 = 64;
char name[10] = "Arduino";
EEPROM.put(address, val1);
address += sizeof(val1); //+4
EEPROM.put(address, val2);
address += sizeof(val2); //+1
EEPROM.put(address, name);
address += sizeof(name); //+10
Этот замечательный код лапшой разрастается по всему проекту, применяясь к месту и не совсем в каждом из 100 EEPROM параметров, имеющих разный тип, длину и адрес. Немудрено, что где-то да и допустит торопливый студент ошибку.
Кроме того, обычно студенты используют РТОС, а потому нужно понимать, что обращение к EEPROM из разных потоков может привести либо к фейлам, либо ко всяким там дедлокам. Поэтому если студент использует EEPROM, я вначале прошу нарисовать дизайн, чтобы посмотреть как он собирается работать этой подсистемой.
Обычно все сводится к двум вариантам:
Доступ к EEPROM только из одного места. Типа такой
EepromManager
, который запускается в отдельной задаче и проходится по списку кешеруемых EEPROM параметров и смотрит, было ли в них изменение, и если да, то пишет его в EEPROM.Тут очень большой и толстый плюс: Не нужно блокировать работу с EEPROM, все делается в одном месте.
Но есть и минусы: обычно запись таких параметров происходит, когда пользователь что-то послал через внешний интерфейс, скажем Modbus протокол, ну типа он говорит, что я тут записал новые единицы измерения - Ок отвечает ему программа, я их закешировала, но на самом деле запись еще не прошла. Она пройдет позже, а ответить пользователю нужно прямо сейчас. И вот пользователь получает ответ, что все хорошо, новые единицы установлены, но вдруг во время отложенной записи происходит сбой и реальные единицы на самом деле не записались. В итоге устройство как бы обмануло пользователя. Понятно, что оно должно выставить ошибку и как-то об этом сообщить в своем статусе, но все же, пользователь уже немного с недоверием начинает смотреть на ваше устройство.
Второй способ - пишем всегда сразу по месту.
Плюс в том, что пользователь всегда получает достоверный ответ. Мы не задумываясь пишем параметр в EEPROM там где надо, и это выглядит просто.
Но проблем от этого не меньше: так как мы можем писать хоть что, хоть откуда, хоть куда - скажем журнал ошибок из разных подсистем из разных задач, то придется задуматься о блокировке ресурса EEPROM.
Кроме того, возможна проблема с быстрыми протоколами, когда ответить нам нужно в течении ограниченного времени, скажем 5 мс, а те кто работал с EEPROM знают, что записывается там все постранично. Ну точнее, чтобы записать однобайтовый параметр, EEPROM, копирует целую страницу во свой буфер, меняет в этом буфере этот один несчастный байт, стирает страницу, и затем записывает буфер (ну т.е. всю страницу) и того на запись одной страницы сразу тратится от 5 до 10 мс, в зависимости от размера страницы.
Но в обоих этих способах, мы хотим, чтобы доступ к параметрам не был похож, на тот код с Ардуино, что я привел, а был простым и понятным, в идеале, чтобы было вообще так:
//Записываем 10.0F в EEPROM по адресу, где лежит myEEPROMData параметр
myEEPROMData = 10.0F;
Но так мы делать не будем, потому что иногда нам понадобится по месту вернуть статус операции записи, вдруг EEPROM битая или проводки отпаялись. И посему мы будем делать, что-то похожее на это:
//Записываем в EEPROM строку из 5 символов по адресу параметра myStrData
auto returnStatus = myStrData.Set(tStr6{"Hello"});
if (!returnStatus)
{
std::cout << "Ok"
}
//Записываем в EEPROM float параметр по адресу параметра myFloatData
returnStatus = myFloatData.Set(37.2F);
Ну что же приступим
Анализ требований и дизайн
Пока что не будем заморачиваться проблемой блокировок, постараемся сделать только удобный доступ к самим параметрам, а затем на основе этого уже можно будет модифицировать решение для любого использования.
Давайте поймем, что мы вообще хотим. Сформируем требования более детально:
Каждая наша переменная(параметр) должна иметь уникальный адрес в EEPROM
Мы не хотим руками задавать этот адрес, он должен высчитываться сам, на этапе компиляции, потому что мы не хотим, чтобы студент нечаянно задал неверный адрес и сбил все настройки
Мы не хотим постоянно лазить в EEPROM, когда пользователь хочет прочитать параметр
Обычно EEPROM подключается через I2C или SPI. Передача данных по этим интерфейсам тоже отнимает время, поэтому лучше кэшировать параметры в ОЗУ, и возвращать сразу копию из кеша.
При инициализации параметра, если не удалось прочитать данные с EEPROM, мы должны вернуть какое-то значение по умолчанию.
На плате могут быть несколько EEPROM, а может вообще и не EEPROM, а скажем второй процессор, где хранятся разные данные, поэтому мы должны предусмотреть, возможность того, чтобы параметр мог использовать заданный драйвер для записи и чтения в нужное место.
Все должно быть дружелюбным простым и понятным :)
Давайте прикинем дизайн класса, который будет описывать такой параметр и удовлетворять нашим требованиям: Назовем класс CaсhedNvData
CachedNvData
Вообще все должно быть понятно из картинки, но на всякий случай:
При вызове метода Init()
мы должны полезть в EEPROM и считать оттуда нужный параметр с нужного адреса.
Адрес будет высчитываться на этапе компиляции, пока эту магию пропустим. Прочитанное значение хранится в data
, и как только кому-то понадобится, оно возвращается немедленно из копии в ОЗУ с помощью метода Get()
.
А при записи, мы уже будем работать с EEPROM через nvDriver
. Можно подсунуть любой nvDriver, главное, чтобы у него были методы Set()
и Get()
. Вот например, такой драйвер подойдет.
NvDriver
В комментариях @gleb_l верно подметил, что главное проверить целостность EEPROM, упустил этот момент, поэтому допишу, что за целостность отвечает драйвер, он будет писать и сразу либо считать контрольную сумму, либо писать инверсную копию.
Остался еще один штрих, придумать, как автоматически формировать адрес каждого параметра. Для того, чтобы адрес высчитывался автоматически, необходимо, чтобы все параметры для EEPROM были зарегистрированы в каком-нибудь списке. Тогда список может сам посчитать адрес параметра по его положению в списке и собственно вернуть его когда надо.
Например, если у нас есть 3 параметра:
//Длина параметра 6 байт
constexpr CachedNvData<NvVarList, tString6, myStrDefaultValue, nvDriver> myStrData;
//Длина параметра 4 байта
constexpr CachedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;
//Длина параметра 4 байт
constexpr CachedNvData<NvVarList, std::uint32_t, myUint32DefaultValue, nvDriver> myUint32Data;
То когда мы сделаем какой-то такой список:
NvVarList<100U, myStrData, myFloatData, myUint32Data>
У нас бы у myStrData
был бы адрес 100, у myFloatData
- 106, а у myUint32Data
- 110. Ну и соответственно список мог бы его вернуть для каждого из параметра.
Собственно нужно чтобы этому списку передавался начальный адрес, и список параметров в EEPROM. Также нужно чтобы у списка был метод GetAdress()
, который возвращал бы адрес нужного параметра.
Идея этого метода в том, чтобы найти в списке тип равный типу самого параметра, и по номеру этого элемента автоматически рассчитать адрес. Важно, чтобы типы всех параметров были разные, это добивается тем, что ссылка на значение по умолчанию должна быть уникальная для каждого параметра.
Сделаем такой базовый класс, назовем его NvVarListBase:
NvVarListBase
В прицнипе то и все.
Код
А теперь самая простая часть - пишем код. Комментировать не буду, вроде бы и так понятно
CaсhedNvData
template<typename NvList, typename T, const T& defaultValue, const auto& nvDriver>
class CaсhedNvData
{
public:
ReturnCode Set(T value) const
{
//Ищем адрес EEPROM параметра в списке
constexpr auto address =
NvList::template GetAddress<NvList,T,defaultValue,nvDriver>();
//Записываем новое значение в EEPROM
ReturnCode returnCode = nvDriver.Set(
address,
reinterpret_cast<const tNvData*>(&value), sizeof(T));
//Если значение записалось успешно, обновляем копию в ОЗУ
if (!returnCode)
{
memcpy((void*)&data, (void*)&value, sizeof(T));
}
return returnCode;
}
ReturnCode Init() const
{
constexpr auto address =
NvList::template GetAddress<NvList,T,defaultValue,nvDriver>();
//Читаем значение из EEPROM
ReturnCode returnCode = nvDriver.Get(
address,
reinterpret_cast<tNvData*>(&data), sizeof(T));
//Если значение не прочиталось из EEPROM, устанавливаем значение по умолчанию
if (returnCode)
{
data = defaultValue;
}
return returnCode;
}
T Get() const
{
return data;
}
using Type = T;
private:
inline static T data = defaultValue;
};
template<const tNvAddress startAddress, const auto& ...nvVars>
struct NvVarListBase
{
template<typename NvList, typename T, const T& defaultValue, const auto& nvDriver>
constexpr static size_t GetAddress()
{
//Ищем EEPROM адрес параметра с типом
//CaсhedNvData<NvList, T, defaultValue, nvDriver>
using tQueriedType = CaсhedNvData<NvList, T, defaultValue, nvDriver>;
return startAddress +
GetAddressOffset<tQueriedType>(NvVarListBase<startAddress,nvVars...>());
}
private:
template <typename QueriedType, const auto& arg, const auto&... args>
constexpr static size_t GetAddressOffset(NvVarListBase<startAddress, arg, args...>)
{
//Чтобы узнать тип первого аргумента в списке,
//создаем объект такого же типа как и первый аргумент
auto test = arg;
//если тип созданного объекта такой же как и искомый, то заканчиваем итерации
if constexpr (std::is_same<decltype(test), QueriedType>::value)
{
return 0U;
} else
{
//Иначе увеличиваем адрес на размер типа параметра и переходим к
//следующему параметру в списке.
return sizeof(typename decltype(test)::Type) +
GetAddressOffset<QueriedType>(NvVarListBase<startAddress, args...>());
}
}
};
Использование
А теперь встанем не место студента и попробуем это все дело использовать.
Задаем начальные значения параметров:
using tString6 = std::array<char, 6U>;
inline constexpr float myFloatDataDefaultValue = 10.0f;
inline constexpr tString6 myStrDefaultValue = {"Habr "};
inline constexpr std::uint32_t myUint32DefaultValue = 0x30313233;
Зададем сами параметры:
//поскольку список ссылается на параметры, а параметры на список.
//Используем forward declaration
struct NvVarList;
constexpr NvDriver nvDriver;
//Теперь можем использовать NvVarList в шаблоне EEPROM параметров
constexpr CaсhedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;
constexpr CaсhedNvData<NvVarList, tString6, myStrDefaultValue, nvDriver> myStrData;
constexpr CaсhedNvData<NvVarList, uint32_t, myUint32DefaultValue, nvDriver> myUint32Data;
Теперь осталось определить сам список параметров. Важно, чтобы все EEPROM параметры были разных типов. Можно в принципе вставить статическую проверку на это в NvVarListBase, но не будем.
struct NvVarList : public NvVarListBase<0, myStrData, myFloatData, myUint32Data>
{
};
А теперь можем использовать наши параметры хоть где, очень просто и элементарно:
struct NvVarList;
constexpr NvDriver nvDriver;
using tString6 = std::array<char, 6U>;
inline constexpr float myFloatDataDefaultValue = 10.0f;
inline constexpr tString6 myStrDefaultValue = {"Habr "};
inline constexpr uint32_t myUint32DefaultValue = 0x30313233;
constexpr CaсhedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;
constexpr CaсhedNvData<NvVarList, tString6, myStrDefaultValue, nvDriver> myStrData;
constexpr CaсhedNvData<NvVarList, uint32_t, myUint32DefaultValue, nvDriver> myUint32Data;
struct NvVarList : public NvVarListBase<0, myStrData, myFloatData, myUint32Data>
{
};
int main()
{
myStrData.Init();
myFloatData.Init();
myUint32Data.Init()
myStrData.Get();
returnCode = myStrData.Set(tString6{"Hello"});
if (!returnCode)
{
std::cout << "Hello has been written" << std::endl;
}
myStrData.Get();
myFloatData.Set(37.2F);
myUint32Data.Set(0x30313233);
return 1;
}
Можно передавать ссылку на них в любой класс, через конструктор или шаблон.
template<const auto& param>
struct SuperSubsystem
{
void SomeMethod()
{
std::cout << "SuperSubsystem read param" << param.Get() << std::endl;
}
};
int main()
{
SuperSubsystem<myFloatData> superSystem;
superSystem.SomeMethod();
}
Собственно и все. Теперь студенты могут работать с EEPROM более юзерфрендли и допускать меньше ошибок, ведь часть проверок за них сделает компилятор.
P.S. Хотел еще рассказать про то, как можно реализовать драйвер работы с EEPROM через QSPI (студенты слишком долго понимали как он работает), но слишком разношерстный получался контекст, поэтому думаю описать это в другой статье, если конечно будет интересно.