C++ (Tiny)

The cpptiny target is designed to be used in embedded systems with highly constrained resources. It has a small memory footprint and does not allocate on the heap or use the STL. It works well on microcontrollers with under 2k of RAM or on a desktop PC. A compiler that can target the C++14 standard is required.

Features

  • Header only
  • Small memory footprint
  • No heap memory allocation
  • Does not use the STL
  • Produces well-optimized assembly when compiler optimizations are enabled

Example

Code generation

$ bakelite runtime -l cpptiny -o bakelite.h  # One-time generation of the library
$ bakelite gen -l cpptiny -i proto.bakelite -o proto.h

Use the generated code to implement a simple protocol.

#include "proto.h"

int main(int argc, char *arv[]) {
  Serial port("/dev/ttyUSB0", 9600); // Magic, easy to use, portable serial port class.. :)

  // Create an instance of our protocol. Use the Serial port for sending and receiving data.
  Protocol proto(
    []() { return port.read(); },
    [](const char *data, size_t length) { return port.write(data, length); }
  );

  // Send a message
  HelloMsg msg;
  msg.code = 42;
  strcpy(msg.message, "Hello world!");
  proto.send(msg);

  // Wait for a reply
  while(true) {
    // Check and see if a new message has arrived
    Protocol::Message messageId = proto.poll();

    switch(messageId) {
    case Protocol::Message::NoMessage: // Nope, better luch next time
      break;

    case Protocol::Message::ReplyMsg: // We received a reply!
      //Decode message
      ReplyMsg msg;
      int ret = proto.decode(msg);
      if(ret != 0) {
        send_err("Decode Failed", ret);
        return;
      }

      cout << "Reply: " << msg.text << endl;
    default:
      send_err("Unkown message id:", messageId);
      break;
  }
}

Runtime

The code generated by Bakelite is broken into two parts. The runtime, generated with bakelite runtime and the protocol implementation generated with bakelite gen. The protocol implementation contains the type definitions, serializers, and protocol implementation defined in your .proto file.

The runtime contains the library code that supports your protocol implementation. The runtime does not change and only needs to be re-generated when upgrading Bakelite. Typically, the runtime is written to a file named bakelite.h.

The runtime uses the Bakelite namespace. The protocol implementation does not use a namespace.

Memory Ownership

Bakelite's cpptiny implementation does not dynamically allocate memory. The read/write buffers owned by the Protocol class are sufficient for most of Bakelite's needs. However, if a struct contains variable-length data, such as string or arrays, the compiler cannot determine ahead of time how much memory is required. In this case, a buffer needs to be provided when decoding a message.

For example:

# This struct uses 48 bytes
struct FixedLengthMsg {
  numbers: int32[8]
  text: string[16]
}

# this struct uses at least 2 bytes but has no upper bound in size.
# In practice, it's limited by the protocol's maxSize.
struct VariableLengthMsg {
  numbers: int32[]
  text: string[]
}

Decoding these two structs would look something like this:

Protocol protocol();

// Fixed length structs only use the memory declared in the struct type
FixedLengthMsg fixed;
protocol.decode(fixed);

// Variable-length structs need an additional buffer
VariableLengthMsg variable;
const char buffer[256];
protocol.decode(variable, buffer, sizeof(buffer));

The buffer passed to decode needs to stay in scope and not be re-used during the lifetime of the decoded struct. If the buffer is re-used before the struct goes out of scope, undefined behavior will occur.

For example:

// Shared buffer
const char buffer[256];

// Decode first message
VariableLengthMsg mag1;
protocol.decode(msg1, buffer, sizeof(buffer));

// Decode second message
VariableLengthMsg mag2;
protocol.decode(msg2, buffer, sizeof(buffer));

// This is fine
cout << msg2.text << endl;

// Don't do this!
cout << msg1.text << endl;

Memory Overhead

The read/write buffers account for the majority of the memory used by Bakelite. Each buffer will use the maxSize bytes, plus the framing overhead.

If we take an example protocol with a maxSize of 256 bytes, COBS framing, and CRC8, each buffer will use 261 bytes (256 data, 2 COBS overhead, 1 CRC, 1 message ID, and 1 null terminator).

The total size of the Protocol object on a 64bit AMD64 system would be 576 bytes (Two 261 byte buffers, and 54 bytes of additional overhead). The same object compiled for an AVR system would use 552 bytes.

If you are using a system where there isn't much RAM available, consider reducing your maxSize, and if needed, sending smaller messages.

API

Type Mappings

Bakelite Type C++ Type
int8, int16, int32, int64 int8_t, int16_t, int32_t, int64_t
uint8, uint16, uint32, uint64 uint8_t, uint16_t, uint32_t, uint64_t
float32, float64 float, double
bool bool
bytes[n] uint8_t[n]
bytes[] Bakelite::SizedArray
string[n] char[n]
string[] char *
T[n] #Fixed array T[n]
T[] #Variable array Bakelite::SizedArray
struct T struct T {}
enum T: S enum class T: S {}

Protocol

The Protocol class is generated by bakelite if you have a protocol section in your protocol definition. The constructor takes two lambdas or C function pointers that user used for reading and writing data.

example definition:

protocol {
  maxLength = 256
  framing = COBS
  crc = CRC8

  messageIds {
    ...
  }
}
Protocol(ReadFn read, WriteFn write)

arguments:

  • read - A function with the signature int(). When called, returns one byte, or -1 if no data is available.
  • write - A function with the signature int(const char *data, size_t length). When called, write length bytes to the output device. Return the number of bytes written.
poll() -> Protocol::MessageId

Call this function to wail for a message. It will read any available data from the stream.

returns:
If a message is available, it's message ID will be returned. If no message is available Protocol::MessageId::NoMessage is returned.

decode(Struct &message, char *buffer = 0, size_t length = 0) -> int

Decodes a message and stores it in the message parameter. If a message contains variable length value, then buffer and length need to be specified. See Memory Ownership for more information.

arguments:

  • message - Any struct with an assigned message-id.
  • buffer - A buffer used to write the contents of variable length fields.
  • length - Length of buffer in bytes.

returns:
0 on success.

send(Struct message) -> int

Sends a message. The message is serialized, encoded as a frame, and sent to the stream. You can pass any struct, as long as it was assigned a message ID in the protocol spec.

arguments:

  • message - Any struct with an assigned message-id.

returns:
0 if successful.

Struct

A struct is generated for every struct defined in the protocol specification.

For example, this struct definition:

struct TestMessage {
  text: string[]
  code: uint8
}

is equivalent to:

struct TestMessage {
  char *text;
  uint8_t code;

  int pack(stream) {
    ...
  }

  int unpack(stream) {
    ...
  }
}

The pack() and unpack() function are provided for serialization.

pack(stream) -> int

Serialize the struct and write it to the stream.

arguments:

  • stream - Any stream like object that implements read() and write() functions.

returns:
0 on success.

unpack(stream) -> int

Deserialize a struct from the stream.

arguments:

  • stream - Any stream like object that implements read() and write() functions.

returns:
0 on success.

Enum

An enum class is generated for each enum in your protocol definition. Unlike the python implementation, Enums do not have their own pack and unpack functions.

For example:

enum MyColor: uint8 {
  Red = 1
  Green = 2
  Blue = 3
}

Would map to:

enum class MyColor: uint8_t {
  Red = 1
  Green = 2
  Blue = 3
}