Пульт на три кнопки я собрал за час, а ловил потом три вечера: первая кнопка изредка срабатывала так, будто её нажали на третьей. Виновника нашёл на бумаге, а не в железе: у меня были пары глобальных lastReading1, lastChange1, lastReading2, lastChange2, lastReading3, lastChange3 — и в одной ветке я читал состояние второй кнопки, когда думал про третью. Компилятор молчал честно: оба имени существуют, типы сходятся. Антидребезг при этом был написан по всем правилам — окно 50 мс на millis(), как в уроке про кнопку и digitalRead. Сломалась не логика, а раскладка: шесть переменных на кнопку, размазанные по файлу.
Класс — это способ сложить пин, сырую reading, стабильное состояние и метку времени в один пучок и больше их не рассыпать. Здесь мы соберём класс Button с методами begin(), changed() и isLow(), заведём массив из трёх кнопок и посчитаем честную цену — сколько байт RAM съедает каждый объект на Uno. Автомат на состояниях без классов я уже разбирал в уроке про кнопку-автомат — там та же механика жила в отдельных переменных; теперь она переезжает внутрь типа, а список глобальных ужимается до одной строки.
Оговорка про масштаб сразу: класс — не про «взрослый код ради взрослого кода». Две кнопки спокойно живут и на плоских переменных, и я не буду уверять, что ради них нужен тип. Рубеж, где пучок начинает окупаться, я для себя вывел на трёх: с трёх кнопок пары переменных перестают читаться с одного взгляда, и опечатка в индексе случается у каждого. Если у вас одна кнопка — урок про кнопку и digitalRead останется достаточным ещё долго.
Класс: struct, к которому приделали руки
Объявление класса выглядит как struct из урока про структуры, только после полей идут методы — функции, которые работают с полями своего объекта. У Button пять полей: пин, последний сырой уровень с пина, стабильное состояние, метка millis() последней смены и окно антидребезга. Конструктор с двумя аргументами заполняет их при создании — кнопка рождается уже настроенной, а не обрастает флагами по ходу скетча. begin() переносит в себя pinMode(INPUT_PULLUP) — ту подтяжку, что держит линию HIGH, пока кнопка не замкнёт её на землю.
Рабочая лошадь — changed(): читает пин, следит за дребезгом через окно debounceMs, отсчитанное от последней смены уровня, и возвращает true ровно один раз — на смене стабильного состояния. Все остальные проходы loop() получают false и не засоряются проверками. Методы, написанные прямо внутри тела класса, компилятор разворачивает на месте вызова — inline по умолчанию, и вызов knopki[i].changed() стоит столько же тактов, сколько ручной if с теми же переменными. Я сверял листинг для пары методов: тот же машинный код, что у плоской версии, отличается только раскладка исходника.
class Button { byte pin; // пин кнопки byte lastReading; // сырой уровень с прошлого прохода byte stable; // стабильное состояние после окна unsigned long lastChange; // millis() последней смены пина unsigned int debounceMs; // окно антидребезгаpublic: Button(byte p, unsigned int ms) { pin = p; lastReading = HIGH; stable = HIGH; lastChange = 0; debounceMs = ms; } void begin() { pinMode(pin, INPUT_PULLUP); } bool changed() { // true ровно один раз на смену bool front = false; byte c = digitalRead(pin); if (c != lastReading) { lastChange = millis(); lastReading = c; } if (millis() - lastChange > debounceMs && c != stable) { stable = c; front = true; } return front; } bool isLow() { return stable == LOW; } // стабильное «нажата»}; const unsigned int DEB = 50; // окно, мсButton knopki[3] = { Button(2, DEB), Button(3, DEB), Button(4, DEB) }; void setup() { Serial.begin(9600); for (int i = 0; i < 3; i++) knopki[i].begin(); Serial.print("sizeof(Button) = "); Serial.println(sizeof(Button)); // 9 байт на объект} void loop() { for (int i = 0; i < 3; i++) { if (knopki[i].changed()) { Serial.print("knopka "); Serial.print(i); Serial.println(knopki[i].isLow() ? ": nazhata" : ": otpushena"); } }}Три кнопки — это массив объектов: каждая создаётся конструктором со своим пином, а setup() сжимается в цикл for из трёх строк. Хотите четвёртую кнопку — дописываете элемент в массив и меняете потолок цикла; в глобалах не возникает ни одной новой переменной. Обработка событий живёт там же, в цикле: changed() вернул true — смотрим isLow() и решаем, что делать. Это ровно та раскладка, которой не хватало пульту из первого абзаца: состояние кнопки одно, у него одно имя и один владелец.
Метка lastChange хранится в unsigned long — и это не прихоть: millis() переполняется на 49,7 сутки, и сравнение в лоб с int сломалось бы через двое суток непрерывной работы; механика переполнения разобрана в отдельном уроке, а разность millis() - lastChange корректна в любом моменте цикла. Окно 50 мс — ходовое для тактильных кнопок: дребезг дешёвой тактовой укладывается в 5-20 мс, и запас двойной. Мокрым кнопкам в подвале ставьте 100 мс: контакт окисляется, и паразитные фронты прилетают пачками.
Отдельно про подтяжку, потому что класс прячёт её в begin() — и прятать опасно без понимания. Пин без подтяжки висит в воздухе: пока кнопка не нажата, вход читает то HIGH, то LOW от наводок, и changed() стреляет сама. INPUT_PULLUP в begin() решает это для всех кнопок сразу — и ровно поэтому у changed() в коде нет ни одной настройки режима вне класса. Если пойдёте на внешние резисторы 10 кОм и обычный INPUT, правка тоже живёт в одном месте — в begin().
Теперь цена. sizeof(Button) печатает девять: byte pin — 1, byte lastReading — 1, byte stable — 1, unsigned long lastChange — 4, unsigned int debounceMs — 2. Выравнивания на AVR нет, поля лежат вплотную, поэтому честно девять, а не двенадцать. Три кнопки — 27 байт из 2048 RAM Uno, полтора процента памяти. За эти байты вы получаете один список полей вместо девяти разрозненных глобальных, опечатки в именах, которые ловятся при сборке, и begin() с changed(), которые не рассыпаются по файлу. Плата с десятью кнопками съест девяносто байт — всё ещё меньше пяти процентов, и масштаб сохраняется линейно.
| Поле | Тип | Байт | Что хранит |
|---|---|---|---|
| pin | byte | 1 | номер пина с кнопкой |
| lastReading | byte | 1 | сырой уровень с прошлого прохода loop() |
| stable | byte | 1 | стабильное состояние после окна антидребезга |
| lastChange | unsigned long | 4 | millis() последней смены уровня на пине |
| debounceMs | unsigned int | 2 | окно антидребезга в миллисекундах |
| объект целиком | sizeof(Button) | 9 | три кнопки — 27 байт из 2048 RAM Uno |
Класс растёт естественно. Понадобился подсчёт нажатий — добавили поле count и метод reset(); понадобился длинный фронт — метод pressedFor(), который отдаёт millis() - lastChange, пока кнопка в нажатом состоянии. Поля и методы прирастают к тому же пучку, и соседний код при этом не меняется вовсе: loop() по-прежнему спрашивает changed(). На пульте из первого абзаца я заодно получил свободно третье поведение: двойное нажатие отличил по времени между двумя срабатываниями changed() — таймер уже был внутри объекта, писать его заново не пришлось.
И про ловушку обратного направления: не тащите в класс то, что кнопке не принадлежит. Светодиод, который кнопка включает, — не поле Button: это чужая обязанность, и вписав её внутрь, вы свяжете класс с конкретной макеткой. Мой потолок: кнопка знает всё о своём пине и ничего о том, что её состояние включает. Тогда же класс переезжает между скетчами копипастой без правок — у меня он кочует по проектам с 2022 года нетронутым.
Сколько стоит объект и где переплатить
Виртуальных методов у класса нет — а значит нет и vtable, скрытой таблицы указателей, которая добавляет два байта к каждому объекту и лишний прыжок в вызове. Добавите virtual — расклад изменится; для кнопок он не нужен ни при каком сценарии. Вторая граница — динамическая память: new на 8-битных AVR ненадёжен, у чипа нет защиты от фрагментации кучи, и сообщества embedded-C++ это признают прямым текстом. Все объекты здесь статические: объявлены на верхнем уровне, живут от включения до выключения питания, heap не трогают.
Передавайте объект в функцию по ссылке из урока про указатели и ссылки: nastroit(Button& k) работает с оригиналом, а не с копией из девяти байт на каждый вызов. Если функция только читает поля — пишите const Button&: попытку испортить объект компилятор поймает при сборке. И держите методы короткими: changed() на десяток строк — норма; метод на полтора экрана — сигнал, что внутри просится отдельный класс или обычная функция.
- Класс = поля + методы: пин, сырая reading, стабильное состояние и метка millis() живут в одном объекте, а не в парах глобальных по всему файлу
- changed() возвращает true ровно один раз на смену состояния; окно антидребезга отсчитывается от последней смены пина
- Методы в теле класса — inline: машинный код тот же, что у плоской версии, замедления нет
- sizeof(Button) = 9 байт, три кнопки — 27 из 2048 RAM; без virtual нет и vtable с её двумя байтами на объект
- Все объекты статические: new на 8-битных AVR ненадёжен, а в функции объект передаётся по ссылке Button& без копий
- 1. Сколько RAM занимает один объект Button из урока на Uno?
- 2. Что вернёт changed(), пока кнопка удерживается нажатой дольше окна?
- 3. Почему методы, описанные прямо в теле класса, не замедляют скетч?
- 4. Что добавляет к каждому объекту два байта и заводится только с virtual?
- 5. Как передать объект Button в функцию без копии из девяти байт?
Частые вопросы по теме урока
- Сколько RAM занимает один объект Button из урока на Uno?
- byte pin, byte lastReading, byte stable, unsigned long lastChange (4 байта) и unsigned int debounceMs (2 байта) — итого 9; выравнивания на AVR нет, поля лежат вплотную. 9 байт: три байта-поля, четыре на метку millis(), два на окно
- Что вернёт changed(), пока кнопка удерживается нажатой дольше окна?
- changed() срабатывает на смене стабильного состояния: одно нажатие — один true. Пока состояние не меняется, метод возвращает false. true один раз — на смене состояния, дальше false
- Почему методы, описанные прямо в теле класса, не замедляют скетч?
- Методы в теле класса компилятор встраивает в точку вызова: машинный код получается тем же, что у плоской версии с if и переменными. компилятор разворачивает их на месте вызова — inline по умолчанию
- Что добавляет к каждому объекту два байта и заводится только с virtual?
- Virtual-методы требуют vtable — скрытой таблицы, на которую указывает каждый объект: два байта на объект плюс лишний прыжок при вызове. Без virtual её нет. vtable — скрытая таблица указателей на методы
- Как передать объект Button в функцию без копии из девяти байт?
- Ссылка передаёт оригинал без копии; const Button& дополнительно запрещает функции менять поля. new на 8-битных AVR не надёжен — объекты держат статическими. по ссылке Button& или const Button&