资讯动态

嵌入式通信协议移植:nanopb实战与protobuf序列化指南

发布时间:2026/9/9 18:09:54 来源:尧图企业网站定制
简介面向STM32、ARM等嵌入式平台的nanopb轻量级protobuf移植例程帮助资源受限的单片机开发者将高效二进制数据交换能力接入实际项目。资源共47个文件、压缩包约3.54MB涵盖7个C源码、5个头文件、1个proto定义以及Visual Studio工程文件与编译验证产物直接打开即可对照学习。目前已有1433人浏览学习适合正在为MCU选型或改造通信协议的工程师。通过阅读proto定义与pb编码解码核心源码可掌握nanopb无需动态内存分配、静态缓冲区优先的移植要点结合多个示例工程与可执行验证程序能熟悉从.proto编写、工具链生成C代码到集成进STM32/ARM工程并完成序列化与反序列化验证的完整流程可直接应用于实际固件开发。 我最早接触 nanopb是在一个电池管理系统的项目里。设备端是一颗 Cortex-M3 主控上位机要用 Python 做数据分析和界面展示。最开始大家图省事直接在通信协议里定义了结构体加 CRC联调的时候痛苦得不行字段一多上位机和下位机各维护一份解析表版本一升级老设备和新上位机就不兼容中间还隔了一条 4G 链路调试要靠打印原始字节流猜字段。后来我们把协议全部切到 protobuf嵌入式端选用 nanopb 作为序列化实现问题才真正解决。这篇文章就围绕 nanopb 在嵌入式环境下的移植过程展开。我会从选型思路、工具链搭建、.proto 定义、代码生成、自定义 stream、完整例程、常见问题排查这几个维度把整个链路讲透。如果你是做 STM32、其他 MCU 或者 RTOS 相关开发手里的设备又需要和 PC、手机或者云端做结构化通信这篇文章可以直接当移植手册来参考。1. 为什么在嵌入式里要上 protobuf而不是自己定结构体1.1 从一次电池管理系统联调说起做嵌入式通信最原始的方式是把结构体指针直接强转成字节数组然后按固定偏移去解析。这种方案在单机板卡内部调试时没问题一旦涉及多设备、多版本、多语言平台麻烦就来了。我们的电池管理系统当时就是这样的下位机上报电压、电流、温度、SOC、单体电芯信息等十几组数据上位机拿到裸字节流后靠“偏移量对照表”去拆稍有改动就需要同步更新两边的协议文档。后来有一次在联调现场新版本的固件在协议里新增了一个告警字段结果字段偏移量变了导致上位机把 SOC 解析成一个异常的大数充电策略直接误判。虽然最后问题定位只花了一个多小时但那次之后我下定决心要把协议层做一次彻底的改造。protobuf 这类序列化方案解决的核心问题就是“结构自描述”和“跨版本兼容”。数据在传输时按字段编号编码接收端按编号解析因此增删字段只要遵循规则就不会破坏老设备的解析能力。对于嵌入式这种资源受限的环境直接用标准 protobuf 库不现实nanopb 就是专门把 protobuf 裁剪到 MCU 上运行的 C 实现。1.2 为什么选择 nanopb 而不是直接上 protobuf标准 protobuf 的 C 实现动辄占用几百 KB 级别的代码空间而且依赖动态内存和异常机制基本上和 MCU 无关。nanopb 的设计目标恰恰相反它有以下特点纯 C 实现不依赖 C 运行时编译体积小在我的实测项目里编码、解码、公共部分三个源文件加起来大约只占 5 KB 左右的 ROM不强制使用动态内存。你可以在 .proto 里限制字符串长度、数组最大个数nanopb 会生成定长数组的结构体整份报文可以静态分配编码和解码基于一张字段描述表不涉及复杂的反射机制执行效率远高于 JSON在一颗 72MHz 的 MCU 上编解码几十个字段通常只需要几十微秒与标准 protobuf 二进制格式完全兼容上位机可以用 Python/C/Java 等任意语言的标准库解析。当然 nanopb 也有它的代价。它不会替你做动态内存管理字符串和 repeated 数组必须有明确上限它还要求你的 .proto 定义非常严谨比如哪个字段是必填、哪个字段允许缺省这些都会影响生成的代码行为。如果你只是想“数据能通”就行而不管协议怎么演化那 nanopb 带来的反而是负担。1.3 什么场景不适合用 nanopb也不是所有嵌入式通信场景都适合 protobuf。比如两个板卡之间有高度确定性的传感器裸数据流每个采样点就是固定字节数的数组此时最好的方案是原始缓冲区加环形队列而不是 protobuf再比如数据模型本身非常动态服务端可能随时下发任意字段组合此时 XML 或 JSON 反而更灵活不过 MCU 场景里这种需求比较少见。说白了nanopb 最适合的是“设备与上位机/云平台之间的结构化业务通信”尤其是报文设计里有嵌套、重复、可选字段、版本演进的场景。这正好是传统结构体协议最容易翻车的地方。2. 移植前的准备版本选择、工具链与工程目录2.1 nanopb 源码的构成与版本选择nanopb 的源码托管在 GitHub官方仓库的发布版本比较稳定我建议直接拉正式 release 而不是 master因为 master 可能会引入新功能或破坏性变更。目前主流版本是 0.4.x和 0.3.x 相比0.4 生成的代码在字段描述表上做了重构API 也有差异网上很多老文章还是 0.3 的写法看的时候要注意甄别。nanopb 仓库里的代码大致分两部分运行时库runtime library也就是pb.h、pb_common.h/c、pb_encode.h/c、pb_decode.h/c这部分需要编进你的 MCU 工程里代码生成器generator主要是一个 Python 工具nanopb_generator.py或者是一个protoc插件protoc-gen-nanopb用来把.proto文件转换成.pb.c和.pb.h。还有一份nanopb.proto文件它是 nanopb 特有的选项定义文件比如给字段指定max_size、max_count在后续生成代码时会用到。2.2 protoc 插件环境Linux 和 Windows 两种方式我平时主要用 Linux 开发环境配置比较直接。先安装protobuf-compiler和 Python 的 protobuf 支持然后把 nanopb 仓库克隆到本地。假设你的 proto 文件在proto/目录nanopb 仓库在nanopb/目录生成命令是这样的protoc --nanopb_out./app \ -I./proto \ -I./nanopb/generator/proto \ ./proto/sensor.proto这里有个容易踩的坑.proto文件里如果import nanopb.proto那么-I参数必须把nanopb/generator/proto目录也包含进去否则protoc会报找不到nanopb.proto的错。我第一次就卡在这里查了好一会儿才发现是路径问题。Windows 下如果不想折腾 Python 环境可以直接下载 nanopb 官方发布的 Windows 预编译包里面已经带好了protoc、protoc-gen-nanopb和nanopb.proto。在命令行里进入目录后执行protoc --nanopb_out. -I. -I.\generator\proto sensor.proto也可以使用仓库里自带的 Python 生成器直接生成不依赖系统protocpython nanopb/generator/nanopb_generator.py proto/sensor.proto两种方式生成的代码是一样的选顺手的就好。2.3 推荐的项目目录结构建议在工程里把 nanopb 运行时库和生成的协议代码分开管理。我常用的目录结构如下project/ ├── proto/ │ └── sensor.proto ├── third_party/ │ └── nanopb/ │ ├── pb.h │ ├── pb_common.h │ ├── pb_common.c │ ├── pb_encode.h │ ├── pb_encode.c │ ├── pb_decode.h │ └── pb_decode.c ├── app/ │ ├── sensor.pb.c │ ├── sensor.pb.h │ └── main.c └── Makefile / CMakeLists.txt / Keil project这样做的理由是third_party/nanopb这部分属于第三方运行时基本不需要改动升级 nanopb 版本时整体替换即可app/sensor.pb.c和sensor.pb.h是生成产物每次修改.proto后重新生成会覆盖最好把它们和手写的应用代码分开。集成到 STM32CubeIDE、Keil 或者 CMake 工程时只需要把pb_common.c、pb_encode.c、pb_decode.c及生成的sensor.pb.c加入编译头文件路径包含third_party/nanopb和app目录即可。3. 从 .proto 到 C 代码生成、集成与自定义 stream3.1 定义传感器上报协议我选一个典型的传感器上报场景来做例子。设备端上报自己的设备 ID、固件版本以及一组采样点数据每个采样点包含序号、温度、湿度、时间戳。.proto文件定义如下syntax proto3; import nanopb.proto; package sensor; message Sample { uint32 seq 1; int32 temperature 2; // 单位 0.01°C2534 表示 25.34°C uint32 humidity 3; // 单位 0.01%5267 表示 52.67% uint32 timestamp 4; // Unix 时间戳 } message SensorReport { uint32 device_id 1; string firmware_version 2 [(nanopb_fieldopt).max_size 16]; repeated Sample samples 3 [(nanopb_fieldopt).max_count 8]; }注意这里面有两个很关键的点。第一firmware_version是 string 类型如果不在字段选项里写max_size 16nanopb 生成的结构体里会是一个动态分配的指针这对嵌入式来说不可控加了之后它就是一个char firmware_version[16]的定长数组。第二samples是 repeated 嵌套消息如果不写max_count 8同样会生成指针加了之后生成的是一个长度为 8 的结构体数组。这就是 nanopb 和标准 protobuf 最大的区别标准 protobuf 只在堆上动态扩张nanopb 必须在编译期把上限定死。3.2 生成 C 代码命令与常见路径问题用前面提到的 protoc 插件命令生成后会得到sensor.pb.h和sensor.pb.c。打开头文件核心内容有两块。第一块是协议字段的宏定义。nanopb 会为每个消息生成一个XXX_init_default宏用来初始化一个默认值/零值的结构体这个宏在实际代码里非常常用#define SensorReport_init_default {0, , 0, {Sample_init_default, Sample_init_default, ...}}第二块是字段描述表的声明。nanopb 的编码解码并不像标准 protobuf 那样跑反射而是依赖一张描述字段编号、类型、偏移量的表也就是SensorReport_fields。你不需要关心这张表的具体结构只要把它作为参数传给pb_encode和pb_decode即可。生成代码时还有个大坑是路径问题。nanopb.proto本身在 nanopb 仓库的generator/proto目录里你的工程里的.proto如果 import 了它那么编译生成时必须加-I指向那个目录。如果是在 CI 环境里配脚本这个路径需要额外注意否则换个机器就报错。3.3 把生成的代码集成进 MCU 工程拿到生成的.pb.c和.pb.h之后准备工作就剩把 nanopb 运行时库和生成代码一起加进工程编译。我用 CMake 时只需要在add_executable里加上add_executable(app main.c app/sensor.pb.c third_party/nanopb/pb_common.c third_party/nanopb/pb_encode.c third_party/nanopb/pb_decode.c ) target_include_directories(app PRIVATE app third_party/nanopb )如果是 Keil就在 Source Group 里把上述.c文件添加进来然后在 C/C 选项卡的 Include Paths 里添加app和third_party/nanopb两个目录。整个过程不需要修改 nanopb 源码也不需要额外配置堆栈只要把文件拖进工程就行。3.4 自定义 stream把数据接到串口或网络nanopb 的编解码接口都基于 stream 对象。pb_ostream_t和pb_istream_t各自包含一个 callback 函数指针和 state 指针。默认情况下pb_ostream_from_buffer会生成一个写内存缓冲区的 stream适合先在缓冲区里编码完再统一发送。但实际项目里更省内存的做法是自定义一个 stream让编码结果直接通过串口发出同时把发出去的字节数带回来。假设你的设备用的是 STM32 HAL 库可以这样实现一个串口输出回调typedef struct { UART_HandleTypeDef *huart; uint32_t timeout_ms; } uart_ostream_state_t; bool uart_ostream_callback(pb_ostream_t *stream, const pb_byte_t *buf, size_t count) { uart_ostream_state_t *state (uart_ostream_state_t *)stream-state; return HAL_UART_Transmit(state-huart, (uint8_t *)buf, (uint16_t)count, state-timeout_ms) HAL_OK; } void send_report(UART_HandleTypeDef *huart, SensorReport *report) { uart_ostream_state_t state { .huart huart, .timeout_ms 100 }; pb_ostream_t stream { .callback uart_ostream_callback, .state state, .max_size 1024, .bytes_written 0, }; if (!pb_encode(stream, SensorReport_fields, report)) { // 编码失败PB_GET_ERROR(stream) 可以取得错误信息 } // 编码完成后stream.bytes_written 就是总共发出的字节数 }核心思想是pb_encode每编码出一段数据就会调用一次 callback把这段数据交给你自定义的发送函数。这样就不需要先准备一个大缓冲区再把整个报文二次发送内存占用能减少很多。这个模式在流式输出、低内存 MCU 上特别实用。4. 完整例程编码、传输、解码与校验4.1 编码端示例下面给一个可以直接跑通的完整例程。假设设备要上报两条采样数据编码到缓冲区里备用。#include stdio.h #include string.h #include pb_encode.h #include sensor.pb.h int encode_report(uint8_t *buffer, size_t buffer_len, size_t *written_len) { SensorReport report SensorReport_init_default; pb_ostream_t stream pb_ostream_from_buffer(buffer, buffer_len); report.device_id 0x5A000001; strncpy(report.firmware_version, 1.2.0, sizeof(report.firmware_version) - 1); report.samples_count 2; report.samples[0].seq 1; report.samples[0].temperature 2534; // 25.34°C report.samples[0].humidity 5267; // 52.67% report.samples[0].timestamp 1750000000; report.samples[1].seq 2; report.samples[1].temperature 2538; // 25.38°C report.samples[1].humidity 5301; // 53.01% report.samples[1].timestamp 1750000010; if (!pb_encode(stream, SensorReport_fields, report)) { return -1; // 可用 PB_GET_ERROR(stream) 获取错误描述 } *written_len stream.bytes_written; return 0; }这里有几个细节要注意。SensorReport_init_default负责把结构体初始化为全零状态没有这一步结构体里的内存是随机值编码时可能带上垃圾数据。strncpy复制版本号时sizeof(report.firmware_version) - 1是为了确保不会超出数组边界但别忘了最后一个字节是留给\0的。samples_count这个字段是 nanopb 自动为 repeated 数组生成的长度计数器赋值前必须先把数组内容填好再告诉编码器“数组里有几个元素”。4.2 解码端示例解码端可以运行在另一台设备或者上位机上。对上位机来说用标准 protobuf 库解码即可如果也放在 MCU 上就用pb_decode处理。#include stdio.h #include pb_decode.h #include sensor.pb.h int decode_report(const uint8_t *buffer, size_t buffer_len) { SensorReport report SensorReport_init_default; pb_istream_t stream pb_istream_from_buffer(buffer, buffer_len); if (!pb_decode(stream, SensorReport_fields, report)) { return -1; // 可用 PB_GET_ERROR(stream) 获取错误描述 } printf(device_id0x%08X, fw%s, samples%d\n, report.device_id, report.firmware_version, report.samples_count); for (int i 0; i report.samples_count; i) { printf( sample[%d]: seq%u, temp%d.%02d, hum%u.%02u, ts%u\n, i, report.samples[i].seq, report.samples[i].temperature / 100, report.samples[i].temperature % 100, report.samples[i].humidity / 100, report.samples[i].humidity % 100, report.samples[i].timestamp); } return 0; }解码前同样需要用SensorReport_init_default初始化结构体尤其是 repeated 数组的长度计数器samples_count如果不初始化解码器无法正确填充数组。解码成功后samples_count会被覆写成实际解码出来的元素个数然后你按这个数量遍历数组即可。需要注意pb_decode只负责解码不会对业务逻辑做校验。比如温度字段的合理范围、设备 ID 是否合法这些都需要你自己在解码之后判断。4.3 数据校验与调试方法protobuf 本身不提供校验和或 CRC 机制所以在实际传输链路中一般要在报文外层封一层简单的帧格式。我的习惯是帧头使用固定的 2 字节同步字比如0xAA 0x55用于接收端找报文起点帧头后面放 2 字节长度表示 protobuf 载荷的长度载荷是 nanopb 编码出来的 protobuf 字节流末尾放 1 字节累加和或者 2 字节 CRC16用于纠错和丢弃错帧。这里讲的“帧格式”是工程实践层面的补充protobuf 本身不关心这些底层传输细节。调试时如果两边数据对不上最有效的方式是把发送端的字节流打印成 hex用标准 protobuf 工具先做纯离线解析。protoc 自带--decode_raw参数可以直接把字节流里各字段的编号和原始值打印出来能快速判断是编码端的问题还是接收端解析逻辑的问题。5. 常见问题与排查技巧5.1 编译期问题速查移植过程中最先遇到的往往是编译问题。下面整理了一份速查表现象可能原因解决方案error: unknown type name pb_size_t没有包含 nanopb 头文件或头文件路径不对检查 Include Path 是否包含 nanopb 仓库根目录undefined reference to pb_encode没有把pb_encode.c加入编译把pb_encode.c、pb_decode.c、pb_common.c加入工程error: nanopb_fieldopt is not a member生成时没有导入nanopb.proto检查.proto里是否import nanopb.proto且 protoc 的-I是否指向生成器目录编译后结构体数组超大max_count配置过大repeated 字段的上限根据真实业务设定不要随意给大值这里我特别想强调一个容易被忽略的点pb_common.c不是可选的。在 nanopb 0.4.x 版本里pb_common.c提供了字段描述表的公共处理逻辑只加入 encode 和 decode 两个文件会导致链接错误。很多移植失败都是因为少了这一个文件。5.2 运行期问题速查运行期问题比编译期问题隐蔽得多排查起来更花时间。常见的情况如下现象可能原因解决方案pb_encode返回 falsePB_GET_ERROR显示 buffer too small编码缓冲区太小或字段 max_size/max_count 过大调大缓冲区或缩小字段的上限定义pb_encode返回 false提示 missing required fieldproto3 里通常没有 required如果用了 proto2 则检查必填字段是否未赋值确认所有 required 字段都有值pb_decode返回 false提示 invalid field number收发双方 .proto 里的字段编号不一致对比两边的 .proto 文件字段编号是协议的一部分更改后必须同步解码成功但字符串是空的发送端字符串没有以\0结尾或max_size设置过小拷贝字符串时预留一个字节给\0设备间通信偶发错帧没有帧校验或帧格式设计有歧义增加长度字段和 CRC接收端按状态机解析帧结构体内存占用爆涨嵌套消息里 repeated 字段过多或字符串 max_size 设置不合理缩小 max_count/max_size嵌套层级较深的场景可以用 oneof 复用内存还有一个我实际踩过的坑协议里如果把时间戳字段定义成uint64在 32 位 MCU 上会带来额外的运算开销和结构体对齐填充。多数场景下uint32的时间戳足够用到 2038 年之前没必要为未来几十年的“可能需求”提前付出代价。5.3 内存与性能优化建议nanopb 的优势之一是可控的内存占用但前提是配置得当。几点优化经验代码末尾建议加-Os编译优化可以把pb_encode.c、pb_decode.c、pb_common.c的整体 ROM 占用控制在几 KB 级别字符串字段的max_size按业务实际最长值加 1 来设定不要随手写 64 或 128。一个结构体里有十几个字符串字段每个多 32 字节RAM 消耗是很可观的repeated 数组的max_count相当于在结构体里展开一个结构体数组比如上面例子里samples[8]会直接在SensorReport结构体内占用 8 个Sample的空间按需缩小这个数字是节省 RAM 最直接的方式如果消息里有多个可选的业务分支考虑用oneof让它们共享存储而不是同时为每个分支都分配字段尽量避免把整个报文编码到超大缓冲区里再二次拷贝用自定义 stream 直接发送可以省掉一个大数组。性能方面pb_encode和pb_decode内部会遍历字段描述表字段数量、嵌套层数、字符串和数组长度都会影响耗时。实际测试中这种量级的报文在 M 级主频的 MCU 上开销完全可接受。真正要避免的是在高频中断或实时性要求极高的路径里做超长报文的编解码这种情况建议拆分成小块或者异步处理。6. 一点个人体会做过几次 nanopb 移植之后我的一个很深的感受是nanopb 本身的移植难度并不高真正难的是协议设计。字段编号一旦定了就是长期契约不能随意改动温度用摄氏度还是 0.01 摄氏度、时间戳用秒还是毫秒、单位是写在协议里还是约定在文档里这些问题如果在项目初期没有想清楚后续联调就是无休止的返工。所以我现在的做法是在写任何嵌入式通信代码之前先把.proto文件写好找上位机开发的同事一起评审字段编号、单位和上限评审通过后才开始集成和编码解码。这个过程看起来多花了一两天时间但省下的是联调阶段大量的扯皮时间。另外建议把pb_encode和pb_decode的返回值错误处理做扎实。不要图省事只在调试时打印错误正式代码里要设计好错误上报机制。因为一旦现场设备出现编码失败、解码丢帧的情况能拿到具体的PB_GET_ERROR信息比在几万行 C 代码里盲猜要高效得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价