不想自定义协议,需要处理字节序、对齐、版本兼容性等问题;
也不想使用太重的JSON,一个简单的传感器数据包,JSON 可能就要占用几百字节;
想用protobuf 标准库,但是又太大了,那么可以考虑使用nanopb。
Protocol Buffers 是Google 推出的语言无关、平台无关、可扩展的序列化结构化数据的机制。
nanopb 保持 Protocol Buffers 强大功能的同时,将代码体积压缩到 3KB 以内,并且只需要不到 300 字节的栈空间。
https://github.com/nanopb/nanopb/
Zlib license
nanopb 官方文档:https://jpa.kapsi.fi/nanopb/docs/
一、为什么嵌入式需要高效序列化?
假如,项目使用 JSON 打包并传输数据。一个包含 5 个字段的数据包:
{"temp":25.6,"humi":60.5,"time":1234567890,"id":"NODE01","batt":3.7}
这个 JSON 字符串有 74 字节。看起来不大,但考虑到:每秒上报 10 次数据、每次传输都要消耗宝贵的电量,累计下来,通信负担会很大。
而且,还会有可维护性问题:
- 字段名拼写错误要到运行时才发现增加新字段需要手动同步多端代码没有版本管理机制,老设备和新协议冲突时只能硬着头皮兼容
Protocol Buffers
Protocol Buffers(下称 protobuf)解决了这些问题:
二进制编码:同样的数据只需要约 30 字节
强类型约束:字段类型在编译期确定
版本兼容:通过字段编号管理协议演进
但 Google 官方的 C++ 实现,在MCU上很难跑起来:
代码体积:protobuf-lite 编译后也有 300+ KB
内存分配:依赖 C++ STL 和动态内存
依赖复杂:需要完整的 C++11 编译器支持
这在资源64KB Flash,20KB RAM这类常见 MCU 上根本跑不起来。
nanopb 的设计哲学
nanopb 是专门为嵌入式系统重新实现的 protobuf 库,核心理念是:用编译期生成静态结构体,替代运行时动态分配
这带来几个关键优势:
极小体积:核心代码不到 3KB(encode + decode)
零动态内存:默认全部使用栈或静态分配
纯 C 实现:兼容 C89 及以上所有标准
可裁剪:可以只编译 encoder 或 decoder
下面这个图展示了 nanopb 和其他方案的对比:
知名的嵌入式实时操作系统Zephyr中也把nanopb包含到其Module构建系统中:
二、nanopb 的核心工作机制
从 .proto 到 C 结构体
nanopb 的工作流程分为两个阶段:编译期代码生成 和 运行时编解码。
假设我们有这样一个 proto 定义:
syntax = "proto3";
message SensorData {
float temperature = 1;
float humidity = 2;
uint32 timestamp = 3;
}
运行 nanopb 生成器后,会得到两个文件:
sensor.pb.h - 定义数据结构:
typedef struct _SensorData {
float temperature;
float humidity;
uint32_t timestamp;
} SensorData;
extern const pb_msgdesc_t SensorData_msg;
sensor.pb.c - 包含字段描述信息(精简示意):
const pb_msgdesc_t SensorData_msg = {
.field_info = SensorData_field_info,
.field_count = 3,
.largest_tag = 3,
/* ... */
};
关键在于 pb_msgdesc_t 这个结构体,它包含了所有编解码所需的元信息。
编码过程的内存布局
编码时的数据流向如下:
核心代码在 pb_encode.c 中的 pb_encode() 函数:
注意这里没有任何动态内存分配。pb_field_iter_t 是栈上的局部变量,所有字段信息都从编译期生成的 field_info 数组中读取。
变长编码的巧妙之处
protobuf 使用 varint 编码整数,这是节省空间的关键。原理是:小整数用更少的字节。
例如数字 300 的编码:
-
- 固定 32 位:
0x0000012C
-
- → 4 字节varint:
0xAC 0x02
- → 2 字节
实现代码:
对于大部分传感器数据(温度、湿度、计数器),数值都很小,varint 能节省 50% 以上的空间。
关于变长编码,在协议处理时使用很广泛,是一种可以有效提高协议处理效率的手段,之前我们分享的文章:嵌入式中轻量级通信协议利器!,也有详细对比了定长与MSB变长编解码:
三、使用示例
student.proto:
syntax = "proto2";
message Student
{
required uint32 num = 1;
required uint32 py_score = 2;
required uint32 c_score = 3;
}
应用代码:
void protobuf_test(void)
{
uint8_t buffer[64] = {0};
Student pack_stu = {0};
pb_ostream_t o_stream = {0};
Student unpack_stu = {0};
pb_istream_t i_stream = {0};
// 组包
pack_stu.num = 88;
pack_stu.py_score = 90;
pack_stu.c_score = 99;
o_stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
pb_encode(&o_stream, Student_fields, &pack_stu);
// 解包
i_stream = pb_istream_from_buffer(buffer, sizeof(buffer));
pb_decode(&i_stream, Student_fields, &unpack_stu);
printf("unpack_stu.num = %dn", unpack_stu.num);
printf("unpack_stu.py_score = %dn", unpack_stu.py_score);
printf("unpack_stu.c_score = %dn", unpack_stu.c_score);
}
重要经验
经验 1:使用 pb_ostream_t 直接写入硬件缓冲区
不要先编码到 RAM 缓冲区再复制,而是直接写入 DMA 缓冲区:
// 自定义输出回调
bool uart_write_callback(pb_ostream_t *stream,
const pb_byte_t *buf,
size_t count)
{
HAL_UART_Transmit_DMA(&huart1, (uint8_t*)buf, count);
return true;
}
// 使用自定义流
pb_ostream_t stream = {&uart_write_callback, NULL, SIZE_MAX, 0};
pb_encode(&stream, SensorData_fields, &msg);
这样可以零拷贝发送,节省一半内存和一次拷贝时间。
经验 2:针对性编译选项
如果只需要发送数据(传感器节点),可以只编译 encoder:
// 在编译选项中定义
#define PB_ENCODE_ONLY
这能再省下 1-2KB Flash。
经验 3:版本兼容性处理
当协议需要新增字段时,利用 proto3 的特性:
message SensorData {
float temperature = 1;
float humidity = 2;
uint32 timestamp = 3;
string device_id = 4;
float pressure = 5; // 新增字段
}
老设备发送的数据中没有 pressure 字段,新服务器会自动填充默认值 0.0。新设备发送的 pressure 字段,老服务器会自动忽略。无需任何兼容性代码。
何时不应该用 nanopb?
虽然 nanopb 很强大,但以下场景不推荐:
极端资源受限
-
- (Flash < 8KB):自定义协议更合适
实时性要求极高
-
- (微秒级响应):直接硬件寄存器操作
需要动态结构
- (字段数量不确定):考虑 MessagePack 或 CBOR
四、总结
nanopb 通过编译期代码生成 + 静态内存布局,在保持 protobuf 强大功能的同时,将资源占用压缩到适合嵌入式系统的级别。
三个核心要点:
3KB 代码体积:适合绝大多数 MCU
零动态内存:栈空间可控,无碎片化风险
无痛协议演进:字段编号机制天然支持版本兼容
612