Мою домашнюю метеостанцию я собрал из двух плат, не прочитав ничего про связь между ними. Датчик жил на балконе на первой Uno, дисплей с часами — на второй в комнате; между ними я протянул один провод «на сигнал» и был уверен, что заработает. Не заработало ничего: обе платы мигали светодиодами и говорили каждый сам с собой через USB, друг другу не ушло ни одного байта. Три вечера я подозревал датчик, а сломана была сама картинка: я соединил передатчик с передатчиком и забыл третий провод, без которого ток не проходит круг.
Вторая попытка вышла дороже. Я воткнул провода в пины 0 и 1 — туда, где у Uno живёт аппаратный UART, — и следующие полчаса проиграл в догонялки с загрузчиком: скетч не льётся, avrdude ругается на not in sync, отцепляю провод — льётся. Порт этот мы обжили ещё в первом уроке про монитор порта: те же пины 0 и 1 через USB-мост смотрят в компьютер, и пока к ним прицеплено что-то ещё, загрузчик делит линию не с тем соседом. Обе беды лечатся правилами старше Arduino на полвека: говорить в уши, а не в рот, и держать общий провод.
В этом уроке соединяем две платы по-настоящему: крест-накрест, TX к RX, плюс общий GND — и наводим порядок в разговоре. Протокол ведущий-ведомый: одна плата задаёт вопросы, вторая только отвечает; команда без ответа считается пропавшей, а живость связи проверяет пинг. Словарь команд и ответов OK и ERR мы уже собрали в уроке про командный парсер — здесь тот же словарь поедет по проводу между платами. В симуляторе обе половины будут жить в одном скетче: движок не умеет две платы сразу, зато протокол — кто когда говорит и что отвечает — покажет целиком.
Крест-накрест: TX к RX и общий GND
У каждой Arduino есть передатчик TX и приёмник RX — рот и уши. Соединяя две платы, мы подключаем рот одной к уху другой: TX первой — в RX второй, TX второй — в RX первой. Напрямую, рот к рту, соединять бессмысленно: обе говорят, никто не слушает — ровно то, что случилось у меня с «одним проводом на сигнал». Крестовина из двух проводов даёт дуплекс: по каждой линии идёт свой поток. Но слушать одновременный разговор всё равно нельзя — порядок наведём правилом протокола ниже.
Третий провод — общий GND — не опция. UART — это напряжение на проводе относительно земли: передатчик выдаёт 0 или 5 вольт, приёмник меряет их относительно своей земли. Нет общего провода — нет точки отсчёта: приёмник читает кашу из случайных нулей и единиц, а то и молчит вовсе. На третьем вечере я уже правильно перекрестил два провода — и они молчали, пока не легла земля.
Скорости должны совпасть с двух сторон: 9600 на одной плате и 115200 на другой дадут не ошибку, а тихий мусор — приёмник собирает из чужого ритма случайные байты. Ставьте 9600: на коротком проводе между макетками надёжнее всего. Про буферы помним из урока про буфер UART: 64 байта на приём и 64 на передачу, а на 9600 бод байт стоит около миллисекунды — команда из десяти символов доезжает за десять мс.
Куда втыкать — отдельная развилка. На Uno аппаратный UART торчит на пинах 0 и 1, и он же занят USB-мостом: скетч при прошивке льётся через те же пины. На время загрузки провода отцепляем, потом возвращаем; когда надоест — впереди по плану SoftwareSerial, второй порт программно на любых пинах.
| Симптом | Диагноз | Лечение |
|---|---|---|
| Обе платы молчат, байтов нет | TX соединён с TX: обе говорят, никто не слушает | крест-накрест: TX каждой — в RX другой |
| На приёмнике каша из символов | разные скорости или нет общего GND | одинаковый Serial.begin с двух сторон, третий провод на землю |
| Скетч не льётся, avrdude: not in sync | провод сидит на пинах 0/1 во время прошивки | отцепить провода на время загрузки, потом вернуть |
| Работает через раз, на ощупь | длинные провода-хвосты и наводки от реле и моторов | короче провода, ниже скорость, GND потолще |
Ведущий и ведомый: кто первый начал
Дуплекс — коварная роскошь. Мой второй протокол был «равноправным»: обе платы рапортовали телеметрию каждые полсекунды. По отдельности каждая вела себя безупречно, вместе — на приёмнике каша: две строки наезжали друг на друга, и чья сейчас температура в логе, было не понять. UART передаст оба потока байт за байтом, но перемешанными: по линиям порядок, в сумме винегрет.
Лечится порядком, а не железом: протокол ведущий-ведомый. Ведущий всегда начинает разговор — шлёт команду. Ведомый молчит, пока не спросили, и на каждую команду отвечает ровно одной строкой. На линии больше не бывает двух говорящих: очередь одна, и она принадлежит ведущему. Словарь тот же, что в парсере: команда, ответ OK с фактом или ERR с диагнозом, — только теперь он живёт между двумя платами.
Второе правило — проверка живости. Ведущий раз в несколько секунд отправляет пинг, ведомый отзывается понгом: «ведомый молчит» и «ведомый мёртв» снаружи выглядят одинаково, а это разные беды. Пропали два-три понга подряд — журнал ведущего переходит на warn, как мы договаривались в уроке про уровни логов. Тикать пингом удобно на millis(): почему delay() тут не друг, разобрано в отдельном уроке.
И третье — таймаут на ответ. Ведущий ждёт не вечно, а условные две секунды: интервал пинга плюс запас. Не дождался — команда объявляется пропавшей, и в журнал идёт строка с тем, что именно пропало: молчание неотличимо от смерти платы.
// Плата ведомого: лампа на пине 9, слушает свою RXint kodKomandy(String slovo) { if (slovo == "led") return 1; if (slovo == "pwm") return 2; if (slovo == "ping") return 3; if (slovo == "help") return 4; return 0;} void setup() { Serial.begin(9600); pinMode(9, OUTPUT);} void loop() { if (Serial.available() > 0) { String cmd = Serial.readStringUntil(10); cmd.trim(); int probel = cmd.indexOf(" "); String slovo = cmd; String param = ""; if (probel > 0) { slovo = cmd.substring(0, probel); param = cmd.substring(probel + 1); } switch (kodKomandy(slovo)) { case 1: if (param == "1") { digitalWrite(9, HIGH); Serial.println("OK led: dezhurka=1"); } else if (param == "0") { digitalWrite(9, LOW); Serial.println("OK led: dezhurka=0"); } else Serial.println("ERR led: nuzhno 0 ili 1"); break; case 2: { int yarkost = param.toInt(); if (yarkost >= 0 && yarkost <= 255) { analogWrite(9, yarkost); Serial.print("OK pwm: pwm="); Serial.println(yarkost); } else Serial.println("ERR pwm: diapazon 0-255"); break; } case 3: Serial.print("OK pong: t="); Serial.println(millis() / 1000); break; case 4: Serial.println("Komandy: led 0-1 / pwm 0-255 / ping / help"); break; default: Serial.print("ERR: neizvestno: "); Serial.println(cmd); break; } }}Модель протокола в симуляторе
Честная оговорка, как обещал: движок симулятора не держит две платы сразу и не умеет SoftwareSerial, поэтому ниже — учебная модель протокола в одном скетче. Ведущий — это вы: то, что вы печатаете в строке ввода монитора, ведущий отправляет в провод. Провод — строковая переменная bufer: командная строка кладётся в неё целиком, как в настоящий UART кладутся байты. Ведомый — вторая половина скетча: забирает строку из провода, разбирает и отвечает.
В журнале каждая поездка видна парой строк: VED> — то, что ушло от ведущего, VEDOM> — то, что ответил ведомый. Печать строк из порта и ожидание терминатора разобраны в уроке про readStringUntil, разрезка команды на слово и параметр — в уроке про парсер; здесь то же самое, только между вводом и разбором вклинился провод. Раз в три секунды ведущий отправляет пинг сам — журнал дышит даже без вашего участия.
Понг отвечает парами имя=значение — t=12, — чтобы плоттер рисовал ряд точками вверх и вы видели, что связь жива. На реальную плату модель разворачивается назад: ведущую половину — в скетч первой Uno, ведомую — во вторую, строку bufer — в два провода крест-накрест. Логика разговора не меняется ни на байт, и это не должно меняться: она и есть протокол.
- Запустите и не трогайте ввод: посчитайте по меткам t= в понгах, с каким периодом ходит пинг, и найдите ряд t= в плоттере. Это сердцебиение протокола.
- Отправьте led 1, потом pwm 64: лампа загорится и притухнет. Проверьте: на каждую команду — ровно одна строка VEDOM>.
- Отправьте led 5 и затем zheka: первая упрётся в ERR led, вторая — в ERR про неизвестную команду. Молчащих команд быть не должно.
- Поставьте в автопинге 8000 вместо 3000 и сравните плотность журнала: пинг — плата за знание, что связь жива.
- Разверните модель на две платы: скетч режется пополам, ведущей половине — ввод и автопинг, ведомой — всё до vedomiy(), а строка bufer превращается в два провода крест-накрест.
- Две платы соединяются крест-накрест: TX каждой — в RX другой, плюс общий GND; без земли приёмнику не от чего мерить напряжение
- Пины 0 и 1 Uno делятся между UART и USB-мостом: на время прошивки провода отцепляем, иначе avrdude не синхронизируется
- Протокол ведущий-ведомый: начинает всегда ведущий, ведомый молчит и отвечает ровно одной строкой на каждую команду
- Пинг раз в несколько секунд отличает «молчит» от «мёртв»: два-три пропавших понга подряд — повод для warn в журнале
- На каждую команду — таймаут и эхо: ответа нет две секунды — команда объявляется пропавшей, и в журнале сказано, какая именно
- 1. Как правильно соединить две платы по UART?
- 2. Почему ведомый никогда не начинает разговор сам?
- 3. Ведущий отправил команду и не получил ответ. Что делает таймаут?
- 4. Скетч перестал загружаться после подключения второй платы. Первое, что проверить?
Частые вопросы по теме урока
- Как правильно соединить две платы по UART?
- Передатчик говорит в уши приёмника: TX — в RX, крест-накрест. Общий GND обязателен — это точка отсчёта для напряжения на проводе. TX каждой платы — в RX другой, плюс общий провод GND
- Почему ведомый никогда не начинает разговор сам?
- UART передаст оба потока, но приёмник получит перемешанный винегрет. Ведущий-ведомый выводит одновременность из протокола, а не из железа. Потому что ведомый занят опросом датчика
- Ведущий отправил команду и не получил ответ. Что делает таймаут?
- Таймаут переводит молчание в факт: команда пропавшая, и в журнале сказано какая. Без эха отправитель не отличит тишину от смерти платы. Через две секунды объявляет команду пропавшей и пишет в журнал, какая именно пропала
- Скетч перестал загружаться после подключения второй платы. Первое, что проверить?
- Пины 0 и 1 Uno обслуживают и UART, и загрузку через USB-мост. Чужой провод на них сбивает avrdude — отцепляйте на время Upload. Провод на пинах 0/1: на время загрузки его надо отцепить