Pull to refresh

uMCPIno: Пишем простой протокол с гарантированной доставкой для Arduino

Reading time19 min
Views16K

Приветствую вас, Глубокоуважаемые!


На каком-то этапе жизни, каждому упорному упоротому DIY-щику перестает хватать кантовского Arduino как «вещи-в-себе» they just can't!: поморгать светодиодиком, взять данные с датчиков и передать по проводу на PC конечно весело, но святой Грааль кроется в мобильности, в освобождении от «медных пут», в истинной свободе среди волн вселенского эфира.
Вот здесь нам и открывается суровая реальность неустойчивых каналов связи, ошибок передачи, недоставленных сообщений.
Боже упаси претендовать на оригинальность в этой области: человечество давно использует целый ворох протоколов на все случаи жизни.
Но наша цель — научиться, а так как я ярый сторонник разведки боем, то учиться мы будем, изобретая свой собственный протокольный «велосипед».
Сегодня я предлагаю разработать протокол, который обеспечивает гарантированную доставку, целостность и очередность сообщений между двумя абонентами (соединение точка-точка, Point-to-Point), умеет и применяет алгоритм Нагла и protocol pipelining, чтобы это ни значило. При этом он должен иметь минимальный оверхед и втискиваться даже в тесный Arduino UNO.



Всех заинтересовавшихся прошу на борт, задраиваем люки, открываем кингстоны, заполняем балластные цистерны. Нам предстоит экскурсия в прошлое, destination: year 1974!

Для нетерпеливых (я и сам такой!)
Вот репозиторий на гитхаб, где лежат реализации:


По старой доброй традиции, для описания криптографических алгоритмов и протоколов привлекают как минимум двух признанных экспертов в этой области, если кто еще их не знает, знакомьтесь:
Алиса
image

И
Боб
image


Сначала опишем простую задачу


Алиса и Боб сидят в соседних траншеях и не могут поднять головы чтобы увидеть друг друга. Переговариваться они могут только голосом, рядом то и дело свистят пули и рвутся снаряды, заглушая их крики, да и ко всему прочему, когда кто-то из них говорит, приходится так кричать, что за своим криком становится вообще ничего не слышно.
Ситуация осложняется тем, что их подслушивают враги — и приходится использовать кодовый язык, по какой-то причине состоящий из длинных последовательностей цифр.
Поскольку и Алиса и Боб — люди, им периодически приходится отлучаются поесть или в туалет, и они настолько нетерпеливы, что им может приспичить в самый неподходящий момент!

Как и зачем устанавливать соединение?


Как нам обустроить достоверную передачу данных в такой удручающей ситуации, когда казалось бы, все просто обречено на провал?

Первое решение, которое может прийти в голову — использовать стоп-слово кодовые фразы для начала и конца передачи.

Ну скажем, если Алиса хочет передать сообщение, то ей нужно крикнуть «Начинаю передачу!» и дождаться пока Боб не ответит «Начинаю прием!».
Если Алиса не дожидается ответа Боба, она просто повторяет свой запрос на начало передачи. Естественно, не стоит делать это слишком часто, иначе, как мы знаем, можно просто не услышать ответ Боба.

Прекрасно. Но что произойдет, если Алиса в ответ услышит из соседней траншеи «Начинаю передачу!»?
Оказывается, Боб тоже решил передать какие-то важные сведения прямо сейчас. У Алисы мягкий характер, и она могла подумать: «Окей, я подожду, мое сообщение в принципе не срочное, пусть сначала передаст Боб». Подумав это, она отвечает «Начинаю прием!».

Поскольку в военное время значение синуса может достигать четырех, скорость звука конечна, и на осмысление услышанного у Алисы и Боба требуется некоторое время, да и Боб, как кавалер, может решить уступить даме, он, пожав плечами кричит «Начинаю прием!»…

Для иллюстрации возникшего безобразия воспользуемся временными диаграммами. Время на них идет сверху вниз.

Случай, когда Алиса с Бобом не сошлись по времени:


Случай, когда сообщение было утеряно:


Это фиаско. Все становится слишком запутанно и усугубляется еще тем, что любую из фраз адресат может или услышать или не услышать, и в каждом случае собеседник не знает, было ли услышано его послание адресатом.

Теперь и Алиса и Боб ожидают прием. Логично было бы осознать, что произошла коллизия, и нужно кому-то возобновлять передачу. Но как быть, если все просто повторится по новой? И вот мы опять там, откуда начинали.

Если вы считаете что ситуация крайне редкая, вспомните как последний раз общались с кем-нибудь по голосовой связи, когда у вашего абонента или у вас (или сразу у обоих) медленное соединение с интернетом. «Алло алло алло, ты пропадаешь». «Вас не слышно алло алло».

Тем временем в траншеях ситуация накаляется, командиры требуют передачи донесений.
Самое время обратиться к первоисточникам: проштудировать Маркса, Энгельса вернутся более чем на 40 лет назад и посмотреть, как такие проблемы решались инженерами DEC при проектировании протокола DDCMP.

Согласно разработчикам DDCMP, Алисе и Бобу нужно отринуть эмоции и уподобиться конечным автоматам.
Это значит, что с этих пор наши Алиса и Боб будут иметь только несколько фиксированных состояний, переходы между этими состояниями могут происходить строго по определенным правилам при возникновении определенных событий.

Сначала просто перечислим состояния:

  • HALTED
  • INITIAL START
  • ACKNOWLEDGED START
  • RUNNING


Как вы могли заметить, их всего четыре. И теперь, что бы ни происходило, каждый из абонентов как минимум точно знает, что его визави находится только в одном из этих состояний. На самом деле, забегая немного вперед, скажу, что почти всегда один абонент будет знать в каком именно состоянии находится второй абонент но это не точно.

Рассмотрим состояния по отдельности, подробно


HALTED — самое простое состояние, никто никуда не идет, все остаются на своих местах, ничего не передается и не принимается, любые внешние раздражители игнорируются. Все, кроме одного — волеизъявления вышестоящего начальства. В оригинальном протоколе DDCMP переход из состояния HALTED может быть только в состояние INITIAL START по запросу пользователя — Алиса или Боб получают приказ установить связь.

Что происходит при получении Алисой или Бобом такого приказа?
Они немедленно отмечает себе, что состояние изменилось с HALTED на INITIAL START, этот переход, как и любой другой подразумевает выполнение строго определенной последовательности действий. В данном случае нужно прокричать “ДЕЛАЙ РАЗ!” и засечь на часах время. Все.

Итак, Алиса прокричала что от нее требовалось, и нажала кнопку на секундомере. Теперь, чтобы понять что ожидать от Боба, разберемся, что может произойти с Алисой, когда она находится в состоянии INITIAL START.

— От момента, как Алиса засекла время прошло, скажем 10 секунд и она не услышала никакой реакции от Боба (заметим, я не говорю, что Боб ничего не прокричал ей в ответ — это не известно, а известно только то, что Алиса ничего не услышала за это время, Алиса мудрая и рациональная женщина и полагается исключительно на факты). Это событие мы называет таймаутом — превышен интервал ожидания. В этом случае протокол предписывает нам повторить: прокричать «ДЕЛАЙ РАЗ!» и опять засечь время. Пока не густо.

— Если Алиса услышала, что Боб прокричал то же самое — «ДЕЛАЙ РАЗ!», то Алиса невозбранно переходит в состояние ACKNOWLEDGED START, о чем она должна незамедлительно прокричать «ДЕЛАЙ ДВА!» и снова засечь время на часах.

— Опять, если Алиса услышала от Боба «ДЕЛАЙ ДВА!», то она тут же переходит в состояние RUNNING (!), кричит «ПРИНЯТО НОООООЛЬ!». Если ее секундомер был запущен — она выключает его из пущей предосторожности.

Очень важно не делать никаких лишних движений, не предусмотренных текущим состоянием. Чтобы ни кричал Боб, как бы не ругался и не умолял, Алиса реагирует только так, как условились.

Такие вещи удобно представлять в виде таблицы. Итак, начнем с уже описанных состояний HALTED и INITIAL START, а дальше-больше будем таблицу пополнять.

ТЕКУЩЕЕ СОСТОЯНИЕ СОБЫТИЕ
НОВОЕ СОСТОЯНИЕ ДЕЙСТВИЕ
ЛЮБОЕ Приказ «Разорвать связь» HALTED
HALTED
Приказ «Установить связь»
INITIAL STATE 1) Прокричать «ДЕЛАЙ РАЗ!»
2) Запустить таймер
INITIAL START
Услышал «ДЕЛАЙ РАЗ!»
ACKNOWLEDGED START
1) Прокричать «ДЕЛАЙ ДВА!»
2) Запустить таймер
Услышал «ДЕЛАЙ ДВА!» RUNNING
1) Прокричать «ПРИНЯТО НОООЛЬ!»
2) Остановить таймер
Время истекло — таймаут INITIAL START
1) Прокричать «ДЕЛАЙ РАЗ!»
2) Запустить таймер


Я осознанно опускаю некоторые моменты из оригинального описания DDCMP — нам они не потребуются, мы же хотим не просто повторить DDCMP, а построить на его базе такой же, только другой новый протокол.

Но вернемся к описанию состояний и переходов. Следующее состояние — ACKNOWLEDGED START.
Будучи в этом состоянии, все что может волновать Алису или Боба, это:

— как и прежде истечение времени ожидания, в этом случае нужно остаться в этом же состоянии, прокричать «ДЕЛАЙ ДВА!» и по новой запустить таймер

— услышанное «ДЕЛАЙ ДВА!» переводит в состояние RUNNING, при этом нужно прокричать «ПРИНЯТО НООООЛЬ!» и остановить таймер;

— услышанное «ДЕЛАЙ РАЗ!» оставляет в этом же состоянии, нужно прокричать «ДЕЛАЙ ДВА!» и запустить таймер;

— услышанное «ПРИНЯТО НОООЛЬ!» — переход в состояние RUNNING, остановить таймер.

Занесем все вышесказанное в таблицу.
ТЕКУЩЕЕ СОСТОЯНИЕ СОБЫТИЕ
НОВОЕ СОСТОЯНИЕ ДЕЙСТВИЕ
ACKNOWLEDGED START
Услышал «ДЕЛАЙ РАЗ!»
ACKNOWLEDGED START
1) Прокричать «ДЕЛАЙ ДВА!»
2) Запустить таймер
Услышал «ДЕЛАЙ ДВА!» RUNNING
1) Прокричать «ПРИНЯТО НОООЛЬ!»
2) Остановить таймер
Услышал «ПРИНЯТО НОООЛЬ!» RUNNING
1) Остановить таймер
Время истекло — таймаут ACKNOWLEDGED START 1) Прокричать «ДЕЛАЙ ДВА!»
2) Запустить таймер


С рукопожатием почти все готово — осталось рассмотреть только одно состояние RUNNING, потому что один из абонентов может уже в него перейти, а второй — срочно убежать в туалет, а вернувшись все позабыть и попытаться по новой установить связь.

С точки зрения процедуры рукопожатия (мы пока не касаемся передачи данных, ради которой все и затевалось — это отдельная история) в состоянии RUNNING нас интересуют два события:

— если нам кричат «ДЕЛАЙ РАЗ!» — все очень плохо, это полный рассинхрон, все нужно начинать заново. Оригинальный протокол предписывает просто перейти в состояние HALTED. Но нам это ничем не поможет — если по какой-то причине такое произошло на автономной Arduinо, которая передает какие-то данные с каких-то сенсоров, то для нас это полный провал. Как мы знаем, из HALTED можно перейти в INITIAL START только по приказу начальства.
Поэтому мы модифицируем здесь протокол: прием в состоянии HALTED команды «ДЕЛАЙ РАЗ!» должен работать как приказ начальства — т.е. переводить в состояние INITIAL START, прокричать «ДЕЛАЙ РАЗ!», запустить таймер. Более того, в некоторых случаях удобно сразу после подачи питания самому себе отдавать приказ о установке связи.
Таким образом теперь, у нас в самом неудобном случае просто произойдет переустановка соединения.

— второе событие, на которое нужно реагировать в состоянии RUNNING — если мы слышим из соседнего окопа «ДЕЛАЙ ДВА!». Вот это уже более интересно. В этом случае нужно прокричать «ПРИНЯТО ЭР!» Где под ЭР подразумевается число успешно принятых в текущем сеансе связи сообщений. Это новое понятие. Чуть ниже мы все рассмотрим еще более детально, а пока для порядка сведем все, что узнали к текущему моменту, в таблицу:

ТЕКУЩЕЕ СОСТОЯНИЕ СОБЫТИЕ
НОВОЕ СОСТОЯНИЕ ДЕЙСТВИЕ
ACKNOWLEDGED START
Услышал «ДЕЛАЙ РАЗ!»
ACKNOWLEDGED START
1) Прокричать «ДЕЛАЙ ДВА!»
2) Запустить таймер
Услышал «ДЕЛАЙ ДВА!» RUNNING
1) Прокричать «ПРИНЯТО НОООЛЬ!»
2) Остановить таймер
Услышал «ПРИНЯТО НОООЛЬ!» RUNNING
1) Остановить таймер
Время истекло — таймаут ACKNOWLEDGED START 1) Прокричать «ДЕЛАЙ ДВА!»
2) Запустить таймер


Теперь, если Алиса с Бобом строго следуют протоколу, то у них просто нет никаких вариантов попасть в непонятное, кроме как таки установить соединение, сообща перейдя в состояние RUNNING или, в плохом случае, до щелчка победного конца пытаться его установить.

Въедливый читатель может попробовать перебрать все варианты и прийти к заключению, что череда состояний и переходов оказывается замкнутой и строго детерминированной. Мы (с помощью разума инженеров DEC) теперь связали Алису и Боба набором таких правил, что просто следуя которым они установят соединение, если в текущих условиях это вообще в возможно в принципе.

Как теперь передавать данные?


Окей, это была приятная разминка. Конфетно-букетный период в отношениях двух узлов сети. Напомним, что мы вообще затеяли: нам нужно передавать данные с гарантированной доставкой и очередностью! С восстановлением после сбоев. В той степени, в которой это позволят аппаратные ресурсы (ведь и Алиса и Боб могут оказаться немощным 8-битными контроллерами с 2 килобайтами оперативной памяти!).

Инженеры DEC учат нас, что передаваемые сообщения нужно нумеровать, нужно подсчитывать, сколько мы отправили, сколько приняли и сколько из отправленных нами сообщений фактически дошло до адресата.

Самое время для лирического отступления!
Признаться. когда я увидел наименования переменных в описании протокола DDCMP, я решил что это неспроста: американцы очень любят притягивать за уши красивые аббревиатуры.

Для нашего удобства есть даже несколько ресурсов, где интересующиеся могут прикоснуться к прекрасному.
Мой любимые вот этот — Dumb Or Overly Forced Astronomical Acronyms Site (or DOOFAAS)

Чего только стоят эти выдумки!
Вот например:

WASP — Wideband Analog SPectrometer (А вовсе не то, что я вы подумали!)
SAURON — Spectroscopic Areal Unit for Research on Optical Nebulae
CISCO — Cooled Infrared Spectrograph and Camera for OHS (Так вот это что означает!)

А вот, просто огонь:
SQUIRT (ах да, статья 18+!) — Satettile QUick Research Testbed
SHIT (Ни больше ни меньше!) — Super Huge Interferometric Telescope, с припиской «look for yourself», к которой привязана ссылка на abstract к статье с одноименным названием.

Так вот, переменные, обозначающие число принятых, отправленных и доставленных пакетов на узле в оригинальном описании протокола именуются как R N A.

Ах, почему же они не назвали протокол именно так — RNA! Своего рода РНК сети. Протоколы DECnet имели все шансы стать интернет протоколами, если бы история сложилась иначе.


Но вернемся в наши окопы


Оригинальный стандарт протокола определяет, что все счетчики 8-битные и приращиваются по модулю 256. Это значит, что может быть максимум 256 отправленных сообщений, на которые еще не было получено подтверждение.
А если подтверждение не получено, то может потребоваться их повторная передача, а если она может потребоваться, то до момента их подтверждения их требуется хранить. У нас ведь гарантированная доставка!

Физические параметры наших Алисы и Боба диктуют нам иные условия. В 8-битных Arduino такой объем данных просто негде хранить и нам приходится идти на компромисс. И я уже не говорю о том, что в стандарте длина пакетов (сообщений) в байтах ограничивается 16-битным числом, т.е. 64 килобайтами — непозволительная роскошь!

Итак, соединение установлено. Что дальше?


В тот момент, когда Алиса или Боб переходят в состояние RUNNING, счетчики обнуляются.
Как я уже упомянул, оригинальный протокол подразумевает нумерацию сообщений по модулю 256, нам же придется уменьшить это число в угоду малому объему памяти в Arduino-подобных штуках.
Чтобы сразу иметь возможность ограничить все приращения счетчиков, мы введем некую константу UMCP_PACKETS_NUMBER, и теперь все приращения будут происходит по этому модулю.

Если взять UMCP_PACKETS_NUMBER = 8, а максимальный размер пакета UMCP_PACKET_DATA_SIZE — порции данных, передаваемой за раз, ограничить 64 байтами, то все как раз влезет в Arduino UNO и еще немного останется на пользовательские нужды.
Важно помнить, что оба этих параметра должны быть одинаковыми у обоих абонентов.

Очевидно, теперь, если Алиса с Бобом успешно установили соединение, и кому-то из них нужно передать данные, то данные во-первых нужно разбить на порции, по размеру не превышающие 64 байта, в во-вторых, каждый пакет еще должен содержать состояние двух счетчиков отправителя: количество принятых и отправленных сообщений (R и N).

Посмотрите, как легко теперь организовать т.н. pipelining и как непринужденно можно обработать ошибочные ситуации!

Если сразу после установки соединения Алиса отправляет подряд, скажем, 3 пакета, то у всех них счетчик R будет установлен в 0 (она еще не приняла ни одного пакета), а счетчик N будет увеличиваться на единицу с каждым новым пакетом.

Если Боб успешно принимает их все, то для подтверждения получения всех трех пакетов ему будет достаточно отправить подтверждение только для последнего, фактически, если он просто отправит в ответ состояние своих счетчиков R = 3 и N = 0, то Алиса сразу поймет, что все отправленные ею сообщения достигли адресата.

Это был идеальный случай, когда не произошло никаких форсмажоров. Давайте теперь рассмотрим что вообще могло бы пойти не так и как с этим следует бороться.

Если Боб по какой-то причине пропускает первый пакет и принимает один из последующих, то он сразу обращает внимание, что счетчик N в нем (число переданных Алисой пакетов) явно превышает счетчик R на стороне Боба и Боб с легкостью осознает, что пропустил первый пакет. В этом случае ему нужно просто сыграть самого плоского Капитана Очевидность и сообщить Алисе состояние своего счетчика принятых пакетов (R=0). Алиса при этом понимает, что ее N = 3, а у Боба R = 0, то есть нужно по новой передавать пакеты, начиная с первого.

Если посмотреть на эту схему внимательно, то можно увидеть, что любая передача состояния своих счетчиков любым абонентом сразу информирует его визави о результате передачи пакетов данных, и разница между счетчиком переданных на одной стороне и принятых на другой говорит о том, сколько пакетов было утеряно и с какого номера это началось.

То есть в самом плохом случае происходит полный повтор передачи, в среднем случае счетчик A на стороне передающего увеличивается до значения счетчика R на приемной стороне, и происходит «досылка» потерянных пакетов.

Несложно понять, что таким образом сохраняется непрерывность приращения счетчиков, а значит и гарантируется очередность передачи сообщений (пакетов).
Кроме переменных RNA у каждого абонента есть два флага SACK и SPEP. Если устанавливается первый, то нужно отправить подтверждение (Send ACKnowledgement), если второй — то нужно отправить запрос на подтверждение (Send REPly to a message).

Кстати, в оригинальном DDCMP подразумевался еще один флаг — SNAK (Send Negative AcKnowledgement). Его установка подразумевает посылку сообщения об ошибке с каким-то кодом. Но в нашей версии протокола все ошибки мы будем разруливать исключительно при помощи механизма таймаутов, потому что протокол может быть использован, например, в гидроакустике или радиосвязи в общей полосе частот — нет смысла засорять общую среду кодами ошибок.
Если сообщение было принято с ошибкой целостности, то строго говоря это непринятое сообщение.

На этом моменте у въедливого читателя должно возникнуть ощущение чего-то упущенного. Что-то в этой стройной схеме не так. И это правда. Об этом чуть позже.
А пока предлагаю по примеру процесса установки соединения собрать все фрагментарные мысли по поводу передачи данных в таблицу.

Поскольку у нас теперь только одно состояние, то таблица будет содержать только две колонки — событие и действия, какие нужно предпринять. Чтобы не возникло путаницы между тем, кому принадлежат переменные, локальные пометим индексом L, а удаленные (те, что содержатся в принятом сообщении — индексом R).
СОБЫТИЕ ДЕЙСТВИЯ
Пришел пакет данных NR=RL+1 1) Остановить таймер
2) Передать пакет пользователю
3) RL=RL+1
4) Если RR=NL или AL<=RR<=NL пометить все
переданные пакеты с номерами от AL до RR как
подтвержденные
5) AL=RR
Пришел запрос на подтверждение — REP 1) SACK = true
2) SREP = false
Пришло подтверждение — ACK 1) Остановить таймер
2) SREP = false
3) Если RR=NL или AL<=RR<=NL пометить все
переданные пакеты с номерами от AL до RR как
подтвержденные
4) AL=RR
Истек интервал ожидания 1) SREP = true
Передача закончена и установлен флаг SREP 1) Послать запрос на подтверждение REP(RL, NL)
2) Запустить таймер
Передача закончена и AL<NL 1) Переслать сообщение с номером AL+1
2) Запустить таймер
Передача закончена, установлен флаг SACK 1) Послать подтверждение ACK(RL,NL)
Передача закончена, AL=NL, флаги SACK и SREP
не установлены, есть данные для отправки
1) NL=NL+1
2) Послать следующий пакет данных


Теперь внимательно посмотрим, прокрутим всю схему в голове. Осознаем, чего здесь не хватает.
В оригинальном описании DDCMP, от которого мы отошли достаточно сильно, это нечто называется флагом SELECT — узел (Алиса или Боб) может быть «избранным» или не быть им.
А смущало нас то, что не был оглашен никакой механизм, разрешающий или запрещающий передачу.
Ну так вот он: это флаг SELECT. Применяется он крайне просто: если флаг установлен, то передавать можно, если нет — нельзя.

Все контрольные сообщения типа ACK и REP должны содержать этот флаг. Последний пакет в очереди тоже должен содержать этот флаг. Если узел «зашивает» флаг в пакет, то он его «отдает», и соответственно у него он уже не установлен. Узел, который обнаруживает этот флаг в пакете, напротив, обязан установить его у себя. Это сродни передаче эстафетной палочки или игры в фаршму (помните такое?).

Самое важно в работе с этим флагом то, что один из узлов должен иметь этот флаг по умолчанию, а другой — нет. То есть еще один очень важный таймер — таймер возврата флага SELECT.

Теперь у нас есть полный набор правил для установки соединения и передачи данных по нему.

Не коснулись мы только конкретной реализации этого свода правил.
Ну что ж, исправим это!

Формирование и формат пакетов


Называется это Message Framing — правила анализа и формирования сообщений и и формат.
Давайте посчитаем, сколько чего нам потребуется.

1. Нам как минимум нужно, чтобы каждое сообщение содержало состояние счетчиков R и N отправителя. Для Arduino мы условились что у нас может быть максимум 8 отправленных но неподтвержденных сообщений. Но так как мы передаем байты, запихнем оба счетчика в один байт, пусть они будут 4-битные.

Формироваться этот байт будет так:
с = (RL & 0x0F) | (NL << 4);

А читать состояние счетчиков будем так:
NR = (c >> 4) & 0x0F;
RR = c & 0x0F;

c — соответствующий байт из сообщения

2. Еще мы помним, что у нас каждое сообщение должно содержать состояние флага SELECT. А самих разных типов сообщений будет:
Смешное название
Серьезное название
Описание
Значение флага SELECT
Значение PTYPE
«ДЕЛАЙ РАЗ!»
STR
STaRt
true 40
«ДЕЛАЙ ДВА!»
STA
STart Acknowledged true 36
«ПРИНЯТО НОООЛЬ!»
ACK(NL=0, RL=0)
ACKnowledgement
true 33
«ПРИНЯТО ЭР, ПОСЛАНО ЭН» ACK(NL,RL) ACKnowledgement true 33
«ПОДТВЕРДИ, КАК ПОНЯЛ?»
REP(NL,RL)
REPly to a message true 34
«ПАКЕТ ДАННЫХ»
DTA(NL,RL)
DaTA packet false 17
«КРАЙНИЙ ПАКЕТ ДАННЫХ»
DTE(NL,RL) DaTa packet — End true 49


То есть всего 6 разных типов сообщений. Все сообщения кроме DTA «отпускают» флаг SELECT — на них нужна немедленная реакция удаленного абонента, а без флага он ее передать не сможет. Сообщение DTA не отдает флаг чтобы стал возможным pipelining.

В целом нам хватит и 3 бит на тип сообщения, но чтобы не возиться с битами, отведем на тип целый байт — в случае доработки у нас будет некая свобода действий.

Если сообщение несет в себе данные, то нам нужно передать их количество и контрольную сумму. Так как максимальный размер пакета 64 байта, то и на контрольную сумму и на длину мы тоже возьмем по байту — вдруг придется увеличить размер пакета.

3. Еще нам потребуется какая-нибудь сигнатура начала сообщения и отдельная контрольная сумма для заголовка.

С учетом всего этого, заголовок (он же контрольные сообщения) выглядит так:
Смещение, байт
Описание Размер, бит
0 SIGN=0xAD
8
1 PTYPE 8
2 TCNT 4
2 RCNT 4
3 HCHK 8


А блок данных вот так:
Смещение, байт
Описание Размер, бит
4 DCNT
8
5..5+DCNT-1
DATA
8*DCNT
5+DCNT DCHK 8


Собственно, вот и все. Это полное описание протокола, который у нас получился из DDCMP.
Теперь можно пройтись и по реализации.

Как оно устроено и как этим пользоваться?


Сначала немного о структуре репозитория.
Как я уже упомянул в начале, код проекта лежит на гитхабе: uMCPIno

Для того, чтобы посмотреть как все работает, на PC можно запустить тестовое приложение.

В архиве запускаем uMCPIno_Test.exe, выбираем нужный COM-порт и пробуем как работает.
Можно проверить на паре виртуальных COM-портов (я так обычно делаю).
Для чего можно запустить две копии приложения. Только не забываем в одной копии включить «SELECTED BY DEFAULT» — это будет Master, а в другой — выключить. Кстати, если интересно, можно посмотреть что будет, если не придерживаться этого правила =)

Опция «EXTRAS» позволяет видеть все движения мыслей внутри мозга протокола. Будут выводиться все изменения состояний флагов SELECT, события от таймеров, изменения состояния узла, а также значения переменных R и N в передаваемых и принимаемых сообщениях.

Я подключаю мою Arduino UNO к ноутбуку через преобразователь UART<->USB. Штыревые разъемы позволяют в любой момент имитировать обрыв линии:


Если теперь запустить на ноутбуке приложение, то после нажатия кнопки «CONNECT» ардуина сама установит соединение:


А вот так система реагирует на попытку отправки через «оборванную» линию:


Для того, чтобы встроить uMCPIno в свое приложение под PC:
  1. В репозитории есть библиотека uMCPIno. Подключаем ее в references своего проекта
  2. Она содержит класс uMCPInoPort. Объявляем его экземпляр:
    uMCPInoPort port;
    port = new uMCPInoPort("COM1", UCNLDrivers.BaudRate.baudRate9600, true, 8100, 2000, 64, 8);
    

    Параметры по порядку: имя порта, потом скорость порта, состояние SELECT по умолчанию, интервал для SELECT, интервал таймаута, размер пакета и максимальное число неподтвержденных сообщений.
  3. Подписываемся на события:
    когда меняется флаг SELECT — port.Select:
    OnSelectChangedEventHandler

    когда меняется состояние — port.State:
    OnStateChangedEventHandler

    кода удаленный узел подтверждает получение пакета:
    OnDataBlockAcknowledgedEventHandler

    когда таки приходит пакет данных:
    OnDataBlockReceivedEventHandler

  4. Перед работой открываем порт
    port.Open();

  5. Для отправки данных вызываем метод:
    port.Send(byte[] data);

  6. По завершении работы закрываем порт:
    port.Close();



Просто, как два байта переслать!

Теперь перейдем к реализации для Arduino. Два примера лежат в папке github.com/AlekUnderwater/uMCPIno/tree/master/Arduino

Первый — это просто конвертер из и в uMCP. Первый Serial служит для связи с Хостом, а Serial1 (если он есть на вашей плате) или SoftwareSerial на пинах 2 и 3 — для связи с другим узлом uMCPIno. Сюда можно подключить Bluetooth или Радиомодуль.

Второй — шаблон проекта с поддержкой протоколоа uMCPIno

Оба проекта имеют настройки, куда можно и нужно лазить. Вот они:

Состояние флага SELECT по умолчанию. Если установлено в (true), то даже в случае если удаленный узел не вернет флаг, он будет установлен в true по таймеру.
#define CFG_SELECT_DEFAULT_STATE          (false)


Для задания периода этого таймера есть следующая настройка: интервал возврата флага SELECT в миллисекундах
#define CFG_SELECT_DEFAULT_INTERVAL_MS    (4000)


Интервал ожидания ответа в миллисекундах, лучше оставлять его немного меньше интервала возврата флага SELECT.
#define CFG_TIMEOUT_INTERVAL_MS           (3000)


Фактическая скорость передачи линии в бодах. Этот параметр нужен, чтобы определить, когда закончится передача.
#define CFG_LINE_BAUDRATE_BPS             (9600)


Интервал накопления данных для алгоритма Нагла. Нагло примем его равным 100 миллисекундам. В течении этого времени ожидаем набора пакета, если он не набирается — то отсылаем как есть. Задача алгоритма Нагла в том, чтобы избавить сеть от кучи мелки пакетов размером от одного до нескольких байт.
#define CFG_NAGLE_DELAY_MS                (100)


Этими настройками задаются скорости портов для связи с управляющей системой (Хостом) и линией. Не стоит путать скорость порта с линией с физической скоростью передачи.
#define CFG_HOST_CONNECTION_BAUDRATE_BPS  (9600) // Host connection port speed
#define CFG_LINE_CONNECTION_BAUDRATE_BPS  (9600) // Line connection port speed


Если эта настройка включена, то при подаче питания на контроллер протокол сам скомандует себе начать устанавливать соединение.
#define CFG_IS_AUTOSTART_ON_POWERON       (true) 


Это размер в байтах буфера под входящие пакеты данных
#define CFG_IL_RING_SIZE                  (255)


Дальше посмотрим, как выглядит основной цикл скетча:

void loop()
{
  uMCP_ITimers_Process();
  DC_Input_Process();
  DC_Output_Process();

  // флаг ip_ready устанавливается парсером, когда обнаружен новый входящий пакет
  if (ip_ready)
  {
    uMCP_OnIncomingPacket();
  }

  // Если мы стоим а есть данные на отправку, или это подача питания - автостартуем
  if ((state == uMCP_STATE_HALTED) && ((ih_Cnt > 0) || (isStartup && CFG_IS_AUTOSTART_ON_POWERON)))
  {
    if (isStartup)
    {
      isStartup = false;
    }
    
    uMCP_STATE_Set(uMCP_STATE_ISTART);
    uMCP_CtrlSend(uMCP_PTYPE_STR, 0, 0, true);
  }
  else if (state == uMCP_STATE_RUNNING)
  {
    uMCP_Protocol_Perform();

    // если есть принятые пакеты данных - сообщить пользователю
    if (il_ready)
    {
      il_ready = false;
      USER_uMCPIno_DataPacketReceived();
    }  
  }
}


Теперь посмотрим, как работает протокол. Основная логика содержится в функции uMCP_Protocol_Perform(); Вот ее код:

void uMCP_Protocol_Perform()
{
  if (state == uMCP_STATE_RUNNING)
  {
    // Если таймер не запущены и мы ничего не ждем и еще и флаг SELECT установлен
    if ((!iTimer_State[uMCP_Timer_TX]) &&
        (!iTimer_State[uMCP_Timer_TMO]) &&
        (select))
    {
      // Если нет исходящих данных
      if (ih_Cnt == 0)
      {
        // Если нужно отослать REP - Отсылаем
        if (srep)
        {
          uMCP_CtrlSend(uMCP_PTYPE_REP, N, R, true);
          srep = false;
        }
        // Если есть неподтвержденные сообщения - повторяем передачу
        else if (sentBlocksCnt > 0)
        {
          uMCP_DataBlockResend((A + 1) % UMCP_PACKETS_NUMBER, true, true);
        }
        // Если нужно выслать SACK или каким-то упущением у нас остался флаг а передавать
        // нечего - просто отдать флаг при помощи сообщения ACK
        else if ((!selectDefaultState) || (sack))
        {
          uMCP_CtrlSend(uMCP_PTYPE_ACK, N, R, false);
          sack = false;
        }
      }
      // Если есть что передавать - передавать
      else if (ih_Cnt > 0)
      {
        // Если последние данные на отправку лежат уже долго или их накопилось на пакет - отправляем
        if ((ih_Cnt >= UMCP_PACKET_DATA_SIZE) || (millis() >= ih_TS + CFG_NAGLE_DELAY_MS))
        {
          // Увеличивая переменную N
          N = (N + 1) % UMCP_PACKETS_NUMBER;
          uMCP_NextDataBlockSend();
        }
      }
    }
  }
}


Парсер пакетов, который живет в функции
On_NewByte_From_Line
тоже устроен по принципу конечного автомата и работает «побайтно». Сделано это в целях экономии памяти.

Остальная реализация не представляет особого интереса. Разберем лучше, как пользователю взаимодействовать с протоколом. В разбираемом примере есть четыре «точки» соприкосновения.

Первая — функция отправки данных по линии uMCPIno:
bool uMCPIno_SendData(byte* dataToSend, byte dataSize);

Тут все просто — у вас есть байтовый буфер dataToSend, его размер dataSize. Функция возвращает true, если отправка возможна (есть куда складывать данные), и false в противном случае.
Чтобы не гонять впустую, можно сразу проверить наличие достаточного места при помощи функции:
bool uMCP_IsCanSend(byte dataSize);


Для того, чтобы анализировать входящие пакеты данных, нужно добавить свой код в тело функции
void USER_uMCPIno_DataPacketReceived();


Входящие данные записываются в кольцевой буфер il_ring. Чтение из него побайтно можно организовать так:

  while (il_Cnt > 0)
  {
    c = il_ring[il_rPos];
    il_rPos = (il_rPos + 1) %  CFG_IL_RING_SIZE;
    il_Cnt--;

    // Здесь в "c" - очередной байт из приемного буфера 
  }


Для изощренных утех есть функция
 void USER_uMCP_OnTxBufferEmptry();

Которая вызывается, когда все данные успешно отправлены. В нее также можно и нужно помещать какой-то свой код.

Зачем это все и куда?


Я занялся этим в основном Just for fun. Плюс ко всему, мне нужен был какой-то простой и, что самое важное, «легковесный» протокол для отправки данных через наши гидроакустические модемы uWAVE. Так как они передают данные через воду со скоростью всего 80 бит/сек, а при их максимальной дальности связи в 1000 метров и скорости звука в воде порядка 1500 м/с передача сопряжена с ощутимыми задержками, да и гидроакустический канал — один (если не самый!) из самых шумных, медленных и неустойчивых.
Во многом благодяря этому пришлось отказаться от механизма негативных подтверждений (NAK) — если есть возможность не передавать — в воде 100% лучше не передавать.
В реальности протокол пригодился и при передаче данных по радиоканалу при помощи модулей DORJI и хорошо известных ардуинщикам НС-012.

Что дальше?


Если будет время, я планирую добавить возможность адресации (которая, к слову, была в DDCMP). Так как основная задача этого протокола сейчас — обеспечение удобства на всяких испытания наших гидроакустических модемов и прочих Sensors Networks, то там есть свои (буквально!) подводные камни. Скажу лишь, что задача не решается простым добавлением полей «Sender» и «Target».
Возможно, дело дойдет и до Geographic Routing и всякого такого прочего.

P.S.


Традиционно буду очень признателен за конструктивную критику, пожелания и предложения. Всегда важно понимать, делаешь ли ты для людей что-то полезное или впустую тратишь время.
Возможно, в попытках избежать перехода этого лонгрида в роман «Война и Мир», я упустил какие-то детали — не стесняйтесь спрашивать.

P.P.S.


Огромная благодарность пристыдившим мою неграмотность, указавшим на ошибки (грамматические и логические):

Проект был изначально open source, но теперь и статья тоже получилась open source.
Only registered users can participate in poll. Log in, please.
Текст не великоват?
13.95% Великоват. Не дочитал до конца6
11.63% Терпимо, но можно было бы и покороче изложить5
58.14% Мне норм25
16.28% Можно было бы и более детально описать, некоторые моменты не раскрыты7
43 users voted. 4 users abstained.
Tags:
Hubs:
Total votes 19: ↑17 and ↓2+15
Comments13

Articles