Продвинутый курс Arduino · Секреты платы

Урок 1. Вечная память EEPROM

Выключили плату — и рекорды, настройки, код сейфа испарились. Учим контроллер помнить данные вечно: счётчик включений, несгораемый рекорд и сейф, которому можно менять код с кнопок.

Урок 1 из 8⏱ примерно 50 минутОбновлено: 13 августа 2026

Что понадобится

  • Контроллер Uno и кабель USB
  • Макетная плата
  • Экран LCD1602 и потенциометр 10 кОм (контраст)
  • 3 кнопки
  • Сервопривод SG90 (засов сейфа)
  • Провода

Все детали есть в обучающем наборе, по которому идёт курс.

С возвращением! Это первый урок продвинутого курса — он для тех, кто уже прошёл базовый курс и умеет обращаться с кнопками, экраном и сервоприводом. Новых деталей сегодня не будет совсем: мы откроем возможность, которая всё это время пряталась внутри самой платы.

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

Сегодня мы её вылечим. К концу урока плата будет помнить, сколько раз её включали, а сейф — хранить код, который переживает выключение и который можно менять с кнопок, ни разу не подключаясь к компьютеру.

Три памяти одной платы

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

Внутри контроллера — три разные памятиФлеш-память32 килобайтасама программа (скетч)выключили питание:✓ всё остаётсязагрузили новый скетч:✗ перезаписываетсяОперативная2 килобайтапеременные во время работывыключили питание:✗ всё стираетсязагрузили новый скетч:✗ перезаписываетсяEEPROM1024 байтато, что вы сохранили самивыключили питание:✓ всё остаётсязагрузили новый скетч:✓ всё остаётсягерой этого урока
Флеш хранит программу, оперативная память — переменные, а EEPROM — то, что вы решили сохранить сами.

Флеш-память — это склад программы. Когда вы нажимаете «Загрузить», скетч записывается именно сюда, поэтому плата помнит программу и без компьютера. Но при каждой новой загрузке склад перезаписывается целиком.

Оперативная память — рабочий стол. Здесь живут все переменные: count, record, массивы с кодом сейфа. Стол быстрый, но питается от розетки в прямом смысле: пропало питание — и всё, что на нём лежало, исчезло.

EEPROM — несгораемый архив. Записанное в него хранится без всякого питания: выключайте плату хоть на год. И — важное отличие от флеша — загрузка нового скетча этот архив не трогает. Название расшифровывается длинно и по-английски, поэтому в уроке мы будем звать его просто вечной памятью.

Шкаф на 1024 ящика

EEPROM — шкаф на 1024 пронумерованных ящикаадрес ящика (0…1023)7025512552255325542551023сюда мы записали своё число — оно переживёт выключениеСвежая, ни разу не тронутая память заполнена числами 255. В каждом ящике — один байт: целое число от 0 до 255.
У каждого ящика есть номер-адрес. Открыть ящик можно по номеру — как ячейку камеры хранения.

Устроена вечная память проще некуда: это 1024 пронумерованных ящика, и в каждом лежит одно число от 0 до 255 — один байт. Ни имён, ни папок у ящиков нет, только номера: от 0 до 1023. Хотите что-то сохранить — сами решаете, в какой ящик положить, и сами запоминаете номер.

Свежая, ни разу не тронутая память заполнена числами 255 — так она выходит с завода. Запомните эту деталь, она скоро сыграет.

Шаг 1. Заглянем внутрь

Для работы с вечной памятью нужна библиотека EEPROM — она встроена в среду, как Servo или LiquidCrystal: устанавливать ничего не придётся, достаточно строки #include. Схема не нужна вообще — только плата и USB.

// Продвинутый курс, урок 1. Шаг 1: заглядываем в вечную память.

#include <EEPROM.h>   // встроенная библиотека — устанавливать ничего не нужно

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

  // EEPROM.read(адрес) достаёт число из ящика с этим номером.
  // byte — целое число от 0 до 255: ровно один ящик вечной памяти.
  byte value = EEPROM.read(0);

  Serial.print("V yashchike 0 lezhit: ");
  Serial.println(value);
}

void loop() {
  // Пусто: всё интересное случилось один раз, при включении.
}

Новинка здесь — тип byte: целое число от 0 до 255, ровно один ящик вечной памяти. Можно было бы взять привычный int, но byte честнее показывает, с чем мы имеем дело.

Загрузите скетч и откройте монитор порта. Скорее всего, вы увидите V yashchike 0 lezhit: 255 — память вашей платы ещё девственно чиста. Если там другое число — тоже нормально: значит, в этот ящик кто-то уже заглядывал с отвёрткой, например при заводской проверке. Сейчас мы всё равно положим туда своё.

Шаг 2. Запишем — и проверим главное обещание

// Продвинутый курс, урок 1. Шаг 2: записываем число навсегда.

#include <EEPROM.h>

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

  EEPROM.write(0, 7);            // положить число 7 в ящик 0
  byte value = EEPROM.read(0);   // и сразу проверить, что оно там

  Serial.print("Zapisali. Teper v yashchike 0: ");
  Serial.println(value);
}

void loop() {
}

EEPROM.write(0, 7) читается как «положи в ящик 0 число 7». Загрузите скетч — монитор подтвердит запись. Пока ничего волшебного: переменная тоже так умеет.

А теперь — волшебное. Проделайте опыт по пунктам:

Опыт с выключением: выдерните USB из платы. Сосчитайте до десяти — плата полностью обесточена. Вставьте кабель обратно, снова загрузите скетч из первого шага (тот, который только читает) и откройте монитор порта.

В ящике 0 — семёрка. Она пережила и полное отключение питания, и загрузку другой программы. Вот это и есть вечная память: новая прошивка стирает программу, но не трогает EEPROM. Ваши данные теперь живут отдельно от скетча.

Шаг 3. Плата считает свои включения

Соберём из этого первую полезную вещь — счётчик включений. Идея: при старте прочитать число из ящика, прибавить единицу и положить обратно.

// Продвинутый курс, урок 1. Шаг 3: плата считает свои включения.

#include <EEPROM.h>

const int ADDR_COUNT = 0;   // ящик, в котором живёт счётчик

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

  byte count = EEPROM.read(ADDR_COUNT);
  if (count == 255) {
    count = 0;   // 255 — значит, память ещё пустая: включений не было
  }

  count = count + 1;                 // это включение тоже считается
  EEPROM.write(ADDR_COUNT, count);   // и сразу запоминается навсегда

  Serial.print("Menya vklyuchali raz: ");
  Serial.println(count);
}

void loop() {
}

Обратите внимание на проверку if (count == 255). Мы договорились: раз свежая память заполнена числами 255, встретили 255 — считаем, что счётчика ещё не было, и начинаем с нуля. Это обычный приём: особое значение играет роль таблички «здесь пусто», как -1 в пианино из двадцатого урока.

Загрузите и понажимайте кнопку сброса на плате (или повыдёргивайте USB): с каждым включением число растёт. Плата ведёт дневник, и никакое выключение ей больше не страшно.

У вечной памяти есть ресурс. Каждый ящик выдерживает около 100 000 перезаписей — потом начинает врать. Звучит как «много», но программа, которая пишет в EEPROM в каждом проходе loop(), тратит этот запас со скоростью сотен записей в секунду и убьёт ячейку за считаные минуты. Правило простое: записывайте только то, что изменилось, и только когда изменилось — по нажатию, по событию, по факту нового рекорда. У библиотеки даже есть вежливая команда EEPROM.update(адрес, число): она сначала читает ящик и пишет, только если там лежит другое число. Чтение, кстати, ресурс не тратит совсем.

Шаг 4. Числа больше 255: put и get

Наш счётчик — byte, поэтому на 255-м включении он упрётся в потолок. Рекорд игры в миллисекундах тоже в один байт не влезет. Нужен int — а он занимает два байта, то есть два ящика. Раскладывать число по ящикам вручную? Можно, но незачем: у библиотеки есть пара команд поумнее.

int record = 1250;int занимает два байта —в один ящик не влезаетEEPROM.put(0, record)EEPROM.get(0, record)226041два ящика: 4 × 256 + 226 = 1250put сам раскладывает число по ящикам, get сам собирает обратно. Ваша забота одна:читать get-ом тот же тип и с того же адреса, куда писали put-ом.
put раскладывает переменную по соседним ящикам, get собирает обратно. Сколько ящиков занять — команды понимают по типу переменной.
// Продвинутый курс, урок 1. Шаг 4: счётчик, которому хватит места надолго.

#include <EEPROM.h>

const int ADDR_COUNT = 10;   // int займёт ДВА ящика: 10 и 11

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

  int count;
  EEPROM.get(ADDR_COUNT, count);   // get сам читает оба ящика в переменную

  if (count == -1) {
    // Свежая память — это два ящика по 255. Для int такая пара
    // читается как -1: значит, счётчик ещё ни разу не записывали.
    count = 0;
  }

  count = count + 1;
  EEPROM.put(ADDR_COUNT, count);   // put сам раскладывает int по двум ящикам

  Serial.print("Menya vklyuchali raz: ");
  Serial.println(count);
}

void loop() {
}

EEPROM.put(адрес, переменная) записывает переменную целиком, начиная с указанного ящика, а EEPROM.get(адрес, переменная) — читает прямо в неё. Работают они с любым типом: int, long, float. Единственная ваша забота — читать тем же типом и с того же адреса, каким писали.

И посмотрите на проверку пустоты: теперь она сравнивает с -1. Два свежих ящика по 255 для типа int читаются именно как −1 — такая у целых чисел внутренняя кухня. Сам счётчик мы переселили в ящики 10 и 11: ящик 0 занят опытом из третьего шага, и затирать его не хочется.

Заведите карту адресов. Когда в вечной памяти живёт несколько вещей, строка-другая комментария в начале скетча спасает от беды «рекорд затёр настройку»: ящик 0 — счётчик-байт, ящики 10–11 — счётчик-int, ящики 20–23 — код сейфа. Мы нарочно оставляем между ними зазоры: вдруг какая-то запись подрастёт.

Собираем схему сейфа

Пора применить всё это к настоящему устройству. Схема — в точности из двадцать девятого урока, ни одного нового провода: экран с потенциометром контраста, три кнопки верхом на канавке и сервопривод-засов. Если сейф у вас так и стоит собранным — просто подключите его. Если нет — вот схема, а подробный порядок сборки есть в самом уроке про сейф.

+abcdefghiкнопка 1кнопка 2кнопка 3Vvedikod:**__12345678910111213141516151520911242628301234561Экран, потенциометр контраста и все их провода — в точности как в уроке 26, тут ничего не менялось.2Три кнопки сидят верхом на канавке: кнопка 1 в столбиках 9–11, кнопка 2 в 24–26, кнопка 3 в 28–30.3Сигналы кнопок — с верхней стороны канавки: столбик 9 → вывод 7, столбик 24 → вывод 8, столбик 28 → вывод 10.4Земли кнопок — с нижней стороны, короткими проводами в столбики 7 и 18: там уже «минус» через контакты 5 и 16 экрана.5Сервопривод-засов: оранжевый провод на вывод 9, красный на шину «+», коричневый на шину «−».6К качалке сервопривода прикрепите картонный засов — он и будет держать дверцу.Питание макетки — с верхних шин: нижние закрыты экраном. Вывод 6 свободен — пригодится для зуммера в задании.Провод соединён только там, где на нём светлая шляпка; шины он пересекает между отверстиями.
Кнопки на выводах 7, 8 и 10, серво-засов на 9, экран как в уроке 26. Всё без изменений.

Напоминание про кнопки: сигнальный провод каждой кнопки уходит сверху от канавки, земляной — снизу. Перепутаете сторону — кнопка будет «нажата» всегда: два вывода одной стороны соединены внутри неё намертво.

Шаг 5. Код переезжает в вечную память

В старом сейфе код лежал в программе: SECRET[] = {1, 3, 2, 2}. Теперь его место — ящики 20…23: по ящику на цифру. При старте сейф будет загружать код из памяти, а не знать его заранее.

// Продвинутый курс, урок 1. Шаг 5: сейф хранит код в вечной памяти.

#include <LiquidCrystal.h>
#include <Servo.h>
#include <EEPROM.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
Servo zasov;

const int BUTTONS[3] = {7, 8, 10};
const int SERVO_PIN = 9;
const int CODE_LEN = 4;

const int ADDR_CODE = 20;                       // код лежит в ящиках 20…23
const byte FACTORY[CODE_LEN] = {1, 2, 3, 1};    // заводской код — на первый запуск

const int ANGLE_LOCKED = 90;
const int ANGLE_OPEN = 0;
const int OPEN_SECONDS = 5;
const int MAX_FAILS = 3;
const int LOCKOUT_SECONDS = 15;

int secret[CODE_LEN];   // рабочий код: больше не зашит в программу!
int attempt[CODE_LEN];
int entered = 0;
bool wasPressed[3] = {false, false, false};
int fails = 0;

void setup() {
  lcd.begin(16, 2);
  for (int i = 0; i < 3; i++) {
    pinMode(BUTTONS[i], INPUT_PULLUP);
  }
  zasov.attach(SERVO_PIN);
  zasov.write(ANGLE_LOCKED);

  loadCode();   // достаём код из вечной памяти
  showInput();
}

void loadCode() {
  for (int i = 0; i < CODE_LEN; i++) {
    byte digit = EEPROM.read(ADDR_CODE + i);
    if (digit < 1 || digit > 3) {
      // В ящике пусто (255) или мусор — значит, это первый запуск:
      // записываем цифру заводского кода.
      digit = FACTORY[i];
      EEPROM.write(ADDR_CODE + i, digit);
    }
    secret[i] = digit;
  }
}

void loop() {
  for (int i = 0; i < 3; i++) {
    bool pressed = (digitalRead(BUTTONS[i]) == LOW);
    if (pressed && !wasPressed[i]) {
      addPress(i + 1);
      delay(30);
    }
    wasPressed[i] = pressed;
  }
}

void addPress(int number) {
  attempt[entered] = number;
  entered = entered + 1;
  showInput();
  if (entered == CODE_LEN) {
    checkCode();
  }
}

void checkCode() {
  if (codeCorrect()) {
    fails = 0;
    openSafe();
  } else {
    fails = fails + 1;
    if (fails >= MAX_FAILS) {
      lockout();
    } else {
      lcd.clear();
      lcd.print("Nevernyy kod");
      lcd.setCursor(0, 1);
      lcd.print("Popytka ");
      lcd.print(fails);
      lcd.print(" iz ");
      lcd.print(MAX_FAILS);
      delay(2000);
    }
  }
  entered = 0;
  showInput();
}

void lockout() {
  for (int s = LOCKOUT_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("STOP! 3 oshibki");
    lcd.setCursor(0, 1);
    lcd.print("Zhdi ");
    lcd.print(s);
    lcd.print(" s");
    delay(1000);
  }
  fails = 0;
}

void openSafe() {
  zasov.write(ANGLE_OPEN);
  for (int s = OPEN_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("OTKRYTO!");
    lcd.setCursor(0, 1);
    lcd.print("Zakroyu cherez ");
    lcd.print(s);
    delay(1000);
  }
  zasov.write(ANGLE_LOCKED);
}

bool codeCorrect() {
  for (int i = 0; i < CODE_LEN; i++) {
    if (attempt[i] != secret[i]) {
      return false;
    }
  }
  return true;
}

void showInput() {
  lcd.clear();
  lcd.print("Vvedi kod:");
  lcd.setCursor(0, 1);
  for (int i = 0; i < CODE_LEN; i++) {
    if (i < entered) {
      lcd.print("* ");
    } else {
      lcd.print("_ ");
    }
  }
}

Вся новизна собрана в функции loadCode(). Она читает четыре ящика подряд и каждую цифру проверяет: у нас кнопки 1, 2 и 3, так что в честном коде могут быть только числа от 1 до 3. Встретилось что-то другое — например заводское 255 — значит, кода тут ещё не было: подставляем цифру заводского кода 1-2-3-1 и тут же записываем её в ящик. Так сейф при самом первом запуске сам заводит себе код в памяти.

Загрузите, откройте сейф кодом 1-2-3-1 — работает как раньше. Снаружи разницы нет, но внутри код уже не в программе, а в архиве. Проверить легко: перезагрузите скетч заново — сейф по-прежнему открывается, потому что loadCode() нашла в ящиках правильные цифры и заводской код подставлять не стала.

Шаг 6. Меняем код с кнопок

А теперь — то, ради чего всё затевалось. Раз код живёт в памяти, его можно переписывать прямо с кнопок сейфа, как в настоящих гостиничных сейфах. Договоримся о правилах:

Vvedikod:*___Staryykod:**__NOVYYkod:***_держим кнопку 1три секундыстарый кодверныйОбычная работасейф ждёт код, как раньшеПроверка хозяинабез старого кода смена не начнётсяНовый код4 нажатия — и он уходит в EEPROMПосле записи сейф возвращается к обычной работе и понимает только новый код —даже если выдернуть питание сразу после смены.
Удержание первой кнопки — вход в смену кода. Без старого кода новый не поставить.

Держите кнопку 1 три секунды — сейф понимает, что это не набор, и переходит в режим смены. Сначала он спрашивает старый код: иначе любой прохожий поменял бы код, пока хозяин отвернулся. Старый код верен — сейф просит новый, ровно четыре нажатия. И записывает его в ящики 20…23 командой update.

// Продвинутый курс, урок 1. Шаг 6: сейф со сменой кода с кнопок.

#include <LiquidCrystal.h>
#include <Servo.h>
#include <EEPROM.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
Servo zasov;

const int BUTTONS[3] = {7, 8, 10};
const int SERVO_PIN = 9;
const int CODE_LEN = 4;

const int ADDR_CODE = 20;
const byte FACTORY[CODE_LEN] = {1, 2, 3, 1};

const int ANGLE_LOCKED = 90;
const int ANGLE_OPEN = 0;
const int OPEN_SECONDS = 5;
const int MAX_FAILS = 3;
const int LOCKOUT_SECONDS = 15;
const unsigned long HOLD_MS = 3000;   // сколько держать кнопку 1 для смены кода

int secret[CODE_LEN];
int attempt[CODE_LEN];
int entered = 0;
bool wasPressed[3] = {false, false, false};
int fails = 0;
unsigned long holdStart = 0;   // когда зажали кнопку 1 (0 — не зажата)

void setup() {
  lcd.begin(16, 2);
  for (int i = 0; i < 3; i++) {
    pinMode(BUTTONS[i], INPUT_PULLUP);
  }
  zasov.attach(SERVO_PIN);
  zasov.write(ANGLE_LOCKED);

  loadCode();
  showInput();
}

void loadCode() {
  for (int i = 0; i < CODE_LEN; i++) {
    byte digit = EEPROM.read(ADDR_CODE + i);
    if (digit < 1 || digit > 3) {
      digit = FACTORY[i];
      EEPROM.write(ADDR_CODE + i, digit);
    }
    secret[i] = digit;
  }
}

void loop() {
  for (int i = 0; i < 3; i++) {
    bool pressed = (digitalRead(BUTTONS[i]) == LOW);
    if (pressed && !wasPressed[i]) {
      addPress(i + 1);
      delay(30);
    }
    wasPressed[i] = pressed;
  }

  watchHold();   // отдельно следим, не держат ли кнопку 1 для смены кода
}

void watchHold() {
  bool pressed = (digitalRead(BUTTONS[0]) == LOW);
  if (pressed && holdStart == 0) {
    holdStart = millis();               // начали держать — запомнили момент
  }
  if (!pressed) {
    holdStart = 0;                      // отпустили — отсчёт с нуля
  }
  // Смена кода начинается, только если удержание началось С ПУСТОГО набора:
  // первая «цифра» от этого же нажатия отменяется.
  if (pressed && holdStart != 0 && millis() - holdStart >= HOLD_MS && entered == 1) {
    holdStart = 0;
    entered = 0;
    changeCode();
  }
}

void changeCode() {
  lcd.clear();
  lcd.print("Staryy kod:");
  int old[CODE_LEN];
  readCode(old);

  if (!sameCode(old, secret)) {
    lcd.clear();
    lcd.print("Nevernyy kod");
    delay(1500);
    showInput();
    return;   // чужак не сменит код: старый он не знает
  }

  lcd.clear();
  lcd.print("NOVYY kod:");
  int fresh[CODE_LEN];
  readCode(fresh);

  for (int i = 0; i < CODE_LEN; i++) {
    secret[i] = fresh[i];
    EEPROM.update(ADDR_CODE + i, fresh[i]);   // в вечную память — навсегда
  }

  lcd.clear();
  lcd.print("Kod smenyon!");
  delay(1500);
  showInput();
}

// Блокирующий набор кода: здесь ожидание уместно, как в игре из урока 21, —
// пока хозяин меняет код, сейфу больше нечем заняться.
void readCode(int target[]) {
  waitAllReleased();
  int got = 0;
  drawStars(got);
  while (got < CODE_LEN) {
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BUTTONS[i]) == LOW) {
        target[got] = i + 1;
        got = got + 1;
        drawStars(got);
        delay(30);
        while (digitalRead(BUTTONS[i]) == LOW) {}   // ждём отпускания
        delay(30);
      }
    }
  }
}

void waitAllReleased() {
  bool anyPressed = true;
  while (anyPressed) {
    anyPressed = false;
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BUTTONS[i]) == LOW) {
        anyPressed = true;
      }
    }
  }
  delay(30);
}

void drawStars(int got) {
  lcd.setCursor(0, 1);
  for (int i = 0; i < CODE_LEN; i++) {
    if (i < got) {
      lcd.print("* ");
    } else {
      lcd.print("_ ");
    }
  }
}

bool sameCode(int a[], int b[]) {
  for (int i = 0; i < CODE_LEN; i++) {
    if (a[i] != b[i]) {
      return false;
    }
  }
  return true;
}

void addPress(int number) {
  attempt[entered] = number;
  entered = entered + 1;
  showInput();
  if (entered == CODE_LEN) {
    checkCode();
  }
}

void checkCode() {
  if (codeCorrect()) {
    fails = 0;
    openSafe();
  } else {
    fails = fails + 1;
    if (fails >= MAX_FAILS) {
      lockout();
    } else {
      lcd.clear();
      lcd.print("Nevernyy kod");
      lcd.setCursor(0, 1);
      lcd.print("Popytka ");
      lcd.print(fails);
      lcd.print(" iz ");
      lcd.print(MAX_FAILS);
      delay(2000);
    }
  }
  entered = 0;
  showInput();
}

void lockout() {
  for (int s = LOCKOUT_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("STOP! 3 oshibki");
    lcd.setCursor(0, 1);
    lcd.print("Zhdi ");
    lcd.print(s);
    lcd.print(" s");
    delay(1000);
  }
  fails = 0;
}

void openSafe() {
  zasov.write(ANGLE_OPEN);
  for (int s = OPEN_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("OTKRYTO!");
    lcd.setCursor(0, 1);
    lcd.print("Zakroyu cherez ");
    lcd.print(s);
    delay(1000);
  }
  zasov.write(ANGLE_LOCKED);
}

bool codeCorrect() {
  for (int i = 0; i < CODE_LEN; i++) {
    if (attempt[i] != secret[i]) {
      return false;
    }
  }
  return true;
}

void showInput() {
  lcd.clear();
  lcd.print("Vvedi kod:");
  lcd.setCursor(0, 1);
  for (int i = 0; i < CODE_LEN; i++) {
    if (i < entered) {
      lcd.print("* ");
    } else {
      lcd.print("_ ");
    }
  }
}

Разберём два тонких места. Первое — функция watchHold(). Удержание мы ловим через millis(), как в десятом уроке: запомнили момент нажатия, смотрим, сколько держат. Но нажатие кнопки 1 — это ещё и ввод цифры! Поэтому условие требует entered == 1: удержание засчитывается, только если эта цифра была первой в наборе, и тогда набор просто отменяется — entered = 0.

Второе — набор кода в readCode() сделан блокирующим: программа честно стоит и ждёт четырёх нажатий. Помните разговор из урока про игру на реакцию? Ожидание на месте уместно, когда плате больше нечем заняться, — а пока хозяин меняет код, сейфу заняться действительно нечем. Зато перед каждым набором waitAllReleased() дожидается, чтобы все кнопки были отпущены: иначе палец, ещё лежащий на кнопке после удержания, немедленно «ввёл» бы первую цифру.

Главная проверка урока: смените код (например, на 3-2-1-3), затем выдерните USB и сосчитайте до десяти. Подключите обратно. Старый код 1-2-3-1 сейф больше не признаёт, а новый — открывает. Поздравляем: ваша плата больше ничего не забывает.

Частые проблемы

  • Счётчик включений растёт на 2 за одно включение. Это не ошибка: при открытии монитора порта плата перезапускается — среда дёргает ей сброс. Включение «посчиталось» и при подаче питания, и при открытии монитора.
  • Сейф открывается кодом 1-2-3-1, хотя вы его не ставили. Так и задумано: это заводской код, который loadCode() записала при первом запуске. Смените его удержанием кнопки 1.
  • После смены кода сейф тут же начал «набирать» сам. Скорее всего, вы убрали waitAllReleased() или отпустили кнопку слишком медленно — сейф принял дожатие за ввод. Проверьте, что обе задержки delay(30) на месте.
  • Числа в мониторе — крякозябры. Скорость монитора должна быть 9600, как в Serial.begin.

Задания для тренировки

  • РазминкаСохраните в вечную память рекорд игры на реакцию из двадцать первого урока: рекорд — это int, так что вам понадобятся put и get и проверка на −1. Теперь рекорд не сгорает при выключении — побейте вчерашнего себя.
  • ПосложнееПусть при старте сейф на секунду показывает на экране, сколько раз его включали, — счётчик из третьего шага уже готов, осталось выделить ему свободный ящик и строку на экране.
  • Со звёздочкой ⭐В сейфе из двадцать девятого урока была дыра в защите: три ошибки включают блокировку на 15 секунд, но хитрец может просто выдернуть питание — и счётчик ошибок обнулится. Залатайте дыру: пусть ошибки помнятся вечно, а недосиженная блокировка продолжается после включения. Разбор ниже.
Показать разбор «несгораемая блокировка»

Счётчику ошибок выделяем собственный ящик 30 и меняем его строго в трёх местах: ошибка — fails + 1 и запись; верный код — ноль и запись; отбытая блокировка — ноль и запись. Все записи идут через update: чаще всего число не меняется, и ресурс памяти не тратится.

// Продвинутый курс, урок 1. Разбор задания: несгораемая блокировка.
// Отличие от шага 6: счётчик ошибок хранится в EEPROM, поэтому
// «выдернуть питание» больше не сбрасывает наказание.

#include <LiquidCrystal.h>
#include <Servo.h>
#include <EEPROM.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
Servo zasov;

const int BUTTONS[3] = {7, 8, 10};
const int SERVO_PIN = 9;
const int CODE_LEN = 4;

const int ADDR_CODE = 20;    // ящики 20…23 — код
const int ADDR_FAILS = 30;   // ящик 30 — счётчик ошибок подряд
const byte FACTORY[CODE_LEN] = {1, 2, 3, 1};

const int ANGLE_LOCKED = 90;
const int ANGLE_OPEN = 0;
const int OPEN_SECONDS = 5;
const int MAX_FAILS = 3;
const int LOCKOUT_SECONDS = 15;
const unsigned long HOLD_MS = 3000;

int secret[CODE_LEN];
int attempt[CODE_LEN];
int entered = 0;
bool wasPressed[3] = {false, false, false};
int fails = 0;
unsigned long holdStart = 0;

void setup() {
  lcd.begin(16, 2);
  for (int i = 0; i < 3; i++) {
    pinMode(BUTTONS[i], INPUT_PULLUP);
  }
  zasov.attach(SERVO_PIN);
  zasov.write(ANGLE_LOCKED);

  loadCode();

  fails = EEPROM.read(ADDR_FAILS);
  if (fails == 255) {
    fails = 0;   // свежая память — ошибок ещё не было
  }
  if (fails >= MAX_FAILS) {
    lockout();   // выключали в наказании? Досиживать всё равно придётся
  }

  showInput();
}

void loadCode() {
  for (int i = 0; i < CODE_LEN; i++) {
    byte digit = EEPROM.read(ADDR_CODE + i);
    if (digit < 1 || digit > 3) {
      digit = FACTORY[i];
      EEPROM.write(ADDR_CODE + i, digit);
    }
    secret[i] = digit;
  }
}

void rememberFails() {
  // update бережёт ресурс: пишет, только если число изменилось.
  EEPROM.update(ADDR_FAILS, fails);
}

void loop() {
  for (int i = 0; i < 3; i++) {
    bool pressed = (digitalRead(BUTTONS[i]) == LOW);
    if (pressed && !wasPressed[i]) {
      addPress(i + 1);
      delay(30);
    }
    wasPressed[i] = pressed;
  }

  watchHold();
}

void watchHold() {
  bool pressed = (digitalRead(BUTTONS[0]) == LOW);
  if (pressed && holdStart == 0) {
    holdStart = millis();
  }
  if (!pressed) {
    holdStart = 0;
  }
  if (pressed && holdStart != 0 && millis() - holdStart >= HOLD_MS && entered == 1) {
    holdStart = 0;
    entered = 0;
    changeCode();
  }
}

void changeCode() {
  lcd.clear();
  lcd.print("Staryy kod:");
  int old[CODE_LEN];
  readCode(old);

  if (!sameCode(old, secret)) {
    lcd.clear();
    lcd.print("Nevernyy kod");
    delay(1500);
    showInput();
    return;
  }

  lcd.clear();
  lcd.print("NOVYY kod:");
  int fresh[CODE_LEN];
  readCode(fresh);

  for (int i = 0; i < CODE_LEN; i++) {
    secret[i] = fresh[i];
    EEPROM.update(ADDR_CODE + i, fresh[i]);
  }

  lcd.clear();
  lcd.print("Kod smenyon!");
  delay(1500);
  showInput();
}

void readCode(int target[]) {
  waitAllReleased();
  int got = 0;
  drawStars(got);
  while (got < CODE_LEN) {
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BUTTONS[i]) == LOW) {
        target[got] = i + 1;
        got = got + 1;
        drawStars(got);
        delay(30);
        while (digitalRead(BUTTONS[i]) == LOW) {}
        delay(30);
      }
    }
  }
}

void waitAllReleased() {
  bool anyPressed = true;
  while (anyPressed) {
    anyPressed = false;
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BUTTONS[i]) == LOW) {
        anyPressed = true;
      }
    }
  }
  delay(30);
}

void drawStars(int got) {
  lcd.setCursor(0, 1);
  for (int i = 0; i < CODE_LEN; i++) {
    if (i < got) {
      lcd.print("* ");
    } else {
      lcd.print("_ ");
    }
  }
}

bool sameCode(int a[], int b[]) {
  for (int i = 0; i < CODE_LEN; i++) {
    if (a[i] != b[i]) {
      return false;
    }
  }
  return true;
}

void addPress(int number) {
  attempt[entered] = number;
  entered = entered + 1;
  showInput();
  if (entered == CODE_LEN) {
    checkCode();
  }
}

void checkCode() {
  if (codeCorrect()) {
    fails = 0;
    rememberFails();   // прощение тоже запоминаем
    openSafe();
  } else {
    fails = fails + 1;
    rememberFails();   // ошибка записана НАВСЕГДА — до верного кода
    if (fails >= MAX_FAILS) {
      lockout();
    } else {
      lcd.clear();
      lcd.print("Nevernyy kod");
      lcd.setCursor(0, 1);
      lcd.print("Popytka ");
      lcd.print(fails);
      lcd.print(" iz ");
      lcd.print(MAX_FAILS);
      delay(2000);
    }
  }
  entered = 0;
  showInput();
}

void lockout() {
  for (int s = LOCKOUT_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("STOP! 3 oshibki");
    lcd.setCursor(0, 1);
    lcd.print("Zhdi ");
    lcd.print(s);
    lcd.print(" s");
    delay(1000);
  }
  fails = 0;
  rememberFails();   // наказание отбыто — и это тоже в вечную память
}

void openSafe() {
  zasov.write(ANGLE_OPEN);
  for (int s = OPEN_SECONDS; s >= 1; s--) {
    lcd.clear();
    lcd.print("OTKRYTO!");
    lcd.setCursor(0, 1);
    lcd.print("Zakroyu cherez ");
    lcd.print(s);
    delay(1000);
  }
  zasov.write(ANGLE_LOCKED);
}

bool codeCorrect() {
  for (int i = 0; i < CODE_LEN; i++) {
    if (attempt[i] != secret[i]) {
      return false;
    }
  }
  return true;
}

void showInput() {
  lcd.clear();
  lcd.print("Vvedi kod:");
  lcd.setCursor(0, 1);
  for (int i = 0; i < CODE_LEN; i++) {
    if (i < entered) {
      lcd.print("* ");
    } else {
      lcd.print("_ ");
    }
  }
}

Самое интересное — в setup(): прочитав счётчик, сейф проверяет, не был ли он выключен посреди наказания. Если ошибок уже три, блокировка запускается прямо при включении. Выдёргивать питание стало бессмысленно: наказание терпеливо ждёт в вечной памяти. Единственное послабление — отсчёт 15 секунд начинается заново: запоминать ещё и «сколько осталось досидеть» мы не стали, хотя после этого урока вы знаете, как сделать и это.

Что вы теперь умеете

  • различать три памяти платы и знать, какая из них что переживает;
  • читать и писать вечную память: read и write для байтов, put и get для больших чисел;
  • беречь ресурс памяти: писать по событию и доверять update;
  • отличать «пустую» память по особому значению — 255 или −1;
  • вести карту адресов, чтобы данные не затирали друг друга;
  • строить устройства с настоящей настройкой: сейф теперь хранит код сам и меняет его с кнопок.

В следующем уроке плата научится слушаться клавиатуры компьютера: монитор порта из окна для чтения превратится в пульт управления — со своим языком команд.

Частые вопросы

Сколько раз можно записывать в EEPROM?

Производитель гарантирует около 100 000 перезаписей каждой ячейки. Звучит как много, но цикл, который пишет в память без остановки, исчерпает ресурс за минуты. Поэтому правило одно: записывать только тогда, когда значение действительно изменилось, — команда EEPROM.update() делает это сама. При таком подходе памяти хватит на десятки лет.

Данные правда не сотрутся даже через год без питания?

Правда. EEPROM хранит записанное без всякого питания минимум 20 лет — так обещает производитель контроллера. Стирает данные только новая запись поверх старой. Загрузка нового скетча EEPROM не очищает.

А чтение EEPROM тоже расходует ресурс?

Нет. Изнашивает ячейку только запись. Читать можно сколько угодно раз — хоть в каждом проходе loop().

Хватит ли 1024 байт на мои проекты?

Обычно с запасом. Настройка, рекорд или код сейфа занимают от одного до нескольких байт. 1024 байта — это, например, 512 целых чисел int. Тесно станет, только если писать в EEPROM длинные тексты, но для них эта память и не предназначена.

← Все уроки курса