В этой статье, мы расскажем о том, как разработали собственный простой формат пакетов для передачи данных и почему мы это сделали, а также покажем несколько реализаций структуры данных под разработанный формат.
О собственном формате пакетов данных мы задумались после создания ряда программно-аппаратных решений, которые в основном носили демонстрационный характер, но могли бы использоваться и в реальных проектах. Речь идет о двух проектах — спектроанализаторе и уловителе сигналов. В обоих случаях мы использовали внешнюю плату, данные с которой пересылались через COM-порт, и это в принципе работало. Проблема заключается в том, что использованная в реализации идея с разбиением одного крупного значения на байты будет работать только в идеальных условиях. И вот почему: разделенное на байты число не имеет четких признаков своего начала или конца, а потому при приеме может быть неверно интерпретировано принимающей стороной. Но это не единственная проблема — помимо отсутствия маркеров или разметки данных, довольно высока вероятность того, что даже при удачном стечении обстоятельств их приемник будет не в состоянии опознать поврежденные данные.
Со всем этим мы столкнулись почти сразу, но не придав этому значения и посчитав два проекта слишком учебными, решение проблемы мы обошли стороной. Но потом, одному из авторов данной статьи пришлось столкнуться с реальной проблемой — спектроанализатор принимал не те данные… И вот тогда мы задумались о собственном бинарном формате пакетов, достаточно простом, чтобы поместиться в микроконтроллер, и достаточно узнаваемым как человеком, так и компьютером. Формат пакетов был придуман достаточно быстро и получил название SF6 (Send Frame with 6 fields — SF6) и о нем мы расскажем далее.
Наш формат пакетов достаточно прост по своей структуре и является бинарным, но при этом он содержит хорошо узнаваемые данные (в форме байтов). Эти данные представляют собой последовательности байтов, в которых закодированы некоторые связные цепочки символов ASCII. Эти цепочки служат маркерами, за которые цепляется распознающий пакет код, и между этими цепочками нет никаких разделителей: ни новой строки, ни пробелов, ни символов табуляции. Такая структура делает формат менее избыточным, а в совокупности с его фиксированным размером в байтах, позволяет организовать очень простой и быстрый кодер/энкодер пакетов.
Пакет данных в формате SF6 представляет собой последовательность байтов размером в 292 байта и содержит в себе следующие поля: «магическое число», идентификаторы служебных секций, уникальный идентификатор пакета, «особый» идентификатор, заголовок начала секции данных, данные в байтовом виде и заголовок конца секции данных.
«Магическое число» — это сигнатура формата, опознавательный маркер пакета, который одновременно служит признаком начала очередной порции данных, т.е. фрейма данных. В нашем случае «магическое число» является комбинацией байтов, которую можно интерпретировать как строку из ASCII символов следующего вида: «SF6!». За сигнатурой идет первый маркер, который указывает на служебную информацию, и представляет собой последовательной байтов соответствующих ASCII строке вида «SF6_». Сразу за этим маркером идет поле, которое представляет собой уникальный идентификатор пакета, записанный последовательностью данных в обратном порядке. К примеру, идентификатор 0x01abcdef
будет представлен последовательностью байтов [0xef, 0xcd, 0xab, 0x01]
. Уникальный идентификатор пакета всегда имеет фиксированный размер в байтах,
и он равен 4.
После уникального идентификатора идет второй маркер, который является точно таким же, как и первый, и представляет собой ту же ASCII строку «SF6_». За вторым маркером идет «особый» идентификатор, который имеет фиксированный размер равный 4 байтам. Он также записывается в обратном порядке, и в изначальном варианте представлял собой 32-битный хэш от последовательности байтов внутри секции данных.
После всех служебных пометок идет заголовок секции данных, который представляет собой ASCII последовательность вида «SF6_@BDF» (BDF — Begin of Data Frame). За заголовком секции данных идут сами данные в байтовом виде и данные представлены в виде последовательности байтов размером ровно 256 байт. Размер секции фиксированный и не содержит в себе никаких разделителей между байтами — в этой секции они представлены непрерывным потоком. Завершает пакет заголовок конца секции данных, который является также ASCII строкой вида «SF6_@EDF» (EDF — End of Data Frame).
Структура простая и легко разбирается даже на слабых устройствах с ограниченным функционалом. Отметим также, что идентификатор пакета (id — identifier) и «особый» идентификатор (qn — qualified number) не регламентированы по использованию и могут быть использованы самым разным образом для передачи нужных служебных данных. Это позволяет использовать данные поля по усмотрению программиста и именно он придает им конкретный смысл, несмотря на то, что наличие id и qn обязательно во фрейме данных.
Реализован SF6 в виде структуры данных на D, которая хранит внутреннее содержание пакета в нескольких полях: id
, qn
, data
и packet
. К ним прилагаются сеттеры/геттеры позволяющие изменять/считывать содержимое пакета, при этом сеттеры принимают на вход массивы беззнаковых байтов (но, есть перегруженные варианты сеттеров для полей id
и qn
, принимающие на вход 32-битное значение), а геттеры возвращают такие же массивы.
Идеология работы со структурой данных SF6_Packet
проста: c помощью сеттера setPacket
в структуру вносятся байты, полученные из некоторого источника, затем запускается процедура decode
, которая разбирает пакет на секции, и извлекаются байты данных с помощью геттера getData
. Помимо такой работы со структурой возможно не только считывание пакета, но и его формирование, для чего предусмотрено использование сеттеров, устанавливающих отдельные компоненты пакета в нужные значения с последующим его созданием с помощью процедуры encode
и извлечением полученного пакета с помощью геттера getPacket
.
Структура SF6_Packet
реализована в следующем коде на D:
import std.conv; template addProperty(T, string propertyName, string defaultValue = T.init.to!string) { import std.string : format, toLower; const char[] addProperty = format( ` private %2$s %1$s = %4$s; void set%3$s(%2$s %1$s) { this.%1$s = %1$s; } %2$s get%3$s() { return %1$s; } `, "_" ~ propertyName.toLower, T.stringof, propertyName, defaultValue ); } /+ + Simple Frame protocol with 6 fields (SF6) + + Protocol structure: + magic number: "SF6!" + system information marker: "SF6_" + id: 4 byte value + system information marker: "SF6_" + qn: 4 byte value + begin data frame (BDF): "SF6_@BDF" + data: 256 bytes + end data frame (EDF): "SF6_@EDF" + + id is identifier of packet (no reglament) + qn is another identifier for packet (no reglament) +/ class SF6_Packet { private { // magic number is "SF6!" enum ubyte[4] MAGIC_NUMBER = [0x53, 0x46, 0x36, 0x21]; // system fields is "SF6_" enum ubyte[4] SF = [0x53, 0x46, 0x36, 0x5f]; // data begin marker is "SF6_@BDF" enum ubyte[8] BDF = [0x53, 0x46, 0x36, 0x5f, 0x40, 0x42, 0x44, 0x46]; // data end marker is "SF6_@EDF" enum ubyte[8] EDF = [0x53, 0x46, 0x36, 0x5f, 0x40, 0x45, 0x44, 0x46]; // data portion size (in bytes) enum DATA_LENGTH = 256; // packet size enum PACKET_SIZE = MAGIC_NUMBER.length + (2 * SF.length) + (2 * SF.length) + BDF.length + DATA_LENGTH + EDF.length; } mixin(addProperty!(ubyte[4], "ID")); mixin(addProperty!(ubyte[4], "QN")); mixin(addProperty!(ubyte[256], "Data")); mixin(addProperty!(ubyte[], "Packet")); private { // encode value auto fromUnsignedInteger(uint value) { ubyte[4] data; data[0] = (value & 0xff); data[1] = (value & (0xff << 8)) >> 8; data[2] = (value & (0xff << 16)) >> 16; data[3] = (value & (0xff << 24)) >> 24; return data; } } this() { } void setID(uint id) { _id = fromUnsignedInteger(id); } void setQN(uint qn) { _qn = fromUnsignedInteger(qn); } auto encode() { _packet ~= MAGIC_NUMBER; _packet ~= SF; _packet ~= _id; _packet ~= SF; _packet ~= _qn; _packet ~= BDF; _packet ~= _data; _packet ~= EDF; } auto decode() { if (_packet.length == PACKET_SIZE) { auto magicNumber = _packet[0..MAGIC_NUMBER.length]; auto edfNumber = _packet[$-EDF.length..$]; if ((magicNumber == MAGIC_NUMBER) && (edfNumber == EDF)) { auto idPosition = MAGIC_NUMBER.length + SF.length; auto qnPosition = idPosition + SF.length + 4; auto dataPosition = qnPosition + 4 + BDF.length; _id = _packet[idPosition..idPosition+SF.length]; _qn = _packet[qnPosition..qnPosition+4]; _data = _packet[dataPosition..dataPosition+DATA_LENGTH]; } } } bool isValid() { return ((_packet[0..4] == MAGIC_NUMBER) && (_packet[4..8] == SF) && (_packet[12..16] == SF) && (_packet[20..28] == BDF) && (_packet[284..292] == EDF)); } }
Кроме этого, мы создали реализацию передатчика SF6 пакетов для Arduino (использовали Arduino MKR Zero). В этом случае плата получает данные от встроенного АЦП разрядностью 12 бит, разбивает полученные значения на два байта, упаковывает их в SF6 пакет и передает пакет по UART, который не задействован для общения платы с компьютером. Реализация выглядит так:
// send data via UART void sendData(byte *data, unsigned int length) { for (unsigned int i = 0; i < length; i++) { Serial1.write(data[i]); } } // encode unsigned long (is analog to D's uint) as array of bytes void encodeUnsignedLong(byte *data, unsigned long value) { data[0] = (value & 0xff); data[1] = (value & (0xff << 8)) >> 8; data[2] = (value & (0xff << 16)) >> 16; data[3] = (value & (0xff << 24)) >> 24; } // send SF6 data packet: data - array of bytes, id - identifier of packet, hash - data hash); void send_SF6Packet(byte *data, unsigned int id, unsigned int hash) { byte MAGIC_NUMBER[4] = {0x53, 0x46, 0x36, 0x21}; // system fields is "SF6_" byte SF[4] = {0x53, 0x46, 0x36, 0x5f}; // data begin marker is "SF6_@BDF" byte BDF[8] = {0x53, 0x46, 0x36, 0x5f, 0x40, 0x42, 0x44, 0x46}; // data end marker is "SF6_@EDF" byte EDF[8] = {0x53, 0x46, 0x36, 0x5f, 0x40, 0x45, 0x44, 0x46}; byte ID[4], HASH[4]; encodeUnsignedLong(ID, id); encodeUnsignedLong(HASH, hash); sendData(MAGIC_NUMBER, 4); sendData(SF, 4); sendData(ID, 4); sendData(SF, 4); sendData(HASH, 4); sendData(BDF, 8); sendData(data, 256); sendData(EDF, 8); } // simple hashing (R.Pike, B.Kernigan "Practice of programming") unsigned int simpleHash(byte *data, unsigned int length) { unsigned int MULTIPLIER = 31; unsigned int h; for (unsigned int i = 0; i < length; i++) { h = h * MULTIPLIER + data[i]; } return h % length; } int id = 0; void setup() { Serial1.begin(230400); } void loop() { byte data[256]; analogReadResolution(12); for (unsigned long i = 0; i < 256; i += 2) { unsigned int d = analogRead(A1); data[i] = (byte) (d & 0x00ff); data[i+1] = (byte) ((d & 0xff00) >> 8); } unsigned int hash = simpleHash(data, 256); send_SF6Packet(data, id, hash); id++; }
Для испытания концепции формата помимо платы Arduino MKR Zero была использована плата Sipeed Maix Bit на базе процессора архитектуры RISC-V (RV64IMAFDC) с прошитым в нее MaixPy (интерпретатор Python 3 на базе Micropython). Под эту плату был
написан код, который реализует прием пакетов SF6 от Arduino MKR Zero, разбор пакетов, выполнение быстрого преобразования Фурье для поступивших данных и вывод результата на миниатюрный LCD-экран размером 320×240. Код для платы Maix Bit:
class SF6_Packet: # signature MAGIC_NUMBER = bytes([0x53, 0x46, 0x36, 0x21]) # system info marker SF = bytes([0x53, 0x46, 0x36, 0x5f]) # begin data frame BDF = bytes([0x53, 0x46, 0x36, 0x5f, 0x40, 0x42, 0x44, 0x46]) # end data frame EDF = bytes([0x53, 0x46, 0x36, 0x5f, 0x40, 0x45, 0x44, 0x46]) # data frame size (in bytes) DATA_LENGTH = 256; # packet length (in bytes) PACKET_LENGTH = 292; def fromUnsignedInt(self, value): data = [] data.append(value & 0xff) data.append((value & (0xff << 8)) >> 8) data.append((value & (0xff << 16)) >> 16) data.append((value & (0xff << 24)) >> 24) return bytes(data) def __init__(self): self._id = [] self._qn = [] self._data = [] self._packet = [] def encode(self): self._packet.extend(self.MAGIC_NUMBER) self._packet.extend(self.SF) self._packet.extend(self._id) self._packet.extend(self.SF) self._packet.extend(self._qn) self._packet.extend(self.BDF) self._packet.extend(self._data) self._packet.extend(self.EDF) def decode(self): if (len(self._packet) == self.PACKET_LENGTH): magicNumber = self._packet[:len(self.MAGIC_NUMBER)] edfNumber = self._packet[len(self._packet) - len(self.EDF):] if ((magicNumber == self.MAGIC_NUMBER) and (edfNumber == self.EDF)): idPosition = len(self.MAGIC_NUMBER) + len(self.SF) qnPosition = idPosition + len(self.SF) + 4; dataPosition = qnPosition + 4 + len(self.BDF) self._id = self._packet[idPosition:idPosition+len(self.SF)]; self._qn = self._packet[qnPosition:qnPosition+4]; self._data = self._packet[dataPosition:dataPosition+self.DATA_LENGTH]; from fpioa_manager import fm, board_info from machine import UART from Maix import FFT import image import lcd # Pin 15 - TX fm.register (board_info.PIN15, fm.fpioa.UART1_TX) # Pin 17 - RX fm.register (board_info.PIN17, fm.fpioa.UART1_RX) # UART: 230 400 baud uart = UART(UART.UART1, 230400, timeout=1000) # init packet class sf6 = SF6_Packet() data = [] lcd.init() img = image.Image() sample_points = 1024 FFT_points = 512 lcd_width = 320 lcd_height = 240 hist_num = FFT_points if hist_num > 320: hist_num = 320 hist_width = int(320 / hist_num) #changeable x_shift = 0 while True: while len(data) < sample_points: d = uart.read(292) if not (d is None): sf6._packet = d sf6.decode() p = sf6._data data.extend(p) FFT_res = FFT.run(bytearray(data), FFT_points) FFT_amp = FFT.amplitude(FFT_res) img = img.clear() x_shift = 0 for i in range(hist_num): if FFT_amp[i] > 240: hist_height = 240 else: hist_height = FFT_amp[i] img = img.draw_rectangle((x_shift,240-hist_height,hist_width,hist_height),[255,255,255],2,True) x_shift = x_shift + hist_width lcd.display(img) data = []
Выглядит это впечатляюще:

Но и на этом наша команда не остановилась: мы создали приемник пакетов SF6 и для компьютера, воспользовавшись D и его библиотекой из реестра dub под названием serialport. Для этого мы написали простую демонстрацию, в которой использована старая версия SF6, отличающаяся только наименованием служебного поля qn (в старой версии оно называется hash), и которая выглядит следующим образом:
#!/usr/bin/env dub /+ dub.sdl: name "packets" dependency "serialport" version="~>2.2.3" +/ import serialport; import std.algorithm; import std.format; import std.range; import std.stdio; import std.string; import std.conv; template addProperty(T, string propertyName, string defaultValue = T.init.to!string) { import std.string : format, toLower; const char[] addProperty = format( ` private %2$s %1$s = %4$s; void set%3$s(%2$s %1$s) { this.%1$s = %1$s; } %2$s get%3$s() { return %1$s; } `, "_" ~ propertyName.toLower, T.stringof, propertyName, defaultValue ); } class SF6_Packet { private { // magic number is "SF6!" enum ubyte[4] MAGIC_NUMBER = [0x53, 0x46, 0x36, 0x21]; // system fields is "SF6_" enum ubyte[4] SF = [0x53, 0x46, 0x36, 0x5f]; // data begin marker is "SF6_@BDF" enum ubyte[8] BDF = [0x53, 0x46, 0x36, 0x5f, 0x40, 0x42, 0x44, 0x46]; // data end marker is "SF6_@EDF" enum ubyte[8] EDF = [0x53, 0x46, 0x36, 0x5f, 0x40, 0x45, 0x44, 0x46]; // data portion size (in bytes) enum DATA_LENGTH = 256; // packet size enum PACKET_SIZE = MAGIC_NUMBER.length + (2 * SF.length) + (2 * SF.length) + BDF.length + DATA_LENGTH + EDF.length; } private { mixin(addProperty!(ubyte[4], "ID")); mixin(addProperty!(ubyte[4], "Hash")); mixin(addProperty!(ubyte[256], "Data")); mixin(addProperty!(ubyte[], "Packet")); // encode value auto fromUnsignedInteger(uint value) { ubyte[4] data; data[0] = (value & 0xff); data[1] = (value & (0xff << 8)) >> 8; data[2] = (value & (0xff << 16)) >> 16; data[3] = (value & (0xff << 24)) >> 24; return data; } } this() { } void setID(uint id) { _id = fromUnsignedInteger(id); } void setHash(uint hash) { _hash = fromUnsignedInteger(hash); } auto encode() { _packet ~= MAGIC_NUMBER; _packet ~= SF; _packet ~= _id; _packet ~= SF; _packet ~= _hash; _packet ~= BDF; _packet ~= _data; _packet ~= EDF; } auto decode() { if (_packet.length == PACKET_SIZE) { auto magicNumber = _packet[0..MAGIC_NUMBER.length]; auto edfNumber = _packet[$-EDF.length..$]; if ((magicNumber == MAGIC_NUMBER) && (edfNumber == EDF)) { auto idPosition = MAGIC_NUMBER.length + SF.length; auto hashPosition = idPosition + SF.length + 4; auto dataPosition = hashPosition + 4 + BDF.length; _id = _packet[idPosition..idPosition+SF.length]; _hash = _packet[hashPosition..hashPosition+4]; _data = _packet[dataPosition..dataPosition+256]; } } } bool isValid() { return ((_packet[0..4] == MAGIC_NUMBER) && (_packet[4..8] == SF) && (_packet[12..16] == SF) && (_packet[20..28] == BDF) && (_packet[284..292] == EDF)); } } void main() { enum PORT_SPEED = 230_400; SerialPortBlk device = new SerialPortBlk("/dev/ttyACM0", PORT_SPEED); device.config = SPConfig(PORT_SPEED, DataBits.data8, Parity.none, StopBits.one); ubyte[292] buffer; device.read(buffer); auto p = new SF6_Packet; p.setPacket(buffer); p.decode; p.getID.writeln; p.getHash.writeln; p.getData.writeln; p.getPacket.writeln; p.isValid.writeln; float[] data; auto tmp = p.getData; for (int i = 0; i < 256; i += 2) { uint value; value |= tmp[i]; value |= (tmp[i+1] << 8); data ~= value; data ~= (value / 1024.0) * 3.3; } data.writeln; }
Запускается демонстрация командой:dub run --single packets2.d
Реализация пакетов SF6 оказалась очень простой и доступной, что позволило нам использовать её для своих проектов. Описанная выше проблема со спектроанализатором была решена в тот же день, когда была написана первая версия формата SF6, и потребовала незначительной модификации кода с добавлением файла sf6.d. Это событие нас очень порадовало и поэтому мы решили рассказать о собственном формате пакетов, который пусть и является экспериментальным, но все же может быть использован и вами, уважаемые читатели…