Язык Arduino: setup, loop, типы данных и функции
«Язык Arduino» — это обычный C++ с небольшой обвязкой. Никакого отдельного языка не
существует: скетч компилируется тем же g++, что и любая другая программа на C++,
просто часть рутины спрятана. Понимание того, что именно спрятано, экономит потом
много времени. Разбираем структуру скетча, типы, функции и работу со временем.
Из чего состоит скетч
Минимальная программа — две функции:
void setup() {
// выполняется один раз при включении и после сброса
}
void loop() {
// выполняется бесконечно, пока есть питание
}
setup() вызывается один раз: здесь настраивают выводы, открывают Serial,
инициализируют датчики. loop() вызывается сразу после и повторяется бесконечно.
Спрятана здесь функция main() — та самая точка входа, обязательная в любой
программе на C++. Она есть, просто лежит в ядре Arduino и выглядит примерно так:
int main() {
init(); // настройка таймеров и АЦП
setup();
for (;;) {
loop();
}
}
Отсюда два практических следствия. Выйти из программы некуда — loop() крутится
всегда, «конца» у скетча нет. И всё, что должно случиться один раз, обязано быть в
setup(): код, записанный вне функций, выполнится не так, как вы ожидаете.
Слово void перед именем означает, что функция ничего не возвращает. Это не
особенность Arduino, а обычный C++.
Переменные и их область видимости
Где объявлена переменная — там она и живёт:
int counter = 0; // глобальная: видна везде, живёт всё время работы
void setup() {
Serial.begin(115200);
}
void loop() {
int temporary = 5; // локальная: создаётся заново на каждом проходе
counter += temporary; // и исчезает в конце loop()
}
Самая частая ошибка новичка — объявить переменную в setup(), а использовать в
loop(). Компилятор ответит 'x' was not declared in this scope: к моменту вызова
loop() этой переменной уже не существует. Лечится переносом объявления на уровень
файла, выше setup().
Есть промежуточный вариант — static. Такая переменная создаётся один раз, но видна
только внутри своей функции:
void loop() {
static int callCount = 0; // ноль присвоится только при первом вызове
callCount++;
}
Константы объявляют через const — компилятор проверит, что их не меняют, и часто
не станет отводить под них память:
const uint8_t ledPin = 13;
Типы данных
На AVR-платах (UNO, Nano, Mega) размеры типов отличаются от привычных по «большому» программированию, и это напрямую влияет на расход памяти:
| Тип | Размер на AVR | Диапазон |
|---|---|---|
bool | 1 байт | false / true |
uint8_t, byte | 1 байт | 0…255 |
int8_t | 1 байт | −128…127 |
int, int16_t | 2 байта | −32 768…32 767 |
unsigned int, uint16_t | 2 байта | 0…65 535 |
long, int32_t | 4 байта | ±2,1 млрд |
unsigned long, uint32_t | 4 байта | 0…4,29 млрд |
float | 4 байта | ~7 значащих цифр |
На AVR int занимает два байта, а не четыре. Счётчик int seconds, считающий
секунды, переполнится через девять часов. На ESP32 тот же int четырёхбайтовый — и
скетч, работавший там годами, на UNO сломается за сутки. Когда важен диапазон,
пишите явные типы: uint32_t вместо unsigned long.
Отдельно про double: на AVR он не даёт никакой дополнительной точности — компилятор
делает его тем же четырёхбайтовым float. На ESP32 double настоящий,
восьмибайтовый.
Подробнее про расход памяти и как её экономить — в статье Память Arduino и ESP32.
Условия и циклы
Здесь всё как в обычном C++:
if (temperature > 30) {
digitalWrite(fanPin, HIGH);
} else if (temperature < 25) {
digitalWrite(fanPin, LOW);
}
for (uint8_t i = 0; i < 8; i++) {
digitalWrite(ledPins[i], HIGH);
}
while (Serial.available() > 0) {
char incoming = Serial.read();
}
Одна ловушка стоит отдельного упоминания: = присваивает, == сравнивает. Запись
if (state = HIGH) скомпилируется без ошибки, присвоит переменной HIGH и всегда
окажется истинной. Компилятор обычно предупреждает, но предупреждение легко
пропустить в общем потоке.
Функции
Функция объявляется типом возвращаемого значения, именем и списком параметров:
int readTemperature(uint8_t pin) {
int raw = analogRead(pin);
return raw / 4;
}
void blink(uint8_t pin, uint16_t duration) {
digitalWrite(pin, HIGH);
delay(duration);
digitalWrite(pin, LOW);
}
Функции, которые ничего не возвращают, объявляются как void — как setup() и
loop(). Порядок объявления в файле скетча значения не имеет: Arduino IDE сама
добавляет прототипы перед компиляцией, поэтому вызвать функцию можно и до того, как
она описана.
Выносить повторяющийся код в функции стоит не ради красоты: скетч из одного
двухсотстрочного loop() невозможно отлаживать, потому что непонятно, какая часть
сломалась.
Массивы
const uint8_t ledPins[] = {2, 4, 5, 18, 19};
const uint8_t ledCount = sizeof(ledPins) / sizeof(ledPins[0]);
void setup() {
for (uint8_t i = 0; i < ledCount; i++) {
pinMode(ledPins[i], OUTPUT);
}
}
Приём с sizeof считает длину массива автоматически: добавили пин в список — цикл
подстроился сам. Нумерация начинается с нуля, и выход за границу массива на
микроконтроллере не вызовет ошибку — программа просто прочитает или испортит соседний
участок памяти. Это одна из самых неприятных ошибок: плата начинает вести себя
странно в совершенно другом месте.
Работа с выводами
pinMode(pin, OUTPUT); // OUTPUT, INPUT или INPUT_PULLUP
digitalWrite(pin, HIGH); // HIGH или LOW
int state = digitalRead(pin);
int value = analogRead(pin); // АЦП
analogWrite(pin, 128); // ШИМ
Режим INPUT_PULLUP включает внутренний подтягивающий резистор — с ним кнопку можно
подключить двумя проводами, без внешнего резистора. Логика при этом инвертируется:
не нажата — HIGH, нажата — LOW.
Диапазоны отличаются между платформами:
| Функция | AVR (UNO, Nano) | ESP32 |
|---|---|---|
analogRead() | 0…1023 (10 бит) | 0…4095 (12 бит) |
analogWrite() | 0…255, только ШИМ-выводы | 0…255, почти любой вывод |
У ESP32 часть выводов занята или влияет на загрузку, а у классического ESP32 аналоговые входы блока ADC2 к тому же не работают при включённом Wi-Fi. Полный разбор — в статье Пины ESP32.
Время: почему delay мешает
delay(1000) останавливает вашу программу на секунду. Не «ждёт в фоне», а именно
останавливает: за это время скетч не прочитает кнопку и не разберёт пришедшую
команду. На ESP32 фоновые задачи вроде Wi-Fi-стека при этом продолжают работать —
delay() там уступает им процессор, — но ваш собственный код стоит.
Пока в скетче одна задача, это незаметно. Как только их становится две — например,
мигать светодиодом и следить за кнопкой, — delay() ломает всё:
// Плохо: кнопка опрашивается раз в секунду и половину нажатий теряет
void loop() {
digitalWrite(led, HIGH);
delay(500);
digitalWrite(led, LOW);
delay(500);
if (digitalRead(button) == LOW) { /* … */ }
}
Рабочий подход — не останавливаться, а запоминать момент последнего действия и
сравнивать с текущим временем. Его даёт millis() — счётчик миллисекунд с момента
включения:
const unsigned long blinkInterval = 500;
unsigned long lastBlink = 0;
bool ledState = false;
void loop() {
unsigned long now = millis();
if (now - lastBlink >= blinkInterval) {
lastBlink = now;
ledState = !ledState;
digitalWrite(led, ledState);
}
// сюда можно добавить сколько угодно других проверок —
// они выполняются на каждом проходе, а не раз в полсекунды
if (digitalRead(button) == LOW) { /* … */ }
}
millis() возвращает unsigned long и переполняется примерно через 49 дней
непрерывной работы. Сравнение вида now - lastBlink >= interval переполнение
переживает корректно — за счёт арифметики беззнаковых чисел. А вот запись
millis() >= lastBlink + interval сломается ровно в момент переполнения. Пишите
всегда через разность.
Вывод в Serial
Основной инструмент отладки на микроконтроллере — печать в последовательный порт:
void setup() {
Serial.begin(115200);
}
void loop() {
Serial.print("Температура: ");
Serial.print(temperature);
Serial.println(" C");
delay(1000);
}
print() печатает без перевода строки, println() — с переводом. Скорость в
Serial.begin() должна совпадать со скоростью, выбранной в мониторе порта, иначе
вместо текста будут иероглифы.
Подробнее про обмен данными и разбор команд — в статье Serial-порт.
Директивы препроцессора
Строки, начинающиеся с #, обрабатываются до компиляции:
#include <Servo.h> // подключить библиотеку
#define LED_PIN 13 // текстовая замена по всему файлу
#define — это именно замена текста, без типа и без проверок. Современный C++
предлагает лучший вариант:
const uint8_t ledPin = 13; // у константы есть тип, компилятор её проверит
#define остаётся полезен для условной компиляции — например, чтобы отладочный вывод
можно было отключить одной строкой:
#define DEBUG 1
#if DEBUG
#define debugPrint(x) Serial.println(x)
#else
#define debugPrint(x)
#endif
При DEBUG 0 вызовы debugPrint() исчезнут ещё до компиляции — и не займут ни байта
во flash.
Чем это отличается от «большого» C++
Формально доступен почти весь язык, но на микроконтроллере часть возможностей непрактична:
- Исключения и RTTI обычно отключены — на AVR их нет вовсе.
- Стандартная библиотека доступна частично:
std::vectorиstd::stringна плате с двумя килобайтами ОЗУ съедят память мгновенно. - Динамическое выделение (
new,malloc) технически работает, но фрагментирует кучу. На AVR от него лучше отказаться совсем. String— удобный класс Arduino, но на AVR он и есть та самая динамическая память. Для строк надёжнееchar[]фиксированного размера.
Общее правило: на микроконтроллере предпочитают статически известные размеры. Массив на 32 элемента, объявленный один раз, ведёт себя предсказуемо всегда — в отличие от структуры, которая растёт по ходу работы.
Что дальше
- Расход памяти и как его сократить — Память Arduino и ESP32.
- Обмен данными с компьютером — Serial-порт.
- Подключение чужого кода — Библиотеки Arduino IDE.
- Если скетч не компилируется — Ошибки Arduino IDE.