尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32串口通信新方案:nanopb协议传输从入门到实战全解析

STM32串口通信新方案:nanopb协议传输从入门到实战全解析 做 STM32 项目的人迟早会撞上协议传输这件事。串口收发、CAN 报文、以太网帧都只是物理通道真正的麻烦在于“怎么把结构体里的数据可靠地变成对方能看懂的一串字节”。我自己这几年处理过的方案从裸结构体 memcpy、自定义帧头帧尾、到 Modbus、JSON最后落到 nanopb 上才算是把这块骨头啃得比较舒服。这篇就以最常用的 STM32F103 串口通道为例带你把 nanopb 协议传输完整跑起来从工具链、.proto 文件、生成代码、编码发送、解码校验一直到粘包和 CRC 这种实际工程里躲不掉的坑一次讲清楚。标题说 5 分钟搞定我得先说实话这个时间指的是工具链已经装好、你会写 .proto 的前提下从编码到跑通大概 5 到 10 分钟。第一次从头配置开发环境的人先给自己留半小时踩完坑之后第二次就真的能做到 5 分钟。适合谁看正在做多节点通信、设备间指令下发、数据上报或者说已经开始对“结构体直接发送”这种简单粗暴做法感到心虚的人这篇内容应该能帮你少走不少弯路。1. 为什么非用 nanopb 不可——先想清楚协议选型1.1 裸结构体传输的“三座大山”很多从 51 单片机转过来的人第一版通信协议都是这么写的定义好结构体memcpy到发送缓冲区直接发收端memcpy出来直接用。数据量小、只有一块板子自产自销的时候没问题但项目一旦复杂起来这三座大山立刻压在头上。第一座是内存对齐。同样一个结构体Keil AC5、GCC、IAR 编出来的字段偏移可能不一样因为编译器会为了对齐自动填充 padding 字节。本地测试全是通过的发到另一块用不同编译器固化的板子上字段整体错位解出来的全是垃圾值。第二座是大小端。STM32 默认小端很多转串口模块或者网关是 ARM 大端模式还有相当一部分 PC 端工具默认按大端解释数据uint32_t直接发过去字节序直接反了还得专门写一版字节翻转逻辑。第三座是版本升级。结构体里加个字段老设备收到新格式的数据长度对不上、偏移错位整个包直接废掉根本做不到向后兼容。一句话说就是裸结构体只适合“永远不会变、永远不会扩充、永远用同一款编译器”的理想项目。实际工程里这些假设迟早被打破。1.2 nanopb 到底替我们省了哪些事nanopb 是 Protocol Buffers 的嵌入式精简实现。Protocol Buffers 是 Google 搞出来的一套“结构化和字节流之间互相转换”的方案大名叫 protobuf很多后端和 App 通信都在用。嵌入式领域里nanopb 把它的体积和资源占用压到了非常夸张的地步。它做的事情可以理解成你在.proto文件里定义好消息格式工具自动生成 C 结构体和一组编码/解码函数。发送的时候根本不关心每个字段在字节流里的具体位置调用pb_encode就全部搞定了接收端一个pb_decode字节流恢复成结构体字段自动归位。这背后解决了三个关键问题。第一个是跨平台字段用数字编号tag标识跟语言、编译器、大小端都没有关系PC 端用 Python protobuf 解码 STM32 发来的数据天然兼容。第二个是版本兼容老设备不认识的字段会直接被跳过新设备收到旧数据也能正常解字段删了或者随加只要编号不乱动互相之间就还认识。第三个是体积和内存可控nanopb 的动态开销极小编码时可以直接往静态大数组里写解码时直接往静态结构体里解完全不用malloc这在裸机开发里太重要了。1.3 什么场景才值得上 nanopb我也不是劝所有项目都上 nanopb。如果只是两块板子之间传三五个字节的简单指令Modbus RTU 甚至自定义一帧“帧头 命令字 数据 CRC”已经足够好用没必要引入一套生成工具链。nanopb 的优势要项目达到一定复杂度才体现得明显节点多、消息类型多靠人手维护帧格式映射表容易漏需要跟 PC、手机 App、云平台对接跨语言解包是刚需协议要经历多次迭代需要加字段、改配置而不想中断老设备在线升级对 RAM/Flash 敏感又不能牺牲协议表达力的场景如果你的项目命中两三条nanopb 就是值得投入的选择。2. 动手前的三板斧环境、工具链、文件生成2.1 环境准备只需要准备一次使用 nanopb 需要两个东西一是 nanopb 运行时源码二是 .proto 文件生成 C 代码的工具。运行时源码就是指pb.h、pb_common.c、pb_decode.c、pb_encode.c这四个核心文件。去 GitHub 上搜 nanopb下载最新的 0.4.x 版本你需要的其实就这四五个文件别整包往工程里塞。生成工具的选择有两种。第一种是用 protoc 编译器配合 nanopb 的插件方式命令是protoc --nanopb_out.。第二种更直接nanopb 仓库里的generator目录下有个nanopb_generator.py直接用 Python 执行也行。我自己现在习惯用第一种因为后面要生成多种语言的代码时统一走 protoc 更方便。Windows 下如果嫌配环境麻烦可以直接下载 nanopb 官方发布的 Windows 预编译包里面已经带好了protoc.exe和生成脚本。有一点提醒一下生成代码只需要在 PC 上执行不需要跑到单片机上跑 Python所以哪怕你的开发机是 Windows 7 老掉牙的机器也完全没问题。2.2 编写最小 .proto 文件以一个环境数据上报的场景为例。开发板采集温度、湿度、设备编号和一组 ADC 采样值需要打包发给网关。syntax proto3; import nanopb.proto; package sensor; message EnvReport { uint32 device_id 1; int32 temperature 2; // 实际温度乘以100避免浮点数 int32 humidity 3; // 实际湿度乘以100避免浮点数 fixed32 timestamp 4; // Unix 时间戳 repeated sint32 adc_samples 5 [(nanopb).max_count 16]; }这个文件里有两个细节值得专门说明。第一温度湿度没有直接用float而是统一乘以 100 存成整数。嵌入式设备里浮点数本身存储没有问题但从“编码简洁”的角度看float固定占 4 字节且压缩不了而int32的 varint 编码方式在小数值时往往只占 1-2 字节传起来更快对端解析也没有精度歧义。实际上这是 protobuf 社区非常普遍的做法。第二repeated字段必须加上[(nanopb).max_count 16]。这是 nanopb 和标准 protobuf 最不一样的地方。标准 protobuf 里repeated字段在内存中是动态数组需要动态分配而 nanopb 为了能静态编译必须提前约定数组最大长度生成的结构体会是一个定长数组这个max_count不写生成出来的结构体根本没法存放多个值直接默认丢弃数组内容。然后在命令行里执行protoc --nanopb_out. env.proto会得到两个文件env.pb.h和env.pb.c。前者定义了消息对应的结构体后者是字段描述表是给pb_encode/pb_decode用的“协议字典”不需要你手动改。2.3 把生成文件搬进工程无论你用的是 Keil MDK、STM32CubeIDE 还是 GCC 命令行工程要做的都是一件事把运行时源码和生成文件加进编译列表然后配置头文件路径。如果是 Keil 工程需要添加的源文件是pb.cpb_common.cpb_decode.cpb_encode.cenv.pb.c头文件路径需要加入两个目录一个指向 nanopb 源码根目录里面有pb.h另一个指向env.pb.h所在的目录。最容易犯的错就是只加了其中一个结果编译报错找不到pb.h或者找不到env.pb.h。调试 gd32 / stm32 系列时如果用的是 AC5 编译器建议把 C 标准设置成C99AC6 默认就是 C99 以上问题不大。整个 nanopb 核心代码非常干净不依赖任何操作系统和 HAL 库只要是能跑标准 C 的 MCU 都能编过。3. 完整代码示例串口通道上的“环境数据采集上报”3.1 场景定义与关键参数先明确要做什么STM32F103 通过串口 1 以 115200 波特率向网关上报环境数据。帧格式外层的封装协议用自定义方式帧头 2 字节0xAA 0x55 payload 长度 1 字节 payload CRC16 校验 2 字节。CRC 部分只对 payload 做校验帧头帧尾固定值不参与计算这样可以减少一个字节的校验范围含糊带来的歧义。nanopb 本身只负责“结构体 ↔ 字节流”的转换不负责流边界。数据在串口上是连续不断的字节流接收方必须自己通过帧头、长度、CRC 判断“这一帧到哪里结束”。编码 buffer 大小定为 128 字节。根据前面那个 .proto 文件温度湿度字段本身大概率只占 2-3 字节ADC 数组最多 16 个值每个 sint32 采用 zigzag 编码后通常 2 字节左右整包不足 60 字节128 字节足够余量。如果是更复杂的消息建议先用 PC 端工具抓一次编码结果再按最坏情况留出至少 2 倍余量来定 buffer 大小。3.2 编码端完整代码先封装帧层这里给出一份精简但能直接用的实现// frame.h #ifndef __FRAME_H__ #define __FRAME_H__ #include stdint.h #include stdbool.h #include string.h #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_MAX_PAYLOAD 128u #pragma pack(push, 1) typedef struct { uint8_t head1; uint8_t head2; uint8_t len; uint16_t crc; uint8_t payload[FRAME_MAX_PAYLOAD]; } FrameBuf; #pragma pack(pop) uint16_t crc16_ccitt(uint16_t crc, const uint8_t *data, uint16_t len); bool frame_encode(FrameBuf *frame, const uint8_t *payload, uint8_t len); #endif// frame.c #include frame.h uint16_t crc16_ccitt(uint16_t crc, const uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { crc ^ (uint16_t)data[i] 8; for (uint8_t j 0; j 8; j) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc crc 1; } } } return crc; } bool frame_encode(FrameBuf *frame, const uint8_t *payload, uint8_t len) { if (len FRAME_MAX_PAYLOAD) { return false; } memset(frame, 0, sizeof(FrameBuf)); frame-head1 FRAME_HEAD1; frame-head2 FRAME_HEAD2; frame-len len; memcpy(frame-payload, payload, len); frame-crc crc16_ccitt(0xFFFF, payload, len); return true; }核心的数据打包发送函数#include pb_encode.h #include env.pb.h #include frame.h extern UART_HandleTypeDef huart1; static uint8_t s_payload_buf[FRAME_MAX_PAYLOAD]; static uint8_t s_uart_buf[FRAME_MAX_PAYLOAD 8]; bool send_env_report(const EnvReport *report) { // 1. nanopb 编码结构体 - payload 字节流 pb_ostream_t stream pb_ostream_from_buffer(s_payload_buf, sizeof(s_payload_buf)); if (!pb_encode(stream, EnvReport_fields, report)) { return false; } // 2. 自定义帧协议加上帧头、长度、CRC FrameBuf frame; if (!frame_encode(frame, s_payload_buf, (uint8_t)stream.bytes_written)) { return false; } // 3. 按实际长度把整个帧拷到连续发送缓冲区 uint8_t total_len (uint8_t)(5 stream.bytes_written); // head1head2lencrc(2)payload memcpy(s_uart_buf, frame, total_len); // 4. 阻塞发送工程上建议换 DMA HAL_UART_Transmit(huart1, s_uart_buf, total_len, 100); return true; }调用侧的代码void app_periodic_report(void) { EnvReport report EnvReport_init_zero; report.device_id 0x01; report.temperature 2567; // 25.67 ℃ report.humidity 4520; // 45.20 % report.timestamp 0x66554433; // 测试用实际获取时间戳 report.adc_samples_count 4; report.adc_samples[0] 1024; report.adc_samples[1] 255; report.adc_samples[2] -128; report.adc_samples[3] 4095; if (!send_env_report(report)) { // 失败处理点位错误计数或打印日志 } }这里有个一句话提醒给repeated字段赋值后必须同步更新结构体里的adc_samples_count字段否则生成代码不知道数组里有多少个有效元素编码时会直接跳过。我见过不止一次有人填了数组忘记填 count抓包看 payload 长度明显不对排查半天才发现是这里漏了。3.3 解码端完整代码与状态机接收端的工作分成两层第一层是从字节流里切出完整的一帧第二层才是用 nanopb 把 payload 解码成结构体。切帧这件事实质上是个简单的状态机。这里给一个极简的按字节处理版本#include pb_decode.h typedef enum { RX_WAIT_HEAD1 0, RX_WAIT_HEAD2, RX_WAIT_LEN, RX_WAIT_PAYLOAD, RX_CRC_CHECK } RxState; static RxState s_rx_state RX_WAIT_HEAD1; static uint8_t s_rx_len 0; static uint8_t s_rx_cnt 0; static uint8_t s_rx_payload[FRAME_MAX_PAYLOAD]; static EnvReport s_rx_report; void uart_rx_byte_handler(uint8_t byte) { switch (s_rx_state) { case RX_WAIT_HEAD1: if (byte FRAME_HEAD1) { s_rx_state RX_WAIT_HEAD2; } break; case RX_WAIT_HEAD2: if (byte FRAME_HEAD2) { s_rx_state RX_WAIT_LEN; } else if (byte ! FRAME_HEAD1) { s_rx_state RX_WAIT_HEAD1; } break; case RX_WAIT_LEN: if (byte 0 byte FRAME_MAX_PAYLOAD) { s_rx_len byte; s_rx_cnt 0; s_rx_state RX_WAIT_PAYLOAD; } else { s_rx_state RX_WAIT_HEAD1; } break; case RX_WAIT_PAYLOAD: s_rx_payload[s_rx_cnt] byte; if (s_rx_cnt s_rx_len) { s_rx_state RX_CRC_CHECK; } break; case RX_CRC_CHECK: { uint16_t crc_recv ((uint16_t)s_rx_payload[s_rx_len - 1] 8) | ((uint16_t)byte); uint16_t crc_calc crc16_ccitt(0xFFFF, s_rx_payload, s_rx_len - 2); if (crc_recv crc_calc) { if (decode_env_payload(s_rx_payload, s_rx_len - 2)) { // 解析成功上报到应用层 } } s_rx_state RX_WAIT_HEAD1; break; } default: s_rx_state RX_WAIT_HEAD1; break; } }解码部分bool decode_env_payload(uint8_t *payload, uint16_t len) { pb_istream_t stream pb_istream_from_buffer(payload, len); EnvReport report EnvReport_init_zero; if (!pb_decode(stream, EnvReport_fields, report)) { return false; } printf(dev%lu temp%ld humi%ld cnt%u\n, (unsigned long)report.device_id, (long)report.temperature, (long)report.humidity, (unsigned int)report.adc_samples_count); return true; }上面这个状态机没有缓冲整帧原始数据CRC 校验时假设 CRC 的两个字节已经包含在 payload 里。实际应用时你可以按这个思路调整把“接收原始帧”和“解析消息体”两个步骤解耦工程可维护性会好很多。3.4 大小端、栈开销与 buffer 选型记住一个结论你用 nanopb 编出来的字节流天然跟大小端无关。STM32 在小端模式下运行PC 端大端或者小端都能正确解析因为 protobuf 在二进制协议层自己定义了一套字节序规则编解码库内部会处理好。这一点可以说是从根上消灭了裸结构体通信最大的坑。栈开销方面EnvReport结构体里有一个长度 16 的 int32 数组整个结构体大约 16 * 4 若干标量字段 ≈ 80 字节左右。如果再算上pb_istream_t或pb_ostream_t这些上下文对象整体栈占用不到 200 字节。如果消息再复杂一些建议把结构体定义成全局变量而不是栈上局部变量虽然不太符合“局部变量更规范”的习惯但嵌入式里 RAM 紧张时这也是常规取舍。编码 buffer 和解码 buffer 如果是全局的注意不要在中断上下文和主循环同时操作同一个 buffer。实际项目里我一般把 uart 接收做成 DMA 环形缓冲主循环里切帧中断里只填字节避免各种野指针问题。4. 传输可靠性与边界处理帧线、校验与粘包4.1 帧边界与“粘包”问题很多刚接触串口通信的人会问明明我发的是0xAA 0x55 0x10 ... ...为什么对端收数据的时候一帧数据被拆成两次回调或者两帧数据粘在一起这个和串口本身的特性有关。串口是字节流上层 OS 的驱动和 DMA 并不会按你发送时的边界去切分数据。所谓“粘包”本质上是接收方没有按协议把字节流切成正确的帧不是串口的问题也不是 nanopb 的问题。解决思路就是上面那个状态机维护一个状态按字节顺序找帧头、长度、载荷、校验。只要状态机逻辑正确无论底层每次进中断是 1 个字节还是 100 个字节最终都能正确切帧。如果发现解出来 CRC 经常不过优先排查是不是状态机里某个异常分支没有正确复位而不是怀疑 CRC 算法写错了。4.2 CRC 与异常帧处理帧校验用 CRC16 而不是累加和原因很简单串口干扰下累加和的碰撞概率太高了。常见的选择有 CRC16-CCITT多项式0x1021初始值0xFFFF。上面代码里用的就是这个。我在实际项目里吃过一个亏CRC 计算范围没和接收方对齐。发送方只算 payload接收方把帧头也算进去了两边各自都认为“校验范围是一致的”结果抓包推了好几小时才发现。解决办法很简单在文档里明确写清楚“CRC 覆盖 payload 全部字节不含帧头帧尾和长度字段”同时协议头注释里也做同样标注避免后面接手的同事踩坑。4.3 如果消息体很大别硬用大数组如果哪一天你的消息复杂度上去了比如上报日志文件块、OTA 固件分包这时候不适合一次性申请几千字节的 buffer 塞到栈上正确的做法是用 nanopb 支持的 stream callback 模式。编码时数据流直接通过回调函数往串口发解码时从串口接收回调按需灌入数据这样 RAM 峰值大幅下降。编码侧 callback 的例子bool uart_write_cb(pb_ostream_t *stream, const uint8_t *buf, size_t count) { (void)stream; return HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 100) HAL_OK; } // 用这个 stream 代替 pb_ostream_from_buffer pb_ostream_t stream { uart_write_cb, NULL, SIZE_MAX, 0 };这种流式写法的好处是充分发挥 serdes 能力把“一次性大块内存”拆成“逐段发送”但代价是接收端必须有能力逐段消化数据否则一边发一边丢反而更糟。所以这个优化要慎重不是所有场景都适合。4.4 RTOS 下和中断里的注意事项在 FreeRTOS 这类系统里如果串口接收中断直接调用了上文那个切帧函数要特别注意临界区保护。s_rx_state、s_rx_cnt这类全局变量会被中断写入、被任务读取如果刚好读一半被任务打断状态就乱了。实际项目里更稳妥的做法是中断里只把字节压入环形缓冲状态机统一放到一个优先级最高的任务里处理这样既不会丢数据也不会出现共享状态竞争的问题。如果坚持要在中断里直接解析一定要用关中断或者临界区 API 包住状态机的执行区域同时测试时加上串口助手定时连续高负载发送的压测观察是否出现偶发丢帧。5. 常见问题与调试速查表5.1 编译不过找不到 pb.h / 版本不匹配编译报错主要集中在两个地方找不到头文件或者函数签名对不上。找不到头文件99% 是 Keil 或 CubeIDE 的 include path 没有加全。把 nanopb 的根目录和env.pb.h所在目录都加进去如果还报错确认不是你手滑把路径写反了。函数签名对不上最常见的是用了 0.3.x 和 0.4.x 两个版本混编。0.4 之后核心 API 从pb_field_t改成了pb_msgdesc_tpb_encode的参数类型也变过。检查你下载的 nanopb 版本和生成工具版本是否一致最稳妥的办法是全部从同一个 release 包里拿不混搭。5.2 编码成功但对方解不出来如果帧头、长度、CRC 都正常解码还是失败优先按这个顺序排查repeated字段有没有写max_count没有的话数组内容会被丢弃字段编号是否一致发送方和接收方 .proto 文件的字段编号必须一一对应名字可以不同编号错了就是灾难proto2 还是 proto3字段 optional 还是必填proto3 里所有字段默认都是 optional 且不产生has_xxx标志位如果对端生成代码用的是 proto2解析时可能出现 has 标志不一致编码 buffer 是否溢出pb_encode返回 false 时检查stream.bytes_written和stream.bytes_left大概率是 buffer 给小了解码失败但报文长度是正确的也可以在pb_decode返回 false 后用stream.bytes_left观察还剩多少字节辅助定位是消息体截断还是字段定义不匹配。5.3 实测性能与体积数据我实测过一次典型的 EnvReport 消息源数据大约 10 个字段、包含 4 个数组元素在 STM32F103 72MHz 下编码耗时不到 50 微秒解码耗时和编码接近。这个量级的耗时在大多数非实时链路里都可以忽略真正的瓶颈都是串口波特率或者网络吞吐。Flash 占用方面nanopb 全量编译进工程大约 6-10KB 左右看具体使能了哪些功能。单个消息生成的env.pb.o通常在几百字节到 1KB 之间。对比完整 protobuf C 实现动不动几十 KB 的开销nanopb 在 MCU 上简直是轻量到感人。如果有性能敏感场景还可以通过配置PB_FIELD_32BIT、PB_VALIDATE_UTF8这类宏裁剪功能不过一般不建议在一开始就做这么极致的优化先跑通功能再回来裁剪更高效。5.4 nanopb 的 5 分钟之外最后再说一个绕不开的问题.proto文件本身是一种工程资产。它不光是给 nanopb 生成器吃的还可以在同一份文件基础上同时生成 Python、C、Java 客户端协议代码。这意味着 PC 端调试工具、手机 App、云服务端可以复用完全一致的消息定义不再需要“手写一份 JSON 文档 手写一份解析代码”这种双份维护。我后来做网关类项目就是一份.proto文件吃遍所有端这大概是 nanopb 最被低估的价值。调试方面再说一个小技巧串口抓包看到 payload 时先用 nanopb 自带的pb_decode确认能不能正常解析如果手里一时没有对端程序可以用 Python 装一个grpcio-tools里自带的 protobuf 解析能力直接用一个几行的脚本逐个字段打出来比盯着十六进制数组猜快得多。遇到 CRC 对不上的时候优先怀疑校验范围而不是 CRC 多项式写错——多项式写错通常是所有帧全部失败范围不对则可能是偶发帧失败这个特征能帮你缩小排查范围。项目做到中期协议字段只会越来越多消息嵌套、枚举、oneof 这些特性 nanopb 也都支持。只要在一开始把 .proto 文件的字段编号规划得宽松一些比如 10、20、30 这样预留间隔后续扩展的风险会小很多。我的建议是协议设计阶段多花十分钟思考字段编号的长期演进好过上线之后为了兼容性被迫推倒重来。
返回列表