Этот урок является продолжением урока про монитор порта, где разобрана отправка и чтение текстовых данных в консоли
UART (Universal Asynchronous Receiver-Transmitter) - интерфейс связи между цифровыми устройствами, по нему МК может общаться с другими микросхемами и МК. На Arduino-совместимых отладочных платах часто подключен к USB через микросхему USB-UART преобразователя для общения с компьютером через последовательный порт:
- Асинхронный: работает по времени
- Последовательный: данные передаются по одному проводу (в одну сторону)
- Количество пинов: 1 или 2 - раздельные линии на отправку и приём. Может одновременно принимать и отправлять, а также подключаться по одному проводу (только приём или только отправка)
- Скорость: зависит от устройства и качества линии, в среднем до 2 МБод
Скорость передачи данных по интерфейсу задаётся в бодах (baud rate) - бит в секунду. У UART имеется 2 "лишних" бита на каждый отправляемый байт данных, поэтому реальная скорость передачи информации - битрейт (bit rate) - составляет 80% от baud rate. Время передачи одного байта в миллисекундах равна 10000 / baud, а скорость - baud / 10 байт (символов) в секунду
UART чаще других интерфейсов используется для отправки текстовых, а не бинарных команд. Например, среди Ардуино-модулей на UART работают GPS, GSM, GPRS, WiFi и Bluetooth модемы, а также некоторые радио-модемы, и все они настраиваются именно текстовыми командами
Основы #
Роли #
UART не является шиной (но может использоваться как шина, топология выстраивается программистом) - он предназначен для передачи данных между двумя устройствами, причём оба этих устройства могут передавать данные по своему усмотрению, в том числе одновременно. Таким образом, UART - это просто передатчик между равнозначными "устройством 1" и "устройством 2".
Пины #
UART использует максимум 2 пина для подключения. Рассмотрим их функции:
RX(Receive) - пин, принимающий данныеTX(Transmit) - пин, отправляющий данные
- RX, почему ты такой грустный?
- Я приёмный
Подключение #
Здесь всё логично - пин отправки одного устройства подключается к пину приёма второго устройства и наоборот, т.е. крест-накрест. Также не забываем соединить GND - сигналы ходят относительно него.

Здесь D1 общается в обе стороны, D2 только принимает, а D3 - только отправляет
В продаже встречаются электронные модули, где RX и TX перепутаны - в этом случае нужно изучать информацию по конкретному модулю
Логические уровни #
Напряжение питания устройств может отличаться, например 5V МК и 3.3V датчик-модуль, либо наоборот. В этом случае нужно смотреть описание устройства с более низким напряжением питания - поддерживает ли оно высокое напряжение на вход UART (часто используется термин "толерантно", например 3.3V микросхема может быть толерантна к логическому уровню 5V на входе UART). Если не поддерживает - нужно понизить напряжение на линии передачи к этому устройству при помощи делителя напряжения или специального преобразователя уровней:
Линию передачи от низковольтного устройства можно не трогать - например если 3.3V устройство передаёт на 5V устройство, то 5V устройство в большинстве случаев "увидит" этот логический уровень без повышения напряжения.
Реализация #
Аппаратная (Serial) #
Аппаратный UART является отдельным блоком МК и обычно выведен на конкретные пины, их можно найти на распиновке как RXn и TXn, где n - номер UART (если цифры нет - то он единственный):
Иногда UART можно переносить на другие пины (remap), также он может иметь только один из пинов
В Arduino UART обычно представлен классом HardwareSerial, но в явном виде в программе он не используется - объекты созданы в системных файлах ядра и можно просто ими пользоваться. Объекты называются SerialN, где N - номер UART, причём нулевой не указывается:
Serial- UART0 (RX0-TX0 / RX-TX)Serial1- UART1 (RX1-TX1)Serial2- UART2 (RX2-TX2)
И так далее. У разных плат и МК количество UART-ов отличается, но обычно есть хотя бы один (нулевой, Serial) и он подключен через преобразователь к USB гнезду для удобства отладки программы, поэтому во всех примерах для вывода в порт используется именно Serial. Остальные - для подключения внешних железок.
Программная (SoftwareSerial) #
SoftwareSerial.h - стандартная библиотека для создания программного UART на любых пинах. Эта библиотека находится в файлах фреймворка, но у некоторых плат и МК она может отсутствовать - разработчик не написал версию для своей платы. Также для разных МК у этой библиотеки могут быть разные особенности, нужно читать описание к конкретной плате.
#include <SoftwareSerial.h>
SoftwareSerial mySerial(2, 3); // всегда в порядке (RX, TX)
На некоторых МК есть несколько аппаратных UART, иногда их можно назначать на любые пины (например ESP32), поэтому использовать SoftwareSerial на них практически всегда неуместно
Классические Arduino #
Например для UNO/Nano/Mega и прочих из этого поколения действуют следующие ограничения:
- Максимальная скорость -
115200бод - Не может одновременно читать и отправлять
- Если используется несколько программных UART (объектов
SoftwareSerial), то только один из них может принимать данные в один момент времени. Включить текущий объект на приём можно при помощи методаlisten(), а проверить текущее состояние -isListening() - Не все пины на Arduino Mega могут быть назначены для RX, подробнее - в официальной заметке
- Для классических Arduino есть библиотека AltSoftSerial - может одновременно принимать и отправлять
Общие моменты #
Для запуска связи нужно вызвать метод begin() с указанием скорости в бодах. Исторически сложился ряд скоростей, которые используются в терминалах и микросхемах: 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200 бод, иногда используются и более высокие скорости.
Скорость на обоих устройствах должна совпадать
Для остановки связи можно вызвать end(), но обычно UART запускается в начале работы программы и работает всё время.
#include <SoftwareSerial.h>
SoftwareSerial mySerial(2, 3); // всегда в порядке RX, TX
void setup() {
Serial.begin(9600); // UART0, монитор порта
Serial1.begin(115200); // UART1
mySerial.begin(9600); // программный UART на пинах 2 и 3
}
void loop() {
}
Отправка и приём (Stream) #
Все возможности Stream можно посмотреть в документации
В Arduino все варианты Serial совмещены с классом Stream (наследуют его) - инструментом для работы с потоковыми данными. Как происходит передача через Serial "под капотом":
- В программе выделяются кольцевые буферы под приём и отправку, например в классических Arduino это 64 байта на каждый
- Любая отправка со стороны программы (
print(),write()) помещает байты в буфер, из которого они будут друг за другом отправлены в UART асинхронно в прерываниях. Это сделано для того, чтобы не ждать отправки каждого байта. Если отправлять данные, когда буфер отправки заполнен - программа будет ожидать отправки байтов из буфера и освобождения места под новые данные - Приём происходит асинхронно в прерываниях по одному байту, данные помещаются в буфер. Это сделано для того, чтобы данные читались, даже если программа в этот момент занята чем-то другим. Если буфер приёма переполнен - новые данные будут утеряны. Данные, которые мы читаем при помощи
read()иparse()-функций, берутся именно из буфера
Отправку и приём мы разбирали в уроке про монитор порта, коротко перечислю основы:
- Метод
print()печатает данные любого стандартного типа:Stringиchar*строки, целые иfloatчисла. Числа разбиваются на ASCII символы согласно выбранной системе счисления и отправляются как текст, то есть буквально 2-байтное число12345(DEC) отправляется как 5-байтная строка"12345" - Метод
write()отправляет байт данных или массив байт указанной длины - Метод
available()возвращает количество доступных для чтения байт в буфере приёма - Метод
read()возвращает следующий байт из буфера. Возвращаемый тип -int, если в буфере ничего нет - будет результат-1 - Методы
parseInt()иparseFloat()ожидают число в виде текста, собирают его из символов и возвращают результат по достижению таймаута или по нечисловому символу - Метод
readString()читает входящие данные как текст
Текстовые данные #
Отправка и получение в виде текста часто используется для работы с микросхемами и модемами, а также для управления и отладки через монитор порта. Хорошая практика - завершать строку символом переноса строки '\n', тогда такие "пакеты" проще отладить в мониторе (они будут переноситься), а МК сможет читать такую команду до известного символа, например при помощи readStringUntil('\n').
Два МК могут общаться между собой по UART, но для этого нужно придумать свой протокол связи, либо использовать что-то готовое поверх Serial (например, JSON). В уроке про монитор порта мы разбирали простейший текстовый протокол связи вида "команда:значение", давайте рассмотрим пример взаимодействия двух Ардуинок. Для примера я свяжу две Arduino Nano по SoftwareSerial, штатный Serial останется для вывода в монитор порта:
Что будет происходить:
- Переключение светодиода на устройстве 2 каждые 300 мс, команда вида
led:значение - Включение ШИМ со случайным значением на устройстве 2 каждые 1500 мс, команда вида
pwm:значение - Запрос значения
millis()со второго устройства каждые 2000 мс, команда видаmillis:(двоеточие для корректного парсинга) - Приём значения
millis()на первом устройстве по командеms:значение
#include <Arduino.h>
#include <SoftwareSerial.h>
SoftwareSerial uart(2, 3);
void setup() {
Serial.begin(115200);
uart.begin(9600);
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
if (uart.available()) {
String key = uart.readStringUntil(':');
String val = uart.readStringUntil('\n');
Serial.print("Got command: ");
Serial.println(key + ':' + val);
if (key == "led") {
// включить светодиод
digitalWrite(LED_BUILTIN, val.toInt());
} else if (key == "millis") {
// запрос millis, отправляем
uart.println(String("ms:") + millis());
} else if (key == "pwm") {
// включить шим на 5 пине
analogWrite(5, val.toInt());
}
}
}
#include <Arduino.h>
#include <SoftwareSerial.h>
SoftwareSerial uart(2, 3);
void setup() {
Serial.begin(115200);
uart.begin(9600);
}
void loop() {
if (uart.available()) {
String key = uart.readStringUntil(':');
String val = uart.readStringUntil('\n');
if (key == "ms") {
// пришёл ответ millis
Serial.println("Got millis: " + val);
}
}
// отправка состояния светодиода
// каждый раз инвертируем
static uint32_t tmr1;
if (millis() - tmr1 >= 300) {
static bool led;
tmr1 = millis();
led = !led;
uart.println(String("led:") + led);
}
// отправка случайного ШИМ на пин
static uint32_t tmr2;
if (millis() - tmr2 >= 1500) {
tmr2 = millis();
uart.println(String("pwm:") + random(256));
}
// запрос millis
static uint32_t tmr3;
if (millis() - tmr3 >= 2000) {
tmr3 = millis();
uart.println("millis:");
}
}
Бинарные данные #
Для общения между МК гораздо удобнее использовать бинарные данные, чем строки: пакет проще и быстрее собрать и распаковать, это займёт меньше ресурсов МК, а также пакет будет легче и быстрее отправится. Опять же можно использовать как готовые варианты (Firmata, Modbus), так и придумать что-то своё.
Самый простой и универсальный вариант - передавать структуру, в самом базовом случае этот механизм работает так:
// структура (объявлена глобально для передатчика и приёмника)
struct Data {
float valf;
int16_t vali1;
int16_t vali2;
uint8_t arr[3];
};
Здесь огромную роль играет выравнивание памяти: если данные передаются между устройствами с разной архитектурой, то на уровне байтов структуры могут быть разные, даже если визуально они одинаковые. Рекомендуется: использовать явные типы данных (int8, uint16..), располагать данные в порядке убывания размера, а также паковать структуру через атрибут или прагму (подробнее - в уроке про структуры)
void setup() {
Serial.begin(115200);
}
void loop() {
// создание и заполнение
Data data;
data.valf = 3.14;
data.vali1 = millis();
data.vali2 = random(100);
data.arr[0] = 1;
// отправка как массив байт длиной sizeof(data)
Serial.write((uint8_t*)&data, sizeof(data));
delay(1000);
}
void setup() {
Serial.begin(115200);
Serial.setTimeout(3);
}
void loop() {
if (Serial.available()) {
// буфер для чтения
Data data;
// чтение как в массив байт длиной sizeof(data)
Serial.readBytes((uint8_t*)&data, sizeof(data));
// здесь data прочитана и содержит принятые значения
}
}
Это базовый пример, он очень ненадёжен:
- Если приёмник будет "тормозить" (читать медленнее, чем отправляют, или при delay-задержках в программе), то его буфер забьётся
- Высок риск попадания в "середину" пакета данных, из-за чего все последующие чтения будут содержать неверные, смещённые данные из разных пакетов
- Нет контроля целостности пакета, если он повредится при передаче - мы об этом не узнаем
- Чтение синхронное, т.е.
readBytes()ожидает поступления в буфер всей указанной длины, либо тайм-аута (его мы поставили 3 мс) - Поддерживаются данные только в одном формате
Слегка улучшим приёмную часть:
void loop() {
if (Serial.available()) {
// буфер для чтения
Data data;
// чтение как в массив байт длиной sizeof(data)
if (Serial.readBytes((uint8_t*)&data, sizeof(data)) == sizeof(data)) {
// здесь data прочитана и содержит принятые значения
// контроль по длине принятых данных
}
// принудительная очистка буфера, чтобы сбросить остатки при рассинхронизации
while (Serial.available()) Serial.read();
}
}
Данный механизм можно долго улучшать до нормального надёжного протокола связи - по сути, Arduino Stream предоставляет самые низкоуровневые инструменты для работы с потоком данных из UART, которыми нужно пользоваться с пониманием. Для более надёжной и универсальной связи между платами по Serial можно использовать библиотеку StreamPacket.
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками



