☰
STM32串口通信新方案:nanopb协议传输从入门到实战全解析
2026/10/3 14:12:29 网站建设 项目流程

做 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.c
  • pb_common.c
  • pb_decode.c
  • pb_encode.c
  • env.pb.c

头文件路径需要加入两个目录:一个指向 nanopb 源码根目录(里面有pb.h),另一个指向env.pb.h所在的目录。最容易犯的错就是只加了其中一个,结果编译报错找不到pb.h或者找不到env.pb.h。

调试 gd32 / stm32 系列时,如果用的是 AC5 编译器,建议把 C 标准设置成C99,AC6 默认就是 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); // head1+head2+len+crc(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_t,pb_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 这样预留间隔,后续扩展的风险会小很多。我的建议是:协议设计阶段多花十分钟思考字段编号的长期演进,好过上线之后为了兼容性被迫推倒重来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询