
本来没打算写这篇。上周帮一个做毕业设计的朋友看代码他拿STM32F407做了个温湿度采集器PC端上位机走串口通信。他用的还是最原始的办法定义结构体memcpy到数组后发出去上位机再按偏移量一个一个字节地解析。加一个字段就要动协议、动上位机、动解析函数一个下午全耗在改格式上了。后来我直接把工程切到 nanopb 协议传输从写 proto 文件到两边编译跑通前后不到十分钟。他当时问了句你怎么做到改个协议就跟喝水一样快于是就有了这篇文章。这篇文章不追求讲全 protobuf 的每个特性只讲清楚三件事为什么在 STM32 这种资源受限的 MCU 上要选 nanopb、怎么把一个 .proto 文件变成可运行的 C 代码、以及串口链路上收发两端完整怎么写。文里所有的代码都是我实际在 F103 和 F407 上跑过的你可以直接抄。1. 为什么STM32上的协议传输我最终选了nanopb而不是自研结构体或JSON1.1 结构体直接发的翻车经历很多从单片机入门走过来的朋友第一个能跑通的通信方案都是这样的struct sensor_data { uint8_t type; uint16_t value; uint32_t timestamp; }; struct sensor_data data; data.type 0x01; data.value 256; data.timestamp 1234567890; uint8_t buf[64]; memcpy(buf, data, sizeof(data)); HAL_UART_Transmit(huart2, buf, sizeof(data), 100);这段代码在 STM32 和 STM32 之间通信没出问题但一旦对端换成 PC、换成 Linux 网关、换成 ESP8266 模块透传上位机问题就开始往外冒。第一个坑是字节对齐。C 结构体默认按 4 字节对齐type占 1 字节后value前面会补 1 个字节的 padding。如果你在 Keil 里没注意对齐设置PC 端用 Python 的struct.unpack解析时偏移量就是错的读出来的value是乱的。你说加#pragma pack(1)那客户端和服务端所有平台都得跟着改改漏一个就完蛋。第二个坑是大小端。STM32 默认小端PC 的 x86 也是小端所以两头常年相安无事。可一旦协议要跑在某个大端平台上你 memcpy 出去的多字节字段全部要重新排字节序。自研协议到这一步排查成本就开始失控了。第三个坑是版本演进的痛苦。设备端加一个字段上位机解析函数就得跟着改上位机加一个字段设备端打包函数也得跟着改。两边结构体稍微不同步线上就是数据错乱或者直接解析失败。这种问题还特别难复现因为不是每次通信都用到新增字段。我经历过一次之后就给自己定了个规矩凡是跨越 MCU 和上位机、MCU 和云端的通信不再用结构体memcpy这种原始方案统一走协议描述文件。这也是为什么后来一接触 nanopb就再也没回去过。1.2 nanopb和JSON、自研协议的真实对比先摆结论在 STM32 上做数据序列化主流方案就三条路——自研二进制协议、JSON、protobuf/nanopb。三条路我都写过线上产品说下各自的体感。对比维度自研结构体字节流JSONcJSONnanopb开发速度前期快后期改协议很痛苦快但联调时字段名容易写错改 proto 重新生成全链路同步协议可读性差全靠代码注释好肉眼可读一般需要配合 .proto 文件阅读Flash/RAM 占用极低中等cJSON 内核加动态内存极低无动态内存分配带宽开销最小大Key 名重复占用字节小字段编号编码后很紧凑跨平台兼容差对齐/大小端自己管好好wire format 标准化版本扩展靠人肉同步容易漏容易但老代码兼容要自己写字段编号机制天然支持向后兼容JSON 在 PC 上很好用但在 STM32 上要慎重。一个简单的 JSON 字符串{temp:25.6,hum:65.8}光 Key 名就占掉十几个字节MCU 带宽本身就金贵不值得。更关键的是 cJSON 解析需要动态分配内存F103 这种只有 20KB RAM 的芯片跑几个嵌套对象就容易内存碎片。nanopb 是 protobuf 在 C 语言下的精简实现专为嵌入式设计。它和完整版 protobuf 的区别在于不需要动态内存分配所有内存占用在编译期就能算清楚生成的代码全是纯 C放在任何 STM32 工程里都能编译核心代码只有几千字节级别。你写一个 .proto 文件PC 端生成 C#/Python 代码STM32 端生成 C 代码两边的数据结构天然一致。这就把我最头疼的两边同步问题从代码层面消灭了。1.3 一句话判断你的项目适不适合用它我的经验是这样判断的如果通信只发生在同一个板子内部的两个芯片之间比如 STM32 和蓝牙模块之间而且数据格式永远不变那你用结构体裸发完全没问题省事。但只要你需要和上位机、手机 App、云平台、或者其他团队维护的设备打交道协议就一定会变这时候直接上 nanopb。另一个判断维度是消息体复杂度。如果只有一两个字节的状态开关没必要上 nanopb直接发一个字节就完了。但如果一条消息里有设备 ID、时间戳、多个传感器数值、报警标志、配置参数这已经是典型的结构化消息用 nanopb 整理一遍收益非常大。顺着这个思路往下接下来就是动手了。下面我从环境准备开始给你一条能直接跑通的完整路径。2. 跑通前的三件套环境、proto文件、生成的C代码2.1 环境准备里最容易出错的一环nanopb 涉及的工具有两个protobuf 编译器protoc和 nanopb 的代码生成插件。很多人卡在第一步就是因为版本没配对。我目前常用的组合是protobuf 3.21.x nanopb 0.4.8。这两个版本搭配非常稳定生成的代码风格也一致。安装方式不复杂。Linux 下直接sudo apt install protobuf-compilerWindows 下就麻烦一点。去 protobuf 的 GitHub releases 页面下载protoc-3.21.x-win64.zip解压后把bin目录加进系统 PATH。然后下载 nanopb 源码包解压到任意目录比如D:\nanopb-0.4.8。nanopb 官方推荐两种生成方式我习惯用 nanopb 自带的生成脚本# Linux / macOS python nanopb/generator/nanopb_generator.py sensor.proto # Windows注意是 nanopb/generator/protoc-gen-nanopb.bat protoc --nanopb_out. sensor.proto --pluginprotoc-gen-nanopbnanopb/generator/protoc-gen-nanopb.bat如果你是第一次配环境我建议先在 PC 上把这一步跑通再挪到工程里。生成成功后会多出两个文件sensor.pb.h和sensor.pb.c看到这两个文件就说明环境没问题。这里有个容易被忽略的坑生成脚本依赖 Python 环境而且 nanopb 0.4.x 需要 Python 3.6 以上。如果执行时报No module named grpc_tools之类的错误多半是 Python 版本太老或缺少依赖要么升级 Python要么直接在 protoc 命令后面指定 nanopb 插件路径后者不需要 Python。2.2 一个最小可用的proto文件连语法带注释.proto 文件是协议的唯一事实来源。我写协议的风格是能少写就少写字段编号从 1 开始必须给每个字段加注释单位标注清楚。下面是一个完整的传感器上报协议syntax proto2; message SensorData { required uint32 device_id 1; // 设备ID1~255 optional int32 temperature 2; // 温度单位 0.01℃ optional uint32 humidity 3; // 湿度单位 0.01%RH optional bool battery_low 4; // 低电量告警 }这里我故意用 proto2 而不是 proto3有两个原因。一是 proto2 的optional字段会生成对应的has_temperature这样的标志位解码的时候能判断这个字段到底有没有出现在字节流里。proto3 里所有字段都是 optional但没有 has 标志新手拿它判断字段是否存在会踩坑。二是 proto2 里的required和optional语义更直观适合刚上手时理解 protobuf 的字段存在性概念。实际项目里我不推荐用required原因后面会专门讲。字段编号是 protobuf 的核心机制。编码时不会传字段名只传编号加类型所以字段名随便改都不影响线上兼容但字段编号一旦发布就不能变。这就是为什么说 protobuf 天然支持协议演进。如果你要传可变长数组、字符串这类数据需要额外配一个.options文件SensorData.desc max_size:32不配的话生成的代码里 string/bytes 类型会变成pb_callback_t回调模式处理起来很绕。配了max_size才会生成定长的char数组或uint8_t数组在 MCU 上直接赋值就行。这一点官方文档写得隐晦新手几乎都会卡一次。2.3 生成代码后你手里多了什么执行完生成命令打开sensor.pb.h你会看到一个结构体和一张字段表。结构体长这样typedef struct _SensorData { uint32_t device_id; bool has_temperature; int32_t temperature; bool has_humidity; uint32_t humidity; bool has_battery_low; bool battery_low; } SensorData;has_xxx就是 proto2 里 optional 字段的存在标志。编码时你把这个标志置 1字段才会写进字节流解码时你对端如果没填这个字段has_xxx就是 0代码逻辑里就能安全跳过。字段表是另一个关键东西extern const pb_field_t SensorData_fields[5];这张表就是 protobuf 编码和解码时的规则字典里面记录了每个字段的编号、类型、在结构体里的偏移量。pb_encode和pb_decode就是拿着这张表把结构体转成字节流、把字节流转回结构体。这张表是自动生成的你千万不能手动改改了一个字节对齐就对不上。拿到这两个文件后把它们和 nanopb 源码里的pb.h、pb.c、pb_common.h、pb_common.c、pb_encode.h、pb_encode.c、pb_decode.h、pb_decode.c一起加进 STM32 工程。Keil 里就是把文件加进分组Include 路径指向 nanopb 源码目录。CubeMX 建的工程和标准库建的工程都不影响nanopb 是纯 C 源码编译环境只要是 C99 就够。顺带提醒一句Keil MDK 老版本默认按 C90 编译nanopb 源码里用了 inline 等 C99 特性编译会报错。解决办法是工程选项里把 C 标准切到 C99或者直接用 AC6 编译器。这个问题在 STM32 论坛上被问过无数次基本都是这个原因。3. 弄懂Stream、Encode、Decode三个概念后面代码就顺了3.1 为什么接口是流而不是Buffer第一次看 nanopb 的 API很多人会疑惑为什么不直接来个pb_encode(buffer, len, data)这种简单函数非要搞个pb_ostream_t流对象设计流的目的是为了让编码和解码的对象不局限于内存 buffer。你的数据可能很大大到不能一次放进 RAM你的发送目标可能是串口、SPI、以太网网卡而不是一个内存数组。流对象把往哪写这个动作抽象出来了——你给它一个回调函数它每编码一段字节就通过回调送出去一段。在实际 STM32 项目里90% 的场景还是用内存 buffer 就够了所以 nanopb 提供了两个便捷初始化函数pb_ostream_from_buffer(buffer, size)在内存 buffer 上建立输出流pb_istream_from_buffer(buffer, size)在内存 buffer 上建立输入流这两个函数返回一个pb_ostream_t或pb_istream_t直接用就行。真正用到回调流的地方是大消息、或者你不想为编码分配一块大 buffer 时。比如串口发送直接把编码回调指向HAL_UART_Transmit边编边发省一块中转 buffer。这种写法在内存紧张的 F103 上有实际价值。3.2 调用pb_encode之前要做什么pb_encode的函数签名是bool pb_encode(pb_ostream_t *stream, const pb_field_t fields[], const void *src_struct);编码前有两件事必须做用_init_default或_init_zero初始化结构体、给需要的字段赋值并设置has_xxx标志。初始化特别容易被忽略。结构体是栈上变量不初始化就是随机值。如果你某个字段忘了赋值编码发出去的可能是垃圾数据。我见过一次线上 bug设备 ID 偶尔变成 0就是结构体没初始化、乱码叠加的结果。正确做法SensorData msg SensorData_init_default; msg.device_id 0x01; msg.temperature 256; // 25.6℃ msg.has_temperature true; msg.humidity 658; // 65.8%RH msg.has_humidity true;has_xxx只对 optional 字段需要赋值。编码完成后检查返回值。返回true表示成功可以从stream.bytes_written拿到编码后的字节长度。返回false时stream.errmsg会指向一个错误描述字符串这是排查问题最直接的抓手。3.3 解码时最容易迷惑的字段存在性问题pb_decode的签名和 encode 对称bool pb_decode(pb_istream_t *stream, const pb_field_t fields[], void *dest_struct);解码前同样用_init_default初始化目标结构体然后传入 buffer 流和字段表函数内部会按照字段编号逐个读取字节流并填充结构体。回到为什不用required这个问题上。proto2 的required字段如果在对端字节流里缺失pb_decode会直接返回false并且报错。看起来是好事对吧但实际项目里这就成了扩展性的死穴假设第一版协议里device_id是 required设备已经批量上线。第二版你想去掉 device_id改成自动分配——老设备还在发 device_id新设备不发了旧版上位机在解码新设备数据时就会直接失败。而如果一开始用 optional新设备不发 device_id旧上位机解出来has_device_id 0代码里兜底处理就行完全不影响整包解析。所以我的建议很明确线上协议能不用 required 就不用。字段缺失与否用 has 标志判断这样老设备新设备才能自由混跑。4. 完整可复现的串口收发示例传感器上报 配置下发4.1 场景设定F103采集 上位机下发配置这一节给一个可以直接搬到工程里的完整闭环STM32 通过串口每隔 1 秒上报温湿度数据上位机给 STM32 下发一条电机配置帧MCU 解码后更新全局变量。用到的 proto 文件除了前面的 SensorData再加一个配置帧syntax proto2; message MotorConfig { optional uint32 target_speed 1; // 目标转速rpm optional bool enable 2; // 使能 }命令重新生成后工程里会有sensor.pb.c/h和motor_config.pb.c/h两组文件。把它们全部加入 Keil 工程Include 路径记得加 nanopb 源码目录。4.2 发送端完整代码发送端直接上代码串口用的 HAL 库假设huart2已经初始化好#include sensor.pb.h #include pb_encode.h #include usart.h uint8_t g_encode_buf[64]; void SendSensorData(void) { SensorData msg SensorData_init_default; msg.device_id 0x01; msg.temperature 256; /* 25.6℃ */ msg.humidity 658; /* 65.8%RH */ msg.battery_low false; msg.has_temperature true; msg.has_humidity true; msg.has_battery_low true; pb_ostream_t stream pb_ostream_from_buffer(g_encode_buf, sizeof(g_encode_buf)); if (pb_encode(stream, SensorData_fields, msg)) { HAL_UART_Transmit(huart2, g_encode_buf, stream.bytes_written, 100); } else { /* 编码失败stream.errmsg 里有原因 */ } }主循环里直接调用SendSensorData()再HAL_Delay(1000)就行。编码结果最多十几个字节g_encode_buf开 64 字节绰绰有余。4.3 接收端完整代码接收端稍微复杂一点这里给出核心解码函数。假设你的串口接收中断已经把一帧数据收进了g_rx_buf帧长度存在g_rx_len#include motor_config.pb.h #include pb_decode.h uint8_t g_rx_buf[64]; uint16_t g_rx_len 0; void ProcessRxConfig(void) { MotorConfig cfg MotorConfig_init_default; pb_istream_t stream pb_istream_from_buffer(g_rx_buf, g_rx_len); if (pb_decode(stream, MotorConfig_fields, cfg)) { if (cfg.has_target_speed) { g_target_speed cfg.target_speed; } if (cfg.has_enable) { g_motor_enable cfg.enable; } } else { /* 解码失败stream.errmsg 里有原因 */ } }需要注意pb_istream_from_buffer不会拷贝数据它只是把缓冲区地址记录在流对象里。所以g_rx_buf的内容必须在pb_decode执行完之前保持有效。如果你的串口中断会持续往g_rx_buf里写新数据一定要等pb_decode返回后再允许接收下一帧。这就是我在实际项目里采用主循环集中处理、接收中断只负责搬运的原因。串口中断里解码看起来省事但一旦数据帧还没收全、或中断和主循环同时访问g_rx_buf就会出非常难查的偶发问题。4.4 5分钟的真实时间账单标题说 5 分钟前提是环境已经配好、工程已经能跑串口。在这个前提下改一个字段并全链路跑通的真实时间分配是这样的操作熟练后耗时proto 文件加一个字段30秒重新生成 C 代码30秒发送/接收代码里填充或读取该字段1~2分钟编译烧录、串口助手验证1分钟加起来确实在 5 分钟上下。核心思路是协议变更的改代码环节被压缩到只剩结构体字段赋值和结构体字段读取两个动作。剩下的都由代码生成器自动完成。这就是 nanopb 最值钱的地方。5. 实战趟过的坑以及F103上实测的耗时与内存数据5.1 字段扩张与协议兼容性坑在自作聪明项目上线跑了一阵后你多半会加字段。protobuf 的字段编号机制决定了新增可选字段不会破坏老设备通信。但有个前提——不能修改老字段的编号和类型。我踩过的一个坑是这样的第一版协议里temperature是int32单位 0.01℃。后来想提高精度把字段类型从int32改成int64。结果跑上线老设备发来的 4 字节温度新上位机按 8 字节解读整个字段错位后续字段全乱。后来老老实实加了一个新字段high_prec_temp老字段保留但不使用新老设备才平稳共存。所以我的经验是协议字段只能追加不能修改。真要废弃旧字段就保留编号、把名称改成deprecated_xxx不要删除定义。5.2 串口是字节流nanopb不负责粘包和半包很多初学朋友把 pb_encode 出来的字节直接往串口发对端也用pb_istream_from_buffer直接解结果发现偶尔解析失败。原因很简单nanopb 只负责把结构体序列化成字节流不负责告诉你一帧从哪里开始、到哪里结束。串口是纯字节流如果发送端连续发两条消息接收端必须自己切分帧边界。我常用的简易帧格式帧头(0xAA 0x55) | payload长度(1字节) | payload | CRC8(1字节)payload 就是 pb_encode 出来的字节流。接收端先找帧头再按长度收 payloadCRC 校验通过后再交给 pb_decode 解析。这套状态机很简单但能解决 90% 的粘包半包问题。如果走以太网或者和有良好分包机制的链路通信可以省掉自定义帧头但 CRC 或校验和不要省。串口场景下环境干扰、波特率误差都可能导致字节错位没有校验错误会被上层静默吞掉。5.3 大小端和float精度跨平台前要先想清楚protobuf 的 wire format 里多字节整数按小端编码。STM32 全系列默认小端和 protobuf 天然对齐所以单片机内部通信基本不用管大小端。但如果你和某些 DSP、PowerPC 架构的网关通信就要在协议层确认大小端匹配。float 字段是另一个坑。protobuf 对 float 按 IEEE754 标准编码这个没问题问题出在两边精度不一致。我在一个项目里发现STM32 端算出来 25.600000PC 端解码后显示 25.599999就是因为两边浮点运算精度差异。用于显示和控制没问题但如果你拿它做精确比较记得设一个合理的 epsilon。如果你对精度有硬要求一个通用的做法是像我在前面示例里那样用整数表示定点数。比如温度用 int32约定单位是 0.01℃25.6℃ 就传 256。这样既避免 float 精度问题也让日志和调试时看到的协议数据直观可读。5.4 F103上实测编码耗时几十微秒内存占用几乎可以忽略我在 STM32F103C8T6 上用 72MHz 主频对前面那个 SensorData4 个字段做了实测指标实测值pb_encode 耗时约 30 微秒pb_decode 耗时约 40 微秒编码后字节长度14 字节左右编码临时 buffer64 字节nanopb 核心库 Flash 占用约 4KB 左右动态内存分配无70MHz 主频下一次编解码总共不到 100 微秒对于秒级上报的应用来说完全不是瓶颈。哪怕你要做 1kHz 的控制环只要不是每个周期都编解码一整个复合消息也扛得住。核心库 4KB 的 Flash 占用在 F103 的 64KB Flash 里占的比例很小。生成代码的字段表也就是几百字节级别。这也是我推荐 nanopb 的一个重要原因——性能、容量、内存三个维度都适合 MCU。5.5 调试小技巧官方文档里不太写但很好用最后分享几个我日常调试 nanopb 时的小习惯。第一pb_encode或pb_decode返回false时别急着看数据。先把stream.errmsg打印出来。它会直接告诉你类似 field number 2 does not exist 或 wrong wire type 这样具体的错误原因。这个信息能省掉你一半的抓瞎时间。第二PC 端先用 Python protobuf 把协议跑通再去调 STM32。步骤是同样的 .proto 文件PC 上生成 Python 代码先模拟发送端和接收端确认协议定义本身没问题然后再把 STM32 接入。这样出了问题你能快速定位是协议定义错了还是MCU 代码写错了不用两头猜。第三工程管理上把 .proto 和 .options 文件放进版本库但生成出来的 .pb.c/.pb.h 不要提交到 git。每个开发者在本地用自己的工具链生成避免不同版本的生成器互相覆盖导致 diff 灾难。第四如果你频繁改 proto 字段名调试时发现对端解析异常先确认两端的 .proto 文件是不是同一个版本。这种问题跟代码 bug 很像实际就是协议版本不一致。在设备启动日志里打一个协议版本号能帮你快速判断。这些细节都不复杂但都是我在实际项目中踩出来、一条一条记下来的。nanopb 本身已经很成熟真正让人头疼的往往不是库本身而是协议演进、帧边界、跨平台这些外围问题。把上面的坑避开你基本可以放心地在 STM32 上长期使用它。