Size and speed comparison
A microcontroller has a few hundred kilobytes of flash, a few tens of kilobytes of RAM, and a clock in the tens of megahertz. A serialization library has to fit in all three. This page shows what Embedded Proto costs on a Cortex-M4 next to nanopb, the other protobuf library for microcontrollers, and next to three alternatives people consider instead of protobuf: FlatBuffers, CBOR, and JSON. Every number below was measured on the board, with the same messages and the same values in every library.
In short: on the messages a device sends all day, Embedded Proto is the fastest of the five libraries and the smallest of the protobuf libraries. It serializes and parses a telemetry message in a third of the cycles nanopb needs, and it takes 2.1 to 5.2 kB of flash where nanopb takes 2.6 to 6.9 kB. Its message objects are a few bytes larger than nanopb's structs, and the bytes on the wire are identical, because both speak the same protobuf. The one place nanopb is smaller is a schema with many message types, which the last chapter measures.
| Scenario | nanopb flash | Embedded Proto flash | nanopb cycles | Embedded Proto cycles |
|---|---|---|---|---|
| Telemetry, serialize and parse | 5608 | 2508 | 13794 | 4805 |
| Telemetry batch, 20 samples | 5808 | 3776 | 220076 | 92669 |
| Configuration with oneofs | 6852 | 5208 | 69114 | 25673 |
| Command and acknowledgement | 5616 | 2564 | 8811 | 2729 |
| Firmware chunk, 256 bytes | 5872 | 2424 | 16725 | 11969 |
| Firmware chunk, streamed | 5700 | 2772 | 18138 | 17699 |
Flash is in bytes above an empty firmware, cycles are for one serialize and one parse of the message at 84 MHz. 4805 cycles is 57 microseconds.
How the numbers are measured
Every scenario is one C++ function per library. It fills a message with fixed values, serializes it into a byte array, copies the array as if it were received, parses it into a fresh message, and checks every field against the values it started with. A scenario which fails any check reports zero bytes and is not stored. The Embedded Proto and nanopb versions produce byte for byte the same wire data, the others produce what their format produces.
Four things are measured. Flash is the text size of the firmware minus the text size of a firmware which only blinks the LED, so the number is the library plus the generated code plus the scenario, nothing else. Cycles are read from the Cortex-M4 cycle counter around the scenario function, so they include filling and checking the message, not only the library calls. Message RAM is the size of the message object, for ArduinoJson and FlatBuffers the peak of a counting allocator, as those two keep their data on the heap. Wire bytes is the length of the serialized message.
All code is compiled with -Os, -ffunction-sections -fdata-sections and linked with --gc-sections. All C++ is compiled with -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-use-cxa-atexit, the flags STM32CubeIDE and every other embedded IDE set for a C++ project. Each library runs in the configuration its own documentation recommends for a microcontroller: nanopb with PB_NO_ERRMSG, flatcc with its default allocator and page size, zcbor with its default encoding, ArduinoJson 7 with its default pools. Embedded Proto runs with its defaults. No library was tuned beyond that.
Telemetry
The small message every device sends: seven fields of mixed width, an enumeration, and a negative number. Please note the sint32 for the negative value, which is what protobuf advises.
enum Status
{
OK = 0;
WARNING = 1;
ERROR = 2;
}
message Telemetry
{
uint32 device_id = 1;
uint64 timestamp = 2;
float temperature = 3;
float humidity = 4;
uint32 battery_mv = 5;
Status status = 6;
sint32 rssi = 7;
}| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 2508 | 4805 | 48 | 28 |
| nanopb | 5608 | 13794 | 40 | 28 |
| zcbor | 3104 | 9707 | 64 | 37 |
| FlatBuffers | 5628 | 9193 | 4120 | 60 |
| ArduinoJson | 12096 | 58934 | 1088 | 120 |
Embedded Proto parses this message in 4805 cycles, nanopb in 13794. The difference is the generated code: Embedded Proto writes a serialize and a parse function per message with the field types known at compile time, nanopb walks a descriptor table with one generic loop. Serializing alone, without the parse, takes 1885 cycles in Embedded Proto and 5900 in nanopb, and 2088 against 2596 bytes of flash.
Telemetry batch
A device which sleeps between measurements sends them in one go. The batch holds twenty samples as nested messages, so this is the scenario for repeated fields and nesting.
message Sample
{
uint32 offset_ms = 1;
float x = 2;
float y = 3;
float z = 4;
}
message TelemetryBatch
{
uint32 device_id = 1;
uint64 timestamp = 2;
repeated Sample samples = 3;
}The repeated field holds at most twenty samples in every library. In Embedded Proto that is the maxLength option, in nanopb max_count, and the schemas of the other libraries say the same.
| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 3776 | 92669 | 592 | 408 |
| nanopb | 5808 | 220076 | 344 | 408 |
| zcbor | 3328 | 112460 | 672 | 494 |
| FlatBuffers | 6628 | 52505 | 4184 | 540 |
| ArduinoJson | 11960 | 528833 | 2112 | 994 |
Here FlatBuffers is the fastest. It does not parse at all, a FlatBuffer is read in place, so a message which is mostly read wins there. It pays with 4 kB of builder memory on the heap and a third more bytes on the wire. Embedded Proto's message is larger than nanopb's because every nested message carries its own parse state, 12 bytes per sample. zcbor is the smallest in flash and the closest in speed.
Configuration
The complex message: a oneof per peripheral, nested messages in a repeated field, a string, and an optional scalar with presence. Four peripherals are set, two UARTs, an SPI, and a motor.
message UartConfig
{
uint32 baud = 1;
uint32 data_bits = 2;
bool parity = 3;
}
message SpiConfig
{
uint32 prescaler = 1;
bool cpol = 2;
bool cpha = 3;
}
message MotorConfig
{
uint32 max_speed = 1;
bool invert = 2;
uint32 home_pin = 3;
}
message Peripheral
{
uint32 index = 1;
oneof kind
{
UartConfig uart = 10;
SpiConfig spi = 11;
MotorConfig motor = 12;
}
}
message Configuration
{
uint32 version = 1;
string device_name = 2;
repeated Peripheral peripherals = 3;
optional uint32 report_interval_ms = 4;
}| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 5208 | 25673 | 248 | 67 |
| nanopb | 6852 | 69114 | 128 | 67 |
| zcbor | 4824 | 33306 | 164 | 88 |
| FlatBuffers | 8628 | 29555 | 4376 | 252 |
| ArduinoJson | 12932 | 145482 | 1111 | 337 |
Six message types are in play here, and it shows in the flash of the two generated-code libraries, Embedded Proto and zcbor, which sit close together. Embedded Proto is still the fastest by a margin: a oneof costs it a switch statement, nanopb a search through the descriptor. FlatBuffers has to wrap the oneof in a union of tables, which is why its wire bytes are nearly four times the protobuf ones.
Command and acknowledgement
A tiny pair, a command with a oneof action going down and an acknowledgement coming back. Each is a few bytes on the wire, so the fixed cost of every call shows. The scenario does both round trips.
enum Result
{
UNKNOWN = 0;
ACCEPTED = 1;
REJECTED = 2;
BUSY = 3;
}
message DeviceCommand
{
uint32 sequence = 1;
oneof action
{
bool set_output = 2;
sint32 move_to = 3;
uint32 reboot_delay_ms = 4;
}
}
message DeviceAck
{
uint32 sequence = 1;
Result result = 2;
}| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 2564 | 2729 | 24 | 9 |
| nanopb | 5616 | 8811 | 12 | 9 |
| zcbor | 3100 | 7334 | 20 | 16 |
| FlatBuffers | 6432 | 15115 | 4184 | 68 |
| ArduinoJson | 10760 | 26814 | 2185 | 57 |
Two messages of nine bytes together take Embedded Proto 2729 cycles, 32 microseconds, for two serializations and two parses. FlatBuffers spends most of its 15115 cycles setting up its builder, which is a fixed cost a small message can not amortize.
Firmware chunk
The payload: one chunk of a firmware image, 256 bytes, with its position and a checksum. It is measured twice. First with the bytes resident in the message, the way every library can do it.
message FirmwareChunk
{
uint32 offset = 1;
uint32 total_size = 2;
bytes data = 3;
uint32 crc32 = 4;
}| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 2424 | 11969 | 288 | 273 |
| nanopb | 5872 | 16725 | 272 | 273 |
| zcbor | 2972 | 13531 | 36 | 280 |
| FlatBuffers | 5948 | 15280 | 4120 | 296 |
| ArduinoJson | 10904 | 2296087 | 3136 | 978 |
zcbor's 36 bytes of message RAM are honest and misleading at the same time: it does not copy the bytes, the message only points at them, so the 256 bytes live somewhere else in your program. JSON has no bytes type. The chunk goes as an array of 256 numbers, which is why ArduinoJson needs two million cycles and a kilobyte of wire for it.
Then the same chunk streamed through callbacks in windows of 32 bytes, so that the message never holds the payload. Only the two protobuf libraries offer this.
| Library | Flash | Cycles | Message RAM | Wire bytes |
|---|---|---|---|---|
| Embedded Proto | 2772 | 17699 | 60 | 273 |
| nanopb | 5700 | 18138 | 20 | 273 |
The message shrinks from 288 bytes to 60, the bytes on the wire stay the same, and the cycles are within three percent of nanopb's. This is how a device with a few kilobytes of RAM receives a firmware image of any size.
Schema growth
The scenarios above use one to six message types. A real product has more, and each type costs flash. To measure how much, a schema defines twenty message types of the same shape, and three scenarios round trip the first one, the first five, and all twenty of them:
message Growth_07
{
uint32 id = 1;
sint32 value = 2;
float measure = 3;
bool flag = 4;
string label = 5;
}| Library | 1 type | 5 types | 20 types | Per type |
|---|---|---|---|---|
| Embedded Proto | 2812 | 5700 | 14508 | 616 |
| nanopb | 6404 | 7480 | 11360 | 261 |
| zcbor | 3628 | 6220 | 16164 | 660 |
| FlatBuffers | 7196 | 8348 | 12004 | 253 |
Flash in bytes, the last column is the difference between twenty types and one, divided by nineteen. Embedded Proto starts 3.6 kB below nanopb and grows 355 bytes per message type faster, so the two lines cross at about ten message types. Below that Embedded Proto is the smaller library, above that nanopb is. Please note that a type only costs flash when your firmware serializes or parses it. Declaring it in the *.proto file is free, the linker removes the generated code of a message you never use.
The cycles tell the other half: twenty round trips take Embedded Proto 79323 cycles and nanopb 186472. The 616 bytes buy a serialize and a parse function per message, and that is where the speed comes from.
The other formats
FlatBuffers, CBOR, and JSON are not protobuf, so the comparison with them is a comparison of formats as much as of libraries. FlatBuffers reads without parsing and wins where a message is mostly read, at the cost of 4 kB of builder memory and larger messages. zcbor, the CBOR library from Zephyr, is the closest to Embedded Proto in design and in results: a little smaller on the smallest messages, slower on everything, and a third more bytes on the wire. ArduinoJson is the JSON library everyone starts with, and the table shows what a text format costs: three to four times the bytes, ten to twenty times the cycles, and a kilobyte of heap per document.
Getting the same results in your project
Three things make these numbers, and all three are in your hands.
First, the compiler flags. Compile your C++ with -fno-exceptions -fno-rtti and let the linker remove unused code with -ffunction-sections -fdata-sections and --gc-sections. Without the first two, GCC links its exception unwinder and type information into any C++ firmware, which costs about 4.5 kB whether or not the code throws. Every embedded IDE sets these flags for a new project, a CMake or Makefile project has to set them itself. The page on code size has the details.
Second, the sizes of your fields. Every repeated, string, and bytes field has a maximum length, given as a template parameter or in the options file. That length is what the message object takes in RAM, no more. A payload larger than your RAM goes through callback storage instead, as the firmware chunk above does.
Third, the number of message types your firmware uses. Each one costs about 600 bytes of flash. With a handful of messages Embedded Proto is the smallest protobuf library you can pick, with dozens nanopb is smaller, and in both cases Embedded Proto is the faster one. Take the growth table, count your messages, and you know where you land.
Do you want to see the numbers for your own messages? Clone the comparison, put your *.proto file in the Protofiles folder, write a scenario for it, and run the tool. The README of the project describes how.