Замок из кластера про клавиатуру хранил эталонный код прямо во флеше и честно признавал границы: код виден любому, кто подключится к плате, а перебирать можно хоть без паузы. Здесь достраиваются обе половины. PIN переезжает в EEPROM — он переживёт выключение питания, и его можно сменить с той же клавиатуры, не перепрошивая скетч. А попытки становятся счётными: три промаха — и клавиатура молчит тридцать секунд, как у настоящего домофона.
Экран добавляет интерфейс банкомата: вместо цифр на LCD печатаются звёздочки, по одной на нажатую клавишу. Код не читается через плечо и не остаётся на экране. Ввод подтверждается решёткой, звёздочка стирает набранное, светодиод на D12 показывает статус: горит — доступ разрешён, мигает — блокировка, темен — ждёт ввод.
База по памяти уже была: как читается и пишется первый байт EEPROM — здесь та же пара read/write работает на четыре байта кода и один служебный маркер.
Почему звёздочки, а не пустой экран: пустота не отвечает — пользователь не понимает, дошла ли клавиша. Звёздочка — минимальное подтверждение: экран отвечает на каждое нажатие, счёт ведётся видимо, и двойное нажатие одной цифры видно сразу же. Тот же принцип уже работал в замке из прошлого кластера, только счётчик звёздочек печатался в монитор порта; здесь он переезжает на экран и становится интерфейсом.
PIN в EEPROM: четыре байта и маркер первого запуска
Раскладка по памяти: ячейки 0–3 хранят цифры кода, ячейка 4 — маркер 42. Зачем маркер: свежая плата читает из EEPROM 255 — по даташиту это стёртое состояние, единицы во всех битах; нуля там нет, пока его не запишут. На старте скетч читает ячейку 4: не 42 — значит, код ни разу не записывался, и программа молча кладёт код по умолчанию 1234 и ставит маркер. Приём стандартный, маркеры и заводские умолчания разобраны на отдельном уроке.
Цифры кода я храню байтами: значение 0–9 влезает в один байт с запасом в двадцать пять раз, и четырёх ячеек хватает на четырёхзначный код. Соблазн хранить код одним числом int — 1234 как 1234 — кажется экономнее, но разворачивать его обратно в цифры при сравнении дороже, чем читать четыре байта подряд. Отдельный плюс байтового хранения: новый код пишется тем же циклом, что читается.
Цифры кода я храню байтами: значение 0–9 влезает в один байт с запасом в двадцать пять раз, и четырёх ячеек хватает на четырёхзначный код. Соблазн хранить код одним числом int — 1234 как 1234 — кажется экономнее, но разворачивать его обратно в цифры при сравнении дороже, чем читать четыре байта подряд. Отдельный плюс байтового хранения: новый код пишется тем же циклом, что читается.
Цена вопроса — время записи. По даташиту EEPROM.write занимает 3,3 мс на байт: пять байт в setup — около пятнадцати миллисекунд, глазом не заметно. Ресурс ячеек — порядка 100 000 циклов перезаписи каждая: смена кода раз в день растянется на сотни лет, но правило всё равно одно — пишем только по необходимости, а не при каждом нажатии.
Неравенство чтения и записи — свойство технологии: читать можно хоть каждый проход loop, писать — по расписанию редких событий. Отсюда и архитектура: код загружается в RAM один раз при старте, всё сравнение идёт с оперативной копией, и память не дёргается без нужды. Копия обновляется только при смене кода, и рассинхрон невозможен: write и обновление копии стоят в одном цикле, строка за строкой.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); const int STROKI[4] = {2, 3, 4, 5};const int STOLBY[4] = {6, 7, 8, 9};const int LED_OK = 12;char klavishi[16] = {'1','2','3','A','4','5','6','B','7','8','9','C','*','0','#','D'}; byte pinKod[4];byte novyi[4];char nabrano[4];int pos = 0;int etap = 0; // 0 ввод PIN, 1 блокировка, 2 старый код, 3 новый, 4 повторint popytki = 0;unsigned long blokDo = 0;unsigned long otkritoDo = 0;int derzhim = -1; void setup() { for (int r = 0; r < 4; r++) { pinMode(STROKI[r], OUTPUT); digitalWrite(STROKI[r], HIGH); // неактивная строка HIGH: активная — LOW } for (int c = 0; c < 4; c++) { pinMode(STOLBY[c], INPUT_PULLUP); } pinMode(LED_OK, OUTPUT); lcd.init(); lcd.backlight(); Serial.begin(9600); if (EEPROM.read(4) != 42) { // свежая память читает 255 — кода нет EEPROM.write(0, 1); // код по умолчанию 1234 EEPROM.write(1, 2); EEPROM.write(2, 3); EEPROM.write(3, 4); EEPROM.write(4, 42); // маркер: код уже записан Serial.println("Pervyi zapusk: kod 1234"); } for (int i = 0; i < 4; i++) { pinKod[i] = EEPROM.read(i); } priglashenie();} int opros() { for (int r = 0; r < 4; r++) { digitalWrite(STROKI[r], LOW); for (int c = 0; c < 4; c++) { if (digitalRead(STOLBY[c]) == LOW) { digitalWrite(STROKI[r], HIGH); return r * 4 + c; } } digitalWrite(STROKI[r], HIGH); } return -1;} void priglashenie() { lcd.clear(); lcd.setCursor(0, 0); lcd.print("Vvedite PIN:");} void ochistitStroku() { lcd.setCursor(0, 1); lcd.print(" "); lcd.setCursor(0, 1);} bool sovpadaetSPin() { bool ok = (pos == 4); for (int i = 0; i < 4; i++) { if (nabrano[i] - '0' != pinKod[i]) ok = false; } return ok;} bool sovpadaetSNovym() { bool ok = (pos == 4); for (int i = 0; i < 4; i++) { if (nabrano[i] - '0' != novyi[i]) ok = false; } return ok;} void proverkaPin() { bool ok = sovpadaetSPin(); pos = 0; if (ok) { popytki = 0; otkritoDo = millis() + 5000; ochistitStroku(); lcd.print("Dostup est"); Serial.println("PIN vernyi: dostup na 5 s"); } else { popytki = popytki + 1; if (popytki >= 3) { etap = 1; blokDo = millis() + 30000; lcd.clear(); lcd.setCursor(0, 0); lcd.print("BLOK 30 s"); Serial.println("Tri promaha: blok 30 s"); } else { ochistitStroku(); lcd.print("PIN ne tot"); Serial.print("Promah: popytka "); Serial.println(popytki); } }} void proverkaStarogo() { bool ok = sovpadaetSPin(); pos = 0; if (ok) { etap = 3; lcd.clear(); lcd.setCursor(0, 0); lcd.print("Novyi PIN:"); } else { etap = 0; priglashenie(); Serial.println("Staryi kod ne sovpal: smena otmenena"); }} void proverkaPovtora() { bool ok = sovpadaetSNovym(); pos = 0; if (ok) { for (int j = 0; j < 4; j++) { pinKod[j] = novyi[j]; EEPROM.write(j, novyi[j]); } etap = 0; priglashenie(); Serial.println("Kod zamenen i zapisan v EEPROM"); } else { etap = 0; priglashenie(); Serial.println("Povtor ne sovpal: kod ne zamenen"); }} void loop() { if (etap == 1) { if (millis() >= blokDo) { etap = 0; popytki = 0; digitalWrite(LED_OK, LOW); priglashenie(); Serial.println("Blok skonchilsya"); } else { if ((millis() % 800) < 400) digitalWrite(LED_OK, HIGH); else digitalWrite(LED_OK, LOW); } } else if (millis() < otkritoDo) { digitalWrite(LED_OK, HIGH); } else { digitalWrite(LED_OK, LOW); } int i = opros(); if (i >= 0 && i != derzhim) { char k = klavishi[i]; if (etap != 1) { // в блокировке клавиатура молчит if (k == '*') { pos = 0; ochistitStroku(); } else if (k == '#') { if (etap == 0) proverkaPin(); else if (etap == 2) proverkaStarogo(); else if (etap == 3) { for (int j = 0; j < 4; j++) novyi[j] = nabrano[j] - '0'; pos = 0; etap = 4; lcd.clear(); lcd.setCursor(0, 0); lcd.print("Povtorite kod:"); } else if (etap == 4) proverkaPovtora(); } else if (k == 'A' && etap == 0) { etap = 2; pos = 0; lcd.clear(); lcd.setCursor(0, 0); lcd.print("Staryi PIN:"); } else if (k >= '0' && k <= '9' && pos < 4) { nabrano[pos] = k; pos = pos + 1; lcd.setCursor(pos - 1, 1); lcd.print("*"); } } delay(30); } derzhim = i; delay(10);}Три попытки и блокировка на millis
Проверка — та же, что в замке: сначала длина набора, потом поэлементный цикл по четырём местам с флагом. Промах поднимает счётчик popytki, на третьем скетч меняет состояние: blokDo = millis() + 30000, экран печатает BLOK 30 s, светодиод начинает мигать с полупериодом 400 мс. Пока часы не дотикали, клавиатура игнорируется целиком — все клавиши, включая решётку.
Пауза живёт на millis, а не на delay: delay(30000) заморозил бы скетч целиком — не мигал бы светодиод, не обновлялся экран, не наступил бы выход из блокировки. Приём тот же, что в уроке про millis вместо delay: метка в будущем и сравнение в каждом проходе. Честная оговорка: блокировка живёт в RAM, перезагрузка платы сбрасывает её вместе со счётчиком попыток — но для Reset нужен физический доступ к плате.
Верный PIN обнуляет счётчик, зажигает светодиод на пять секунд — метка otkritoDo = millis() + 5000 — и печатает на экране Dostup est. Дальше две дороги: ждать нового ввода или нажать A и пойти менять код.
Прикинем границу атаки в цифрах: четыре цифры — десять тысяч комбинаций. Без блокировки скетч по UART перебирает их за секунды; с блоком в тридцать секунд после каждых трёх попыток полный перебор растягивается на сто тысяч секунд — больше суток непрерывной работы, и это уже не атака, а круглосуточная экспозиция. Растущая пауза после каждой неудачи делает картинку ещё мрачнее для взломщика — пять, десять, двадцать секунд.
| Событие | Что делает замок | Что на LCD и светодиоде |
|---|---|---|
| 1234 и решётка (код по умолчанию) | сброс попыток, доступ на 5 с | Dostup est, светодиод горит |
| неверный PIN | счётчик попыток плюс один | PIN ne tot, светодиод темен |
| третий промах | блок 30 с, клавиатура молчит | BLOK 30 s, светодиод мигает |
| * при наборе | стирает набранное | строка звёздочек пустеет |
| A, затем старый код | переход к смене кода | Novyi PIN: после верного старого |
Звёздочки и смена кода: интерфейс, который не выдаёт секрет
Звёздочка печатается по месту: setCursor(pos, 1) и один символ — без clear() и без перерисовки всей строки. Экран спокоен, а курсор виден как позиция в буфере. Стирание — та же операция в обратную сторону: четыре пробела поверх строки. Обновление по месту против полной перерисовки — главная привычка из урока про причины мерцания LCD, здесь она экономит и время, и нервы.
Смена кода — цепочка из трёх вводов: клавиша A на приглашении, старый PIN с решёткой, новый с решёткой, повтор с решёткой. Повтор сверяется с первым вводом поэлементно: совпало — четыре байта уезжают в EEPROM через write, копия кода в RAM обновляется, на экране снова приглашение. Не совпал — смена отменяется целиком, старый код продолжает работать. Звёздочка стирает набранное на любом из трёх шагов, так что промахнутся с набором нельзя — можно только передумать.
Граница честности: PIN лежит в EEPROM открытыми цифрами, и дамп памяти его прочтёт — как и код старого замка, только теперь хотя бы сменный. Серьёзные изделия хранят не код, а его хэш, а паузу после промаха растят геометрически: пять, десять, двадцать секунд. Для тумбочки, лабораторного ящика и игрушечного домофона того, что здесь, достаточно; для чего-то дороже — нужен другой уровень.
Что дорастить. Счётчик попыток переезжает в EEPROM — счётчик, пережидающий перезагрузки, — и цикл «три промаха, Reset, снова три» перестаёт работать. Мастер-код — второй эталон и вторая проверка: он открывает всегда и сбрасывает блокировку. Фиксированную паузу замените растущей — три строки к блокировщику, а защита вырастает заметно.
И тот же скелет принимает не только цифры: буфер, звёздочки и EEPROM переезжают на код сейфа, пароль точки доступа, лимит на игровую приставку. Ввод с матрицы — просто способ набрать последовательность; что лежит на конце последовательности — решаете вы.
- 1. Что прочитает скетч из свежей, ни разу не писанной ячейки EEPROM?
- 2. Зачем в ячейке 4 лежит маркер 42?
- 3. Что происходит после третьего неверного PIN?
- 4. Почему блокировка построена на millis, а не на delay(30000)?
- 5. Когда скетч пишет в EEPROM?
- PIN — четыре байта в EEPROM (ячейки 0–3), ячейка 4 — маркер 42: свежая память читает 255, по нему пишется код по умолчанию.
- Три промаха — блок на millis()+30000: клавиатура молчит, светодиод мигает; delay здесь сломал бы всё.
- Звёздочки печатаются по месту через setCursor — без clear() и перерисовки; * стирает строку пробелами.
- Смена кода: A — старый PIN — новый — повтор; запись в EEPROM только при полном совпадении повтора.
- EEPROM.write — 3,3 мс на байт и около 100 000 циклов на ячейку: пишем при первом запуске и смене кода, не чаще.
Частые вопросы по теме урока
- Что прочитает скетч из свежей, ни разу не писанной ячейки EEPROM?
- По даташиту стёртая ячейка читается как 255. Поэтому первый запуск отличают маркером, а не нулём. 255 — стёртое состояние, единицы во всех битах
- Зачем в ячейке 4 лежит маркер 42?
- Ни 255, ни 0 сами по себе не говорят, записан ли код. Маркер 42 скетч кладёт при первой записи: есть маркер — есть и код. Чтобы отличить ни разу не настроенную плату от платы с уже записанным кодом
- Что происходит после третьего неверного PIN?
- blokDo = millis() + 30000, клавиши игнорируются, LED мигает с полупериодом 400 мс. По выходу счётчик попыток обнуляется. Клавиатура молчит 30 секунд, светодиод мигает
- Почему блокировка построена на millis, а не на delay(30000)?
- Пауза на delay блокирует всё подряд. Метка в будущем плюс сравнение в loop оставляют скетчу все его обязанности — мигание, экран, выход из блока. delay замораживает весь скетч: ни мигающего светодиода, ни экрана, ни выхода из блока
- Когда скетч пишет в EEPROM?
- Ресурс ячеек — порядка 100 000 перезаписей, пишут по необходимости. Набор и попытки живут в RAM. Только при первом запуске и при смене кода