EEPROM память


Вот и добрались мы до третьего типа памяти, доступного на Arduino: EEPROM (англ. Electrically Erasable Programmable Read-Only Memory – электрически стираемое перепрограммируемое постоянное запоминающее устройство (ЭСППЗУ)), она же энергонезависимая память. Вспомним остальные типы памяти, Flash и SRAM, и их возможности по хранению данных:

Тип Чтение из программы Запись из программы Очистка при перезагрузке
Flash Да, PROGMEM Можно, но сложно Нет
SRAM Да Да Да
EEPROM Да Да Нет

Простыми словами: EEPROM – память, к которой мы имеем полный доступ из выполняющейся программы, т.е. можем во время выполнения читать и писать туда данные, и эти данные не сбрасываются при перезагрузке МК. Круто? Круто. Зачем?

  • Хранение настроек, изменяющихся “из меню” устройства, без перепрошивки;
  • Калибровка, сохранение калибровочных данных;
  • Использование как дополнительной SRAM памяти в случае её нехватки;
  • “Чёрный ящик” – постоянная запись показаний с датчиков для дальнейшей расшифровки сбоев;
  • Запись состояния рабочего процесса для восстановления работы после внезапной перезагрузки.

Единственный важный момент: EEPROM имеет ресурс по количеству перезаписи ячеек. Производитель гарантирует 100 000 циклов записи каждой ячейки, по факту это количество зависит от конкретного чипа и температурных условий, независимые тесты показали 3-6 миллионов циклов перезаписи при комнатной температуре до появления первой ошибки, т.е. заявленные 100 000 взяты с очень большим запасом. Количество чтений каждой ячейки неограниченно. Гарантируется сохранение записанных данных в течение 100 лет при температуре 24°C.

EEPROM представляет собой область памяти, состоящую из элементарных ячеек с размером в один байт (как SRAM). Объём EEPROM разный у разных моделей МК:

  • ATmega328 (Arduino UNO, Nano, Pro Mini): 1 кБ
  • ATmega2560 (Arduino Mega): 4 кБ
  • ATtiny85 (Digispark): 512 Б

Основная задача при работе с EEPROM – не напутать с адресами, потому что каждый байт имеет свой адрес. Если вы пишете двухбайтные данные, то они займут два байта, и следующие данные нужно будет писать по адресу как минимум +2 к предыдущему, иначе они “перемешаются”. Рассмотрим пример хранения набора данных разного типа, расположенных в памяти последовательно друг за другом (в скобках я пишу размер текущего типа данных, на размер которого увеличится адрес для следующего “блока”):

  • byte – адрес 0 (+1)
  • byte – адрес 1 (+1)
  • int – адрес 2 (+2)
  • byte – адрес 4 (+1)
  • float – адрес 5 (+4)
  • int – адрес 9 (+2)
  • и так далее

Важный момент: все ячейки имеют значение по умолчанию (у нового чипа) 255.

Скорость работы с EEPROM (время указано для частоты системного клока 16 МГц):

  • Запись одного байта занимает ~3.3 мс (миллисекунды)
  • Чтение одного байта занимает ~0.4 мкс (микросекунды)

Для работы с EEPROM в среде Arduino у нас есть целых две библиотеки, вторая является более удобной “оболочкой” для первой. Рассмотрим их обе, потому что в “чужом скетче” может встретиться всё что угодно, да и совместное использование этих двух библиотек делает работу с EEPROM невероятно удобной.

Библиотека avr/eeprom.h


Стандартная библиотека eeprom.h идёт в комплекте с компилятором avr-gcc, который компилирует наши скетчи из под Arduino IDE. Полную документацию можно почитать здесь. Для подключения библиотеки в скетч пишем #include <avr/eeprom.h>

Библиотека имеет набор функций для работы с целочисленными типами данных (byte – 1 байт, word – 2 байта, dword – 3 байта), float, и block “блоков” – наборов данных любого формата (структуры, массивы, и т.д.). Под работой подразумевается запись, чтение и обновление. Обновление – крайне важный инструмент, позволяющий избежать лишних перезаписей ячеек памяти. Обновление делает запись, если записываемое значение отличается от текущего в этой ячейке.

Чтение:

  • eeprom_read_byte (адрес) – вернёт значение
  • eeprom_read_word (адрес) – вернёт значение
  • eeprom_read_dword (адрес) – вернёт значение
  • eeprom_read_float (адрес) – вернёт значение
  • eeprom_read_block (адрес в SRAM, адрес в EEPROM, размер) – прочитает содержимое по “адрес в EEPROM” в “адрес в SRAM”

Запись:

  • eeprom_write_byte (адрес, значение)
  • eeprom_write_word (адрес, значение)
  • eeprom_write_dword (адрес, значение)
  • eeprom_write_float (адрес, значение)
  • eeprom_write_block (адрес в SRAM, адрес в EEPROM, размер) – запишет содержимое по “адрес в SRAM” в “адрес в EEPROM”

Обновление:

  • eeprom_update_byte (адрес, значение)
  • eeprom_update_word (адрес, значение)
  • eeprom_update_dword (адрес, значение)
  • eeprom_update_float (адрес, значение)
  • eeprom_update_block (адрес в SRAM, адрес в EEPROM, размер) – обновит содержимое по “адрес в SRAM” в “адрес в EEPROM”

Рассмотрим простой пример, в котором происходит запись и чтение единичных типов данных в разные ячейки:

#include <avr/eeprom.h>

void setup() {
  Serial.begin(9600);
  
  // объявляем данные разных типов
  byte dataB = 120;
  float dataF = 3.14;
  int16_t dataI = -634;  

  // пишем друг за другом
  eeprom_write_byte(0, dataB);  // 1 байт
  eeprom_write_float(1, dataF);  // 4 байта

  // для разнообразия "обновим"
  eeprom_update_word(5, dataI);

  // объявляем переменные, куда будем читать
  byte dataB_read = 0;
  float dataF_read = 0;
  int16_t dataI_read = 0;

  // читаем
  dataB_read = eeprom_read_byte(0);
  dataF_read = eeprom_read_float(1);
  dataI_read = eeprom_read_word(5);

  // выведет 120 3.14 -634
  Serial.println(dataB_read);
  Serial.println(dataF_read);
  Serial.println(dataI_read);
}

void loop() {}

Хранить данные таким образом не очень удобно, потому что менеджмент адресов приходится проводить вручную, считать количество байт в каждом типе и “сдвигать” адрес на нужное количество. Гораздо удобнее хранить разношёрстные данные в структурах, про них мы подробнее говорили в уроке про типы данных.

Мы должны передать функции адрес данных в памяти (оператор &), по сути – указатель, а также преобразовать его к типу void*, потому что функция чтения/записи блока принимает именно такой тип. Подробнее про указатели мы говорили в отдельном уроке. Также функции чтения/записи блока нужно передать размер блока данных в количестве байт. Это можно сделать вручную (числом), но лучше использовать sizeof(), которая посчитает этот размер и передаст в функцию.

#include <avr/eeprom.h>

void setup() {
  Serial.begin(9600);

  // объявляем структуру
  struct MyStruct {
    byte a;
    int b;
    float c;
  };

  // создаём и заполняем структуру
  MyStruct myStruct;
  myStruct.a = 10;
  myStruct.b = 1000;
  myStruct.c = 3.14;

  // записываем по адресу 10, указав размер структуры и приведя к void*
  eeprom_write_block((void*)&myStruct, 10, sizeof(myStruct));

  // создаём новую пустую структуру
  MyStruct newStruct;

  // читаем из адреса 10
  eeprom_read_block((void*)&newStruct, 10, sizeof(newStruct));

  // проверяем
  // выведет 10 1000 3.14
  Serial.println(newStruct.a);
  Serial.println(newStruct.b);
  Serial.println(newStruct.c);
}

void loop() {}

Точно так же можно хранить массивы:

#include <avr/eeprom.h>

void setup() {
  Serial.begin(9600);

  // создаём массив
  float dataF[] = {3.14, 60.25, 9132.5, -654.3};

  // записываем по адресу 20, указав размер
  eeprom_write_block((void*)&dataF, 20, sizeof(dataF));

  // создаём новую пустой массив такого же типа и размера!
  float dataF_read[4];

  // читаем из адреса 20
  eeprom_read_block((void*)&dataF_read, 20, sizeof(dataF_read));

  // проверяем
  // выведет 3.14 60.25 9132.5 -654.3
  for (byte i = 0; i < 4; i++)
    Serial.println(dataF_read[i]);
}

void loop() {}

В библиотеке avr/eeprom.h есть ещё один очень полезный инструмент – EEMEM, он позволяет сделать автоматическую адресацию данных путём создания указателей, значение которым присвоит компилятор.

Рассмотрим пример, в котором запишем в EEPROM несколько переменных, структуру и массив, раздав им автоматически адреса. Важный момент! Адреса задаются снизу вверх по порядку объявления EEMEM, я подпишу их в примере:

#include <avr/eeprom.h>
struct MyStruct {
  byte val1;
  int val2;
  float int3;
};

uint8_t EEMEM byteAddr;     // 27
uint16_t EEMEM intAddr;     // 25
uint32_t EEMEM longAddr;    // 21
MyStruct EEMEM myStructAddr;// 14
int EEMEM intArrayAddr[5];  // 4
float EEMEM floatAddr;      // 0

EEMEM сам раздаёт адреса, основываясь на размере данных. Важный момент: данный подход не занимает дополнительного места в памяти, т.е. нумерация адресов вручную цифрами, без создания EEMEM “переменных”, не занимает меньше памяти!

Давайте вернёмся к нашему первому примеру и перепишем его с EEMEM. При указании адреса через EEMEM нужно использовать оператор взятия адреса &

#include <avr/eeprom.h>
byte EEMEM dataB_addr;
float EEMEM dataF_addr;
int16_t EEMEM dataI_addr;

void setup() {
  Serial.begin(9600);

  // объявляем данные разных типов
  byte dataB = 120;
  float dataF = 3.14;
  int16_t dataI = -634;

  // пишем друг за другом
  eeprom_write_byte(&dataB_addr, dataB);
  eeprom_write_float(&dataF_addr, dataF);

  // для разнообразия "обновим"
  eeprom_update_word(&dataI_addr, dataI);

  // объявляем переменные, куда будем читать
  byte dataB_read = 0;
  float dataF_read = 0;
  int16_t dataI_read = 0;

  // читаем
  dataB_read = eeprom_read_byte(&dataB_addr);
  dataF_read = eeprom_read_float(&dataF_addr);
  dataI_read = eeprom_read_word(&dataI_addr);

  // выведет 120 3.14 -634
  Serial.println(dataB_read);
  Serial.println(dataF_read);
  Serial.println(dataI_read);
}

void loop() {}

Ну и напоследок, запись и чтение блока через EEMEM. Адрес придётся преобразовать в (const void*) вручную:

#include <avr/eeprom.h>
// получим адрес (тут будет 0)
int EEMEM intArrayAddr[5];

void setup() {
  Serial.begin(9600);

  // создаём массив
  int intArrayWrite[5] = {10, 20, 30, 40, 50};

  // пишем по адресу intArrayAddr
  eeprom_write_block((void*)&intArrayWrite, (const void*)&intArrayAddr, sizeof(intArrayWrite));

  // создаём новый массив для чтения
  int intArrayRead[5];

  // читаем по адресу intArrayAddr
  eeprom_read_block((void*)&intArrayRead, (const void*)&intArrayAddr, sizeof(intArrayRead));

  // проверим
  for (byte i = 0; i < 5; i++)
    Serial.println(intArrayRead[i]);
}

void loop() {}

Таким образом можно добавлять “данные” для хранения в EEPROM прямо по ходу разработки программы, не думая об адресах. Рекомендую добавлять новые данные над старыми, чтобы адресация не сбивалась (напомню, адресация идёт снизу вверх, начиная с нуля).

Библиотека EEPROM.h


Библиотека EEPROM.h идёт в комплекте с ядром Arduino и является стандартной библиотекой. По сути EEPROM.h – это удобная оболочка для avr/eeprom.h, чуть расширяющая её возможности и упрощающая использование. Важный момент: подключая в скетч EEPROM.h мы автоматически подключаем avr/eeprom.h и можем пользоваться её фишками, такими как EEMEM. Рассмотрим инструменты, которые нам предлагает библиотека:

  • EEPROM.write(адрес, данные) – пишет данные (только byte!) по адресу
  • EEPROM.update(адрес, данные) – обновляет (та же запись, но лучше) байт данных, находящийся по адресу
  • EEPROM.read(адрес) – читает и возвращает байт данных, находящийся по адресу
  • EEPROM.put(адрес, данные) – записывает (по факту – обновляет, update) данные любого типа (типа переданной переменной) по адресу
  • EEPROM.get(адрес, данные) – читает данные по адресу и сам записывает их в данные – указанную переменную
  • EEPROM[] – библиотека позволяет работать с EEPROM памятью как с обычным массивом типа byte (uint8_t)

В отличие от avr/eeprom.h у нас нет отдельных инструментов для работы с конкретными типами данных, отличными от byte, и сделать write/update/read для float/long/int мы не можем. Но зато у нас есть всеядный put/get, который очень удобно использовать! Также можем пользоваться тем, что нам даёт avr/eeprom.h, которая подключается автоматически с EEPROM.h. Рассмотрим пример с чтением/записью байтов:

#include <EEPROM.h>

void setup() {
  Serial.begin(9600);
  
  // пишем 200 по адресу 10
  EEPROM.update(10, 200);  
  Serial.println(EEPROM.read(10));  // выведет 200
  Serial.println(EEPROM[10]);       // выведет 200
}

void loop() {}

Логика работы с адресами такая же, как в предыдущем пункте урока! Обратите внимание на работу с EEPROM как с массивом, можно читать, писать, сравнивать, и даже использовать составные операторы, например EEPROM[0] += 10 , но это работает только для элементарных ячеек, байтов.

Теперь посмотрим, как работает put/get:

#include <EEPROM.h>

void setup() {
  Serial.begin(9600);

  // объявляем переменные, которые будем писать
  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);

  // объявляем переменные, куда будем читать
  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() {}

Гораздо удобнее чем write_block и read_block, не правда ли? Put и get сами преобразовывают типы и сами считают размер блока данных, использовать их очень приятно. Они работают как с массивами, так и со структурами.

EEPROM.h + avr/eeprom.h


Ну и конечно же, можно использовать одновременно все преимущества обеих библиотек, например автоматическую адресацию EEMEM и put/get. Рассмотрим на предыдущем примере, вместо ручного задания адресов используем EEMEM, но величину придётся привести к целочисленному типу, сначала взяв от него адрес, т.е. (int)&адрес_еемем

#include <EEPROM.h>
float EEMEM dataF_addr;
int16_t EEMEM dataI_addr;
byte EEMEM dataArray_addr[5];

void setup() {
  Serial.begin(9600);

  // объявляем переменные, которые будем писать
  float dataF = 3.14;
  int16_t dataI = -634;
  byte dataArray[] = {10, 20, 30, 40};

  EEPROM.put((int)&dataF_addr, dataF);
  EEPROM.put((int)&dataI_addr, dataI);
  EEPROM.put((int)&dataArray_addr, dataArray);

  // объявляем переменные, куда будем читать
  float dataF_read = 0;
  int16_t dataI_read = 0;
  byte dataArray_read[4];

  // читаем точно так же, как писали
  EEPROM.get((int)&dataF_addr, dataF_read);
  EEPROM.get((int)&dataI_addr, dataI_read);
  EEPROM.get((int)&dataArray_addr, dataArray_read);
  EEPROM[0] += 10;

  // проверяем
  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() {}

С возможностями библиотек разобрались, перейдём к практике.

Реальный пример


Рассмотрим пример, в котором происходит следующее: две кнопки управляют яркостью светодиода, подключенного к ШИМ пину. Установленная яркость сохраняется в EEPROM, т.е. при перезапуске устройства будет включена яркость, установленная последний раз. Для опроса кнопок используется библиотека GyverButton.

Для начала посмотрите на первоначальную программу, где установленная яркость не сохраняется. Программу можно чуть оптимизировать, но это не является целью данного урока.

#define BTN_UP_PIN 3    // пин кнопки вверх
#define BTN_DOWN_PIN 4  // пин кнопки вниз
#define LED_PIN 5       // пин светодиода

#include <GyverButton.h>

GButton btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
GButton btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"

int LEDbright = 0;

void setup() {
  pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
}

void loop() {
  // опрос кнопок
  btnUP.tick();
  btnDOWN.tick();

  if (btnUP.isClick()) {
    // увеличение по клику
    LEDbright += 5;
    setBright();
  }

  if (btnDOWN.isClick()) {
    // уменьшение по клику
    LEDbright -= 5;
    setBright();
  }
}

void setBright() {
  LEDbright = constrain(LEDbright, 0, 255); // ограничили
  analogWrite(LED_PIN, LEDbright);    // изменили яркость
}

В этот код нам нужно добавить:

  • Подключить библиотеку EEPROM.h
  • При запуске: чтение яркости из EEPROM и включение светодиода
  • При клике: запись актуального значения в EEPROM

#define BTN_UP_PIN 3    // пин кнопки вверх
#define BTN_DOWN_PIN 4  // пин кнопки вниз
#define LED_PIN 5       // пин светодиода

#include <EEPROM.h>
#include <GyverButton.h>

GButton btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
GButton btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"

int LEDbright = 0;

void setup() {
  pinMode(LED_PIN, OUTPUT); // пин светодиода как выход
  EEPROM.get(0, LEDbright); // прочитали яркость из адреса 0
  analogWrite(LED_PIN, LEDbright);  // включили
}

void loop() {
  // опрос кнопок
  btnUP.tick();
  btnDOWN.tick();

  if (btnUP.isClick()) {
    // увеличение по клику
    LEDbright += 5;
    setBright();
  }

  if (btnDOWN.isClick()) {
    // уменьшение по клику
    LEDbright -= 5;
    setBright();
  }
}

void setBright() {
  LEDbright = constrain(LEDbright, 0, 255); // ограничили
  EEPROM.put(0, LEDbright);           // записали по адресу 0
  analogWrite(LED_PIN, LEDbright);    // изменили яркость
}

Итак, теперь при запуске у нас восстанавливается последняя настроенная яркость, и при изменении она записывается. Напомню, что EEPROM изнашивается от перезаписи. Конечно для того, чтобы “накликать” яркость несколько миллионов раз и убить ячейку, у вас уйдёт очень много времени, но процесс записи нового значения можно и нужно оптимизировать, особенно в более серьёзных проектах, ниже поговорим об этом подробнее.

Также в нашем коде есть ещё один неприятный момент: при самом первом запуске после прошивки EEPROM не инициализирована, каждая ячейка хранит в себе число 255, и именно такое значение примет переменная LEDbright после первого запуска, при так называемом “первом чтении”. Здесь это не имеет значения, но в более серьёзном устройстве нужно будет задать нужные значения по умолчанию в EEPROM при первом запуске, об этом мы тоже поговорим ниже. Иначе представьте, какие “настройки по умолчанию” получит ваше устройство для яркости/скорости/громкости/номера режима/прочее!

Полезные трюки


Инициализация


Под инициализацией я имею в виду установку значений ячеек в EEPROM “по умолчанию” во время первого запуска устройства. В рассмотренном выше примере мы действовали в таком порядке:

  1. Чтение из EEPROM в переменную
  2. Использование переменной по назначению

При первом запуске кода (и при всех дальнейших, в которых в ячейку ничего нового не пишется) переменная получит значение, которое было в EEPROM по умолчанию. В большинстве случаев это значение не подойдёт устройству, например ячейка хранит номер режима, по задумке разработчика – от 0 до 5, а из EEPROM мы прочитаем 255. Непорядок! При первом запуске нужно инициализировать EEPROM так, чтобы устройство работало корректно, для этого нужно определить этот самый первый запуск.

Можно сделать это вручную, прошив программу, которая забьёт EEPROM нужными данными. Далее прошить уже рабочую программу.

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

  1. Резервируем какую-нибудь ячейку (например, последнюю) под хранение “ключа” первого запуска
  2. Читаем ячейку, если её содержимое не совпадает с ключом – это первый запуск!
  3. В обработчике первого запуска пишем в ячейку нужный ключ
  4. Пишем в остальные ячейки необходимые значения по умолчанию
  5. И после этого уже читаем данные во все нужные переменные

Рассмотрим на всё том же примере со светодиодом и кнопками:

#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 <GyverButton.h>

GButton btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
GButton btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"

int LEDbright = 0;

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.isClick()) {
    // увеличение по клику
    LEDbright += 5;
    setBright();
  }

  if (btnDOWN.isClick()) {
    // уменьшение по клику
    LEDbright -= 5;
    setBright();
  }
}

void setBright() {
  LEDbright = constrain(LEDbright, 0, 255); // ограничили
  EEPROM.put(0, LEDbright);           // записали
  analogWrite(LED_PIN, LEDbright);    // изменили яркость
}

Теперь при первом запуске мы получим инициализацию нужных ячеек. Если нужно переинициализировать EEPROM, например в случае добавления новых данных, достаточно изменить наш ключ на любое другое значение в пределах одного байта (0-254). Я пишу именно до 254, потому что 255 является значением ячейки по умолчанию и наш трюк не сработает.

Скорость


Как я писал выше, скорость работы с EEPROM (время указано для частоты системного клока 16 МГц) составляет:

  • Запись/обновление одного байта занимает ~3.3 мс (миллисекунды)
  • Чтение одного байта занимает ~0.4 мкс (микросекунды)

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

analogWrite(LED_PIN, EEPROM.read(0));   // изменили яркость

Для изменения значения придётся прочитать ячейку, выполнить нужные операции, и снова в неё записать.

Ещё один удобный хак: можно ввести макросы на чтение и запись определённых значений, например:

#define GET_MODE EEPROM.read(0)     // получить номер режима
#define GET_BRIGHT EEPROM.read(1)   // получить яркость
#define SET_MODE(x) EEPROM.write(0, (x))  // запомнить режим
#define SET_BRIGHT(x) EEPROM.put(1, (x))  // запомнить яркость

Получим удобные макросы, с которыми писать код будет чуть быстрее и удобнее, т.е. строка SET_MODE(3) запишет 3 в ячейку 0

Уменьшение износа


Важная тема: уменьшение износа ячеек частыми перезаписями. Ситуаций может быть много, интересных решений для них – тоже. Рассмотрим простейший пример – всё тот же код со светодиодом и кнопкой. Делать будем следующее: записывать новое значение будем только в том случае, если после последнего нажатия на кнопку прошло какое-то время. То есть нам понадобится таймер (воспользуемся таймером на 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 <GyverButton.h>

GButton btnUP(BTN_UP_PIN); // кнопка "яркость вверх"
GButton btnDOWN(BTN_DOWN_PIN); // кнопка "яркость вниз"

int LEDbright = 0;
uint32_t eepromTimer = 0;
boolean eepromFlag = false;

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.isClick()) {
    // увеличение по клику
    LEDbright += 5;
    setBright();
  }

  if (btnDOWN.isClick()) {
    // уменьшение по клику
    LEDbright -= 5;
    setBright();
  }
}

void setBright() {
  LEDbright = constrain(LEDbright, 0, 255); // ограничили
  analogWrite(LED_PIN, LEDbright);    // изменили яркость
  if (!eepromFlag) {        // если флаг опущен
    eepromFlag = true;      // поднять флаг
    eepromTimer = millis(); // сбросить таймер
  }
}

void checkEEPROM() {
  // если флаг поднят и с последнего нажатия прошло 10 секунд (10 000 мс)
  if (eepromFlag && (millis() - eepromTimer >= 10000) ) {
    eepromFlag = false;           // опустили флаг
    EEPROM.put(0, LEDbright);     // записали в EEPROM
  }
}

Вот таким нехитрым способом мы многократно снизили износ EEPROM, я очень часто использую этот “алгоритм” работы с настройками в своих устройствах.

Есть другие задачи, в которых данные в EEPROM пишутся не когда пользователь что-то изменит, а постоянно, т.е. память работает в режиме чёрного ящика и постоянно записывает значения. Это может быть например контроллер печи, который держит температурный режим по специальному закону, и после внезапной перезагрузки должен вернуться к тому месту в процессе, на котором прервался. Тут есть глобально два варианта:

  • Ёмкий конденсатор по питанию микроконтроллера, позволяющий сохранить работу МК после отключения питания на время, достаточное для записи в EEPROM (~3.3 мс). Также МК должен знать о том, что общее питание отключилось: если это высокое напряжение (выше 5 Вольт), то это может быть делитель напряжения на аналоговый пин. Если это 5 Вольт – можно измерять напряжение МК, и момент отключения (разрядка конденсатора) тоже можно отловить и записать нужные данные. Можно взвести прерывание, которое сработает при падении напряжения питания ниже опасного уровня. Можно 5 Вольт завести напрямую на цифровой пин, а сам МК питать через диод и поставить конденсатор – тогда напряжение на измеряющем пине пропадёт до того, как отключится МК, он будет работать от конденсатора. Вот схема: 
  • Можно писать данные (необязательно один байт, можно целую структуру) хитро, размазывая их по всему EEPROM. Тут глобально два варианта:
    • Писать данные каждый раз в следующую ячейку, и закольцевать переход на первую. Также понадобится хранить где-то счётчик, указывающий на адрес текущей ячейки, и этот счётчик тоже придётся хранить хитро, чтобы он не износил ячейку. Например счётчик – это структура, состоящая из счётчика перезаписей этой структуры и счётчика адреса для большой структуры.
    • Писать данные, пока не достигнут лимит количества перезаписей, количество текущих перезаписей хранить например в этой же структуре. Скажем структура занимает 30 байт, то есть в перспективе мы можем найти эту структуру по адресу, кратному 30. Программа работает, счётчик считает количество перезаписей, при достижении опасного количества вся структура “переезжает” на следующие 30 адресов.

Вариантов уменьшения износа ячеек EEPROM можно придумать много, уникально под свою ситуацию. Есть даже библиотеки готовые, например EEPROMWearLevel. Есть очень интересная статья на Хабре, там рассмотрено ещё несколько хороших алгоритмов и даны ссылки на ещё большее их количество.

Важные страницы


  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
Последнее обновление Ноябрь 16, 2019