Очень часто возникает вопрос, связанный с использованием Flash-памяти микроконтроллеров STM32 в качестве EEPROM. И в этой статье рассмотрим один из вариантов эмуляции памяти EEPROM.
Итак, в чем изначально заключается проблема, и почему нельзя просто выделить сектор/страницу во Flash и использовать ее? Ответ, по большому счету, кроется в трех словах - количество циклов перезаписи. Для Flash максимально допустимое число на порядки меньше, чем для EEPROM, навскидку, 10000 циклов против 1000000.
Конечно, есть и другие отличия - скорость записи, чтения и т. д. Но упирается все именно в продолжительность жизни (что соответствует кол-ву циклов перезаписи).
Поэтому и возникает необходимость в программных алгоритмах, которые позволят искусственно увеличить это число для Flash-памяти микроконтроллера. Собственно, у ST есть целый application note, посвященный данному вопросу, более того с готовым примером кода. Так что, в принципе, можно спокойно использовать решение от производителя.
Но так получилось, что я начал править этот проект под себя и в итоге переделал все с нуля. Не буду судить, насколько это оказалось оправданно, просто распишу основные идеи, которые предлагает ST, и затем, как обычно, создадим пример для проверки работоспособности. И заодно разберем основные функции.
Первый ключевой аспект заключается в том, как будут храниться данные. Для каждой переменной существует определенное фиксированное значение - ключ. Выделим под ключ и под саму переменную по 4 байта. Допустим нам надо хранить во Flash значения трех переменных:
Переменная | Значение переменной | Значение ключа |
---|---|---|
data_1 | 0x1010 | 0x11111111 |
data_2 | 0x2020 | 0x22222222 |
data_3 | 0x3030 | 0x33333333 |
Непосредственно в памяти данные будут выглядеть так:
Для каждой переменной сохраняется пара ключ-значение.
А если нам потребуется обновить значение переменной data_2
на новое, к примеру - 0x4567, то мы просто записываем еще одну пару ключ-значение в свободное место памяти:
Получается, что для переменной data_2
сохранено два значения, при этом актуальным является второе, записанное позже. Но при этом первое не удаляется. Таким образом, значения переменных будут обновляться (записываться в свободные адреса) до тех пор, пока страница памяти не окажется полностью заполненной:
Далее в дело вступает второй ключевой аспект. Он заключается в использовании двух страниц Flash-памяти. По заполнению 1-ой страницы мы переносим данные из нее во 2-ую страницу. Но! Переносим не все значения подряд, а только актуальные, то есть записанные последними. И так для каждой переменной:
Заполняем 2-ую страницу этими величинами и очищаем страницу 1:
И далее все эти действия повторяются до тех пор, пока не заполнится и вторая страница, после чего они снова меняются местами.
Что это дает в итоге?
- В первую очередь, количество циклов перезаписи изначально увеличивается на порядки, поскольку мы не очищаем Flash каждый раз, когда нам нужно обновить значение одной переменной. Очистка происходит только по фактическому заполнению страницы.
- Кроме того, мы используем две страницы, что позволяет распределить эти циклы перезаписи между ними.
Для того, чтобы идентифицировать, в каком состоянии находятся страницы Flash-памяти, которые мы используем, добавим в начальные адреса каждой из них ее статус:
Статус | Значение | Описание |
---|---|---|
CLEARED | 0xFFFFFFFF | Страница очищена. |
ACTIVE | 0x00000000 | Страница активна. |
RECEIVING_DATA | 0x55555555 | Страница в процессе копирования данных из другой страницы. |
В итоге первые 4 байта у нас заняты текущим статусом страницы. Поскольку для каждого значения переменной суммарно у нас используются 8 байт, то добавим смещение еще на 4 байта относительно начала страницы (для удобства). Результат будет таким:
Жизнедеятельность страниц может выглядеть так:
И здесь мы получаем еще один бонус от использование нескольких страниц. Рассмотрим такую ситуацию - 1-я страница заполнена и начинается копирование ее данных во 2-ю страницу. И в середине этого процесса возникает сбой питания, и процесс обрывается. После устранения проблемы и включения платы первая страница окажется в состоянии Active, а вторая - в состоянии Cleared. Что просто приведет к повторной попытке переноса данных, то есть данные не утеряны, а по-прежнему спокойно себе лежат в странице 1.
А если бы у нас была только одна страница, то по ее заполнению нам нужно было бы перенести данные в некий массив в оперативной памяти (RAM) контроллера, чтобы затем очистить страницу и обновить ее же из этого массива (оставив таким образом только актуальные, то есть последние, значения переменных). Так вот при обрыве питания в момент, когда страница уже очищена, а данные еще в RAM, они были бы безвозвратно потеряны.
Итак, возвращаемся к эмуляции EEPROM и теперь бегло рассмотрим конкретные функции, которые все это реализуют.
Все функции возвращают одно из значений:
typedef enum { EEPROM_OK = 0, EEPROM_ERROR = 1, } EepromResult;
Для части функций просто рассмотрим их назначение, а некоторые разберем подробнее, если это важно для понимания сути процессов.
EepromResult EEPROM_ClearPage(PageIdx idx)
. Производит очистку страницы, номер которой передан в качестве аргумента.EepromResult EEPROM_CopyPageData(PageIdx oldPage, PageIdx newPage)
. Копирует содержимое страницы oldPage в newPage. При этом обновляет статусы обеих страниц по результатам копирования/очистки.EepromResult EEPROM_Format()
. Полная очистка EEPROM - то есть очистка страниц Flash, которые используются для эмуляции EEPROM.-
EepromResult EEPROM_GetActivePageIdx(PageIdx *idx)
. Позволяет определить номер активной страницы. EepromResult EEPROM_Init()
. Функция инициализации, на ней как раз и остановимся подробнее.
EepromResult EEPROM_Init() { EepromResult res = EEPROM_OK; PageState pageStates[PAGES_NUM]; for (uint8_t i = 0; i < PAGES_NUM; i++) { pageStates[i] = EEPROM_ReadPageState((PageIdx)i); } if (((pageStates[PAGE_0] == PAGE_CLEARED) && (pageStates[PAGE_1] == PAGE_CLEARED)) || ((pageStates[PAGE_0] == PAGE_ACTIVE) && (pageStates[PAGE_1] == PAGE_ACTIVE)) || ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA))) { res = EEPROM_Format(); if (res != EEPROM_OK) { return res; } res = EEPROM_SetPageState(PAGE_0, PAGE_ACTIVE); } if ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_CLEARED)) { res = EEPROM_SetPageState(PAGE_0, PAGE_ACTIVE); } if ((pageStates[PAGE_0] == PAGE_CLEARED) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA)) { res = EEPROM_SetPageState(PAGE_1, PAGE_ACTIVE); } if ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_ACTIVE)) { res = EEPROM_CopyPageData(PAGE_1, PAGE_0); } if ((pageStates[PAGE_0] == PAGE_ACTIVE) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA)) { res = EEPROM_CopyPageData(PAGE_0, PAGE_1); } return res; }
Здесь мы пробегаем по возможным комбинациям состояний страниц. Если статус страниц совпадает, то это сигнализирует об ошибке, форматируем память полностью и начинаем с чистого листа:
if (((pageStates[PAGE_0] == PAGE_CLEARED) && (pageStates[PAGE_1] == PAGE_CLEARED)) || ((pageStates[PAGE_0] == PAGE_ACTIVE) && (pageStates[PAGE_1] == PAGE_ACTIVE)) || ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA))) { res = EEPROM_Format(); if (res != EEPROM_OK) { return res; } res = EEPROM_SetPageState(PAGE_0, PAGE_ACTIVE); }
Если каким-то образом случилось так, что одна страница в состоянии Receiving Data, а вторая очищена - Cleared, то лучшее из того, что мы можем предпринять, это сделать 1-ую из них активной, поскольку в ней по сравнению со 2-ой явно хранятся какие-то полезные данные:
if ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_CLEARED)) { res = EEPROM_SetPageState(PAGE_0, PAGE_ACTIVE); } if ((pageStates[PAGE_0] == PAGE_CLEARED) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA)) { res = EEPROM_SetPageState(PAGE_1, PAGE_ACTIVE); }
И, наконец, ситуация, когда одна из страниц - Receiving Data, а вторая - Active, как раз соответствует рассмотренному нами случаю с обрывом питания. Повторяем операцию копирования:
if ((pageStates[PAGE_0] == PAGE_RECEIVING_DATA) && (pageStates[PAGE_1] == PAGE_ACTIVE)) { res = EEPROM_CopyPageData(PAGE_1, PAGE_0); } if ((pageStates[PAGE_0] == PAGE_ACTIVE) && (pageStates[PAGE_1] == PAGE_RECEIVING_DATA)) { res = EEPROM_CopyPageData(PAGE_0, PAGE_1); }
Идем дальше по функциям:
- EepromResult EEPR
OM_PageTransfer(PageIdx activePage, uint32_t varId, uint32_t varValue)
. Выполняет перенос актуальных данных из одной страницы в другую, кроме того в новую страницу добавляет пару ключ - значение, переданную через аргументы (varId
/varValue
). Осуществляется это так:- Записываем упомянутые данные из аргументов:
varId
/varValue
. - Проходим по всему списку ключей и для каждого находим актуальное значение, которое и пишем в новую страницу.
- Стираем старую страницу.
- Записываем упомянутые данные из аргументов:
- Функции управления состоянием страницы:
PageState EEPROM_ReadPageState(PageIdx idx)
EepromResult EEPROM_SetPageState(PageIdx idx, PageState state).
- И напоследок осталось самое интересное - чтение и запись данных.
Код функции записи:
EepromResult EEPROM_Write(uint32_t varId, uint32_t varValue) { EepromResult res = EEPROM_OK; uint8_t validId = 0; for (uint32_t curVar = 0; curVar < VAR_NUM; curVar++) { if (varIdList[curVar] == varId) { validId = 1; break; } } if (validId == 0) { res = EEPROM_ERROR; return res; } PageIdx activePage = PAGE_0; res = EEPROM_GetActivePageIdx(&activePage); if (res != EEPROM_OK) { return res; } uint32_t startAddr = pageAddress[activePage] + PAGE_DATA_OFFSET; uint32_t endAddr = pageAddress[activePage] + PAGE_SIZE - PAGE_DATA_SIZE; uint32_t addr = startAddr; uint8_t freeSpaceFound = 0; while (addr <= endAddr) { uint32_t idData = FLASH_Read(addr); if (idData == 0xFFFFFFFF) { freeSpaceFound = 1; break; } else { addr += PAGE_DATA_SIZE; } } if (freeSpaceFound == 1) { res = EEPROM_WriteData(addr, varId, varValue); } else { res = EEPROM_PageTransfer(activePage, varId, varValue); } return res; }
Первым делом проверяем, что переданный в функцию ключ varId
является валидным, то есть находится в списке допустимых ключей (этот момент мы разберем подробнее на практическом примере ниже).
Далее определяем номер активной страницы. Остается найти свободное место и записать туда новые данные. При поиске свободного места читаем данные, которые располагаются там, где должны быть ключи. Соответственно, по тому адресу, где на месте ключа значение 0xFFFFFFFF (ячейка памяти пуста), и производим запись.
Если же свободного места не обнаружено, то переходим к процессу переноса данных в другую страницу - EEPROM_PageTransfer()
.
И код для чтения данных:
EepromResult EEPROM_Read(uint32_t varId, uint32_t *varValue) { EepromResult res = EEPROM_OK; PageIdx activePage = PAGE_0; res = EEPROM_GetActivePageIdx(&activePage); if (res != EEPROM_OK) { return res; } uint32_t startAddr = pageAddress[activePage] + PAGE_DATA_OFFSET; uint32_t endAddr = pageAddress[activePage] + PAGE_SIZE - PAGE_DATA_SIZE; uint32_t addr = endAddr; uint8_t dataFound = 0; while (addr >= startAddr) { uint32_t idData = FLASH_Read(addr); if (idData == varId) { dataFound = 1; *varValue = FLASH_Read(addr + 4); break; } else { addr -= PAGE_DATA_SIZE; } } if (dataFound == 0) { res = EEPROM_ERROR; } return res; }
Находим номер активной страницы и начинаем поиск нужного нам ключа (varId
) среди сохраненных в памяти. При этом в отличие от функции записи поиск ведем от конца страницы к началу, то есть в обратном направлении. Связано это с тем, что как мы уже разобрали в начале статьи, при обновлении значений для определенного ключа новые значения записываются в память последовательно. То есть среди всех значений с одним и тем же ключом актуальным будет последнее из них (которое ближе к концу страницы).
Вот и все, находим ключ, вычисляем адрес, где хранится значение (добавив смещение на длину ключа, равную 4 байтам) и читаем данные.
Переходим к практическому примеру использования - будем сохранять величины двух переменных, каждой из которых поставим в соответствие значение ключа.
В файле eeprom.h нам нужно выполнить конфигурацию эмулятора:
#define PARAM_1 0x12121212 #define PARAM_2 0x34343434 #define VAR_NUM 2
VAR_NUM
- это количество ключей, а значит разных переменных, которые мы будем сохранять.- Для каждого из этих ключей задаем значения:
PARAM_1
,PARAM_2
.
В файле eeprom.c помещаем их в массив:
static uint32_t varIdList[VAR_NUM] = {PARAM_1, PARAM_2};
Помимо этого указываем адреса использующихся страниц. У меня для этого примера микроконтроллер STM32F103C8, и адресация Flash-памяти выглядит следующим образом:
Берем две последние страницы:
#define PAGE_0_ADDRESS 0x0801F800 #define PAGE_1_ADDRESS 0x0801FC00 #define PAGE_SIZE 1024
И переходим в функцию main()
:
EEPROM_Init(); uint32_t readData[2] = {0, 0}; EEPROM_Write(PARAM_1, 0x1111); EEPROM_Write(PARAM_2, 0x2222); EEPROM_Read(PARAM_1, &readData[0]); EEPROM_Read(PARAM_2, &readData[1]);
Инициализируем эмуляцию EEPROM, записываем значения для наших ключей и сразу же считываем их из памяти. Ожидаемо получаем:
И в Flash-памяти:
Обновим значение второго параметра и снова считаем:
EEPROM_Write(PARAM_2, 0x3333); EEPROM_Read(PARAM_1, &readData[0]); EEPROM_Read(PARAM_2, &readData[1]);
Результат логичен:
И в памяти видим, что новое значение записалось в свободное место страницы, при этом и старое физически никуда не делось, хотя оно нам больше и не нужно:
Здесь мы просто передавали числа в функцию записи, соответственно, в общем случае там будут некие переменные:
EEPROM_Write(PARAM_1, var1); EEPROM_Write(PARAM_2, var2); // ... EEPROM_Write(PARAM_2, var2);
Из ближайших доработок - необходимо добавить в записываемые данные контрольную сумму для проверки корректности сохраненных значений. Как вариант - сократить ключ на 4 байта и освободившееся место использовать под CRC. И на этом заканчиваем на сегодня - до скорого 🤝
Полный проект - MT_Eeprom.