The heart of working with nanopb is a single, satisfying step: you describe your data in a .proto file, run the nanopb generator, and out comes plain C code that matches your description exactly. No hand written serializers, no byte offsets to maintain, no parsers to debug. This guide takes you through the complete workflow, from writing a small .proto file and its companion .options file, to running the generator with protoc, to reading the generated header and using it in your firmware. By the end you will have a working SensorMessage example you can adapt to your own project, plus a clear picture of where nanopb saves you effort and where it asks for a little care.
Step 1: write your .proto file
Everything starts with a message definition. Nanopb accepts both proto2 and proto3 syntax, so you can use whichever style your team prefers. Here is a small, realistic message for a sensor node:
syntax = "proto3"; message SensorMessage { uint32 device_id = 1; float temperature = 2; string label = 3; }
Each field gets a name, a type, and a number. That number is the field’s permanent identity on the wire, so treat numbers with respect: never reuse one for a different meaning, and when you extend the message later, add new fields with fresh numbers. The names and layout are for humans; the numbers are what actually travels between devices. Save the file as sensor.proto in your project.
Step 2: add a .options file
Plain protobuf never asks how big a string can get, because PC software can just allocate more memory. Your microcontroller cannot, and nanopb builds fixed size C structs, so it needs sizes from you. You give them in a .options file that sits next to the .proto file and shares its base name, sensor.options in this example.
SensorMessage.label max_size:16
That one line tells the generator the label string needs room for 16 bytes. Repeated fields get a max_count in the same style. This step is not optional decoration. A string, bytes, or repeated field with no size in the .options file becomes a callback field, which is a powerful feature but a confusing surprise when you expected a simple array in your struct. Set sizes for everything up front and the generated code will match your mental model.
Step 3: run the generator
The nanopb generator is a protoc plugin written in Python. If you installed the generator package, it includes a bundled protoc, and a single command handles everything. If you installed the generator with pip, you call protoc and point it at the nanopb plugin. Either way, the shape of the command is the same:
protoc -I. --nanopb_out=. sensor.proto
Run it in the folder holding your .proto and .options files. The generator reads both, and produces two new files: sensor.pb.c and sensor.pb.h. If the command fails, read the error closely. Most early failures are mundane: protoc cannot find the .proto file because the include path is off, or the plugin is not on the path. Fix the path, run it again, and the files appear.
Step 4: read the generated header
Open sensor.pb.h before you write any code against it. You will find a C struct that mirrors your message:
typedef struct _SensorMessage { uint32_t device_id; float temperature; char label[16]; } SensorMessage;
Notice the label became a fixed char array sized by your .options entry. You will also find a descriptor constant, SensorMessage_fields, which is the runtime description of the message layout. You pass that descriptor to pb_encode() and pb_decode(), so treat it as the generated code’s handshake with the nanopb runtime. The header also provides initializer helpers such as SensorMessage_init_zero, which give you a clean, zeroed struct to start from. Always start from a zeroed struct; it keeps counts and presence flags honest.
Step 5: put the generated files to work
Add sensor.pb.c and sensor.pb.h to your firmware build alongside the nanopb runtime files, pb_encode.c, pb_decode.c, and pb_common.c. Filling in a message is then ordinary C:
SensorMessage msg = SensorMessage_init_zero; msg.device_id = 7; msg.temperature = 21.5f; strcpy(msg.label, "roof");
From there, encoding is one call with an output stream wrapped around a buffer, and decoding is the mirror image. The generated struct is yours to pass around the application like any other data structure, which is the quiet magic of nanopb: the serialization boundary stays in one small corner of your code instead of leaking into every module.
Make regeneration a habit, not an event
You will change your .proto file. A new sensor appears, a reading gets added, a field gets renamed for clarity. Every change means regenerating the .pb.c and .pb.h files, and the teams that stay happy with nanopb automate that step. Add the generator command to your build system, a Makefile rule or a pre build step in your IDE, so the generated files are always fresh. Hand edited generated files are a trap: the next regeneration quietly erases your edits. If you need custom behavior, put it in your own code that calls into the generated structs, never inside the generated files themselves.
Two mistakes every beginner makes once
First, forgetting the .options file and then staring at a generated struct full of callback fields, wondering where the string array went. If your struct does not look like your message, sizes are the reason. Second, editing the .proto file, rebuilding the firmware, and forgetting to regenerate, so the device encodes with last month’s layout while the server expects the new one. Both mistakes vanish once generation is part of the build and the .options file lives in version control next to the .proto file.
Organizing generated files in a real project
A little structure keeps generated code from turning into clutter. Put your .proto and .options files in their own folder, something like a proto directory at the project root, and have the build generate the .pb.c and .pb.h files into a matching generated folder. Commit the .proto and .options files always. Committing the generated files too is a judgment call: it lets anyone build without installing the generator, at the price of review noise when messages change. Many teams commit them and rely on a build check that regenerates and diffs, which catches the classic mistake of editing the .proto file and forgetting the rest.
Name message files after the domain they describe, sensor.proto rather than messages.proto, once a project grows past one or two message types. Future you, hunting a field definition at midnight, will appreciate the map.
Conclusion
Generating C code with nanopb is a short pipeline: write the .proto file, declare your sizes in a .options file, run the generator, and build with the resulting .pb.c and .pb.h plus the small runtime. The generated header is worth reading every time, because it shows you exactly what your message costs in memory and how to initialize it safely. Wire regeneration into your build, and nanopb becomes invisible infrastructure: change the contract, rebuild, and both the code and the wire format follow. Next, it is worth understanding the encode and decode calls themselves in more depth.
Read next:



Pingback: Nanopb Options File: max_size and max_count