Micro Property - serializador de dados binários minimalista para sistemas embarcados

Micro Property é uma biblioteca para serializar dados com sobrecarga mínima. Ele é projetado para uso em microcontroladores e uma variedade de dispositivos incorporados com restrição de memória que precisam operar em linhas de comunicação de baixa velocidade.



Claro, eu conheço formatos como xml, json, bson, yaml, protobuf, Thrift, ASN.1. Eu até encontrei uma árvore exótica, que por si só é uma matadora de JSON, XML, YAML e outros como eles .



Então, por que todos eles não se encaixaram? Por que fui forçado a escrever outro serializador?



Após a publicação do artigo nos comentários, eles forneceram vários links para os formatos CBOR , UBJSON e MessagePack que eu perdi . E provavelmente resolverão meu problema sem precisar escrever uma bicicleta.

É uma pena que não tenha conseguido encontrar essas especificações antes, então adicionarei este parágrafo para os leitores e para meu próprio lembrete de não ter pressa em escrever o código ;-).

Resenhas de formatos em Habré: CBOR , UBJSON



imagem





Requisitos iniciais



Imagine que você precise modificar um sistema distribuído que consiste em várias centenas de dispositivos de diferentes tipos (mais de dez tipos de dispositivos executando funções diferentes). Eles são combinados em grupos que trocam dados entre si por meio de linhas de comunicação serial usando o protocolo Modbus RTU.



Além disso, alguns desses dispositivos são conectados a uma linha de comunicação CAN comum, que fornece transferência de dados em todo o sistema como um todo. A taxa de transferência de dados na linha de comunicação Modbus é de até 115200 Baud, e a velocidade no barramento CAN é limitada à velocidade de até 50kBaud devido ao seu comprimento e à presença de graves interferências industriais.



A grande maioria dos dispositivos é desenvolvida em microcontroladores das séries STM32F1x e STM32F2x. Embora alguns deles funcionem no STM32F4x também. E, claro, sistemas baseados em Windows / Linux com microprocessadores x86 como controladores de nível superior.



Para estimar a quantidade de dados que são processados ​​e transmitidos entre dispositivos ou armazenados como configurações / parâmetros operacionais: Em um caso - 2 números de 1 byte e 6 números de 4 bytes, no outro - 11 números de 1 byte e 1 número de 4 bytes e etc. Para referência, o tamanho dos dados em um quadro CAN padrão é de até 8 bytes e, em um quadro Modbus, de até 252 bytes de carga útil.



Se você ainda não penetrou na profundidade da toca do coelho, adicione a estes dados de entrada: a necessidade de manter o controle das versões de protocolo e versões de firmware para diferentes tipos de dispositivos, bem como o requisito de manter a compatibilidade não apenas com os formatos de dados existentes atualmente, mas também para garantir a junção trabalho de dispositivos com gerações futuras, que também não param e são constantemente desenvolvidos e processados ​​conforme a funcionalidade se desenvolve e batentes são encontrados nas implementações. Além disso, interação com sistemas externos, expansão de requisitos, etc.



Inicialmente, devido aos recursos limitados e às baixas velocidades das linhas de comunicação, um formato binário foi usado para a troca de dados, que estava vinculado apenas a registradores Modbus. Mas tal implementação não passou no primeiro teste de compatibilidade e extensibilidade.



Portanto, ao redesenhar a arquitetura, foi necessário abandonar o uso de registradores Modbus padrão. E nem mesmo porque outras linhas de comunicação são utilizadas além deste protocolo, mas sim pela organização excessivamente limitada das estruturas de dados baseadas em registradores de 16 bits.



De fato, no futuro, com a evolução inevitável do sistema, pode ser necessário, (e de fato, já era necessário), transferir strings de texto ou matrizes. Em teoria, eles também podem ser exibidos no mapa de registro do Modbus, mas isso acaba sendo óleo, porque vem a abstração sobre a abstração.



Obviamente, você pode transferir dados como um blob binário com referência à versão do protocolo e ao tipo de bloco. E embora à primeira vista, essa ideia possa parecer boa, porque ao fixar certos requisitos para a arquitetura, você pode definir formatos de dados de uma vez por todas, economizando significativamente nos custos indiretos que serão inevitáveis ​​ao usar formatos como XML ou JSON.



Para facilitar a comparação de opções, fiz para mim a seguinte tabela:
:

:



  • . , .


:



  • , .
  • . , .
  • . , , . , .
  • , .


:



:

  • .


:

  • . , .
  • , , .




E imagine como várias centenas de dispositivos começam a trocar dados binários entre si, mesmo com a vinculação de cada mensagem à versão do protocolo e / ou tipo de dispositivo, então a necessidade de usar um serializador com campos nomeados torna-se imediatamente óbvia. Afinal, mesmo uma simples interpolação da complexidade de suportar tal solução como um todo, embora depois de um tempo muito curto, o obriga a agarrar sua cabeça.



E isto, mesmo sem ter em conta os anseios esperados do cliente em aumentar a funcionalidade, a presença de ombreiras obrigatórias na implementação e “menores”, à primeira vista, melhorias, que certamente trarão consigo um especial picante da procura de ombreiras recorrentes no trabalho bem coordenado de tal zoo ...



imagem



Quais são as opções?



Após tal raciocínio, você involuntariamente chega à conclusão de que é necessário, desde o início, estabelecer uma identificação universal de dados binários, inclusive ao trocar pacotes em linhas de comunicação de baixa velocidade.



E quando cheguei à conclusão de que não se pode viver sem um serializador, primeiro olhei para as soluções existentes que já se provaram do melhor lado e que já são usadas em muitos projetos.



Os formatos básicos xml, json, yaml e outras variantes de texto com uma sintaxe formal muito conveniente e simples, que é adequada para o processamento de documentos e ao mesmo tempo conveniente para leitura e edição por humanos, tiveram que ser eliminados imediatamente. E apenas por causa de sua conveniência e simplicidade, eles têm uma sobrecarga muito grande ao armazenar dados binários, que apenas precisavam ser processados.



Portanto, em vista dos recursos limitados e das linhas de comunicação de baixa velocidade, optou-se por usar um formato de apresentação de dados binários. Mas mesmo no caso de formatos que podem converter dados em uma representação binária, como Buffers de protocolo, FlatBuffers, ASN.1 ou Apache Thrift, a sobrecarga de serialização de dados, bem como a conveniência geral de seu uso, não contribuíram para a implementação imediata de qualquer uma dessas bibliotecas.



O formato BSON, que possui uma sobrecarga mínima, foi o mais adequado para a combinação de parâmetros. E eu considerei seriamente usá-lo. Mas, como resultado, ele decidiu abandoná-lo, uma vez que todas as outras coisas permanecendo iguais, até mesmo a BSON terá despesas gerais inaceitáveis.

Pode parecer estranho para alguns que você tenha que se preocupar com uma dúzia de bytes extras, mas, infelizmente, essa dúzia de bytes terá que ser transmitida toda vez que uma mensagem for enviada. E no caso de trabalhar em linhas de comunicação de baixa velocidade, até dez bytes extras em cada pacote são importantes.



Em outras palavras, ao operar com dez bytes, você começa a contar cada um deles. Mas junto com os dados, endereços de dispositivos, somas de verificação de pacotes e outras informações específicas para cada linha de comunicação e protocolo também são transmitidos para a rede.

O que aconteceu



Como resultado do pensamento e de alguns experimentos, um serializador com os seguintes recursos e características foi obtido:



  • A sobrecarga para dados de tamanho fixo é de 1 byte (sem contar o comprimento do nome do campo de dados).
  • , , — 2 ( ). , CAN Modbus, .
  • — 16 .
  • , , .. . , 16 .
  • (, ) — 252 (.. ).
  • — .
  • . .
  • « », , . , , - ( 0xFF).
  • . , . .
  • , . .




  • 8 64 .
  • .
  • ( ).
  • — . , , . ;-)
  • . , .


Eu gostaria de observar separadamente



A implementação é feita em C ++ x11 em um único arquivo de cabeçalho usando o mecanismo de modelagem SFINAE (A falha de substituição não é um erro).



Suportado pela leitura correta dos dados no buffer (variável) b Cerca de um tamanho maior do que o tipo de dados armazenados. Por exemplo, um número inteiro de 8 bits pode ser lido em uma variável de 8 a 64 bits. Estou pensando, talvez valesse a pena adicionar um pacote de inteiros, cujo tamanho exceda 8 bits, para que eles possam ser transmitidos em um número menor.



Os arrays serializados podem ser lidos copiando para a área de memória especificada ou obtendo uma referência normal aos dados no buffer original, se você quiser evitar a cópia, nos casos em que não é necessário. Mas esse recurso deve ser usado com cautela, porque arrays de inteiros são armazenados na ordem de bytes da rede, que pode ser diferente entre as máquinas.



A serialização de estruturas ou objetos mais complexos nem foi planejada. Geralmente é perigoso transferir estruturas na forma binária por causa do possível alinhamento de seus campos. Mas se este problema for resolvido de uma maneira relativamente simples, então ainda haverá um problema de conversão de todos os campos de objetos contendo inteiros para a ordem de bytes da rede e vice-versa.



Além disso, em caso de emergência, as estruturas sempre podem ser salvas e restauradas como um array de bytes. Naturalmente, neste caso, a conversão de inteiros deverá ser feita manualmente.



Implementação



A implementação pode ser encontrada aqui: https://github.com/rsashka/microprop



Como usar está escrito em exemplos com vários graus de detalhes:



Uso rápido
#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); //  




Uso lento e cuidadoso
#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);




Exemplo de implementação usando enum como identificador de dados
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...);
  }
};




O código é publicado sob a licença do MIT, então use-o para a saúde.



Terei todo o prazer em receber qualquer feedback, incluindo comentários e / ou sugestões.



Update: Não me enganei ao escolher uma foto para o artigo ;-)



All Articles