C: nanopb vs protobuf-c
Why this article exists
Both protobuf-c and nanopb claim “Protocol Buffers for C.” They optimize for different worlds. protobuf-c targets general services with heap-allocated messages. nanopb targets embedded systems with static memory budgets. Treating them as interchangeable APIs produces the wrong memory model, the wrong failure modes, and unfair suite comparisons.
In this article you will compare the two C libraries along allocation, size limits, APIs, and typical failure modes. After reading it, you should be able to choose which C library fits a deployment. You should also be able to explain why a valid Protocol Buffers message can still be rejected by nanopb.
Short answer
Use protobuf-c when you want classic generated structs, descriptor-driven pack/unpack, and heap-friendly services (protobuf-c path).
Use nanopb when RAM or flash is tight. You declare maximum sizes in options. You accept stream- and callback-oriented encode/decode instead of free-form heap trees.
The wire format remains Protocol Buffers binary (wire format). What diverges is the engineering: allocation, code generation, and APIs.
Suite pins (this shared code repository for many projects): protobuf-c v1.5.0; nanopb 0.4.9 (c/third_party/VERSIONS.md, registration in ser_nanopb.c).
Prerequisites
- C: protobuf-c path.
- Wire format.
- Soft: 301 untrusted input (bounds matter even more on embedded targets).
Mental model
Picture two libraries that encode and decode the same Protocol Buffers bytes but live under different memory contracts. The field numbers, variable-length integers, and length-delimited fields are the same. The way each library stores values in C memory is not.
Same wire (field numbers, varints, LEN fields)
│
┌─────┴──────┐
▼ ▼
protobuf-c nanopb
descriptor static layout /
heap unpack streams + max sizes
Design axes (comparison)
| Axis | protobuf-c | nanopb |
|---|---|---|
| Primary goal | General C interoperability | Embedded / constrained devices |
| Schema at runtime | ProtobufCMessageDescriptor tables |
Field lists / generated bind info; options in .proto or .options |
| Allocation | unpack typically uses the heap; free with *_free_unpacked |
Prefer static structs; optional dynamic allocation only with care |
| Size limits | Practical heap limits | Max count/size often required for repeated fields, strings, and bytes |
| API shape | Fill a struct → get_packed_size / pack; then unpack |
pb_ostream_t / pb_istream_t; pb_encode / pb_decode |
| Callbacks | Less central | Common for large or unknown-length fields |
| Submessages | Pointers plus recursive unpack | Nested structs or callbacks, depending on options |
| Unknown fields | Often retained for round-trip | Policy and options; not “always keep” the way desktop stacks often do |
| Typical failure | Out of memory, or a leak if free is skipped | Encode/decode fails if data exceeds a static max |
| Suite registration | protobuf-c |
nanopb (a separate log name—compare within C and the schema-driven family) |
Ownership contrast. Ownership means who allocates memory and who must free it. protobuf-c leans on the heap for unpack. nanopb prefers pre-sized static storage. In the common case, nothing needs to be freed at all.
protobuf-c
struct (stack/heap)
│
▼ get_packed_size + pack
caller buffer (you allocated)
│
▼ unpack
heap message ──► free_unpacked()
nanopb
static struct (max sizes baked in)
│
▼ pb_encode → pb_ostream_t
static buffer (pre-sized from worst case)
│
▼ pb_decode → pb_istream_t
static struct ──► usually nothing to free
Minimal nanopb sketch
Illustrative only—field names and generated symbols depend on your .proto / .options and nanopb version (upstream docs).
/* After nanopb codegen: MiniUser has e.g. name[32], tags_count, tags[8] */
MiniUser user = MiniUser_init_zero;
user.id = 1;
strncpy(user.name, "Ada", sizeof(user.name) - 1);
user.has_name = true; /* if your options use has_ flags */
uint8_t buffer[64];
pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
if (!pb_encode(&ostream, MiniUser_fields, &user)) {
/* buffer full or encode error */
}
MiniUser out = MiniUser_init_zero;
pb_istream_t istream = pb_istream_from_buffer(buffer, ostream.bytes_written);
if (!pb_decode(&istream, MiniUser_fields, &out)) {
/* truncated, bad wire, or exceeds max sizes */
}
When valid Protocol Buffers still fails nanopb
Suppose name is generated as an 8-byte array (max_size:8, including a null terminator in some setups). A peer sends a longer string that is still valid length-delimited Protocol Buffers. What happens?
- protobuf-c
unpack→ a heap string of full length (until you run out of memory). - nanopb
pb_decode→ false / error: the value does not fit the static contract.
That is intentional for embedded safety. It is not a wire-format bug. The same pattern applies when a repeated field’s count exceeds max_count. Teams sometimes mislabel a max-size reject as “our encodings are incompatible.” The real issue is a deployment contract about size.
Step-by-step: how nanopb thinks about encode
Logical model of nanopb. Always check your nanopb version’s documentation for details.
N1 — Describe fields with size budgets
Code generation (from .proto plus optional .options) produces a C struct where:
- Scalars are plain fields.
- Strings and bytes are often fixed arrays, or a pointer-plus-callback pair with a maximum length.
- Repeated fields have a max count (or a callback that streams elements).
If the logical Protocol Buffers message can be unbounded, nanopb forces you to cap it at design time. That is the opposite of “malloc until it fits.”
N2 — Output stream
pb_ostream_t = buffer stream | callback stream
pb_encode(&stream, FieldList, &struct)
Encode walks the field list:
- For each present field, emit a tag (same wire key rule as everywhere else).
- Emit the payload (varint, fixed, or length-delimited).
- Nested messages encode into the stream as length-delimited blobs.
- On buffer full or max exceeded → false / error (no silent realloc by default).
N3 — No separate “get_packed_size then malloc” requirement
You can size a static buffer from worst-case maxima, or use a sizing stream. The mental model is stream into a budget, not “measure an unlimited tree and then allocate.”
Step-by-step: how nanopb thinks about decode
N4 — Input stream
pb_istream_t from buffer or callback
pb_decode(&stream, FieldList, &struct)
N5 — Tag loop with hard limits
- Read a tag → field number and wire type.
- Match against the field list.
- Decode into static storage. If a repeated count would exceed its max → fail.
- Unknown fields: skip by wire type (and optional callbacks).
- Nested messages: decode a length-delimited blob into a nested struct (or a callback).
N6 — Success means “fits the static contract”
A message that is valid Protocol Buffers for protobuf-c can still be rejected by nanopb if it exceeds configured maxima. That is by design for embedded safety.
Side-by-side with protobuf-c pack/unpack
| Stage | protobuf-c | nanopb |
|---|---|---|
| Prepare | Fill a struct (heap pointers are fine) | Fill a static struct / set counts |
| Size | get_packed_size over an unlimited logical tree |
Worst-case size from maxima, or a sizing encode |
| Encode | pack into a caller buffer |
pb_encode to a pb_ostream_t |
| Decode | unpack → heap tree |
pb_decode into a preallocated struct |
| Teardown | free_unpacked |
Usually nothing (stack or static) |
| Hostility | Need an external size cap | Maxima and stream limits help; you still validate |
When to choose which
| Situation | Lean |
|---|---|
| Linux or service C with variable-length documents | protobuf-c |
| MCU with no heap or a tiny heap | nanopb |
| Same process as desktop tools that need full unknown-field round-trip | Often protobuf-c |
| Sensor stream with a fixed maximum number of samples | nanopb |
| Team already owns protobuf-c everywhere | Stay with it; do not dual-stack without a reason |
Whether to use Protocol Buffers at all is a 301 product and multi-language (polyglot) question. This page is about which C library once that choice is made.
In this suite
| Entry | Role |
|---|---|
protobuf |
Official Google libprotobuf on C (sysroot); full generated messages from benchmark_v2.proto |
protobuf-c |
Separate log name; linked v1.5.0; timed path is currently the shared fixture_pb_v2 wire helper (see C Overview) |
nanopb |
Separate log name; linked 0.4.9 (ser_nanopb.c); timed path is currently the same shared wire helper—not a full nanopb pb_encode / options-codegen benchmark |
protobuf-wire |
In-tree proto3 tags only (not Google upb) |
| Open this slice on the Dashboard | Compare within C and the schema-driven family (301 using this suite) |
Do not treat suite nanopb vs protobuf-c rows as a head-to-head of full library stacks until each times its native generated path. The article above still describes the real libraries for product choices outside the suite.
Common mistakes
- Using nanopb without setting max sizes, then “fixing” by enabling unbounded dynamic mode everywhere (that loses the point of nanopb).
- Assuming nanopb decode allocates like protobuf-c.
- Mixing generated headers from different generators in one translation unit.
- Cross-ranking C Dashboard numbers against Python or Rust when choosing a library (cross-language fidelity).
- Calling a max-size reject a “wire bug” when the peer used protobuf-c with no caps.
What this article is not
- A full nanopb options reference (see upstream nanopb docs).
- upb-C or other C bindings.
- A hand-rolled wire lab (see the lab).
Key takeaways
- Same wire format, different memory contracts.
- protobuf-c = descriptor tables plus heap-friendly pack/unpack.
- nanopb = static budgets plus streams; fails closed when data exceeds caps.
- In this suite there are two names under one language. Compare fairly, and choose by deployment constraints.