Serial-порт: обмен данными между платой и компьютером
Serial — это одновременно канал прошивки, главный инструмент отладки и способ управлять платой с компьютера. Разобраться в нём стоит раньше, чем в чём-либо ещё: без вывода в порт микроконтроллер остаётся чёрным ящиком, который либо мигает, либо нет. Ниже — как настроить, как принимать команды не блокируя программу и что делать с кракозябрами.
Что это такое
Под именем Serial обычно скрывается UART — последовательный интерфейс, передающий
байты по двум линиям: TX (передача) и RX (приём). На платах без собственного USB эти
линии заведены на мост USB-UART, который показывается компьютеру как COM-порт.
Отсюда важное следствие: выводы UART0 заняты. На AVR-платах это пины 0 и 1, на классическом ESP32 — GPIO 1 и 3. Подключать к ним что-то постороннее нельзя: помешает и прошивке, и отладке.
У чипов с собственным USB — ESP32-S3, ESP32-C3, ESP32-C6, а также Arduino Leonardo и
Micro — всё зависит от настройки Tools → USB CDC On Boot. Когда она включена,
Serial работает через встроенный USB, а не через UART, и выводов не занимает;
физический UART0 при этом доступен отдельно под именем Serial0. Когда выключена —
Serial идёт через UART0 и занимает выводы: GPIO 43 и 44 у ESP32-S3, GPIO 21 и 20
у ESP32-C3.
Побочный эффект работы через USB: скорость в Serial.begin() для него формальна —
данные идут по USB на полной скорости независимо от указанного числа.
Начало работы
void setup() {
Serial.begin(115200);
}
void loop() {
Serial.println("Работаю");
delay(1000);
}
Аргумент begin() — скорость в бодах. Она должна совпадать со скоростью, выбранной
в мониторе порта, иначе вместо текста появятся случайные символы. Практически всегда
используют два значения: 9600 — исторический стандарт, ещё встречается в старых
примерах, и 115200 — разумный современный выбор, вчетверо быстрее и так же надёжен.
На ESP32 первые строки после включения — это лог самого загрузчика, он печатается на скорости 115200 независимо от вашей настройки. Если выбрать 9600, начало вывода будет выглядеть мусором, а дальше пойдёт нормальный текст. Это не ошибка.
print, println и write
Три метода, которые постоянно путают:
int value = 65;
Serial.print(value); // отправит текст "65" — два байта: '6' и '5'
Serial.println(value); // то же плюс перевод строки
Serial.write(value); // отправит один байт со значением 65 — это символ 'A'
print() превращает число в текст, write() отправляет сырой байт. Для человека,
читающего монитор порта, нужен print(). Для бинарного протокола между двумя
устройствами — write().
У print() есть второй аргумент — основание системы счисления, удобно при отладке
регистров и адресов:
Serial.print(255, HEX); // FF
Serial.print(255, BIN); // 11111111
Для форматированного вывода на ESP32 есть printf(), работающий как в обычном C:
Serial.printf("Температура %.1f C, влажность %d%%\n", temperature, humidity);
На AVR-платах Serial.printf() отсутствует — там придётся собирать строку через
snprintf() в буфер char[].
Приём данных без блокировки
Начинающие обычно пишут так:
// Плохо: блокирует loop() до перевода строки или таймаута в секунду
String command = Serial.readStringUntil('\n');
Проблема та же, что у delay(): пока метод ждёт данные, программа стоит. Плата не
крутит моторы, не читает датчики, не отвечает по сети.
Рабочий приём — накапливать байты в буфер по мере поступления и реагировать только когда строка собрана целиком:
const uint8_t bufferSize = 64;
char commandBuffer[bufferSize];
uint8_t bufferLength = 0;
void loop() {
while (Serial.available() > 0) {
char incoming = Serial.read();
if (incoming == '\n' || incoming == '\r') {
if (bufferLength > 0) {
commandBuffer[bufferLength] = '\0';
handleCommand(commandBuffer);
bufferLength = 0;
}
} else if (bufferLength < bufferSize - 1) {
commandBuffer[bufferLength++] = incoming;
}
// символы сверх размера буфера отбрасываем, чтобы не выйти за границу
}
// остальная работа устройства продолжается на каждом проходе
}
void handleCommand(const char* command) {
Serial.print("Получено: ");
Serial.println(command);
}
Serial.available() возвращает количество принятых байтов, ждущих в буфере, — вызов
не блокирует. Цикл while разбирает всё, что пришло, и управление возвращается в
loop().
Приёмный буфер невелик: на AVR-платах это 64 байта. Если программа надолго уходит в
delay() или тяжёлые вычисления, пришедшие за это время данные просто потеряются —
без всякого сообщения об ошибке. Ещё одна причина не блокировать loop().
Разбор команд
Когда строка собрана, её обычно надо разделить на команду и аргументы. На AVR это
делают функциями стандартной библиотеки C, без String:
void handleCommand(char* command) {
char* verb = strtok(command, " ");
if (verb == nullptr) return;
if (strcmp(verb, "led") == 0) {
char* argument = strtok(nullptr, " ");
if (argument != nullptr) {
digitalWrite(LED_BUILTIN, atoi(argument) != 0);
}
} else if (strcmp(verb, "ping") == 0) {
Serial.println("pong");
} else {
Serial.print("Неизвестная команда: ");
Serial.println(verb);
}
}
strtok() режет строку по разделителю, strcmp() сравнивает строки — обратите
внимание, что сравнение через == для char* сравнивает адреса, а не текст.
atoi() превращает текст в число.
Такой текстовый протокол удобен тем, что его можно отлаживать руками из монитора
порта: набрал led 1 и увидел результат. Именно так устроены
команды управления RoboArm R1.
Настройки монитора порта
В Arduino IDE монитор открывается кнопкой с лупой или сочетанием Ctrl+Shift+M (Cmd+Shift+M на macOS). Два выпадающих списка внизу окна определяют почти всё:
Скорость должна совпадать с указанной в Serial.begin().
Окончание строки (No line ending, Newline, Carriage return, Both NL & CR)
определяет, какие символы добавятся к отправляемому тексту. Если плата ждёт команду
до перевода строки, а в мониторе стоит No line ending, она не дождётся ничего —
классическая причина «плата не отвечает на команды». Ставьте Newline, а в коде
обрабатывайте и \n, и \r, как в примере выше.
Открытый монитор порта занимает порт. Пока он открыт, залить скетч не получится —
IDE обычно закрывает его сама, но внешние программы и веб-инструменты в браузере
этого не делают. Ошибка Access is denied при загрузке — почти всегда именно это.
Дополнительные UART на ESP32
У классического ESP32 три аппаратных UART. Serial занят прошивкой, а Serial1 и
Serial2 свободны — на них вешают GPS-модули, датчики, вторую плату:
void setup() {
Serial.begin(115200); // отладка через USB
Serial2.begin(9600, SERIAL_8N1, 16, 17); // RX на GPIO 16, TX на GPIO 17
}
void loop() {
while (Serial2.available() > 0) {
Serial.write(Serial2.read()); // всё из Serial2 — в монитор порта
}
}
Третий и четвёртый аргументы begin() — номера выводов RX и TX. Их можно назначить
почти на любые свободные пины: внутри чипа стоит коммутационная матрица. Какие выводы
свободны, разобрано в статье Пины ESP32.
Параметр SERIAL_8N1 — формат кадра: 8 бит данных, без контроля чётности, один
стоповый бит. Это значение по умолчанию, менять его приходится редко.
На AVR-платах дополнительные UART есть только у Mega 2560 (Serial1…Serial3). У
UART у UNO и Nano всего один, и он занят.
Serial из браузера
Современные браузеры на движке Chromium умеют открывать последовательный порт напрямую через Web Serial API — без установки программ. Так работает наш веб-терминал: можно подключиться к плате, отправить команду и увидеть ответ, ничего не устанавливая.
Ограничения: нужен Chrome, Edge или другой браузер на Chromium, и страница должна
быть открыта по HTTPS или с localhost. Safari и Firefox Web Serial не
поддерживают.
Частые проблемы
В мониторе кракозябры. Скорость в мониторе не совпадает со скоростью в
Serial.begin(). Если совпадает, но мусор идёт только в первых строках после сброса
ESP32 — это лог загрузчика, так и должно быть.
Пусто, хотя скетч печатает. Проверьте, что вызван Serial.begin(), и что выбран
правильный порт. На платах с нативным USB (ESP32-S3, ESP32-C3, Leonardo) порт
исчезает и появляется заново при сбросе — монитор нужно переоткрыть.
Плата не отвечает на команды. В мониторе стоит No line ending, а код ждёт
перевод строки. Или наоборот: код сравнивает строку целиком, а монитор дописал \r,
которого код не ожидал.
Порт занят, скетч не заливается. Закройте монитор порта и все внешние программы, подключённые к плате, включая вкладки браузера с веб-терминалом.
Данные теряются. Программа блокируется в delay() дольше, чем заполняется
приёмный буфер. Переписывайте на неблокирующую схему — см.
Язык Arduino.
Что дальше
- Основы скетча и неблокирующий код — Язык Arduino.
- Текстовый протокол на практике — RoboArm R1: команды управления.
- Если порт не виден в системе — Ошибки Arduino IDE.