EEPROM (Electrically Erasable Programmable Read-Only Memory) - электрически стираемое перепрограммируемое постоянное запоминающее устройство (ЭСППЗУ), она же энергонезависимая память. Вспомним предыдущие типы памяти и их возможности по хранению данных:
Тип | Чтение из программы | Запись из программы | Очистка при перезагрузке |
---|---|---|---|
Flash | Да, PROGMEM | Можно, но сложно | Нет |
SRAM | Да | Да | Да |
EEPROM | Да | Да | Нет |
Простыми словами: EEPROM - память, к которой мы имеем полный доступ из выполняющейся программы, т.е. можем читать и писать туда данные, и эти данные не сбросятся при перезагрузке МК. Обычно используется для:
- Хранение настроек, изменяющихся "из меню" устройства, без перепрошивки
- Калибровка, сохранение калибровочных данных
- Использование как дополнительной SRAM памяти в случае её нехватки
- "Чёрный ящик" - постоянная запись показаний с датчиков для дальнейшей расшифровки сбоев
- Запись состояния рабочего процесса для восстановления работы после внезапной перезагрузки
EEPROM представляет собой область памяти, состоящую из элементарных ячеек с размером в один байт. Объём EEPROM разный у разных моделей МК:
- ATmega328 (Arduino UNO, Nano, Pro Mini): 1 кБ
- ATmega2560 (Arduino Mega): 4 кБ
- ATtiny85 (Digispark): 512 Б
- ESP8266/ESP32 (эмуляция): 4 кБ
Скорость работы с EEPROM на AVR (время не зависит от частоты системного клока):
- Запись одного байта - ~3.3 мс
- Чтение одного байта - ~0.4 мкс
Возможны искажения при записи данных в EEPROM при слишком низком напряжении питания, настоятельно рекомендуется использовать BOD или вручную мониторить напряжение перед записью
Ресурс #
Важный момент: EEPROM имеет ресурс по количеству перезаписи ячеек. Производитель гарантирует 100 000 циклов записи каждой ячейки (AVR), по факту это количество зависит от конкретного чипа и температурных условий, независимые тесты показали 3-6 миллионов циклов перезаписи при комнатной температуре до появления первой ошибки, т.е. заявленные 100 000 взяты с очень большим запасом. Но есть небольшое уточнение - при заявленных 100 000 циклах перезаписи гарантируется сохранность записанных данных в течение 100 лет при температуре 24°C, если перезаписывать по миллиону - данные испортятся быстрее. В то же время, количество чтений каждой ячейки не ограничено.
У МК ESP8266/ESP32 EEPROM эмулируется из Flash памяти, а её ресурс сильно меньше - всего 10 000 циклов записи!
Адресация #
EEPROM память представлена отдельным адресным пространством, начиная с адреса 0
, юзер самостоятельно манипулирует адресами и должен следить за адресацией при чтении и записи данных. Например:
uint8_t
- адрес 0 (+1)uint8_t
- адрес 1 (+1)uint16_t
- адрес 2 (+2)uint8_t
- адрес 4 (+1)float
- адрес 5 (+4)int16_t
- адрес 9 (+2)- И так далее
Все ячейки имеют значение по умолчанию 255
(у нового чипа)
Библиотеки #
Для AVR есть встроенная библиотека avr/eeprom.h
- она не очень удобная и рассматривать её не будем. Фреймворк Arduino предоставляет более удобный инструмент для работы с EEPROM памятью - библиотеку EEPROM.h
, она также поддерживается на ESP8266/ESP32 для совместимости проектов.
Для AVR Arduino: подключая в скетч EEPROM.h мы автоматически подключаем avr/eeprom.h и можем пользоваться её фишками, такими как EEMEM
EEPROM.write(адрес, данные)
- пишет один байт данных по адресуEEPROM.update(адрес, данные)
- обновляет (записывает, если отличается) байт данных по адресу. Не реализована для esp8266/32EEPROM.read(адрес)
- читает и возвращает байт данных по адресуEEPROM.put(адрес, переменная)
- записывает (по факту - обновляет, update) данные из переменной любого типа по адресуEEPROM.get(адрес, переменная)
- читает данные по адресу и сам записывает их в указанную переменнуюEEPROM[]
- библиотека позволяет работать с EEPROM памятью как с обычным массивом типаuint8_t
Отличия для ESP8266/ESP32 #
- Перед началом работы нужно вызвать
EEPROM.begin(размер)
с указанием максимального объёма памяти: 4.. 4096 Байт - Для применения записи нужно вызвать
EEPROM.commit()
: например несколько раз делается write(), put(), и в завершение - commit() - В некоторых версиях SDK отсутствует
EEPROM.update()
В ESP8266/ESP32 EEPROM работает иначе - при запуске begin
выделяется массив в оперативной памяти, содержимое сектора Flash копируется в него и вся работа происходит в нём. При вызове commit
эти данные записываются во Flash память. "Обновление" ячеек здесь не имеет смысла - при вызове commit
в любом случае перезаписываются все 4 кБ отведённой памяти, т.е. даже один изменённый байт уменьшает ресурс всего "EEPROM"! Таким образом, использовать библиотеку EEPROM на ESP категорически не рекомендуется, гораздо правильнее хранить данные в местной файловой системе в виде файлов - файловая система сама заботится о ресурсе памяти и "размазывает" её использование на весь доступный объём. Как пример удобной реализации - моя библиотека FileData.
Примеры #
Работа с байтами #
#include <EEPROM.h>
void setup() {
Serial.begin(115200);
//EEPROM.begin(100); // для esp8266/esp32
// пишем 200 по адресу 10
EEPROM.update(10, 200);
//EEPROM.commit(); // для esp8266/esp32
Serial.println(EEPROM.read(10)); // выведет 200
Serial.println(EEPROM[10]); // выведет 200
}
void loop() {}
put() и get() #
#include <EEPROM.h>
void setup() {
Serial.begin(115200);
//EEPROM.begin(100); // для esp8266/esp32
// объявляем переменные, которые будем писать
float dataF = 3.14;
int16_t dataI = -634;
byte dataArray[] = {10, 20, 30, 40};
EEPROM.put(0, dataF);
EEPROM.put(4, dataI);
EEPROM.put(6, dataArray);
//EEPROM.commit(); // для esp8266/esp32
// объявляем переменные, куда будем читать
float dataF_read = 0;
int16_t dataI_read = 0;
byte dataArray_read[4];
// читаем точно так же, как писали
EEPROM.get(0, dataF_read);
EEPROM.get(4, dataI_read);
EEPROM.get(6, dataArray_read);
// проверяем
Serial.println(dataF_read);
Serial.println(dataI_read);
Serial.println(dataArray_read[0]);
Serial.println(dataArray_read[1]);
Serial.println(dataArray_read[2]);
Serial.println(dataArray_read[3]);
}
void loop() {}
put()
и get()
сами определяют тип данных и считают размер блока данных, использовать их очень приятно. Они работают как с массивами, так и со структурами.
Структуры #
Самый удобный хранить набор данных в EEPROM - структура. Структура позволяет объединить любые данные под одним именем, и одной строчкой загонять их в EEPROM и так же читать обратно. Также не придётся думать об адресации! Пример:
#include <EEPROM.h>
struct Data {
byte bright = 0;
int counter = 0;
float fvalue = 0;
};
// глобальный экземпляр для личного использования
Data data;
void setup() {
// прочитать из адреса 0 в data
EEPROM.get(0, data);
// меняем
data.bright = 10;
data.counter = 1234;
data.fvalue = 3.14;
// поместить в EEPROM по адресу 0
EEPROM.put(0, data);
}
void loop() {}
Реальный пример #
Рассмотрим пример, в котором происходит следующее: две кнопки управляют яркостью светодиода, подключенного к ШИМ пину. Установленная яркость сохраняется в EEPROM, т.е. при перезапуске устройства будет включена яркость, установленная последний раз. Для опроса кнопок используется библиотека EncButton. Для начала посмотрите на первоначальную программу, где установленная яркость не сохраняется. Программу можно чуть оптимизировать, но это не является целью данного урока.
#define BTN_UP_PIN 3 // пин кнопки вверх
#define BTN_DOWN_PIN 4 // пин кнопки вниз
#define LED_PIN 5 // пин светодиода
#include <EncButton.h>
Button btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
Button btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"
int LEDbright = 0;
// применить яркость
void setBright() {
LEDbright = constrain(LEDbright, 0, 255); // ограничили
analogWrite(LED_PIN, LEDbright); // изменили яркость
}
void setup() {
pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
}
void loop() {
// опрос кнопок
btnUP.tick();
btnDOWN.tick();
if (btnUP.click()) {
// увеличение по клику
LEDbright += 5;
setBright();
}
if (btnDOWN.click()) {
// уменьшение по клику
LEDbright -= 5;
setBright();
}
}
В этот код нам нужно добавить:
- Подключить библиотеку EEPROM.h
- При запуске: чтение яркости из EEPROM и включение светодиода
- При клике: запись актуального значения в EEPROM
#define BTN_UP_PIN 3 // пин кнопки вверх
#define BTN_DOWN_PIN 4 // пин кнопки вниз
#define LED_PIN 5 // пин светодиода
#include <EEPROM.h>
#include <EncButton.h>
Button btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
Button btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"
int LEDbright = 0;
void setBright() {
LEDbright = constrain(LEDbright, 0, 255); // ограничили
EEPROM.put(0, LEDbright); // записали по адресу 0
analogWrite(LED_PIN, LEDbright); // изменили яркость
}
void setup() {
pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
EEPROM.get(0, LEDbright); // прочитали яркость из адреса 0
analogWrite(LED_PIN, LEDbright); // включили
}
void loop() {
// опрос кнопок
btnUP.tick();
btnDOWN.tick();
if (btnUP.click()) {
// увеличение по клику
LEDbright += 5;
setBright();
}
if (btnDOWN.click()) {
// уменьшение по клику
LEDbright -= 5;
setBright();
}
}
Итак, теперь при запуске у нас восстанавливается последняя настроенная яркость, а при изменении она записывается. Напомню, что EEPROM изнашивается от перезаписи. Конечно для того, чтобы "накликать" яркость несколько миллионов раз и убить ячейку, у вас уйдёт очень много времени, но процесс записи нового значения можно и нужно оптимизировать, особенно в более серьёзных проектах, ниже поговорим об этом подробнее. Также в нашем коде есть ещё один неприятный момент: при самом первом запуске после прошивки EEPROM не инициализирована, каждая ячейка хранит в себе число 255, и именно такое значение примет переменная LEDbright
после первого запуска. Здесь это не имеет значения, но в более серьёзном устройстве нужно будет задать нужные значения по умолчанию в EEPROM при первом запуске, об этом мы тоже поговорим ниже. Иначе представьте, какие "настройки по умолчанию" получит ваше устройство для яркости/скорости/громкости/номера режима!
Запись и чтение строк #
У нас есть два типа строк: массивы символов и String-строки. С массивом символов всё более-менее понятно: это массив, он имеет фиксированный размер, его можно записать при помощи put()
и прочитать при помощи get()
. Также такая строка может входить в структуру, что очень удобно. В этом случае нужно объявить структуру с указанием максимальной длины строки, которая может там храниться. Например для какого-нибудь проекта с WiFi нам хочется хранить логин и пароль от роутера и режим работы:
struct Cfg {
char ssid[17];
char pass[17];
byte mode;
};
А как записать и прочитать динамические данные, такие как String-строки? Можно рассмотреть два способа: с массивом ограниченной длины (как в примере выше) и полностью динамическое хранение.
Будем считать, что максимальная длина строки - 20 символов. Простой пример:
#include <EEPROM.h>
#define STR_ADDR 0 // адрес хранения строки в EEPROM
void setup() {
Serial.begin(115200);
// читаем
char str[20];
EEPROM.get(STR_ADDR, str);
// выводим
Serial.print("Read text: ");
Serial.println(str);
}
void loop() {
// читаем строку из порта
if (Serial.available()) {
char str[20];
int len = Serial.readBytes(str, 20);
// завершающий символ, добавляем вручную
str[len] = 0;
// записываем
EEPROM.put(STR_ADDR, str);
Serial.print("Saved text: ");
Serial.println(str);
}
}
При динамическом хранении мы будем сохранять также длину строки, в первой ячейке от которой идёт счёт. А уже дальше - саму строку. Писать и читать будем посимвольно - по другому в Arduino AVR String не предусмотрено (например в реализации ESP есть метод concat(char* str, size_t len)
):
#include <EEPROM.h>
#define STR_ADDR 0 // адрес хранения строки в EEPROM
void setup() {
Serial.begin(115200);
String str;
int len = EEPROM.read(STR_ADDR); // читаем длину строки
str.reserve(len); // резервируем место (для оптимизации скорости прибавления)
// читаем строку
for (int i = 1; i < len + 1; i++) {
str += (char)EEPROM.read(STR_ADDR + i);
}
// выводим
Serial.print("Read text: ");
Serial.println(str);
}
void loop() {
// читаем строку из порта
if (Serial.available()) {
String inc = Serial.readString();
Serial.print("Save text: ");
Serial.println(inc);
int len = inc.length(); // длина строки
EEPROM.write(STR_ADDR, len); // записываем её
// и далее саму строку посимвольно
for (int i = 0; i < len; i++) {
EEPROM.write(STR_ADDR + 1 + i, inc[i]);
}
}
}
Полезные трюки #
Инициализация #
Под инициализацией я имею в виду установку значений ячеек в EEPROM "по умолчанию" во время первого запуска устройства. В рассмотренном выше примере мы действовали в таком порядке:
- Чтение из EEPROM в переменную
- Использование переменной по назначению
При первом запуске кода (и при всех дальнейших, в которых в ячейку ничего нового не пишется) переменная получит значение, которое было в EEPROM по умолчанию. В большинстве случаев это значение не подойдёт устройству, например ячейка хранит номер режима, по задумке разработчика - от 0 до 5, а из EEPROM мы прочитаем 255. Непорядок! При первом запуске нужно инициализировать EEPROM так, чтобы устройство работало корректно, для этого нужно определить первый запуск. Можно сделать это вручную, прошив программу, которая забьёт EEPROM нужными данными. Далее прошить уже рабочую программу. При разработке программы это очень неудобно, ведь количество сохраняемых данных может меняться в процессе разработки, поэтому можно использовать следующий алгоритм:
- Резервируем какую-нибудь ячейку (например, последнюю) под хранение "ключа" первого запуска
- Читаем ячейку, если её содержимое не совпадает с ключом - это первый запуск!
- В обработчике первого запуска пишем в ячейку нужный ключ
- Пишем в остальные ячейки необходимые значения по умолчанию
- После этого уже читаем данные во все нужные переменные
Рассмотрим на всё том же примере со светодиодом и кнопками:
#define INIT_ADDR 1023 // номер резервной ячейки
#define INIT_KEY 50 // ключ первого запуска. 0-254, на выбор
#define BTN_UP_PIN 3 // пин кнопки вверх
#define BTN_DOWN_PIN 4 // пин кнопки вниз
#define LED_PIN 5 // пин светодиода
#include <EEPROM.h>
#include <EncButton.h>
Button btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
Button btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"
int LEDbright = 0;
void setBright() {
LEDbright = constrain(LEDbright, 0, 255); // ограничили
EEPROM.put(0, LEDbright); // записали
analogWrite(LED_PIN, LEDbright); // изменили яркость
}
void setup() {
pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
if (EEPROM.read(INIT_ADDR) != INIT_KEY) { // первый запуск
EEPROM.write(INIT_ADDR, INIT_KEY); // записали ключ
// записали стандартное значение яркости
// в данном случае это значение переменной, объявленное выше
EEPROM.put(0, LEDbright);
}
EEPROM.get(0, LEDbright); // прочитали яркость
analogWrite(LED_PIN, LEDbright); // включили
}
void loop() {
// опрос кнопок
btnUP.tick();
btnDOWN.tick();
if (btnUP.click()) {
// увеличение по клику
LEDbright += 5;
setBright();
}
if (btnDOWN.click()) {
// уменьшение по клику
LEDbright -= 5;
setBright();
}
}
Теперь при первом запуске мы получим инициализацию нужных ячеек. Если нужно переинициализировать EEPROM, например в случае добавления новых данных, достаточно изменить ключ на любое другое значение в пределах одного байта (0-254). Я пишу именно до 254, потому что 255 является значением ячейки по умолчанию и трюк не сработает.
Сброс до "заводских" #
Чтобы вернуть настройки к изначально заданным в программе, нужно "спровоцировать" инициализацию. Очевидный способ сделать это - изменить ключ инициализации, который мы назвали INIT_KEY
. Либо можно просто вызвать EEPROM.put(адрес, базовые настройки)
в нужном месте программы.
Уменьшение износа #
Ситуаций может быть много, интересных решений для них - тоже. Рассмотрим простейший пример - всё тот же код со светодиодом и кнопкой. Делать будем следующее: записывать новое значение будем только в том случае, если после последнего нажатия на кнопку прошло какое-то время. То есть нам понадобится таймер (воспользуемся таймером на millis
), при нажатии на кнопку таймер будет сбрасываться, а при срабатывании таймера будем писать актуальное значение в EEPROM. Также понадобится флаг, который будет сигнализировать о записи и позволит записать именно один раз. Алгоритм такой:
При нажатии на кнопку:
- Если флаг опущен - поднять флаг
- Сбросить таймер
- Если сработал таймер и флаг поднят:
- Опустить флаг
- Записать значения в EEPROM
Посмотрим на всё том же примере:
#define INIT_ADDR 1023 // номер резервной ячейки
#define INIT_KEY 50 // ключ первого запуска. 0-254, на выбор
#define BTN_UP_PIN 3 // пин кнопки вверх
#define BTN_DOWN_PIN 4 // пин кнопки вниз
#define LED_PIN 5 // пин светодиода
#include <EEPROM.h>
#include <EncButton.h>
Button btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
Button btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"
int LEDbright = 0;
uint32_t eepromTimer = 0;
boolean eepromFlag = false;
void setBright() {
LEDbright = constrain(LEDbright, 0, 255); // ограничили
analogWrite(LED_PIN, LEDbright); // изменили яркость
eepromFlag = true; // поднять флаг
eepromTimer = millis(); // сбросить таймер
}
void checkEEPROM() {
// если флаг поднят и с последнего нажатия прошло 10 секунд (10 000 мс)
if (eepromFlag && (millis() - eepromTimer >= 10000) ) {
eepromFlag = false; // опустили флаг
EEPROM.put(0, LEDbright); // записали в EEPROM
}
}
void setup() {
pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
if (EEPROM.read(INIT_ADDR) != INIT_KEY) { // первый запуск
EEPROM.write(INIT_ADDR, INIT_KEY); // записали ключ
// записали стандартное значение яркости
// в данном случае это значение переменной, объявленное выше
EEPROM.put(0, LEDbright);
}
EEPROM.get(0, LEDbright); // прочитали яркость
analogWrite(LED_PIN, LEDbright); // включили
}
void loop() {
// проверка EEPROM
checkEEPROM();
// опрос кнопок
btnUP.tick();
btnDOWN.tick();
if (btnUP.click()) {
// увеличение по клику
LEDbright += 5;
setBright();
}
if (btnDOWN.click()) {
// уменьшение по клику
LEDbright -= 5;
setBright();
}
}
Вот таким нехитрым способом мы многократно снизили износ EEPROM, я очень часто использую этот "алгоритм" работы с настройками в своих устройствах. Есть другие задачи, в которых данные в EEPROM пишутся не когда пользователь что-то изменит, а постоянно, т.е. память работает в режиме чёрного ящика и постоянно записывает значения. Это может быть например контроллер печи, который держит температурный режим по специальному закону, и после внезапной перезагрузки должен вернуться к тому месту в процессе, на котором прервался. Тут глобально есть два варианта:
- Ёмкий конденсатор по питанию МК, позволяющий сохранить работу МК после отключения питания на время, достаточное для записи в EEPROM (~3.3 мс). Также МК должен знать о том, что общее питание отключилось: если это высокое напряжение (выше 5 Вольт), то это может быть делитель напряжения на аналоговый пин. Если это 5 Вольт - можно измерять напряжение МК, и момент отключения (разрядка конденсатора) тоже можно отловить и записать нужные данные. Можно взвести прерывание, которое сработает при падении напряжения питания ниже опасного уровня. Можно 5 Вольт завести напрямую на цифровой пин, а сам МК питать через диод и поставить конденсатор - тогда напряжение на измеряющем пине пропадёт до того, как отключится МК, он будет работать от конденсатора. Схема:
- Можно писать данные (необязательно один байт, можно целую структуру) хитро, размазывая их по всему EEPROM. Тут два варианта:
- Писать данные каждый раз в следующую ячейку и закольцевать переход на первую. Также понадобится хранить где-то счётчик, указывающий на адрес текущей ячейки, и этот счётчик тоже придётся хранить хитро, чтобы он не износил ячейку. Например счётчик - это структура, состоящая из счётчика перезаписей этой структуры и счётчика адреса для большой структуры
- Писать данные, пока не достигнут лимит количества перезаписей, количество текущих перезаписей хранить например в этой же структуре. Скажем структура занимает 30 байт, то есть в перспективе мы можем найти эту структуру по адресу, кратному 30. Программа работает, счётчик считает количество перезаписей, при достижении опасного количества вся структура "переезжает" на следующие 30 адресов
Вариантов уменьшения износа ячеек EEPROM можно придумать много, уникально под свою ситуацию. Есть даже библиотеки готовые, например EEPROMWearLevel. Есть очень интересная статья на Хабре, там рассмотрено ещё несколько хороших алгоритмов и даны ссылки на ещё большее их количество.
Библиотека EEManager #
Я часто использую EEPROM в своих проектах, поэтому обернул все рассмотренные выше конструкции в библиотеку EEManager. Библиотека подходит для всех архитектур, в которых есть стандартная EEPROM.h. В библиотеке реализовано:
- Работа с данными любого типа
- Чтение и запись в указанную переменную
- Функция "ключа первого запуска" для задания начальных значений
- Отложенное обновление по тайм-ауту для уменьшения износа
Я надеюсь вы полностью разобрались с самым последним примером с кнопкой и светодиодом, поэтому покажу работу EEManager на его основе:
#define INIT_KEY 50 // ключ первого запуска. 0-254, на выбор
#define BTN_UP_PIN 3 // пин кнопки вверх
#define BTN_DOWN_PIN 4 // пин кнопки вниз
#define LED_PIN 5 // пин светодиода
#include <EncButton.h>
Button btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
Button btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"
#include <EEManager.h>
int LEDbright = 0;
EEManager memory(LEDbright); // передаём переменную в менеджер
void setBright() {
LEDbright = constrain(LEDbright, 0, 255); // ограничили
analogWrite(LED_PIN, LEDbright); // изменили яркость
memory.update(); // сообщаем, что данные нужно обновить
}
void setup() {
pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
// запускаем менеджер, указав адрес и ключ запуска
// он сам проверит ключ, а также прочитает данные
// из EEPROM и запишет в переменную
memory.begin(0, INIT_KEY);
analogWrite(LED_PIN, LEDbright); // включили
}
void loop() {
// здесь произойдёт запись по встроенному таймеру
memory.tick();
// опрос кнопок
btnUP.tick();
btnDOWN.tick();
if (btnUP.click()) {
// увеличение по клику
LEDbright += 5;
setBright();
}
if (btnDOWN.click()) {
// уменьшение по клику
LEDbright -= 5;
setBright();
}
}