Гирлянда на фасаде живёт по расписанию, насос — по датчику влажности, подсветка крыльца — по движению. Три исполнителя, один порт, один человек за клавиатурой. Печатать три числа подряд и надеяться, что parseInt сам разложит их по местам — путь к будням вида «насос включился вместо света». Число само по себе не носит адреса: кто из троих должен отреагировать на 1? Строка вида led 1 on носит: первое поле — кому, второе — канал, третье — что сделать. Это уже не команда с параметром из прошлого урока, а настоящий пакет из трёх полей.
Здесь режу такую строку на три части по пробелам и собираю обратно в действие: светодиод на D12 и реле на D7 в симуляторе, у вас — что угодно с двумя пинами. Включить один исполнитель словом on вы уже умели после урока про светодиод из монитора — теперь адресуется каждый. Заодно разберу, почему честный разбор строки надёжнее вылавливания чисел parseInt-ом: у parseInt в шкафу скелет в виде секунды молчания и мусора в буфере, и оба скелета вылезают в самый неподходящий момент.
Три поля и два пробела
Поле — кусок текста между пробелами. Разрез идёт в два приёма: indexOf(" ") находит первый пробел, substring(0, p1) забирает всё до него, substring(p1 + 1) — всё после. Повторяем то же на хвосте — получаем три поля без единой регулярки. Строку до этого берём целиком через readStringUntil, как показано в уроке про приём строки. Пробел в роли разделителя выбран не по прихоти: его не стыдно напечатать с телефона, он не путается с минусом в числах и не теряется в подчёркиваниях. Запятая и точка с запятой — соседний жанр CSV, он разобран отдельно в уроке про strtok и atoi, там же и C-вариант разреза без String.
Каждое поле проверяю отдельно, и первое же неверное поле останавливает разбор с конкретной причиной: неизвестное устройство — bad с эхом того, что услышано, состояние не ON и не OFF — bad с перечнем допустимых. Канал тоже под проверкой: пока у каждого вида исполнителей по одному каналу, всё, кроме 1, получает отказ. Хвост строки после третьего поля я игнорирую не молча — лишний мусор в команде говорит, что печатал кто-то другой или что строка доехала с хвостом от соседнего ввода. Такой порядок — структура, устройство, канал, состояние — превращает разбор в лестницу: bad всегда называет ступень, на которой нога соскользнула.
Строка до разреза проходит trim(). Хвост \r из монитора иначе приклеится к последнему полю, и OFF превратится в OFF\r — сравнение не сойдётся, а вы будете смотреть в правильный код и не понимать, что не так. Одна строка trim() в начале стоит трёх часов гаданий с выпученными глазами — проверено не на одном проекте.
Поля я держу словами, а не номерами: led, а не 1 — потому что строка лога должна читаться через месяц без легенды. Сравните ok: led 1 = ON и ok: 1 1 1: первое показывает, что произошло, второе требует таблицы соответствия рядом с монитором. Цена — три-четыре лишних байта на команду, несколько миллисекунд при 9600 бод. Машина-собеседник слова переваривает без нареканий: сравнить две строки для процессора не дороже, чем сравнить два числа, а выигрывает человек, который читает лог.
// led 1 on — три поля: устройство, канал, состояниеconst int LED = 12;const int RELAY = 7; void runCommand(String line) { line = line.trim(); if (line.length() == 0) return; int p1 = line.indexOf(" "); // первый пробел if (p1 < 0) { Serial.println("bad: format: ustroystvo kanal sostoyanie"); return; } String dev = line.substring(0, p1); String rest = line.substring(p1 + 1); // всё после первого поля int p2 = rest.indexOf(" "); // второй пробел if (p2 < 0) { Serial.println("bad: net kanala i sostoyaniya"); return; } String chan = rest.substring(0, p2); String state = rest.substring(p2 + 1); state = state.trim(); state = state.toUpperCase(); int pin = 0; if (dev == "led") pin = LED; else if (dev == "relay") pin = RELAY; else { Serial.print("bad: ne znayu ustroystvo "); Serial.println(dev); return; } if (chan != "1") { Serial.println("bad: kanal poka odin"); return; } if (state == "ON") digitalWrite(pin, HIGH); else if (state == "OFF") digitalWrite(pin, LOW); else { Serial.println("bad: sostoyanie tolko ON ili OFF"); return; } Serial.print("ok: "); Serial.print(dev); Serial.print(" "); Serial.print(chan); Serial.print(" = "); Serial.println(state);} void setup() { Serial.begin(9600); pinMode(LED, OUTPUT); pinMode(RELAY, OUTPUT); Serial.println("gotov: led 1 on, relay 1 off");} void loop() { if (Serial.available()) runCommand(Serial.readStringUntil(10)); // 10 = LF delay(20);}Почему строка надёжнее parseInt
parseInt устроен как тёрка: он пропускает всё, что не похоже на цифру, хватает первое число и уходит. Строка abd1234zxy вернёт 1234, а мусор до и после останется лежать в буфере порта — следующий вызов выловит из него что-то ещё, и это будет уже не то, что вы печатали. В протоколе из трёх полей такая тёрка опасна дважды: она не отличает led от relay, потому что буквы для неё шум, и молча съедает порядок полей, превращая адресный пакет в лотерею. Разбор строки через indexOf и substring видит пакет целиком: три поля, их границы, их порядок — и отвечает за каждый кусок по имени.
Вторая ловушка parseInt — тайм-аут. По умолчанию Serial.setTimeout стоит 1000 миллисекунд: если цифра так и не пришла, parseInt сидит в loop() ровно секунду, и всё это время диод не мигает, кнопка не опрашивается, всё стоит. Разбор строки по indexOf и substring не блокируется никогда: пришла строка — режем, не пришла — идём дальше по loop без единой паузы. Тайм-аут можно и укоротить через setTimeout, но честнее не звать число там, где его не ждали: чтение порта начинается только после available(), как в скетче выше.
Буфер порта, из которого мы режем поля, конечен — 64 байта на Uno, и его границы с последствиями переполнения разобраны в уроке про буфер UART. Три коротких поля плюс два пробела — байтов двадцать, пятикратный запас. Но одна вставка из буфера обмена на сотню символов переполняет буфер молча: хвост теряется без всякого сообщения, и пострадает следующий за длинной строкой ввод. Ещё одна причина отклонять длинные строки в парсере, а не надеяться на вежливость печатающего.
Есть и третья причина, тихая: parseInt без числа возвращает 0. Ноль выглядит честным значением — канал 0, порог 0 — и протекает в логику незаметно, пока кто-нибудь не спросит, почему плата выключила всё сама. Строковый разбор в такой ситуации просто не доходит до числа: поле пустое, парсер останавливается на отказе и называет поле по имени. Отказ с именем поля и тихий ноль в переменной — два разных качества жизни, и второе выбирают ровно до первого запуска в бою.
Обратите внимание на ответ в конце разреза: ok: led 1 = ON. Плата повторяет все три поля как поняла их — этот приём я утащил из прошлого урока про эхо-подтверждения и он же станет героем следующего. Когда полей станет больше трёх, а команд больше десятка, цепочка else if перерастёт в меню с подкомандами — урок про текстовое меню показывает следующий этаж архитектуры. Здесь же всё умещается в один экран, и это осознанный потолок: парсер, который видно целиком, чинится быстрее, чем парсер, который надо листать.
Когда полей станет четыре — скажем, led 1 on 300 для мигания триста миллисекунд, — разрез просто продолжается: ещё один indexOf, ещё один substring, ещё одна проверка. Цепочка растёт линейно, лестница отказов не ломается: bad по-прежнему называет ступень, на которой всё остановилось. Так словарь из двух исполнителей разрастается до щита автоматизации, оставаясь одним экраном кода.
| Способ | Что умеет | Где ломается |
|---|---|---|
| parseInt | вылавливает первое число из потока | не знает адреса числа, мусор остаётся в буфере, при пустом буфере ждёт 1000 мс |
| readStringUntil + indexOf + substring | режет строку на поля целиком и видит структуру пакета | двойные пробелы съедают пустые поля — нужен trim и проверка длины |
| strtok + atoi | C-стиль без String: ноль выделений памяти и фрагментации | мутирует строку на месте и пугает новичков указателями |
| readString | собирает всё, что пришло, до тайм-аута | без перевода строки честно ждёт полную секунду — блокирует loop |
- 1. Первое действие с командной строкой до разреза на поля?
- 2. Строка pump 2 off. Сколько в ней полей и сколько пробелов-разделителей?
- 3. Почему разбор строки надёжнее parseInt для пакета из трёх полей?
- 4. Сколько миллисекунд parseInt ждёт число при пустом буфере по умолчанию?
- 5. К последнему полю прилип невидимый \r от монитора. Что случится?
- Одна строка — три поля: устройство, канал, состояние; разделитель — пробел
- Разрез: indexOf(" ") находит границу, substring режет по ней, trim чистит края до всего
- parseInt выхватывает число без адреса и мусорит буфер — для пакетов из полей не годится
- Пустой буфер и parseInt — пауза 1000 мс в loop: сначала available(), потом чтение
- Каждое поле проверяется отдельно, и bad называет поле-виновника — разбор превращается в лестницу отказов
Частые вопросы по теме урока
- Первое действие с командной строкой до разреза на поля?
- Сначала trim(): невидимый CR с хвоста иначе приклеится к последнему полю, и сравнение с OFF не сойдётся при внешне правильной строке. trim() — срезать пробелы и хвосты по краям
- Строка pump 2 off. Сколько в ней полей и сколько пробелов-разделителей?
- pump, 2 и off — три поля, между ними два пробела-разделителя. Поля отделяются n-1 разделителем, и indexOf находят именно их позиции. Три поля, два пробела
- Почему разбор строки надёжнее parseInt для пакета из трёх полей?
- parseInt глотает буквы как шум и хватает первое число — адрес теряется, а непереваренный хвост остаётся в буфере портить следующий ввод. parseInt не знает, к какому полю относится число, и оставляет мусор в буфере
- Сколько миллисекунд parseInt ждёт число при пустом буфере по умолчанию?
- Дефолтный тайм-аут — 1000 мс: на каждый вызов без цифр loop замирает на секунду. Лечится проверкой available() или осознанным setTimeout. 1000 мс — дефолт Serial.setTimeout
- К последнему полю прилип невидимый \r от монитора. Что случится?
- Строка OFF\r не равна OFF — это разные последовательности байтов. trim() до разреза убирает хвост, и сравнение снова работает. Сравнение с OFF не сойдётся, и команда получит bad