C: nanopb vs protobuf-c
Problem
Both protobuf-c and nanopb claim “Protobuf for C,” but they optimize for different worlds: general services with heap messages versus embedded systems with static budgets. Treating them as interchangeable APIs produces the wrong memory model, the wrong failure modes, and unfair suite comparisons.
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/flash are tight, you can declare maximum sizes in options, and you accept stream/callback-oriented encode/decode instead of free-form heap trees. Wire format remains Protobuf binary (wire format); engineering—allocation, codegen, APIs—diverges.
Suite pins (this monorepo): 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).
Mental model
Same wire (tags, 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 |
| Schema at runtime | ProtobufCMessageDescriptor tables |
Field lists / generated bind info; options in .proto or .options |
| Allocation | unpack typically heap; free with *_free_unpacked |
Prefer static structs; optional dynamic with care |
| Size limits | Practical heap limits | Max count/size often required for repeated/string/bytes |
| API shape | Fill struct → get_packed_size / pack; unpack |
pb_ostream_t / pb_istream_t; pb_encode / pb_decode |
| Callbacks | Less central | Common for large/unknown-length fields |
| Submessages | Pointers + recursive unpack | Nested structs or callbacks depending on options |
| Unknown fields | Often retained for round-trip | Policy/options; not “always keep” like desktop stacks |
| Typical failure | OOM / leak if free skipped | Encode/decode fail if data exceeds static max |
| Suite registration | protobuf-c |
nanopb (separate name—compare within C + schema family) |
Ownership contrast
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 Protobuf still fails nanopb
Suppose name is generated as a 8-byte array (max_size:8 including null in some setups) and the peer sends a longer string that is valid length-delimited Protobuf:
- protobuf-c
unpack→ heap string of full length (until you OOM). - nanopb
pb_decode→ false / error: value does not fit the static contract.
That is intentional for embedded safety, not a wire-format bug. The same applies to repeated counts above max_count.
Step-by-step: how nanopb thinks about encode
(Logical model of nanopb; always check your nanopb version docs.)
N1 — Describe fields with size budgets
Codegen (from .proto + optional .options) produces a C struct where:
- Scalars are plain fields.
- Strings/bytes often are fixed arrays or pointer+callback with a maximum length.
- Repeated fields have a max count (or callback to stream elements).
If the logical Protobuf message can be unbounded, nanopb forces you to cap it at design time—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 tag (same wire key rule).
- Emit payload (varint, fixed, length-delimited).
- Nested messages encode into the stream (length-delimited).
- 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 unlimited tree 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 tag → field number / wire type.
- Match against the field list.
- Decode into static storage; if repeated count would exceed max → fail.
- Unknown fields: skip by wire type (and optional callbacks).
- Nested: decode length-delimited into nested struct (or callback).
N6 — Success means “fits the static contract”
A message that is valid Protobuf for protobuf-c can still be rejected by nanopb if it exceeds configured maxima—by design for embedded safety.
Side-by-side with protobuf-c pack/unpack
| Stage | protobuf-c | nanopb |
|---|---|---|
| Prepare | Fill struct (heap pointers OK) | Fill static struct / set counts |
| Size | get_packed_size unlimited logical |
Worst-case from maxima or sizing encode |
| Encode | pack into caller buffer |
pb_encode to pb_ostream_t |
| Decode | unpack → heap tree |
pb_decode into preallocated struct |
| Teardown | free_unpacked |
Usually nothing (stack/static) |
| Hostility | Need external size cap | Maxima + stream limits help; still validate |
When to choose which
| Situation | Lean |
|---|---|
| Linux/service C, variable-length documents | protobuf-c |
| MCU, no heap or tiny heap | nanopb |
| Same process as desktop tools needing full unknown-field round-trip | Often protobuf-c |
| Sensor stream with fixed max samples | nanopb |
| Team already owns protobuf-c everywhere | Stay; don’t dual-stack without reason |
Product/polyglot choice of “Protobuf or not” is 301; this page is which C engine.
In this suite
| Entry | Role |
|---|---|
protobuf-c |
Classic path; see protobuf-c article |
nanopb |
c/src/serializers/ser_nanopb.c — separate log name; version 0.4.9 |
| Shared helpers | Some C schema entries share fixture/wire helpers—read the language C Overview and serializer notes for what is timed and what fidelity means |
| C Results | Compare within C and schema-driven family (301 using this suite) |
Do not treat a faster nanopb row as “protobuf-c is wrong for servers,” or the reverse for MCUs.
Common mistakes
- Using nanopb without setting max sizes, then “fixing” by enabling unbounded dynamic mode everywhere (loses the point).
- Assuming nanopb decode allocates like protobuf-c.
- Mixing generated headers from different generators in one translation unit.
- Cross-ranking C Results against Python/Rust for engine choice (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
- Full nanopb options reference (see upstream nanopb docs).
- upb-C or other C bindings.
- Hand-rolled wire lab (lab).
Key takeaways
- Same wire, different memory contracts.
- protobuf-c = descriptor + heap-friendly pack/unpack.
- nanopb = static budgets + streams; fails closed when data exceeds caps.
- Suite: two names, one language—compare fairly, choose by deployment constraints.