简介:本资源是一套面向STM32与ARM嵌入式开发者的nanopb轻量级Protocol Buffers移植实战例程,专为内存受限的单片机平台设计,解决传统protobuf库体积大、依赖动态内存等嵌入式适配难题。压缩包共47个文件,含7个C源码(如pb_encode.c、test_update.c)、5个头文件(pb.h、simple.pb.h等)、1个核心.proto定义文件及配套编译中间产物(obj、tlog、pdb等),完整覆盖从.proto定义→nanopb代码生成→STM32工程集成→序列化/反序列化验证的全流程,包体仅3.54MB,便于嵌入式项目快速复用。已有1437人学习下载,资源结构清晰,含ConsoleApplication1示例工程(含sln/vcxproj)与my_pro.zip备用方案,提供可直接编译运行的测试入口、预分配缓冲区实践及跨平台适配要点,助开发者避开内存溢出、字段截断等典型坑点,高效实现嵌入式设备间紧凑、可靠的数据交换。
1. 一个跑在 STM32F407 上的 protobuf 序列化黑匣子:不用 Linux、不靠 glibc,纯裸机 C 实现结构体到二进制的双向翻译
你有没有试过,在没有文件系统、没有动态内存分配、RAM 只有 192KB 的 STM32F407VGT6 上,把一个带嵌套、带 repeated 字段、含枚举和可选字段的传感器数据结构,原样打包成二进制发给上位机解析?不是用 sprintf 拼字符串,不是手写 memcpy 偏移计算,而是真正用.proto文件定义、自动生成 C 结构体和编解码函数——nanopb 就是干这个的。它不是 protobuf 的简化版,而是为嵌入式量身重写的“精简内核”:零 malloc、栈分配优先、可配置字段最大长度、支持 packed 编码、甚至能关掉浮点数支持来省 Flash。本项目就是一套完整可运行的移植例程,覆盖从 CubeMX 初始化、nanopb 工具链集成、.proto到pb_encode.c/pb_decode.c的生成,到最终在 Keil MDK 下实测通过 UART 发送/接收并校验 CRC 的全流程。适合所有正在做工业传感器节点、LoRa 终端、电池供电遥测设备的嵌入式开发者——尤其当你第 3 次因为协议字段对不上而抓包到凌晨两点,你会明白为什么说 nanopb 是嵌入式通信里最值得提前埋下的“后悔药”。
2. 移植架构拆解:从 CubeMX 配置到 nanopb 编译单元的完整链路
2.1 硬件平台与工程基线:STM32F407VGT6 + Keil MDK-ARM v5.38
本例程基于标准 STM32F407VGT6 开发板(100-pin LQFP,主频 168MHz),使用 STM32CubeMX v6.12.0 生成初始化代码。关键配置如下:
- RCC:HSE 8MHz 外部晶振,PLL 倍频至 168MHz
- SYS:Debug → Serial Wire;Timebase Source → TIM6(避免与 HAL_Delay 冲突)
- USART1:Asynchronous 模式,Baud Rate = 115200,Word Length = 8 bits,No Parity,1 Stop bit,Hardware Flow Control = Disabled
- GPIO:PA9/PA10 复用为 USART1_TX/RX
- Project Manager:Toolchain → ARM GCC(但实际使用 Keil,故后续需手动适配头文件路径)
提示:Keil 下必须关闭
__MICROLIB(即不使用 microlib),否则 nanopb 的pb_encode.c中部分size_t类型推导会失败。在Options for Target → Target → Use MicroLIB前打钩取消。
生成工程后,目录结构已预置 nanopb 源码(Middlewares/Third_Party/nanopb/),包含:
nanopb/ ├── pb.h ← 主头文件,定义核心宏与类型 ├── pb_encode.h ├── pb_decode.h ├── pb_common.h ├── pb_encode.c ← 编码实现(无 malloc) ├── pb_decode.c ← 解码实现(带字段跳过逻辑) └── extras/ └── pb_syshdr.h ← Keil 兼容头,重定义 stdint.h/stddef.h 等2.2 nanopb 工具链集成:Python 脚本驱动.proto到 C 的自动化生成
nanopb 不像官方 protobuf 那样依赖protoc插件,它提供独立的generator/nanopb_generator.py脚本,用 Python 3.7+ 即可运行。本项目已将该脚本及依赖(protobufPython 包)打包进/tools/nanopb-gen/目录。
我们定义了一个典型传感器协议sensor_data.proto:
syntax = "proto2"; package sensor; message SensorHeader { required uint32 timestamp_ms = 1; required uint8 node_id = 2; optional uint8 firmware_ver = 3 [default = 1]; } message SensorReading { required float temperature_c = 1; required float humidity_rh = 2; repeated int32 adc_values = 3 [packed=true]; // packed 编码省空间 } message SensorPacket { required SensorHeader header = 1; required SensorReading reading = 2; optional bytes signature = 3; // 用于 OTA 校验 }生成命令(Windows CMD 下执行):
cd tools\nanopb-gen python nanopb_generator.py -I ..\proto -D ..\Src\generated ..\proto\sensor_data.proto参数说明:
-I ..\proto:指定.proto文件搜索路径-D ..\Src\generated:输出 C/H 文件到Src/generated/目录sensor_data.proto:输入文件名
执行后生成:
Src/generated/ ├── sensor_data.pb.h ← 定义 struct sensor_SensorPacket 等,含字段偏移宏 ├── sensor_data.pb.c ← 含 pb_field_t 数组、encode/decode 函数桩 └── sensor_data.options ← 可选配置文件(如字段 size 限制)关键点:sensor_data.pb.h中每个结构体字段都带PB_FIELD_1,PB_FIELD_2宏,对应.proto中字段编号,不是数组下标。例如:
/* sensor_data.pb.h */ typedef struct _sensor_SensorPacket { struct _sensor_SensorHeader header; struct _sensor_SensorReading reading; pb_bytes_array_t *signature; // 注意:指针类型,但实际由栈上 buffer 托管 } sensor_SensorPacket;而sensor_data.pb.c中最关键的Fields数组定义了每个字段的编码规则:
// sensor_data.pb.c const pb_field_t sensor_SensorPacket_fields[4] = { PB_FIELD(1, MESSAGE, POINTER, FIRST, 0, sensor_SensorPacket, header, &sensor_SensorHeader_fields), PB_FIELD(2, MESSAGE, POINTER, OTHER, 0, sensor_SensorPacket, reading, &sensor_SensorReading_fields), PB_FIELD(3, BYTES, POINTER, OPTIONAL, 0, sensor_SensorPacket, signature, NULL), PB_LAST_FIELD };PB_FIELD宏展开后包含字段编号、类型、是否可选、是否 repeated、内存布局方式等——这是 nanopb 运行时解析的核心元数据,不能手改。
2.3 Keil 工程配置:头文件路径、宏定义与内存模型适配
在 Keil 中需显式添加以下 Include Paths(Options for Target → C/C++ → Include Paths):
.\Middlewares\Third_Party\nanopb .\Middlewares\Third_Party\nanopb\extras .\Src\generated .\Core\Inc .\Drivers\STM32F4xx_HAL_Driver\Inc .\Drivers\CMSIS\Device\ST\STM32F4xx\Include .\Drivers\CMSIS\Include同时定义必要宏(C/C++ → Define):
PB_ENABLE_MALLOC=0 // 强制禁用 malloc,全部栈分配 PB_NO_PACKED_STRUCTS=1 // 关闭 packed struct(Keil 对 __attribute__((packed)) 支持不稳定) PB_WITHOUT_FLOAT=0 // 保留 float 支持(若 MCU 有 FPU) PB_BUFFER_SIZE=256 // 全局最大 buffer 长度,根据最大 packet 设定注意:
PB_BUFFER_SIZE必须 ≥ 最大可能序列化后的字节数。本例中SensorPacket最大长度经估算约 210 字节(含 signature 最长 32 字节),故设为 256 安全。
最后,nanopb默认使用stdint.h,但 Keil 的core_cm4.h会先于stdint.h定义uint32_t等,导致重定义警告。解决方案是在pb.h顶部插入:
#ifndef __STDINT_H #include <stdint.h> #endif并在pb_syshdr.h中确保:
#include <stddef.h> #include <stdint.h>3. 核心模块拆解:一个请求从结构体填充到 UART 发送的完整链路
3.1 数据准备:栈上分配 + 手动填充字段(无 malloc,无 heap)
nanopb 要求所有结构体在栈上或静态分配,且repeated/bytes类型字段需额外提供 buffer。本例中,我们定义全局 buffer 和 packet 实例:
// main.c #define MAX_PACKET_SIZE 256 static uint8_t tx_buffer[MAX_PACKET_SIZE]; static uint8_t rx_buffer[MAX_PACKET_SIZE]; // 栈上分配完整 packet(含嵌套结构体内存) static sensor_SensorPacket packet = { .header = { .timestamp_ms = 0, .node_id = 0x01, .firmware_ver = 2 }, .reading = { .temperature_c = 25.3f, .humidity_rh = 65.8f, .adc_values_count = 0, // repeated 字段计数器 .adc_values = {0} // 栈上数组,最多 8 个 int32 }, .signature = NULL // bytes 类型,暂不填 };注意:.adc_values_count必须显式赋值,否则 decode 时无法知道有多少元素;.adc_values是固定长度数组(由.options文件或#define控制),此处默认为 8。
填充 ADC 值的典型操作:
// 采集 5 路 ADC,填入 packet.reading.adc_values for (int i = 0; i < 5; i++) { packet.reading.adc_values[i] = HAL_ADC_GetValue(&hadc1); } packet.reading.adc_values_count = 5; // 关键!必须更新计数3.2 编码流程:pb_encode如何把结构体压成紧凑二进制
编码分三步:准备 stream、调用 encode、获取长度。核心是pb_ostream_t—— 它是一个回调函数容器,不关心数据去哪,只负责“写一个字节”。
// main.c static bool write_callback(pb_ostream_t *stream, const uint8_t *buf, size_t count) { // 将 buf 中 count 字节写入 UART(阻塞式) HAL_UART_Transmit(&huart1, (uint8_t*)buf, count, HAL_MAX_DELAY); return true; // true 表示成功 } void send_sensor_packet(void) { pb_ostream_t stream = pb_ostream_from_callback(write_callback, NULL); // 关键:调用自动生成的 encode 函数 bool status = pb_encode(&stream, sensor_SensorPacket_fields, &packet); if (!status) { // 编码失败:常见原因包括 buffer 不足、float NaN、repeated 超限 Error_Handler(); } // stream.bytes_written 即实际编码字节数,可用于 CRC 计算 uint16_t encoded_len = stream.bytes_written; printf("Sent %d bytes\n", encoded_len); }pb_encode()内部逻辑:
- 遍历
sensor_SensorPacket_fields数组 - 对每个字段,根据类型(MESSAGE/BYTES/FLOAT)调用对应子 encoder
repeated字段会循环调用其元素 encoder,并自动加length-delimited前缀packed=true的 repeated 字段(如adc_values)会合并为单个 tag + 连续值流,比非 packed 节省约 30% 空间
提示:
pb_ostream_from_buffer()更常用(写入内存 buffer),但本例用 callback 直接发 UART,省去中间 copy,适合小包高频场景。
3.3 解码流程:从 UART 接收流到结构体字段还原
解码需先接收完整 packet(需帧头/长度/CRC 校验),再喂给pb_decode()。本例使用简单定长 header + payload 方式:
// 假设已通过 UART 接收 rx_buffer,且已校验 CRC,rx_len 已知 bool parse_received_packet(uint8_t *rx_buffer, size_t rx_len) { pb_istream_t stream = pb_istream_from_buffer(rx_buffer, rx_len); // 清零结构体(重要!nanopb 不自动清空未设置字段) memset(&packet, 0, sizeof(packet)); bool status = pb_decode(&stream, sensor_SensorPacket_fields, &packet); if (!status) { printf("Decode failed: %s\n", PB_GET_ERROR(&stream)); return false; } // 验证关键字段是否被正确设置(optional 字段需检查 has_xxx) if (!packet.header.has_timestamp_ms || !packet.reading.has_temperature_c) { printf("Missing required fields!\n"); return false; } printf("Recv: T=%.1f°C, H=%.1f%%, ADC cnt=%d\n", packet.reading.temperature_c, packet.reading.humidity_rh, packet.reading.adc_values_count); return true; }pb_decode()关键行为:
- 自动跳过未知字段(前向兼容性保障)
- 对
optional字段,设置has_xxx成员为 true/false(如packet.header.has_firmware_ver) - 对
repeated字段,严格按xxx_count值读取,超限则返回 false - 若 buffer 不足,
stream.bytes_left为 0 时返回 false
血泪经验:永远在 decode 前
memset(&packet, 0, sizeof(packet))。nanopb 不初始化未设置字段,残留栈垃圾会导致has_xxx错误或浮点数异常。
4. 避坑指南:Keil + STM32 + nanopb 移植中踩过的 5 个真实坑
4.1 现象:编译报错error: #error "Compiler does not support stdint.h"
原因:Keil 默认不启用 C99 模式,stdint.h未被识别。
解决:Options for Target → C/C++ → Misc Controls中添加--c99;同时确认stdio.h等标准头文件路径已加入(见 2.3 节)。
4.2 现象:pb_encode返回 false,串口无输出,调试发现stream.state为NULL
原因:pb_ostream_from_callback()第二个参数传了NULL,但 callback 函数内部试图访问stream->state(如做超时计数)。
解决:要么改 callback 不依赖state,要么传入有效指针(如&tx_state结构体)。本例 callback 简单,直接删掉对state的引用即可。
4.3 现象:repeated int32 adc_values解码后adc_values_count=0,但 buffer 里明明有数据
原因:.proto中未声明packed=true,而 nanopb 默认对 repeated 使用 length-delimited 编码(每个值带独立 tag),但发送端误用了 packed 格式。
解决:统一在.proto中显式声明repeated int32 adc_values = 3 [packed=true];,并确保 generator 重新运行。用pb_print工具(nanopb extras)dump 二进制可验证格式。
4.4 现象:float字段解码后为0.000000,但发送端确认值正常
原因:Keil 的printf默认不支持 float(use float未勾选),且 nanopb 的 float 编码依赖 IEEE754,若 MCU 无 FPU 或编译器未启用软浮点,memcpy读取字节序可能错乱。
解决:Options for Target → Target → Floating Point Hardware → Not Used(强制软浮点);C/C++ → Misc Controls添加--fpmode=ieee_full;同时确认PB_WITHOUT_FLOAT=0已定义。
4.5 现象:signature字段(bytes类型)解码后signature->size=0,但原始 buffer 有 32 字节
原因:bytes类型在 nanopb 中需手动分配 buffer 并挂载到结构体指针。packet.signature是pb_bytes_array_t*,但未指向有效内存。
解决:定义栈上 buffer 并赋值:
static uint8_t sig_buffer[32]; static pb_bytes_array_t sig_array = {.size = 0}; // size 在 decode 时自动填 sig_array.size = sizeof(sig_buffer); // 先设最大容量 packet.signature = &sig_array;decode 后sig_array.size即为实际接收长度,sig_array.bytes指向数据起始。
5. 二次开发实战:为 OTA 固件升级增加签名验证与分片传输支持
5.1 场景需求:通过 UART 接收 512KB 固件镜像,需防篡改、断点续传、内存受限
原例程仅处理百字节级传感器包,而 OTA 需应对 MB 级数据。nanopb 本身不解决大文件传输,但可复用其协议框架,通过分片 + 状态机实现。核心改造点有三:
(1)扩展.proto定义分片消息
message FirmwareChunk { required uint32 chunk_index = 1; // 从 0 开始 required uint32 total_chunks = 2; // 总片数,首包携带 required bytes data = 3; // 每片 ≤ 256 字节(适配 PB_BUFFER_SIZE) optional uint32 crc32 = 4; // 片内 CRC,快速校验 } message FirmwareHeader { required uint32 firmware_size = 1; // 总大小 required bytes sha256_hash = 2; // 全镜像哈希,32 字节 required uint32 version = 3; // 固件版本号 }(2)实现分片状态机(伪代码)
// 全局状态 typedef enum { FW_IDLE, FW_WAITING_HEADER, FW_RECEIVING_CHUNKS, FW_VERIFYING } fw_state_t; static fw_state_t fw_state = FW_IDLE; static uint32_t expected_chunk = 0; static uint32_t total_chunks = 0; static uint8_t fw_image[FW_MAX_SIZE]; // 外部 SPI Flash 映射或 RAM buffer static uint32_t fw_offset = 0; void handle_firmware_packet(const uint8_t *buf, size_t len) { pb_istream_t stream = pb_istream_from_buffer(buf, len); if (fw_state == FW_WAITING_HEADER) { firmware_FirmwareHeader header = {0}; if (pb_decode(&stream, firmware_FirmwareHeader_fields, &header)) { total_chunks = (header.firmware_size + CHUNK_SIZE - 1) / CHUNK_SIZE; expected_chunk = 0; fw_state = FW_RECEIVING_CHUNKS; } } else if (fw_state == FW_RECEIVING_CHUNKS) { firmware_FirmwareChunk chunk = {0}; if (pb_decode(&stream, firmware_FirmwareChunk_fields, &chunk)) { if (chunk.chunk_index == expected_chunk) { memcpy(fw_image + fw_offset, chunk.data.bytes, chunk.data.size); fw_offset += chunk.data.size; expected_chunk++; if (expected_chunk >= total_chunks) { fw_state = FW_VERIFYING; verify_firmware_image(); // 计算 SHA256 并比对 } } } } }(3)内存优化技巧:用pb_istream_from_buffer()+ 偏移读取替代整包 decode
对FirmwareChunk,若data字段长达 256 字节,pb_decode()会将其拷贝到栈上chunk.data.bytes(需预分配 buffer)。更省内存的做法是:只 decode header,再用 raw buffer 偏移读取 data:
// 先 decode 前 12 字节(chunk_index + total_chunks + tag for data) firmware_FirmwareChunk_header_only hdr = {0}; pb_istream_t hdr_stream = pb_istream_from_buffer(buf, 12); pb_decode(&hdr_stream, firmware_FirmwareChunk_header_only_fields, &hdr); // data 字段起始位置 = tag(1 byte) + length varint(1~5 bytes) + data size_t data_offset = 12; // 粗略估计,实际需解析 varint uint8_t *data_ptr = buf + data_offset; size_t data_len = len - data_offset; // 直接写入 flash,跳过 nanopb 的 bytes 拷贝 flash_write_page(fw_offset, data_ptr, data_len);此法将栈消耗从256+字节降至<20字节,对 RAM 紧张的 MCU 至关重要。
5.2 调试与验证:用 Python 脚本模拟上位机生成测试包
脱离硬件调试 nanopb 协议,最快方式是用 Python 生成合法二进制,再用逻辑分析仪或串口助手注入。本项目附带test_gen.py:
import nanopb_pb2 # 由 protoc 生成(非 nanopb_generator) from google.protobuf import text_format # 构造 Python 对象 msg = nanopb_pb2.SensorPacket() msg.header.timestamp_ms = 1717171717 msg.header.node_id = 0x0A msg.reading.temperature_c = 23.5 msg.reading.humidity_rh = 45.0 msg.reading.adc_values.extend([1024, 2048, 3072]) # 序列化为二进制 binary = msg.SerializeToString() print("Generated binary:", binary.hex()[:64] + "...") # 写入文件供串口发送 with open("test_packet.bin", "wb") as f: f.write(binary)运行后得到test_packet.bin,用 XModem 或自定义串口工具发送,即可在 STM32 端验证 decode 正确性。这招让我少烧了 3 块板子——协议问题在 PC 端就能闭环。
从那以后我每次新增.proto字段,都强制走一遍test_gen.py → 串口注入 → STM32 log 输出全链路,哪怕只是加个注释字段。嵌入式协议没有银弹,只有一次又一次的字节对齐。希望帮到你。
本文还有配套的精品资源,点击获取