Micro Property — библиотека для сериализации данных с минимальными накладными расходами. Она разработана для использования в микроконтроллерах и различных встраиваемых устройствах с ограничениями по размеру памяти, которым приходится работать по низкоскоростным линиям связи.

Конечно, я знаю про такие форматы как xml, json, bson, yaml, protobuf, Thrift, ASN.1. Даже нашел экзотический Tree, который сам является убийцей JSON, XML, YAML и иже с ними.

Так почему же они все не подошли? Зачем я был вынужден написать еще один сериализатор?

Уже после публикации статьи в комментариях дали несколько ссылок на пропущенные мной форматы CBOR, UBJSON и MessagePack. А они с большой долей вероятности решают мою задачу без написания велосипеда.
Жаль, что я не смог найти эти спецификации ранее, поэтому добавлю этот абзац для читателей и для собственного напоминания, что не следует торопиться писать код ;-).
Обзоры форматов на Хабре: CBOR, UBJSON

image


Исходные требования


Представьте, что требуется доработать распределенную систему, состоящую из нескольких сотен устройств разных типов (более десяти типов устройств, выполняющих разные функции). Они объединены в группы, которые обмениваются между собой данными по последовательным линиям связи по протоколу Modbus RTU.

Также, часть этих устройств подключены к общей линии связи CAN, которая и обеспечивает передачу данных в рамках всей системы в целом. Скорость передачи данных по линии связи Modbus до 115200 Бод, а скорость по шине CAN ограничена скоростью до 50кБод из-за её протяженности и присутствия серьезных индустриальных помех.

Устройства в подавляющем большинстве разработаны на микроконтроллерах серий STM32F1x и STM32F2x. Хотя часть из них работает и на STM32F4x. Ну и конечно, Windows/Linux на базе систем с x86 микропроцессорами в качестве контроллеров верхнего уровня.

Для оценки объема данных, которые обрабатываются и передаются между устройствами или хранятся в качестве настроек/параметров работы: В одном случае — 2 числа по 1 байт и 6 чисел по 4 байта, в другом — 11 чисел по 1 байту и 1 число 4 байта и т.д. Для справки, размер данных в стандартном кадре CAN — до 8 байт, а во фрейме Modbus, до 252 байт полезных данных.

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

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

Поэтому, при переработке архитектуры пришлось отказаться от использования стандартных Modbus регистров. И даже не из-за того, что кроме этого протокола используются и другие линии связи, а сколько из-за чрезмерной ограниченности организации структур данных, основанных на 16-битных регистрах.

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

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

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

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

Минусы:

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

Передача двоичных данных с идентификацией полей:

Минусы:
  • Неизбежные накладные расходы для передачи имени и типа данных для каждого поля.

Плюсы:
  • Не требуется управлять версионностью на уровне бинарных данных. А в случае возникновения такой необходимости, номер версии можно передавать как одно и полей данных пакета.
  • Возможность сквозной идентификации данных в любом сообщении, что обеспечивает совместимость как в настоящий момент, так и в отдаленной перспективе.


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

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

image

А какие есть варианты?


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

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

Основные форматы xml, json, yaml и другие текстовые варианты с очень удобным и простым формальным синтаксисом, который хорошо подходит для обработки документов и одновременно удобен для чтения и редактирования человеком, пришлось сразу отбросить. И как раз из-за своего удобства и простоты они имеют очень большие накладные расходы при хранении бинарных данных, которые как раз и требовалось обрабатывать.

Поэтому, в виду ограниченности ресурсов и низкоскоростных линий связи, было решено использовать бинарный формат представления данных. Но и в случае форматов, умеющих преобразовывать данные в бинарное представление, таких как Protocol Buffers, FlatBuffers, ASN.1 или Apache Thrift, накладные расходы при сериализации данных, а так же общее удобство их применения не способствовало к немедленному внедрению любой из подобных библиотек.

Лучше всего по совокупности параметров подходил формат BSON, который имеет минимальный оверхед. И я серьезно рассматривал возможность его применения. Но в результате, все же решил отказаться и от него, т. к. при прочих равных условиях даже у BSON будут неприемлемые накладные расходы.
Кому-то может показаться странным, что приходится беспокоиться о десятке лишних байт, но к сожалению, этот десяток байт должен будет передаваться каждый раз при отправке сообщения. А в случае работы по низкоскоростным линиям связи, даже лишний десяток байт в каждой посылке имеют значение.

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

Что получилось


В результате раздумий и нескольких экспериментов получился сериализатор со следующими особенностями и характеристиками:

  • Оверхед для данных фиксированного размера — 1 байт (без учета длины имени поля данных).
  • Оверхед для данных переменного размера, таких как блоб, текстовая строка или массив — 2 байта (так же без учета длины имени поля данных). Так как использовать данный формат предполагается в устройствах, работающих по протоколам CAN и Modbus, то для хранения размера можно ограничиться одним байтом.
  • Ограничения на размер имени поля — 16 байт.
  • В качестве идентификатора поля используется текстовая строка с завершающим нулевым символом, которая обрабатывается как бинарные данные, т.е. без учета завершающего нуля. Вместо текстовой строки в качестве идентификаторов полей, можно использовать целые числа или произвольные бинарные данные размером до 16 байт.
  • Максимальный размер полей данных переменной длины (блоб, текстовая строка или массив) — 252 байта (т.к. размеры полей хранятся в одном байте).
  • Общий размер сериализованных данных — без ограничений.
  • При работе память не выделяется. Все действия происходят только с внешним буфером без внутреннего выделения и освобождения памяти.
  • Возможен режим работы «только чтение», например для работы с настройками приложения, которые сохранены в программной памяти микроконтроллера. В том числе, корректно отрабатывается ситуация, когда данные размещены в очищенной флеш-памяти (заполненной 0xFF).
  • В режиме редактирования поддерживается только добавление новых данных до заполнения буфера. Возможность обновления полей штатным способом не реализована, потому что для изначальных задач подобный функционал не требовался. Хотя при необходимости есть возможность редактировать данные по указателю в буфере.
  • Ну а в случае крайней необходимости, можно будет попробовать добавить возможность обновления полей. Для этого даже оставлен в резерве один из типов.

Поддерживаемые типы данных


  • Целые числа размером от 8 до 64 бит с преобразованием в сетевой порядок байт и обратно.
  • Логические значения и числа с плавающей запятой одинарной и двойной точности.
  • Двоичные данные переменной длины (блоб или массив байт).
  • Текстовые строки — двоичные данные с завершающим нулевым символом в конце. При сериализации строк после данных записывается нулевой символ, чтобы потом было удобно с ними работать как с обычными строками, без необходимости копировать данные в промежуточный буфер или высчитывать количество символов в строке. Хотя есть возможность выстрелить себе в ногу и сохранить текстовую строку с нулевым символом в где нибудь в середине строки ;-)
  • Одномерные массивы для всех типов целых и вещественных чисел. При работе с массивами целых чисел, они автоматически преобразуются в сетевой порядок байт и обратно.

Хотелось бы отметить отдельно


Реализация сделана на С++ x11 в единственном заголовочном файле с использованием механизма шаблонов SFINAE (Substitution failure is not an error).

Поддерживается корректное чтение данных в буфер (переменную) бОльшего размера, чем сохраненный тип данных. Например, целое число 8 бит можно прочитать в переменную от 8 до 64 бит. Подумываю, может быть стоит добавить упаковку целых чисел, размер которых превышает 8 бит, на предмет возможности их передачи числом меньшего размера.

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

Сериализация структур или более сложных объектов даже не планировалась. Структуры вообще опасно передавать в бинарном виде из-за возможного выравнивания её полей. Но если эта проблема все же решается относительно простым способом, то все равно будет проблема конвертации в сетевой порядок байт и обратно всех полей объектов, содержащих целые числа.

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

Реализация


Реализация находится тут: https://github.com/rsashka/microprop

Как пользоваться, написано в примерах с различной степенью подробности:

Быстрое использование
#include "microprop.h"

Microprop prop(buffer, sizeof (buffer));// Создать сериализатор и назначить ему буфер

prop.FieldExist(string || integer); // Проверить наличие поля с указанным ID
prop.FieldType(string || integer); // Получить тип данных поля

prop.Append(string || integer, value); // Добавить данные
prop.Read(string || integer, value); // Прочитать данные


Медленное и вдумчивое использование
#include "microprop.h"

Microprop prop(buffer, sizeof (buffer)); // Создать сериализатор

prop.AssignBuffer(buffer, sizeof (buffer)); // Назначить буфер
prop.AssignBuffer((const)buffer, sizeof (buffer)); // Назначить read only буфер
prop.AssignBuffer(buffer, sizeof (buffer), true); // Тоже read only буфер

prop.FieldNext(ptr); // Получить указатель на следующее поле
prop.FieldName(string || integer, size_t *length = nullptr); // Указатель на ID поля
prop.FieldDataSize(string || integer); // Размер сериализованных данных

// Дальше все прозрачно
prop.Append(string || blob || integer, value || array);
prop.Read(string || blob || integer, value || array);

prop.Append(string || blob || integer, uint8_t *, size_t);
prop.Read(string || blob || integer, uint8_t *, size_t);

prop.AppendAsString(string || blob || integer, string);
const char * ReadAsString(string || blob || integer);


Пример реализации с использованием enum в качестве идентификатора данных
class Property : public Microprop {
public:
    enum ID {
    ID1, ID2, ID3
  };

  template <typename ... Types>
  inline const uint8_t * FieldExist(ID id, Types ... arg) {
    return Microprop::FieldExist((uint8_t) id, arg...);
  }

  template <typename ... Types>
  inline size_t Append(ID id, Types ... arg) {
    return Microprop::Append((uint8_t) id, arg...);
  }

  template <typename T>
  inline size_t Read(ID id, T & val) {
    return Microprop::Read((uint8_t) id, val);
  }

  inline size_t Read(ID id, uint8_t *data, size_t size) {
    return Microprop::Read((uint8_t) id, data, size);
  }

    
  template <typename ... Types>
  inline size_t AppendAsString(ID id, Types ... arg) {
    return Microprop::AppendAsString((uint8_t) id, arg...);
  }

  template <typename ... Types>
  inline const char * ReadAsString(ID id, Types... arg) {
    return Microprop::ReadAsString((uint8_t) id, arg...);
  }
};


Код выложен под лицензией MIT, так что пользуйтесь на здоровье.

Буду рад любому фидбеку, в том числе и замечаниям и/или предложениям.

Update: Я не ошибся в выборе картинки для статьи ;-)