Ночник в детской я доводил до ума три вечера, а на четвёртый перестал понимать собственный скетч. Приёмы из урока про свои функции я усвоил честно: blinkLed, readPercent, averageOf — функций стало девять, loop свернулся в несколько вызовов, но сам файл разросся до трёх сотен строк, и каждая правка начиналась с получасового блуждания: где лежит эта функция, что она правит, откуда берёт значения. Финальный толчок дала кнопка режима, которую я прикрутил второпях и повесил её состояние прямо в глобалку. Через неделю глобалок было одиннадцать, и я поймал призрака: ночник сам переключался в маяк. Причина нашлась не в железе — две переменные с одинаковым смыслом жили в двух разных функциях, и обе честно делали своё дело. Просто не то, которого ждал я.
В этом уроке три вещи, которые отличают скетч-простыню от скетча-конструктора. Первая — прототипы: почему в Arduino можно звать функцию, написанную ниже loop(), и когда эта услуга среды ломается. Вторая — static: локальная переменная с долгой памятью, которая держит состояние внутри одной функции и не показывает его наружу. Третья — планировка: секции сверху вниз и loop в роли оглавления, по которому чужой человек находит нужную функцию за минуту, а вы сами — через полгода.
Стартовая площадка уже есть: скелет из setup() и loop() из первого урока — это две готовые функции-модуля, просто их вызывает сама плата. Железо не понадобится: весь материал — про организацию кода, демонстрация живёт в симуляторе, а текст лучше читать с открытым IDE и собственным последним скетчем под рукой — сверять будет проще. По ходу дела разберём, почему за глобалкой следит весь скетч, а за static — один владелец, и что из этого следует для ваших одиннадцати тёзок.
Прототипы: обещание перед телом
C++ читает файл сверху вниз, и в обычном .cpp-файле вызов функции до её определения сборку ломает: на строке вызова компилятор ещё не видел имени. Классическое лечение — прототип: одна строка-обещание вида void blinkLed(int pin, int ms); без тела, поставленная до первого вызова. Тело может лежать где угодно — ниже по файлу или вообще в другом файле: компилятор сверяет вызов с обещанием, а тело находит позже, на этапе линковки.
В Arduino IDE эта рутина исчезает: перед компиляцией среда проходит по скетчу и сама дописывает прототипы всех ваших функций в начало файла — за это отвечает arduino-builder, конвейер между вашим текстом и компилятором. Поэтому скетч, где функция определена ниже loop(), а вызывается выше, собирается без единой лишней строки. В обычном C++-проекте тот же текст упадёт — это не мелочь, а разница в правилах, о которой полезно знать заранее: переедете со скетча в PlatformIO или чистый C++ — прототипы придётся писать руками.
У услуги есть цена, и цена эта — редкая, но злая грабля. Авто-генерация спотыкается, когда функция принимает ваш собственный тип — структуру или перечисление, объявленные тут же в скетче: builder тащит объявление функции наверх раньше, чем компилятор успевает узнать новый тип, и сборка падает с руганью на строку, где ничего плохого нет. Я потратил на такой вечер полтора часа и чинил не ту строку, потому что ругань указывала мимо. Лечение одно: ручной прототип вверху скетча — и builder отступается. С int, float, char и массивами такого не бывает никогда.
static: локальная с долгой памятью
Обычная локальная переменная живёт один вызов: родилась на входе в функцию, умерла на выходе — это мы разобрали в прошлом уроке. Слово static меняет время жизни, не трогая видимость: static int last внутри функции инициализируется один раз за всю работу скетча и хранит значение между вызовами. Снаружи переменной по-прежнему нет: имя видно только внутри тела. Получается ящик с личной памятью — функция помнит своё прошлое, а остальному скетчу про это знать не нужно.
Кандидат номер один — предыдущее состояние кнопки. Фронт нажатия — это «нажата сейчас и была не нажата», то есть сравнение с прошлым чтением, и раньше я держал lastKnopka глобальной: она висела наверху среди одиннадцати тёзок и обросла конфликтами. Второй кандидат — счётчик с отчётом раз в N: static int nazhato внутри otchet() живёт между нажатиями, и отдельная глобальная ячейка для него не нужна. Фазы светофора из урока про конечный автомат — легальная глобалка: её читают и пишут три ветви сразу. Отсюда правило, к которому я пришёл на своих призраках: если состояние нужно ровно одной функции — прячьте его в эту функцию static-ом; глобальный регистр оставьте тому, чем пользуются двое и больше.
const int KNOPKA = 2;const int LED = 12; int gori = 0; // общее состояние: его видят две функции int frontKnopki() { static int last = HIGH; // личная память: прошлое чтение int sejchas = digitalRead(KNOPKA); int front = 0; if (sejchas == LOW && last == HIGH) front = 1; last = sejchas; return front; // наружу — только ответ} void pereklyuchit() { if (gori == 0) { gori = 1; digitalWrite(LED, HIGH); } else { gori = 0; digitalWrite(LED, LOW); }} void otchet() { static int nazhato = 0; // личная память: счётчик нажатий nazhato = nazhato + 1; if (nazhato % 5 == 0) { Serial.print("nazhato="); Serial.println(nazhato); }} void setup() { pinMode(KNOPKA, INPUT_PULLUP); pinMode(LED, OUTPUT); Serial.begin(9600); Serial.println("Goto: nazhmi knopku");} void loop() { if (frontKnopki() == 1) { // loop читается как оглавление pereklyuchit(); otchet(); }}Loop здесь — четыре строки, и читается он как пункт меню: поймай фронт, переключи свет, отпишись. Где лежит память фронта — в frontKnopki, где живёт счётчик — в otchet: чтобы ответить, тела читать не нужно, хватает имён. Это я и называю оглавлением: скетч большой, но у него есть одна страница, с которой всё находится.
Заметьте, кто владеет значениями. gori — единственная глобалка, и у неё есть причина: состояние света нужно двум функциям сразу. Всё остальное — return-ы и static: frontKnopki ничего снаружи не правит, она отвечает числом, и за это её можно звать хоть из условия, хоть из чужого скетча. Функция, которая молча меняет чужие значения, — самый дорогой вид связи: мой самопереключающийся ночник был именно таким, а лечится он только одним способом — владелец у значения один, остальные получают ответ через return.
Цена вопроса — байты, и она нулевая: static int съедает те же два байта RAM, что съела бы глобальная, ни в памяти, ни в скорости разницы нет. Разница в обязанностях: за глобалкой следит весь скетч, за static — одна функция, у которой имя не видно снаружи. На Uno с его двумя тысячами ячеек это бухгалтерия на равных, а по надёжности — не на равных. И про сроки жизни далее: RAM стирается без питания, и всё, что обязано переживать выключение, переезжает в EEPROM — там свои правила записи, разобранные отдельно.
| Какое состояние | Где живёт | Почему именно так |
|---|---|---|
| Нужно двум и более функциям | глобальная наверху скетча | одна ячейка, у всех один и тот же вид |
| Нужно одной функции между вызовами | static внутри тела | снаружи имени нет, конфликтовать не с чем |
| Живёт один вызов | обычная локальная | родилась на входе, умерла на выходе |
| Должно переживать питание | EEPROM | RAM стирается при отключении, EEPROM держит годами |
И о границах метода. Функция — это имя-глагол и одна ответственность, а не коробка для любых двух строк: дробить стоит то, у чего есть имя словами, и то, что повторяется. Функция из двух строк без имени-глагола — проигрыш: читающий делает прыжок глазами, а взамен получает «что-то где-то там». У меня был период, когда я нарезал скетч на двадцать микроскопических функций, — читать их стало дольше, чем до нарезки, и часть я склеил обратно. Проверьте себя на своём скетче: выпишите глобальные переменные в столбик и для каждой ответьте, сколько функций её трогают, — у половины обнаружится один клиент, и это готовые кандидаты в static.
Следующая ступень того же похода — файлы: когда функций за тридцать, скетч расселяется по вкладкам IDE, и там прототипы уже пишут руками, по-взрослому. А в следующем уроке каркас получит клиента: команды с клавиатуры компьютера приедут в Serial — посимвольный приём из урока про available и read — и каждая буква превратится в вызов обработчика. Схема «фронт — обработчик — отчёт» останется той же.
- 1. Функция определена ниже loop(), а вызывается выше — и скетч собирается. Почему?
- 2. Что произойдёт с переменной static int nazhato = 0; внутри функции otchet()?
- 3. Функция принимает структуру, объявленную тут же в скетче, и сборка падает со странной руганью. Что делать?
- 4. Состояние нужно ровно одной функции — она хранит прошлое чтение кнопки. Где ему место?
- 5. Что даёт loop-оглавление вида if (frontKnopki() == 1) { pereklyuchit(); otchet(); }?
- Arduino IDE сама дописывает прототипы функций перед компиляцией — поэтому функцию можно звать до её определения; в чистом C++ прототип пишут руками.
- Авто-прототипы ломаются на функциях с вашими типами (struct, enum) из того же скетча — лечится одним ручным прототипом наверху.
- static внутри функции — личная память с долгим сроком: инициализация один раз, значение живёт между вызовами, имя снаружи не видно.
- Состояние с одним клиентом — в static; общее для нескольких функций — в глобальную с честным владением; наружу — через return.
- Loop-оглавление из вызовов-глаголов читается за секунды; дробить стоит то, у чего есть имя словами или что повторяется.
Частые вопросы по теме урока
- Функция определена ниже loop(), а вызывается выше — и скетч собирается. Почему?
- Это делает arduino-builder до передачи кода компилятору. В обычном C++-файле тот же текст не соберётся — там прототип пишут руками. IDE перед компиляцией сама дописывает прототипы всех функций в начало скетча
- Что произойдёт с переменной static int nazhato = 0; внутри функции otchet()?
- static меняет время жизни, а не видимость: значение живёт между вызовами, но имя видно только внутри тела функции. Инициализируется один раз и хранит значение между вызовами
- Функция принимает структуру, объявленную тут же в скетче, и сборка падает со странной руганью. Что делать?
- Авто-прототипы спотыкаются о пользовательские типы: builder тащит объявление наверх раньше, чем компилятор узнал тип. Один ручной прототип лечит. Поставить ручной прототип функции вверху скетча
- Состояние нужно ровно одной функции — она хранит прошлое чтение кнопки. Где ему место?
- Состояние с одним клиентом прячется в владельца: static живёт между вызовами и не показывает имя наружу, конфликтовать не с чем. В static-переменной внутри этой же функции
- Что даёт loop-оглавление вида if (frontKnopki() == 1) { pereklyuchit(); otchet(); }?
- Выигрыш — в чтении и правке: имена-глаголы отвечают, кто за что отвечает, а тела лежат на своих местах. Скорость исполнения не меняется. Смысл виден за четыре строки, тела функций читать не обязательно