Мой класс Button кочевал по скетчам копипастой уже третий год, и однажды подвал сделал то, что не сделал ни один компилятор: я поднял окно антидребезга со штатных пятидесяти до сотни миллисекунд для окисленных контактов — и внёс правку в три скетча из четырёх. Четвёртый, гаражные ворота, остался на пятидесяти и продолжал отстреливать двойные нажатия. Класс был хорош: сломалась логистика одной правки, размазанной по четырём файлам. Библиотека — способ хранить правку в одном месте и раздавать её пересборкой.
Разбирать библиотеку с нуля в IDE — карту папок, установку ZIP, подсветку — соседний курс уже сделал в уроке про сборку библиотеки за полчаса. Здесь — взгляд с другой стороны, со стороны компилятора: чем .ino отличается от .cpp, откуда берутся три самых частых сообщения при выносе кода в заголовок и реализацию, и почему у зрелых библиотек настройка железа живёт в begin(), а не в конструкторе.
Оговорка про симулятор ниже: движок не исполняет классы и не подключает пользовательские include, поэтому в симе библиотечная логика показана функциями на глобальных переменных — ровно та промежуточная ступень, из которой библиотека и вырастает. Полный Knopka.h и Knopka.cpp — в основном блоке для живой платы.
Что видит компилятор: .ino против .cpp
Скетч .ino среда дорабатывает за вас: подставляет include Arduino.h, генерирует прототипы функций, чтобы вызовы работали до объявления. Файл .cpp — отдельная единица трансляции, и среда ничего в него не добавляет: ни Arduino.h, ни прототипов. Отсюда первый классический промах новичка-автора: класс проверен в скетче, вынесен в Knopka.cpp — и компилятор внезапно не знает, что такое byte, millis или digitalRead. В .ino эти имена приходят сами, в .cpp их приносит только #include <Arduino.h> в заголовке библиотеки — и это не стилистика, а требование.
Раскладка такая: Knopka.h — интерфейс, класс с полями и перечнем методов; Knopka.cpp — реализация с префиксами Knopka::; guards #ifndef/#define/#endif страхуют от двойного включения заголовка. Короткий метод вроде isLow() из одной строки можно писать прямо в теле класса — компилятор разворачивает его на месте вызова; длинные тела уходят в .cpp. Мой ориентир по цене: вызов метода из .cpp добавляет пару тактов на call/ret — для кнопки с окном в сто миллисекунд это шум, а код читается как чужая библиотека, а не как общая тетрадь.
Третья деталь, которая экономит вечера: кавычки против угловых скобок. #include "Knopka.h" ищет заголовок рядом с файлом, #include <Arduino.h> — в стандартных путях. Путаница здесь не смертельна, но сообщения компилятора о ненайденном файле читаются хуже всего, когда ищешь сбой в коде, а он в точке над i.
// === Knopka.h — интерфейс библиотеки ===#ifndef KNOPKA_H#define KNOPKA_H // guards: двойное включение не задвоит класс #include <Arduino.h> // byte, millis, pinMode — без этого .cpp их не видит class Knopka { byte pin; byte lastReading; byte stable; unsigned long lastChange; unsigned int debounceMs;public: Knopka(byte p, unsigned int ms = 100); // окно по умолчанию — 100 мс void begin(); // всё железо — здесь, не в конструкторе bool changed(); bool isLow() { return stable == LOW; } // короткий метод — в заголовке}; #endif // === Knopka.cpp — реализация ===#include "Knopka.h" Knopka::Knopka(byte p, unsigned int ms) { pin = p; lastReading = HIGH; stable = HIGH; lastChange = 0; debounceMs = ms; // только копирование, без железа} void Knopka::begin() { pinMode(pin, INPUT_PULLUP); // подтяжка живёт в begin()} bool Knopka::changed() { bool front = false; byte c = digitalRead(pin); if (c != lastReading) { lastChange = millis(); lastReading = c; } if (millis() - lastChange > debounceMs && c != stable) { stable = c; front = true; // true ровно раз на смену состояния } return front;} // === keywords.txt — разделитель: табуляция, не пробел ===# Класс KEYWORD1, методы KEYWORD2Knopka KEYWORD1begin KEYWORD2changed KEYWORD2isLow KEYWORD2 // === Vorota.ino — потребитель: класс живёт в библиотеке ===#include <Knopka.h> Knopka knopki[3] = { Knopka(2), Knopka(3), Knopka(4) }; void setup() { Serial.begin(9600); for (byte i = 0; i < 3; i++) knopki[i].begin();} void loop() { for (byte i = 0; i < 3; i++) { if (knopki[i].changed()) { Serial.print("knopka "); Serial.print(i); Serial.println(knopki[i].isLow() ? ": nazhata" : ": otpushena"); } }}Читаем по слоям. Guards построены на имени KNOPKA_H: первое включение ставит класс в файл, повторное — проскакивает мимо пустым. Кавычки в .cpp привязывают include к соседней папке, угловые скобки — к ядру. Конструктор только копирует аргументы в поля; pinMode ушёл в begin(); дефолтное окно 100 мс объявлено в заголовке — новые скетчи получают новое окно без единой правки вызовов, а вызовы Knopka(2, 50) с явным числом остаются при своих пятидесяти: контракт сохранён для тех, кому старое окно подходит.
Тело changed() в .cpp — обычный вызов метода, и это осознанный размен: пара тактов на прыжок против тела, размноженного по каждому вызову. isLow() остался в классе: одна строка с одним сравнением разворачивается на месте, и выносить её в .cpp значило бы терять такты на пустом месте. Поля как были приватными, так и остались: потребителю не нужно знать про lastReading — интерфейс из трёх методов закрывает всё.
keywords.txt в листинге — минимальный набор: класс в KEYWORD1, методы в KEYWORD2. Табуляция как разделитель, поддержка в IDE 2 и грабли формата — всё это уже разобрано в уроке про то, как чужие библиотеки подключаются из IDE, дублировать не буду; здесь файл приведён, чтобы библиотека из листинга собиралась до последней буквы.
| Сообщение компилятора | Причина | Лечение |
|---|---|---|
| not declared in this scope | в .cpp забыли include — Arduino.h или Knopka.h | добавить include в файл, где живёт код; Arduino.h приносит byte, millis, pinMode |
| multiple definition of ... | тело метода или переменная определены в .h, который включён в две единицы трансляции | тела — в .cpp; короткие методы — в теле класса или со словом inline |
| does not name a type | класс используется раньше, чем объявлен в этом файле | include заголовка — в начало файла; объявление класса — до первого использования |
| Serial из .cpp молчит или сыплет мусором | печать из конструктора глобального объекта: Serial ещё не поднят | печать и железо — в begin(); конструктору оставить копирование аргументов |
Конструктор против begin(): глобальный объект рождается рано
Глобальные объекты создаются до main(): ещё не отработала инициализация ядра, Timer0 не тикает, millis() вернёт нули, Serial не поднят. Конструктор библиотеки, вызывающий Serial.begin или печатающий приветствие, выполнится в этот мёртвый период — и то, что на Uno pinMode из конструктора чаще всего «срабатывает», лишь маскирует ловушку: на других платах и ядрах порядок инициализации другой, и код молча ведёт себя по-разному. Отсюда контракт, по которому живут зрелые библиотеки: конструктор копирует простые аргументы, всё железо — в begin(), который пользователь зовёт в setup() в правильном порядке.
Мой личный билет в этот клуб: мигалка-объект с Serial.begin внутри конструктора печатала мусор один раз из трёх перезагрузок. Причина вскрылась не в коде класса, а в порядке запуска: на момент конструктора UART ещё не существовал. Перенос в begin() сделал поведение детерминированным — и заодно освободил конструктор от единственной обязанности, которую он выполнять не имел права. Если ваш класс считает время через millis() в конструкторе — вы храните мусор в поле lastChange с первой секунды жизни объекта: механика переполнения и отсчётов разобрана в уроке про переполнение millis.
Вторая половина контракта — что библиотека берёт на себя. Окно антидребезга у мягких и окисленных контактов — тема урока про механику дребезга: тактильным кнопкам хватает 50 мс, подвальным герконам просится 100. Дефолтный аргумент в заголовке делает это знание частью библиотеки: правка одной строки в Knopka.h раздаётся всем скетчам пересборкой — тот сценарий с воротами, с которого начался урок, больше не повторится.
Когда классу место в отдельной вкладке, а когда в библиотеке: вкладка — деталь конкретного скетча, умирает вместе с ним; библиотека переживает проекты. Мой рубеж сместился с «третьего копипаста» соседнего курса на более честный: второй скетч, которому нужен тот же класс. Два скетча — уже система, а не совпадение, и правка, размноженная на два файла, к четвёртому обязательно забудется. Проверка интерфейса у меня та же, что у соседей: новый скетч пишется без подглядывания в Knopka.cpp — что потребовало подглядывания, промах интерфейса, а не слабая память.
Дистрибуция коротко: папка со всеми файлами переносится в sketchbook — карта папок в уроке про структуру sketchbook, раскладка версий и дубликатов в уроке про ZIP-установку. Примеры в examples/ добавьте сразу: подпапка с именем скетча внутри, тот самый Vorota.ino из листинга — через полгода ваш собственный пример покажет быстрее, чем чтение собственной реализации. Домашнее: добавьте в Knopka метод pressedFor(), возвращающий millis() - lastChange, пока кнопка нажата, и второй пример KnopkaDveri с сигналом при удержании дольше двух секунд.
- 1. Что обязан подключить Knopka.cpp, чтобы увидеть byte, millis и pinMode?
- 2. Автоподстановка Arduino.h и генерация прототипов работают...
- 3. Как лечится multiple definition при теле метода в .h?
- 4. Почему Serial в конструкторе глобального объекта — плохая идея?
- 5. Короткий метод isLow() прямо в теле класса...
- .ino среда дорабатывает сама; .cpp — отдельная единица трансляции: Arduino.h и свой заголовок подключаются руками
- Заголовок — интерфейс: guards #ifndef/#define/#endif; тела методов — в .cpp, короткие — в теле класса, где они inline
- Три диагностики: not declared — забыт include, multiple definition — тело в .h, does not name a type — класс ниже использования
- Конструктор глобального объекта выполняется до init(): millis вернёт нули, Serial молчит — копирование аргументов в конструкторе, всё железо в begin()
- Дефолтный аргумент в заголовке делает настройку частью библиотеки: правка окна антидребезга раздаётся пересборкой, keywords.txt подсвечивает класс и методы
Частые вопросы по теме урока
- Что обязан подключить Knopka.cpp, чтобы увидеть byte, millis и pinMode?
- Среда дорабатывает только .ino: Arduino.h, автопрототипы. .cpp — отдельная единица трансляции, и Arduino.h приходит туда только через ваш заголовок. #include <Arduino.h>
- Автоподстановка Arduino.h и генерация прототипов работают...
- Скетч-файл среда дорабатывает за автора. Для .cpp это не так — отсюда «неизвестные» byte и millis после выноса кода из скетча. только в .ino
- Как лечится multiple definition при теле метода в .h?
- Тело в .h попадает в каждую единицу трансляции, линкер находит копии. Лечение: тела — в .cpp, короткие — внутри класса, где они inline по умолчанию. переносом тела в .cpp или словом inline
- Почему Serial в конструкторе глобального объекта — плохая идея?
- Глобальные объекты создаются до main() и инициализации ядра. Контракт зрелых библиотек: конструктор копирует аргументы, всё железо — в begin(). конструкторы глобальных объектов выполняются до init(): Serial ещё не поднят
- Короткий метод isLow() прямо в теле класса...
- Методы, объявленные внутри тела класса, inline по умолчанию: вызов стоит столько же, сколько ручной if. Длинные тела уносят в .cpp — там ценой пары тактов на call/ret. компилятор разворачивает на месте вызова — inline