nanopb/protobuf - how to force max size serialization/encoding

1.1k Views Asked by At

Documentation for pb_ostream_from_buffer says

After writing, you can check stream.bytes_written to find out how much valid data there is in the buffer. This should be passed as the message length on decoding side.

So ideally, when I send the serialized data I need to also send the bytes_written as a parameter separate from the buffer.

The problem is that my interface only allows me to send one variable: the buffer.

QUESTION
How do I specify always serialize the struct with no optimizations so that bufsize in

pb_istream_from_buffer(const pb_byte_t *buf, size_t bufsize)

can be a constant (i.e. the macro that specifies the maximum size) instead of needing to pass stream.bytes_written?

1

There are 1 best solutions below

6
absolute.madness On

According to the Protocol Buffers encoding specification there are variable size types (like int32, int64, string, etc) and fixed size types (like fixed32, fixed64, double, etc). Now, this variable size encoding is more than just an optimization, it's a part of the design and specification. So disabling this "optimization" by the means of Protocol Buffers is only possible if your data consists exclusively of fixed length types and has no repeated fields as long as the number of repetitions is not fixed. I presume that this is not the case, since you're asking this question. So the short answer is no, it's not possible by means of the library because it would violate the encoding specification.

But in my opinion the desired effect could be easily achieved by encoding the size into the buffer with little CPU and RAM overhead. I presume you know the maximum size of the message generated by nanopb, we denote it by MAX_MSG_SIZE. We call this message the payload message. Suppose that this MAX_MSG_SIZE can be represented by some integer type, which we denote by wrapped_size_t (e.g. uint16_t).

The idea is simple:

  • allocate the buffer slightly larger than MAX_MSG_SIZE;
  • write the payload message generated by nanopb at some offset into the allocated buffer;
  • use this offset to encode the size of the payload message at the beginning of the buffer;
  • transmit the whole buffer having the fixed size equal to MAX_MSG_SIZE + sizeof(wrapped_size_t) to the receiver;
  • upon reception decode the size of the payload message and pass both the decoded size and the payload message to pb_istream_from_buffer.

I attach the code to illustrate the idea. I used an example from nanopb repository:

#include <stdio.h>
#include <inttypes.h>
#include <string.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "simple.pb.h"


//#define COMMON_ENDIANNES

#ifdef COMMON_ENDIANNES
#define encode_size encode_size_ce
#define decode_size decode_size_ce
#else
#define encode_size encode_size_le
#define decode_size decode_size_le
#endif

typedef uint16_t wrapped_size_t;

/* Maximum size of the message returned by bytes_written */
const size_t MAX_MSG_SIZE = 11;
/* Size of the field storing the actual size of the message
 * (as returned by bytes_written) */
const size_t SIZE_FIELD = sizeof(wrapped_size_t);
/* Fixed wrapped message size */
const size_t FIXED_MSG_SIZE = MAX_MSG_SIZE + sizeof(wrapped_size_t);

void print_usage(char *prog);

/* Get the address of the payload buffer from the transmitted buffer */
uint8_t* payload_buffer(uint8_t *buffer);
/* Encode the payload size into the transmitted buffer (common endiannes) */
void encode_size_ce(uint8_t *buffer, size_t size);
/* Decode the payload size into the transmitted buffer (common endiannes) */
wrapped_size_t decode_size_ce(uint8_t *buffer);
/* Encode the payload size into the transmitted buffer (little endian) */
void encode_size_le(uint8_t *buffer, size_t size);
/* Decode the payload size into the transmitted buffer (little endian) */
size_t decode_size_le(uint8_t *buffer);

int main(int argc, char* argv[])
{
    /* This is the buffer where we will store our message. */
    uint8_t buffer[MAX_MSG_SIZE + sizeof(wrapped_size_t)];
    bool status;

    if(argc > 2 || (argc == 2 && (!strcmp(argv[1], "-h") || !strcmp(argv[1], "--help"))))
    {
      print_usage(argv[0]);
      return 1;
    }
    
    /* Encode our message */
    {
        /* Allocate space on the stack to store the message data.
         *
         * Nanopb generates simple struct definitions for all the messages.
         * - check out the contents of simple.pb.h!
         * It is a good idea to always initialize your structures
         * so that you do not have garbage data from RAM in there.
         */
        SimpleMessage message = SimpleMessage_init_zero;
        
        /* Create a stream that will write to our buffer. */
        pb_ostream_t stream = pb_ostream_from_buffer(payload_buffer(buffer),
                                                     MAX_MSG_SIZE);
        
        if(argc > 1)
          sscanf(argv[1], "%" SCNd32, &message.lucky_number);
        else
        {
          printf("Input lucky number: ");
          scanf("%" SCNd32, &message.lucky_number);
        }
        
        /* Encode the payload message */
        status = pb_encode(&stream, SimpleMessage_fields, &message);
        /* Wrap the payload, i.e. add the size to the buffer */
        encode_size(buffer, stream.bytes_written);
        
        /* Then just check for any errors.. */
        if (!status)
        {
            printf("Encoding failed: %s\n", PB_GET_ERROR(&stream));
            return 1;
        }
    }
    
    /* Now we could transmit the message over network, store it in a file, etc.
     * Note, the transmitted message has a fixed length equal to FIXED_MSG_SIZE
     * and is stored in buffer
     */

    /* But for the sake of simplicity we will just decode it immediately. */
    
    {
        /* Allocate space for the decoded message. */
        SimpleMessage message = SimpleMessage_init_zero;
        
        /* Create a stream that reads from the buffer. */
        pb_istream_t stream = pb_istream_from_buffer(payload_buffer(buffer),
                                                     decode_size(buffer));
        
        /* Now we are ready to decode the message. */
        status = pb_decode(&stream, SimpleMessage_fields, &message);
        
        /* Check for errors... */
        if (!status)
        {
            printf("Decoding failed: %s\n", PB_GET_ERROR(&stream));
            return 1;
        }
        
        /* Print the data contained in the message. */
        printf("Your lucky number was %d; payload length was %d.\n",
               (int)message.lucky_number, (int)decode_size(buffer));
    }
    
    return 0;
}

void print_usage(char *prog)
{
  printf("usage: %s [<lucky_number>]\n", prog);
}

uint8_t* payload_buffer(uint8_t *buffer)
{
  return buffer + SIZE_FIELD;
}

void encode_size_ce(uint8_t *buffer, size_t size)
{
  *(wrapped_size_t*)buffer = size;
}

wrapped_size_t decode_size_ce(uint8_t *buffer)
{
  return *(wrapped_size_t*)buffer;
}

void encode_size_le(uint8_t *buffer, size_t size)
{
  int i;
  for(i = 0; i < sizeof(wrapped_size_t); ++i)
  {
    buffer[i] = size;
    size >>= 8;
  }
}
size_t decode_size_le(uint8_t *buffer)
{
  int i;
  size_t ret = 0;
  for(i = sizeof(wrapped_size_t) - 1; i >= 0; --i)
    ret = buffer[i] + (ret << 8);
  return ret;
}

UPD Ok, if, for some reason, you still wish to stick to the original GPB encoding there's another option available: fill the unused part of the buffer (i.e. the part after the last byte written by nanopb) with some valid data which will be ignored. For instance, you can reserve a field number which doesn't mark any field in your *.proto file but is used to mark the data which will be discarded by the GPB decoder. Let's denote this reserved field number as RESERVED_FIELD_NUMBER. This is used for backward compatibility but you can use it for your purpose as well. Let's call this filling-in the buffer with the dummy data sealing (perhaps there's a better term). This method also requires that you have at least 2 free bytes available to you after pb_encode.

So the idea of sealing is even simpler:

  • calculate how many buffer bytes is left unfilled after pb_encode;
  • mark the rest of the buffer as array of bytes with RESERVED_FIELD_NUMBER.

I attach the updated code, the main function is bool seal_buffer(uint8_t *buffer, size_t size), call it after pb_encode to seal the buffer and you're done. Currently, it has a limitation of sealing no more than 2 ** 28 + 4 bytes, but it could be easily updated to overcome this limitation.

#include <stdio.h>
#include <assert.h>
#include <inttypes.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "simple.pb.h"

/* Reserved field_number shouldn't be used for field numbering. We use it
 * to mark the data which will be ignored upon reception by GPB parser.
 * This number should be 1 to 15 to fit into a single byte. */
const uint8_t RESERVED_FIELD_NUMBER = 15;

/* Maximum size of the message returned by bytes_written (payload size) */
const size_t MAX_MSG_SIZE = 200;

/* Size of the transmitted message (reserve 2 bytes for minimal sealing) */
const size_t FIXED_MSG_SIZE = MAX_MSG_SIZE + 2;

void print_usage(char *prog);

/* Sealing the buffer means filling it in with data which is valid
 * in the sense that a GPB parser accepts it as valid but ignores it */
bool seal_buffer(uint8_t *buffer, size_t size);

int main(int argc, char* argv[])
{
  /* This is the buffer where we will store our message. */
  uint8_t buffer[FIXED_MSG_SIZE];
  bool status;

  if(argc > 2 || (argc == 2 && (!strcmp(argv[1], "-h") || !strcmp(argv[1], "--help"))))
  {
    print_usage(argv[0]);
    return 1;
  }

  /* Encode our message */
  {
    /* Allocate space on the stack to store the message data.
         *
         * Nanopb generates simple struct definitions for all the messages.
         * - check out the contents of simple.pb.h!
         * It is a good idea to always initialize your structures
         * so that you do not have garbage data from RAM in there.
         */
    SimpleMessage message = SimpleMessage_init_zero;

    /* Create a stream that will write to our buffer. */
    pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));

    if(argc > 1)
      sscanf(argv[1], "%" SCNd32, &message.lucky_number);
    else
    {
      printf("Input lucky number: ");
      scanf("%" SCNd32, &message.lucky_number);
    }

    /* Now we are ready to encode the message! */
    status = pb_encode(&stream, SimpleMessage_fields, &message);

    /* Then just check for any errors.. */
    if (!status)
    {
      fprintf(stderr, "Encoding failed: %s\n", PB_GET_ERROR(&stream));
      return 1;
    }

    /* Now the main part - making the buffer fixed-size */
    assert(stream.bytes_written + 2 <= FIXED_MSG_SIZE);
    if(!seal_buffer(buffer + stream.bytes_written,
                    FIXED_MSG_SIZE - stream.bytes_written))
    {
      fprintf(stderr, "Failed sealing the buffer "
                      "(filling in with valid but ignored data)\n");
      return 1;
    }
  }

  /* Now we could transmit the message over network, store it in a file or
     * wrap it to a pigeon's leg.
     */

  /* But because we are lazy, we will just decode it immediately. */

  {
    /* Allocate space for the decoded message. */
    SimpleMessage message = SimpleMessage_init_zero;

    /* Create a stream that reads from the buffer. */
    pb_istream_t stream = pb_istream_from_buffer(buffer, FIXED_MSG_SIZE);

    /* Now we are ready to decode the message. */
    status = pb_decode(&stream, SimpleMessage_fields, &message);

    /* Check for errors... */
    if (!status)
    {
      fprintf(stderr, "Decoding failed: %s\n", PB_GET_ERROR(&stream));
      return 1;
    }

    /* Print the data contained in the message. */
    printf("Your lucky number was %d.\n", (int)message.lucky_number);
  }

  return 0;
}

void print_usage(char *prog)
{
  printf("usage: %s [<lucky_number>]\n", prog);
}

bool seal_buffer(uint8_t *buffer, size_t size)
{
  size_t i;

  if(size == 1)
  {
    fprintf( stderr, "Cannot seal the buffer, at least 2 bytes are needed\n");
    return false;
  }

  assert(size - 5 < 1<<28);
  if(size - 5 >= 1<<28)
  {
    fprintf( stderr, "Representing the size exceeding 2 ** 28 + 4, "
                     "although it's not difficult, is not yet implemented\n");
    return false;
  }

  buffer[0] = (15 << 3) + 2;
  /* encode the size */
  if(size - 2 < 1<<7)
    buffer[1] = size - 2;
  else
  {
    /* Size is large enough to fit into 7 bits (1 byte).
     * For simplicity we represent the remaining size by 4 bytes (28 bits).
     * Note that 1 byte is used for encoding field_number and wire_type,
     * plus 4 bytes for the size encoding, therefore the "remaining size"
     * is equal to (size - 5)
     */

    size -= 5;
    for(i = 0; i < 4; ++i)
    {
      buffer[i + 1] = i < 3? (size & 0x7f) | 0x80: size & 0x7f;
      size >>= 7;
    }
  }
  return true;
}