Инструкции

Язык Arduino: setup, loop, типы данных и функции

13 мин чтения·Август 2026

«Язык 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Диапазон
bool1 байтfalse / true
uint8_t, byte1 байт0…255
int8_t1 байт−128…127
int, int16_t2 байта−32 768…32 767
unsigned int, uint16_t2 байта0…65 535
long, int32_t4 байта±2,1 млрд
unsigned long, uint32_t4 байта0…4,29 млрд
float4 байта~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 элемента, объявленный один раз, ведёт себя предсказуемо всегда — в отличие от структуры, которая растёт по ходу работы.

Что дальше

Связанные статьи