Достаточно часто возникает ситуация, когда надо передавать блоки данных по непрерывному потоку. В этом случае на первый план выходит вопрос, как отделять один блок данных от другого. На второй план выходит вопрос, передавать ли данные в бинарном виде или в текстовом. Добавим к этому возможность продолжения работы при небольших искажениях (потери, мусор, ошибки взаимодействующих узлов) и необходимость эффективной утилизации канала передачи данных. При этом задача должна решаться на простеньком микроконтроллере с ограниченными ресурсами.
Такие задачи возникают, к примеру, при передаче телеметрии и для управления удаленным оборудованием. С одной стороны обычно стоит простейший микроконтроллер, с другой стороны стоит компьютер. Связь между ними может осуществляться по старому, доброму RS232. Хотя бывает и сложнее, например, выход микроконтроллера UART преобразуется в 802.11b, затем идет распространение радиосигнала до радиомачты и в сервер приходит Ethernet.
Если интересен мой велосипед на эту тему, добро пожаловать под кат.
Прежде всего, определимся с требованиями:
- Канал может быть создан в любой момент времени.
- Как контроллер, так и компьютер могут быть подключены к каналу в произвольный момент времени, в том числе и в середине посылок данных.
- При искажении на канале соответствующий блок данных должен быть отброшен.
- На одном канале может находиться несколько устройств.
- Блоки данных могут содержать любую последовательность байт и быть произвольной длины.
- Ресурсы, выделяемые на поддержку протокола, жестко ограниченны.
Получается реализация отдаленно напоминающая UDP.
Рассмотрим несколько распространенных способов на примере передачи трех чисел «напряжений», как один блок и двух других чисел «температура», как другой блок.
Часто встречается решение этой проблемы переводом всех отправляемых данных в текстовой вид, и разделением блоков (пакетов) переводом строки.
Это может выглядеть так:
V 1231, 2400, -231
V 1333, 2100, -232
T 36, -40
Используется sprintf(buf,“V %d, %d”,…).
Проблема передачи: sprintf использует ощутимое количество стека, достаточно долгое.
Проблема приема: на контроллере преобразовать набор символов «-232» в int требует дополнительных ресурсов. Нет контроля типов, 100500 условий проверок на ошибки.
Как плюсы – человек может видеть невооруженным взглядом передаваемые параметры.
Если проект продолжит развиваться на этом протоколе, то через некоторое время будет невозможно поддерживать его и даже доступность человеческого анализа исчезнет.
Частично решить проблему можно передавая не десятичные цифры, а шестнадцатеричные – это упростит обработку, но от остальных проблем не избавит.
Для улучшения контроля можно завернуть передаваемые данные в XML:
<?xml version="1.0" encoding="UTF-8"?>
<voltages>
<voltage num=”1”>1231</voltage>
<voltage num=”1”> 2400</voltage>
<voltage num=”1”> -231</voltage>
</voltages>
Или можно завернуть в JSON:
{
“Voltages”: [1231, 2400, -231]
}
Протокол станет документированным. Но при этом всё равно нет контроля типов на этапе компиляции. А количество дополнительно передаваемых данных становиться чрезмерно большим. При этом остается проблема разбора чисел и текста на ограниченных ресурсах.
Если один из узлов непрерывно что-то передает, то момент подключения второго узла может попасть на середину передаваемых данных. В большинстве случаев на свеже-включенном узле нельзя достоверно определить начало пакета, поэтому придется либо ждать перевода строки, либо полагаться на удачу. Кроме того, нет контроля пакета на корректность (например, из-за близкой грозы один бит передался не так). Эти недостатки можно решить, используя подход близкий к протоколу NMEA:
$GNVLT,1231,2400,-231*71
Здесь начало пакета всегда $, конец пакета это звездочка и CRC (0x71) данных пакета. Здесь решается проблема некорректности пакета (при этом классический CRC здесь очень простой – XOR), но остаются проблемы документирования и контроля типов пакета.
Получается, что при использовании текстового потока много накладных расходов, мало контроля типов, есть сложности в документировании.
Рассмотрим передачу данных двоичным способом. Передаваться будут наборы байт, поэтому необходимо определить структуры:
typedef struct {
int16_t supplyIn;
int16_t supplyOut;
int16_t groundPotential;
}PackVoltages;
typedef struct {
int8_t internal;
int8_t outside;
}PackTemperature;
Имеем 6 байт для напряжений и 2 байта для температур.
Можно объединить эти структуры в одну, добавить туда еще 5 байт на дальнейшее расширение протокола, в структуре завести тип передаваемых данных:
typedef struct{
char type;
union {
PackVoltages voltages;
PackTemperature temperatures;
}Data;
char rezerv[5];
}FullPack;
Получаем пакет в 16 байт (из-за не очевидного выравнивания), а не 12 как могло бы показаться (в нашем примере это не проблема, но при выравнивании надо быть внимательным). Можно включать опции компилятора по плотной упаковке байт, но при этом может возникнуть другая проблема – некоторые процессоры (на ARM ядре) не могут считывать не выровненные данные и найти эту ошибку начинающим джедаям не так просто.
Далее в канал передается всегда по 16 байт. Принимающая сторона ждет получения очередных 16 байт и обрабатывает очередной пакет. Нетрудно заметить, что здесь нет CRC. Добавляем CRC:
typedef struct{
char type;
union {
PackVoltages voltages;
PackTemperature temperatures;
}Data;
char rezerv[5];
char CRC;
}FullPack;
Размер пакета остался 16 байт. При отправке данных нам необходимо проставить тип пакета, посчитать и занести в пакет CRC, затем полученный пакет отправляем.
Преимущества этого подхода в том, что при обработке пакетов появляется контроль типов во время компиляции. Нет ненужных преобразований число – текст и обратно. Принимаемая порция данных всегда одного размера – удобно выделить заранее память.
Недостатков много: есть лишние накладные расходов на передачу данных от небольших величин до десятков раз при существенно разной длине передаваемых данных. Данный способ синхронизации хорошо подходит для клиент-серверного взаимодействия по TCP каналу – ничего по дороге не потеряется, начало пакета всегда известно. В ситуации с подключением к каналу позже инициализации возможна ситуация когда несколько первых байт не дошли. Тогда все принимаемые пакеты будет сдвинуты на это количество байт и данные в них, естественно будут некорректными. Хорошо если CRC их отбросит, при этом связи с узлом не будет. А с вероятностью 1/256 возможны пропуски «битых» пакетов. Эту проблему можно попытаться решить, передавая некий сигнатурный байт «начала» пакета, но, учитывая, что мы передаем двоичные данные этот же байт может встречаться и в самих данных. Поэтому достоверно определить начало пакета не всегда возможно. Еще одной проблемой является выравнивание переменных. На заголовок пакета требуется один байт, в самих данных часто могут встречаться 32-битные числа, что будет приводить периодическим сдвигам данных на 0-2 байта. Досадной неприятностью будет то, что CRC необходимо рассчитывать «вручную» при отправке разных типов пакетов.
Еще один вариант схож с предыдущим, для уменьшения накладных расходов на передачу байт в начале пакета передается его реальная длина. Проблема этого подхода в том, что пакет должен быть заранее полностью посчитан (то есть разместиться в памяти) и только потом передаваться. Это может быть трудной задачей на ограниченных ресурсах микроконтроллера, особенно для больших пакетов. Кроме того, пока библиотека не получит весь пакет, передача начинаться не может, что может плохо сказаться на пропускной способности и латентности канала. Остальные недостатки и преимущества аналогичны предыдущему варианту.
И последний рассматриваемый способ, он же применяется в моей библиотеке. Историческое название бин-протокол или BIN-протокол.
При отправке двоичных данных их можно разделять специально выделенным для этого байтом. При этом, если в данных встречается этот байт, заменять его на другую последовательность байт. При приеме делать обратную процедуру. Подсчет CRC также возложить на передачу пакета.
Для работы разных преобразований необходимо зарезервировать три байта. Лучше всего их выбрать так, чтобы они реже встречались. Правила замены байт:
<Разделяющий> = <Разделяющий> <Разделяющий>
<Окончательный> = <Разделяющий> <Дополнительный>
<Дополнительный> = <Дополнительный>
Отсюда видно, что <Окончательный> не может объявиться в потоке данных никаким образом.
Пакет можно сформировать таким образом:
<Разделяющий> Данные_протокола <Окончательный>
Если хочется повышенной надежности можно сформировать пакет таким образом:
<Окончательный> <Разделяющий> Данные_протокола <Разделяющий> <Окончательный>
Вторая форма повышает процент отброса испорченных мелких пакетов примерно на 15%, при этом добавляет накладных расходов на те же 10-15%, поэтому дальше не будет рассматриваться.
Таким образом, при приеме пакетов, даже если мы подключились в произвольный момент времени, достаточно ждать символа <Разделяющий> чтобы начать прием пакета. И только по приему байта <Окончательный> надо проверить корректность пакета и отправить его на обработку.
Теперь можно посмотреть из чего состоят «данные_протокола»:
Заголовок: 1 байт – тип пакета, 1 байт адрес назначения
Данные: сами данные, обработанные по вышеуказанным правилам
CRC: 1 байт, также обрабатывается по вышеуказанным правилам
То есть можно отправить произвольное количество байт, и они будут обернуты маркерами начала и конца пакета, к ним добавиться тип пакета, адрес назначения и CRC.
Для нашего случая это будет выглядеть как определение структур с данными:
typedef struct {
int16_t supplyIn;
int16_t supplyOut;
int16_t groundPotential;
}PackVoltages;
typedef struct {
int8_t internal;
int8_t outside;
}PackTemperature;
И знание того, что первая структура будет обозначаться символом ‘V’, а вторая ‘T’. Передача этих параметров будет осуществляться через функцию с 3 параметрами – это тип пакета, адрес начала передаваемых данных и длина передаваемых данных.
BP_SendMyPack('T', &packTemperature, sizeof(PackTemperature));
А в канале передачи при этом будет такая последовательность:
На маленьких пакетах получается достаточно большой оверхед, но с ростом размера пакета становится незаметным.
Серым помечена реальная передаваемая информация, белым необходимый минимум вспомогательной информации, и желтым — оверхед моего бинпротокола.
Преимущества данного подхода в том, что мало накладных расходов, произвольное подключение не является проблемой, хороший уровень абстракции от данных – можно использовать одинаковые функции для отправки и приема на разных устройствах, программах, протоколах. Можно добиться хорошего контроля типов на этапе компиляции. Сами данные могут быть в программе выровнены правильным способом, и это не будет добавлять лишние байты при отправке пакетов.
Недостатки: тип пакета и адрес пакета не может быть равным специальным символам и могут принимать значения от 1 до 254. Байт CRC только один и, как следствие, есть 1/256 вероятность пропуска битого пакета.
При передаче параметров в бинарном виде между различными архитектурами необходимо учитывать порядок байт . В случае отличия необходимо использовать преобразующие функции, которые заменяют порядок байт на обратный.
В качестве рабочей иллюстрации протокола прилагается небольшая программка на QT. При старте программа открывается TCP сокет и запускает себя еще раз с параметрами подключения к этому сокету. То есть создается два почти одинаковых экземпляра программы, связанных между собой по TCP сокету. При необходимости можно запустить с нужными ключами, чтобы отдельно запустить сервер или клиент программы.
Доступные ключи:
-didicated – создает сервер, в консоль выводит параметры подключения.
-child – подключается по указанным атрибутам:
-A:11.22.33.44 – айпи адрес подключения (по умолчанию localhost)
-P:12345 – порт подключения
Без ключей – запускает сервер и клиент, и соединяет их.
Программа – транслирует действия пользователя через бинарный протокол в сокет, а также слушает обратный канал и по получаемым данным производит действия.
В программе любая кнопка мышки на черном фоне рисует расширяющийся круг до отпускания кнопки. Нажатие по верхней радужной полоске меняет текущий цвет. Очень весело рисовать на пару с малышом с двух разных компьютеров :)
Пояснения по работе с бинарным протоколом.
В классе MainWindow собранно все взаимодействие протокола и TCP соединения (вместо TCP можно было бы использовать и всё что угодно другое).
В конструкторе MainWindow::MainWindow вызывается приватная функция initBinProtocol(); Которая и инициализирует протокол. В этом же месте в протокол передается адрес функции по «выдаче байт» globalSendCharToExternal(). Затем устанавливаются обработчик сигналов прихода символов в TCP сокете на ReadFromParent(), который в итоге посимвольно передает все полученные байты в обработчик протокола.
После подключения TCP на подключенную сессию вешается еще один обработчик ReadFromChild(), который аналогично передает все полученные байты в обработчик протокола.
В файле PackTypes.h собраны все типы передаваемых пакетов. Фактически это описание протокола. Тип TPackAllTypes введен для удобства обработки на компьютере, на микроконтроллере не обязательно использовать такой тип.
В классе PaintBox содержится собственно работа с протоколом. Пакеты от других экземпляров программы проверяются раз в 50мс по таймеру. При желании можно сделать обработку по приему последнего байта целого пакета.
В протокол события посылаются в моменты нажатия и отпускания кнопок мышки, а также при нажатии на кнопку «очистить» через функцию BP_SendMyPack(). Сначала заполняется структура с бинарными значениями параметров, затем она передается. Для посылки команды очистки – никаких данных не требуется и всё что передается это байт команды.
В функции PaintBox::timerCheckPacks() происходит периодическая проверка на наличествующие команды в буфере протокола (BP_IsPackReceived()) и исполнение их.
В файле Types.h содержатся определения схожих базовых типов для кросс-компиляции разными компиляторами под разные платформы, возможно в Вашем случае его придется отредактировать.
В целом, код задокументирован, так что разобраться не сложно.
Ссылка на гитхаб с библиотекой: git@github.com:Elvish/microkern.git
PS. А как Вы дробите потоки на кусочки? Если есть вопросы, постараюсь ответить. Если тема заинтересовала – в следующей статье могу выдать упрощенный шелл c авто-дополнением для простейших контроллеров (незаменимая вещь для отладки практически любых мелких девайсов).
Автор: Elvish