Посмотр рубрик

Интерфейс UART (Serial)

Этот урок является продолжением урока про монитор порта, где разобрана отправка и чтение текстовых данных в консоли

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.

Полезные страницы #

Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх