Arduino & PC - пакетный обмен данными. Часть 1

Вместо предисловия.
Наверняка многие из вас сталкивались с проблемой взаимодействия Arduino и PC. Материалов, посвященных этому вопросу, в сети достаточно. На этом замечательном ресурсе так же есть ряд примеров того, как организовать обмен данными между этими двумя устройствами:
Раз
Два раза
Однако, после того как канал связи будет организован, возникает вопрос – как обрабатывать данные, поступающие из последовательного порта?
Дело в том, что обмен через Serial соединение напоминает чтение из файла. То есть, непрерывный поток данных, который никак не фрагментирован.
Пример:
Допустим, одно из устройств отправляет три отдельных команды:
— AAAA
— BBBB
— CCCC
Что, при этом, «видит» принимающее устройство?
Оно видит непрерывный поток, который выглядит так:
AAAABBBBCCCC
В данной ситуации понять – где заканчивается одна команда и начинается следующая, не так просто.
Кроме того, работа последовательного соединения организована таким образом, что принимающее устройство, в момент чтения из буфера, может там обнаружить не все содержимое, которое было ему отправлено, а только какую-то его часть. А следующий кусок данных будет получен при следующем чтении из буфера порта.
Для наглядности вернемся к предыдущему примеру:
Допустим, одно из устройств отправляет три отдельных команды:
— AAAA
— BBBB
— CCCC
Если принимающее устройство начнет чтение из буфера раньше, чем передающее завершит передачу, то вполне возможно, что получатель увидит сначала одну часть данных:
— AAAABBB
А потом вторую:
— BCCCC

И как теперь жить?
В частном случае, когда стоит задача только управлять пинами Arduino с компьютера, рекомендую использовать библиотеку Firmata Вполне себе адекватное решение, замечательно выполняющее возложенные на него функции. Однако, весьма специфичное, и «заточить» его под решение других задач не всегда возможно.

Если требуется обмен числами из ограниченного диапазона значений (Например, передача только ASCII символов) то можно ввести понятие «конец строки». Отслеживая его появление, легко можно определить – где закончилась одна строка и началась другая. Стандартный пример «SerialEvent» среды разработки Arduino IDE замечательно иллюстрирует как раз такой способ. Но и здесь есть существенное ограничение. Символ конца строки не может содержаться в самой строке. Если для вас это не критично, то вы счастливый человек :)

Если же перед вами стоит задача передавать команды произвольной длинны, внутри которых могут содержаться числа из диапазона 0-255, то вам потребуется организовать что-то вроде протокола, который, используя собственные маркеры, мог бы как-то «оформлять» пакеты данных.
Самое простое, что в такой ситуации приходит на ум – усложнить маркер конца строки.
Завершать строку команды с помощью нулевого байта мы не можем. Потому что нулевой байт вовсе не обязательно должен означать конец строки. Это вполне может оказаться байтом данных внутри самой строки. И это касается любого значения байта данных. Если же ввести понятие «маркер» конца строки, и сделать его состоящим не из одного байта, а, скажем, из четырех, то вероятность того, что протокол «спутает» данные внутри пакета с маркером конца строки, значительно уменьшается.
Например, мы условимся, что наши пакеты будут разделяться четырьмя байтами, имеющими значение «0, 63, 127, 255». Встретив такую последовательность в потоке данных, мы делаем вывод, что принимаемая строка закончилась и нужно приступать к приему следующей. Вероятность того, что данная последовательность, на самом деле не является маркером, а принадлежит к данным строки, крайне мала. Но она есть. Увеличив длину маркера, мы увеличиваем его уникальность, и тем самым снижаем вероятность ложных срабатываний. Но, во-первых, мы не исключаем эту вероятность полностью, а во-вторых, всему есть разумный предел. Не будете же вы делать маркер длинной в несколько десятков байт!

Как выйти из этой ситуации, сохранив честь и достоинство? :)
Теория вероятности нам подсказывает, что нужно использовать не только уникальность маркера, но и количество уникальных маркеров. Чтобы не утомлять рассуждениями на тему соблюдения баланса между вероятностью ложных срабатываний и сложностью реализации протокола, сразу перейду к делу.
Примем на веру следующее оформление пакета данных:
1. Пакет начинается с уникального маркера, символизирующего начало пакета.
2. Сразу за маркером начала пакета мы будем передавать байт, содержащий количество передаваемых байт данных. Важно: Не длину пакета, а именно длину данных внутри пакета.
3. Сразу за байтом размера данных мы передаем сами данные. Понятно, что количество передаваемых байт должно быть равным тому, что мы только что указали в байте длинны.
4. Как только данные будут переданы, мы формируем и передаем уникальный маркер конца пакета.

В итоге, наш пакет будет выглядеть следующим образом: PacketStartMarker N DataForTransmit PacketStopMarker

Какие механизмы защиты от ложных срабатываний здесь присутствуют?
Во-первых, маркер начала пакета и маркер конца пакета выглядят по-разному. Они могут иметь даже разную длину, если потребуется. Во-вторых, байт длинны данных – это тоже, своего рода маркер. Потому что он указывает не только на то, какой объем данных содержится внутри пакета, но и на то, когда следует ожидать маркер конца пакета. Если наш протокол работает правильно, то он даже не будет пытаться обнаружить маркер конца пакета до тех пор, пока не будет принят весь объем данных. Собственно, это и есть главная защита протокола от ложного завершения принимаемой последовательности.

Ближе к делу:
Пример реализации протокола для Arduino выполнен в виде набора функций. Если у вас возникнет такое желание, то вы можете оформить его в виде класса.

Переменные:

String sp_startMarker;           // Переменная, содержащая маркер начала пакета
String sp_stopMarker; 	         // Переменная, содержащая маркер конца пакета
String sp_dataString; 	         // Здесь будут храниться принимаемые данные
int sp_startMarkerStatus;	 // Флаг состояния маркера начала пакета
int sp_stopMarkerStatus;	 // Флаг состояния маркера конца пакета
int sp_dataLength;		 // Флаг состояния принимаемых данных
boolean sp_packetAvailable;	 // Флаг завершения приема пакета


Кратко о флагах:
Почему тип int, а не Boolean?
Эти флаги содержат количество принятых байт для каждого из маркера и для строки данных, позволяющие определить состояние протокола в любой момент времени. Если длинна принятых байт равна нулю – значит прием маркера еще не начинался. Если больше нуля, но меньше длинны маркера – этот маркер сейчас принимается. Если значение флага равно длине маркера – значит маркер принят. То же самое относится и к строке данных.

Функции:

Первичная инициализация протокола:

void sp_SetUp()
{
  sp_startMarker = "<bspm>";	 // Так будет выглядеть маркер начала пакета
  sp_stopMarker = "<espm>";	 // Так будет выглядеть маркер конца пакета
  sp_dataString.reserve(64);	 // Резервируем место под прием строки данных
  sp_ResetAll();		 // Полный сброс протокола
}


Полный сброс протокола:

void sp_ResetAll()
{
  sp_dataString = "";		// Обнуляем буфер приема данных
  sp_Reset();			// Частичный сброс протокола
}


Частичный сброс протокола:

void sp_Reset()
{
  sp_startMarkerStatus = 0;	// Сброс флага маркера начала пакета
  sp_stopMarkerStatus = 0;	// Сброс флага маркера конца пакета
  sp_dataLength = 0;		// Сброс флага принимаемых данных
  sp_packetAvailable = false;	// Сброс флага завершения приема пакета
}


Теперь главное :)
Начнем с простого.

Отправка данных на PC

void sp_Send(String data)
{
  Serial.print(sp_startMarker);		// Отправляем маркер начала пакета
  Serial.write(data.length());		// Отправляем длину передаваемых данных
  Serial.print(data);			// Отправляем сами данные
  Serial.print(sp_stopMarker);		// Отправляем маркер конца пакета
}


Теперь рассмотрим, как работает прием данных.
Все начинается с функции
void serialEvent();
Очень полезная штука. Срабатывает при поступлении данных в буфер последовательного порта.
Фактически, это встроенный обработчик событий Serial порта:

void serialEvent() 
{
 sp_Read();                         // Вызов «читалки» принятых данных
 if(sp_packetAvailable)             // Если после вызова «читалки» пакет полностью принят
 {
  ParseCommand();                   // Обрабатываем принятую информацию
  sp_ResetAll();                    // Полный сброс протокола.
 }
}


Собственно, сама «читалка»

void sp_Read()
{
  while(Serial.available() && !sp_packetAvailable)            // Пока в буфере есть что читать и пакет не является принятым
  {
    int bufferChar = Serial.read();                           // Читаем очередной байт из буфера
    if(sp_startMarkerStatus < sp_startMarker.length())        // Если стартовый маркер не сформирован (его длинна меньше той, которая должна быть) 
    {  
     if(sp_startMarker[sp_startMarkerStatus] == bufferChar)   // Если очередной байт из буфера совпадает с очередным байтом в маркере
     {
       sp_startMarkerStatus++;                                // Увеличиваем счетчик совпавших байт маркера
     }
     else
     {
       sp_ResetAll();                                         // Если байты не совпали, то это не маркер. Нас нае****, расходимся. 
     }
    }  
    else
    {
     // Стартовый маркер прочитан полностью
       if(sp_dataLength <= 0)                                 // Если длинна пакета на установлена
       {
         sp_dataLength = bufferChar;                          // Значит этот байт содержит длину пакета данных
       }
      else                                                    // Если прочитанная из буфера длинна пакета больше нуля
      {
        if(sp_dataLength > sp_dataString.length())            // Если длинна пакета данных меньше той, которая должна быть
        {
          sp_dataString += (char)bufferChar;                  // прибавляем полученный байт к строке пакета
        }
        else                                                  // Если с длинной пакета данных все нормально
        {
          if(sp_stopMarkerStatus < sp_stopMarker.length())    // Если принятая длинна маркера конца пакета меньше фактической
          {
            if(sp_stopMarker[sp_stopMarkerStatus] == bufferChar)  // Если очередной байт из буфера совпадает с очередным байтом маркера
            {
              sp_stopMarkerStatus++;                              // Увеличиваем счетчик удачно найденных байт маркера
              if(sp_stopMarkerStatus == sp_stopMarker.length())
              {
                // Если после прочтения очередного байта маркера, длинна маркера совпала, то сбрасываем все флаги (готовимся к приему нового пакета)
                sp_Reset();    
                sp_packetAvailable = true;                        // и устанавливаем флаг готовности пакета
              }
            }
            else
            {
              sp_ResetAll();                                      // Иначе это не маркер, а х.з. что. Полный ресет.
            }
          }
          //
        }
      } 
    }    
  }
}


Вот, собственно, и все :) Осталось только пояснить как это все использовать.

String versionString; 	                            // Строка, содержащая какую-нибудь бесполезную информацию :)

// Стандартный сетап.
void setup() 
{    
  versionString = "Version of serial protocol ";    // Присваиваем  бессмысленное значение бесполезной строке :)
  Serial.begin(9600);                               // Инициализируем последовательный интерфейс
  sp_SetUp();			                    // Инициализируем протокол.
  
}

// Стандартный цикл
void loop() 
{

  delay(1000);
}


Как видим, в цикле ничего нет. Потому что события от порта у нас обрабатываются независимо (Помним про serialEvent()). Таким образом, нам не требуется каждый раз лазить в буфер порта и смотреть «а не появилось ли там чего интересного»

Ну и обработчик принятых команд. Здесь каждый напишет то, что ему нужно. Я же приведу пример обработки бесполезной команды :)

void ParseCommand()
{
  if(sp_dataString == "ver?") 	// Если была принята строка «ver?»
  {
   sp_Send(versionString); 	// Отправляем на PC содержимое строки «versionString»
  }
  
}


И напоследок хочу заметить следующее обстоятельство: Функция void sp_Send(String data) определят длину отправляемых данных с помощью data.length(). А это как бэ намекает нам, что нули таким образом передать не получится. Точнее получится, но не корректно. Потому имеет смысл сделать перегрузку для данной функции с возможностью указывать размер отправляемых данных.

Реализация ответной части для PC на С++ в следующей части.
  • +1
  • 16 декабря 2012, 09:23
  • reegool

Комментарии (7)

RSS свернуть / развернуть
+
0
Спасибо за интересную статью!
avatar

Magalex

  • 16 декабря 2012, 19:14
+
0
Хороший подход, но как вы и отметили — классика — это разбиение на пакеты с использованием символа(ов), которые гарантированно не могут встретиться в данных.
простой пример — SLIP:
Serial Line Internet Protocol (SLIP)

SLIP define 4 special symbols:
END, ESC, ESC_END and ESC_ESC:

symbol value (hexademical)
— END 0xC0
ESC 0xDB
ESC_END 0xDC
ESC_ESC 0xDD

Bytes ESC (0xDB) and END (0xC0) are filtered from data by process of byte stuffling.
If ESC (0xDB) or END (0xC0) appears in the data, the ESC symbol (0xDB) is send, followed by ESC_ESC (0xDD) or ESC_END (0xDC), correspondingly.
I. e. the data byte replaced by two bytes:
0xDB -> 0xDB 0xDD
0xC0 -> 0xDB 0xDC

а в rosserial просто сначала идут два байта признака заголовка (0xFF 0xFF), а потом тип сообщения, его длина, а потом само сообщение.
Ещё пример — протокол Modbus

а если очень просто — то можно поступить так.
avatar

noonv

  • 16 декабря 2012, 20:39
+
0
Я делал так: фиксированная длина пакета, 3 байта стартовая последовательность, 2 конечных байта контрольная сумма сообщения. Работает отлично
avatar

_Bot

  • 9 сентября 2013, 20:19
+
+1
неточность в статье. serialEvent вовсе и не прерывание судя по этой статье arduino.cc/en/Reference/SerialEvent. Эта функция вызывается в перерывах между вызовами loop(), так что delay() лучше убрать или поставить минимальный.
avatar

axill

  • 9 октября 2013, 13:41
+
0
Это ТОЧНО не прерывание. Я в свое время потратил кучу времени на изучение HardwareSerial
#include <Arduino.h>

int main(void)
{
	init();

#if defined(USBCON)
	USBDevice.attach();
#endif
	
	setup();
    
	for (;;) {
		loop();
		if (serialEventRun) serialEventRun();
	}
        
	return 0;
}

По большому счеты это вообще бесполезная процедура.
Все это встраивается в loop в цикл
while(Serial.available()){}

А вот переполнение буфера можно получить как нефиг делать
#if (RAMEND < 1000)
	#define SERIAL_BUFFER_SIZE 16
#else
	#define SERIAL_BUFFER_SIZE 64
#endif

Если loop будет длиться больше чем идут SERIAL_BUFFER_SIZE байт, см. исходник
unsigned int i = (unsigned int)(buffer->head + 1) % SERIAL_BUFFER_SIZE;

	// if we should be storing the received character into the location
	// just before the tail (meaning that the head would advance to the
	// current location of the tail), we're about to overflow the buffer
	// and so we don't write the character or advance the head.
	if (i != buffer->tail) {
		buffer->buffer[buffer->head] = c;
		buffer->head = i;
		return 1;
	}
	else{
		return 0;
	}
avatar

GraninDm

  • 10 октября 2013, 08:36
+
0
а когда будет продолжение?
avatar

wren

  • 22 апреля 2014, 16:26
+
0
Люди добрые подскажите пожалуйста в чем может быть причина. Использовал данный пример, но если я использую хотя бы одну функцию из библиотеки One Ware, больше контролер ничего не принимает. Пробывал после использования разрешать прерывания, изменений нет. Подумал что проблемы с запуском serialEvent, сделал опрос (Serial.available()){} в loop, опять ничего.
avatar

Antonio64

  • 28 января 2016, 19:05

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.