Полгода назад я собрал ночной светильник и захотел управлять им с дивана. К Arduino был подключён Bluetooth-модуль HC-05 — для телефона он выглядит как обычный последовательный порт, поэтому я поставил терминальное приложение, набрал на клавиатуре смартфона ON и нажал отправить. И ничего. Свет не включился, а в логе на компьютере посыпались одиночные буквы: O, потом N, потом пустые строки. Код выглядел правильно — я ведь честно вызывал Serial.read(). Проблема пряталась в порядке работы с буфером: read() вынимает по одному символу за вызов, и сравнивать каждый отдельный символ с готовым словом ON бессмысленно. Когда я научился складывать символы в строку и считать команду законченной по переводу строки, реле щёлкнуло с первой же попытки. С того вечера у меня правило: пока плата не умеет слушать, это не проект, а просто мигалка.
Разберём приём данных по шагам. Сначала — буфер порта: куда падают байты, пока скетч занят, и сколько их там может лежать. Затем функции Serial.available() и Serial.read() и золотое правило проверки перед чтением. Дальше соберём полноценное управление светодиодом по serial: команду ON, команду OFF и ветку для опечаток — с ней отладка превращается в удовольствие. Потом научимся принимать числа: яркость, набранная с клавиатуры, станет реальной яркостью светодиода через analogWrite. И по пути поймаем три классические ловушки: невидимый символ возврата каретки, регистр букв и чтение из пустого буфера. После этого урока ваши устройства впервые станут по-настоящему интерактивными.
Буфер порта: почтовый ящик на 64 байта
Пока скетч мигает светодиодом или спит в delay, UART не выключается: аппаратный приёмник ловит байты из провода и складывает их в буфер — на Uno это 64 байта, этакий почтовый ящик. Скетч может подойти к ящику когда угодно: всё пришедшее уже лежит и ждёт. Serial.available() возвращает, сколько байт лежит прямо сейчас: ноль — пусто, три — ждут три байта. Она ничего не читает и не удаляет, только сообщает количество, поэтому проверка if (Serial.available() > 0) означает «в ящике есть хоть что-то». Если вы отправили ON и не читали его, три байта спокойно пролежат хоть минуту. Но ящик не резиновый: при переполнении новые байты отбрасываются, поэтому длинные потоки данных нужно вычитывать вовремя. Для команд с клавиатуры это неактуально, для непрерывных данных с датчиков — критично.
Забирать байт умеет Serial.read(): он вынимает самый старый байт из очереди и возвращает его. Тип возвращаемого значения — int, а не char, и это не случайность: при пустом буфере функция возвращает -1, а для такого сигнала нужно значение за пределами кодов символов. Отсюда золотое правило урока: сначала available(), потом read(). Без проверки скетч тоже запустится, но в моменты, когда данных нет, вы будете обрабатывать фиктивные -1 и удивляться непонятным символам в логе. Первый эксперимент — эхо: получили байт и тут же напечатали его вместе с числовым кодом. Примитив? Да. Но это рентген канала: сразу видно, что реально доходит до платы, включая невидимые переводы строк, которые позже объяснят половину странностей.
void setup() { Serial.begin(9600); Serial.println("Pishite chto ugodno:");} void loop() { if (Serial.available() > 0) { char c = Serial.read(); int code = c; // код символа, чтобы видеть невидимое Serial.print("Simvol: "); Serial.print(c); Serial.print(" kod: "); Serial.println(code); }}Собираем команду: от отдельных байтов к слову ON
Каждый вызов read() вынимает ровно один байт, поэтому команда ON приходит не словом, а тремя отдельными посылками: O, N и невидимый перевод строки. Стратегия сбора такая: заводим строку-копилку и складываем в неё символы, а когда встречается терминатор — перевод строки \n — считаем команду собранной и разбираем её целиком. В Arduino для этого удобно держать переменную типа String и дополнять её оператором +=: каждый проход loop() добавляет максимум один символ, и ничего не теряется. Нюанс, на котором спотыкаются все: вместе с \n монитор может прислать и возврат каретки \r — особенно на Windows. Этот невидимый байт молча приклеится к строке, сравнение с ON провалится, и вы полдня будете искать мистику. Поэтому \r мы просто пропускаем. Готовую команду сравниваем через == — и помните: регистр важен, on и ON для C++ разные слова.
Ветку «иначе» для неизвестных команд не выбрасывайте — она экономит часы. Когда плата отвечает «не поняла» и повторяет, что именно получила, ошибки видно сразу: лишний пробел, русская О вместо латинской O, случайная цифра. Функция trim() срезает пробелы по краям копилки — дешёвая страховка от случайно нажатого пробела перед командой. Обратите внимание и на структуру кода: вся работа с командами живёт внутри if (Serial.available() > 0), поэтому когда данных нет, loop() просто крутится дальше и не блокируется. Это принципиально: скетч продолжает мигать, считать и опрашивать датчики, даже если вы не притрагиваетесь к клавиатуре неделями. Ввод с порта — событие, а не режим работы всей программы; этот же принцип сохранится, когда мы дойдём до мигания без delay.
String cmd = "";const int ledPin = 9; void setup() { Serial.begin(9600); pinMode(ledPin, OUTPUT); Serial.println("Komandy: ON / OFF");} void loop() { if (Serial.available() > 0) { char c = Serial.read(); if (c == '\n') { // команда закончилась cmd.trim(); // срезаем случайные пробелы if (cmd == "ON") { digitalWrite(ledPin, HIGH); Serial.println("Svet: VKL"); } else if (cmd == "OFF") { digitalWrite(ledPin, LOW); Serial.println("Svet: VYKL"); } else if (cmd.length() > 0) { Serial.print("Ne ponyal: "); Serial.println(cmd); } cmd = ""; // копилка готова к новой команде } else if (c != '\r') { // возврат каретки пропускаем cmd += c; // складываем символ в копилку } }}Числа из текста: яркость 0–255 по команде
Текст умеет превращаться в число: у String есть метод toInt(), который читает цифры с начала строки и возвращает целое. Написали 180 — получите 180. Но у метода есть коварная особенность: если строка не похожа на число (пустая, буквенная, с мусором в начале), toInt() молча вернёт 0, а не ошибку. С одной стороны, удобно — программа не падает. С другой — опечатка превратится в тихую яркость ноль, и вы будете искать «сгоревший» светодиод, которого не существует. Поэтому после разбора число прогоняют через constrain(level, 0, 255): диапазон ШИМ на выходе — от 0 до 255, а пользователь мог послать и 999, и отрицательное значение. Дальше число скармливается analogWrite() — и команда из монитора становится реальной яркостью светодиода, скоростью мотора или положением заслонки.
Есть и второй путь, без String: читать цифры по одной и считать значение на ходу — value = value * 10 + (c - '0'). Так делают в экономных прошивках, где каждый байт памяти на счету, и это отличное упражнение на понимание того, что символы цифр — просто коды, идущие подряд. Для наших задач String-вариант нагляднее, но знайте, откуда растут ноги у профессиональных парсеров. Дальше — простор: добавьте к диммеру команду STATUS, печатающую текущую яркость, или две команды для плавного наращивания и убавления. Тот же приём — «текст превратили в число, ограничили, применили» — управляет углом сервопривода, скоростью вентилятора и порогом срабатывания термостата. Любой протокол обмена, который вы когда-нибудь придумаете для своего устройства, начнётся именно отсюда.
String input = "";const int ledPin = 9; void setup() { Serial.begin(9600); pinMode(ledPin, OUTPUT); Serial.println("Vvedite yarkost 0-255:");} void loop() { if (Serial.available() > 0) { char c = Serial.read(); if (c == '\n') { int level = input.toInt(); // текст в число level = constrain(level, 0, 255); // ШИМ живёт в диапазоне 0..255 analogWrite(ledPin, level); Serial.print("Yarkost: "); Serial.println(level); input = ""; } else if (c != '\r') { input += c; } }}- Serial.available() показывает, сколько байт ждёт в буфере на 64 байта; read() вынимает один байт за вызов
- read() из пустого буфера возвращает -1 — поэтому сначала available(), потом read()
- Команду собирают посимвольно в String до перевода строки \n, затем сравнивают целиком
- Фильтруйте \r и следите за регистром: ON и on — разные строки
- toInt() превращает текст в число (мусор даёт 0), constrain() удерживает значение в диапазоне ШИМ 0–255
- 1. Что вернёт Serial.read(), если данных в буфере нет?
- 2. Пользователь отправил «on», а скетч сравнивает строку с «ON». Что произойдёт?
- 3. Почему команда может не срабатывать, даже если буквы вроде правильные?
- 4. Что вернёт input.toInt() для строки «abc»?
Команды работают, светодиод слушается клавиатуры — интерактивность состоялась. Но чем сложнее логика, тем чаще она ведёт себя не так, как в голове: условие срабатывает не всегда, счётчик где-то сбивается, датчик раз в пять минут врёт. Гадать бессмысленно — нужно смотреть внутрь программы, и Serial снова станет главным инструментом, только теперь со стороны передачи данных. В следующем уроке мы займёмся форматированным выводом и отладкой: соберём аккуратный табличный лог с метками времени, научимся печатать переменные и состояния в нужных местах кода и поставим тайм-ауты против зависаний. Возьмите финальный скетч этого урока — диммер подойдёт идеально — и держите его под рукой: на нём и потренируемся.
Частые вопросы по теме урока
- Что вернёт Serial.read(), если данных в буфере нет?
- read() не ждёт: при пустом буфере он сразу возвращает -1. Именно поэтому перед чтением проверяют Serial.available() > 0. -1
- Пользователь отправил «on», а скетч сравнивает строку с «ON». Что произойдёт?
- Оператор == для String сравнивает посимвольно с учётом регистра. Нужно либо писать команды одинаково, либо приводить обе строки к одному регистру. Сравнение провалится: регистр букв имеет значение
- Почему команда может не срабатывать, даже если буквы вроде правильные?
- Монитор может отправлять CR+LF: лишний \r прилипает к команде, и сравнение молча проваливается. Такие невидимые символы отфильтровывают при сборе строки. К строке приклеился невидимый символ \r — возврат каретки
- Что вернёт input.toInt() для строки «abc»?
- toInt() не сообщает об ошибке: для строки без ведущих цифр он возвращает 0. Поэтому результат всегда проверяют и ограничивают через constrain(). 0 — нечисловая строка молча даёт ноль