
1. 为什么要在STM32上自定义串口协议很多人第一次接触STM32串口都是拿它当调试口用printf打印日志、接收几个字符控制LED。但真正到了项目里串口往往要承担更重的任务——和上位机通信、和另一个单片机交换数据、接工业传感器、驱动带协议的外设。这时候你会发现直接收发裸字节根本不够用因为数据会粘包、会丢帧、会被噪声干扰接收端根本分不清哪几个字节是一条完整消息。自定义串口协议就是来解决这个问题的。它的本质很简单在原始字节流之上约定一套双方都认得的格式让接收方知道一条消息从哪里开始、到哪里结束、内容是什么、有没有出错。你可以把它理解成快递包裹——裸字节是一堆散落的物品协议就是那个纸箱加面单面单上写着收件人、物品清单和校验码。我这次要做的例子是十六进制转十进制。上位机通过串口发来一串十六进制数据STM32接收后解析成十进制数值再回传结果。这个场景看着简单但它把串口协议里最核心的几个问题全占了帧头帧尾怎么定、数据长度怎么表示、校验怎么做、接收怎么做到不丢包。把这套跑通你换成温湿度数据、电机指令、传感器读数套路完全一样。这篇文章适合谁如果你已经能让STM32串口收发单个字节但一到接收一整条命令就抓瞎那这篇就是写给你的。我会从协议设计讲起到状态机接收、十六进制解析、十进制回传每一步都给出可复现的代码和背后的取舍逻辑。全程基于STM32标准库和Keil5环境用最常见的USART1你手上有最小系统板就能跟着做。提示本文代码基于STM32F103系列和标准外设库如果你用的是HAL库或别的型号逻辑完全一致只是API名字不同我会在关键处点明对应关系。2. 协议帧格式的设计取舍2.1 一条消息该长什么样设计协议第一步是定帧格式。所谓帧就是一条完整消息的字节序列。最朴素的方案是定长帧——每条消息固定N个字节接收方数够N个就处理。但定长帧太死板十六进制数据长度不固定你没法预知上位机发几个字节。所以更通用的是变长帧用帧头、长度字段、数据区、校验、帧尾拼起来。我采用的帧格式如下字段字节数说明帧头2固定 0xAA 0x55用于定位帧起点长度1数据区字节数不含帧头帧尾校验数据区N实际的十六进制数据校验1从长度到数据区的累加和低8位帧尾1固定 0x0D作为结束标志举个例子上位机要发送十六进制数0x1A 0x2B 0x3C整帧就是AA 55 03 1A 2B 3C [校验] 0D校验 (0x03 0x1A 0x2B 0x3C) 0xFF 0x84。所以完整帧是AA 55 03 1A 2B 3C 84 0D。2.2 每个字段为什么这么定帧头用两个字节而不是一个是因为单字节帧头太容易和真实数据撞车。假设你只用0xAA做帧头而数据区里恰好出现0xAA接收方就会误判成新帧开始。用0xAA 0x55两个字节连续出现才算帧头误判概率大幅下降。这是工程上非常常见的做法成本只多一个字节。长度字段放在帧头之后是为了让接收方能提前知道还要收多少字节。有了长度接收状态机就能精确控制再收N个字节就进入校验阶段不用靠猜。注意长度只统计数据区不含帧头、校验、帧尾这样计算最直观。校验用累加和而不是CRC是权衡的结果。CRC32查错能力更强但计算量大、代码多对于串口这种短帧、低速率、干扰有限的场景累加和足够用而且一行代码就能算完。如果你的项目跑在电机、变频器旁边电磁干扰强那就该上CRC16。选哪种取决于你的实际环境不是越复杂越好。帧尾用0x0D是给接收方一个兜底的结束标志。正常情况下靠长度字段就能判断帧结束但万一长度字段被干扰改错了帧尾能帮你发现异常。这属于冗余设计多一个字节换一份安心。2.3 和常见协议的对比你可能听过Modbus RTU它的帧格式是地址功能码数据CRC靠3.5个字符时间的静默间隔来分帧。这种方式对定时器精度要求高实现起来比本文的显式帧头方案复杂。而像NMEAGPS常用用$开头、换行结尾靠文本分隔符分帧适合人读不适合机读效率。本文这套帧头长度校验帧尾的方案是嵌入式里最通用的自定义协议骨架。它的好处是不依赖定时器、不依赖特殊字符转义、接收逻辑清晰。你以后接任何自定义设备先看它帧格式基本都能套进这个模型。3. 串口底层配置与中断接收3.1 串口初始化的关键参数先把底层跑通。USART1挂在APB2总线上PA9是TX、PA10是RX。初始化代码如下void USART1_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // TX 复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // RX 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate bound; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 开接收中断 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 3; NVIC_InitStructure.NVIC_IRQChannelSubPriority 3; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }这里有几个容易踩的点。TX必须配成复用推挽配成普通推挽发不出数据RX配浮空输入如果配成上拉遇到对方是开漏输出可能电平拉不低。波特率一般用115200和上位机对齐两边不一致就是满屏乱码。3.2 为什么用中断而不是轮询轮询接收的写法是while(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) RESET);死等一个字节。这在简单demo里能用但实际项目里CPU不能一直卡在这儿它还要干别的活。中断接收的好处是字节来了自动进中断CPU平时该干嘛干嘛。但中断接收有个经典陷阱中断里不能做耗时操作。如果你在USART1_IRQHandler里直接解析协议、算校验、转十进制一旦数据量大中断执行时间过长下一个字节来了可能来不及处理导致溢出丢包。正确做法是中断里只做一件事——把字节塞进缓冲区解析放到主循环里做。这就是生产者-消费者模型。3.3 环形缓冲区不丢包的关键缓冲区我用环形队列实现。它有两个指针写指针中断里移动和读指针主循环里移动。中断每收到一个字节就写进去主循环从里面取出来解析。环形的好处是空间复用不用每次清空数组。#define RX_BUF_SIZE 256 typedef struct { u8 buffer[RX_BUF_SIZE]; volatile u16 head; // 写指针中断修改 volatile u16 tail; // 读指针主循环修改 } RingBuffer; RingBuffer rxBuf {0}; // 中断里调用写入一个字节 void RingBuffer_Write(RingBuffer *rb, u8 data) { u16 next (rb-head 1) % RX_BUF_SIZE; if (next ! rb-tail) { // 队列未满 rb-buffer[rb-head] data; rb-head next; } // 满了就丢弃实际项目可加溢出计数 } // 主循环里调用读出一个字节 u8 RingBuffer_Read(RingBuffer *rb, u8 *data) { if (rb-head rb-tail) return 0; // 空 *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RX_BUF_SIZE; return 1; }head和tail必须加volatile因为它们会被中断和主循环同时访问不加编译器可能优化掉导致逻辑错乱。这是很多人调试半天找不到原因的坑。中断服务函数就变得极简void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { u8 ch USART_ReceiveData(USART1); RingBuffer_Write(rxBuf, ch); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }注意USART_ReceiveData读一次就自动清了RXNE标志但显式再清一次更保险不同系列芯片行为略有差异。4. 状态机解析把字节流还原成一条命令4.1 为什么必须用状态机缓冲区里现在是一串连续的字节比如AA 55 03 1A 2B 3C 84 0D AA 55 ...。你要从中切出一条条完整的帧。最笨的办法是找帧头、读长度、取数据但这样写出来的代码一堆嵌套if还容易在异常数据上卡死。状态机是解析不定长协议的标准武器。它把解析过程拆成几个明确的状态每来一个字节就根据当前状态决定下一步去哪。状态之间转移清晰异常时能自动复位不会卡住。我定义这几个状态STATE_HEAD1等待帧头第一个字节0xAASTATE_HEAD2等待帧头第二个字节0x55STATE_LEN接收长度字段STATE_DATA接收数据区STATE_CHECK接收校验字节STATE_TAIL接收帧尾0x0D4.2 状态机的完整实现typedef enum { STATE_HEAD1 0, STATE_HEAD2, STATE_LEN, STATE_DATA, STATE_CHECK, STATE_TAIL } ParseState; #define MAX_DATA_LEN 64 typedef struct { ParseState state; u8 dataLen; u8 dataCnt; u8 data[MAX_DATA_LEN]; u8 checkSum; u8 recvCheck; } FrameParser; FrameParser parser {STATE_HEAD1, 0, 0, {0}, 0, 0}; // 每收到一个字节调用一次返回1表示解析出一条完整帧 u8 Frame_ParseByte(FrameParser *p, u8 byte) { switch (p-state) { case STATE_HEAD1: if (byte 0xAA) p-state STATE_HEAD2; break; case STATE_HEAD2: if (byte 0x55) { p-state STATE_LEN; } else if (byte 0xAA) { // 还是0xAA保持在HEAD2处理AA AA 55的情况 } else { p-state STATE_HEAD1; } break; case STATE_LEN: if (byte 0 || byte MAX_DATA_LEN) { p-state STATE_HEAD1; // 长度非法复位 } else { p-dataLen byte; p-dataCnt 0; p-checkSum byte; // 校验从长度开始累加 p-state STATE_DATA; } break; case STATE_DATA: p-data[p-dataCnt] byte; p-checkSum byte; if (p-dataCnt p-dataLen) { p-state STATE_CHECK; } break; case STATE_CHECK: p-recvCheck byte; if (p-recvCheck (p-checkSum 0xFF)) { p-state STATE_TAIL; } else { p-state STATE_HEAD1; // 校验错丢弃 } break; case STATE_TAIL: p-state STATE_HEAD1; // 无论帧尾对不对都复位 if (byte 0x0D) { return 1; // 一条完整且校验正确的帧 } break; } return 0; }4.3 状态机里几个容易忽略的细节HEAD2状态处理AA AA 55。假设数据流是AA AA 55 ...第一个AA进HEAD2第二个AA来时如果直接复位到HEAD1就会漏掉真正的帧头。正确做法是第二个AA仍然可能是帧头起点所以保持在HEAD2。这个细节不处理遇到连续AA就会丢帧。长度字段的合法性检查。如果长度是0或者超过缓冲区上限直接复位。不检查的话dataCnt可能越界写坏内存这是嵌入式里最危险的bug之一。校验失败后立即复位。校验不过说明这帧数据不可信直接丢弃重新找帧头不要试图修复。宁可丢一帧不能用错数据。帧尾无论对错都复位。到了TAIL状态说明校验已经过了帧尾只是兜底。即使帧尾不是0x0D也复位准备下一帧避免卡死。主循环里的调用逻辑int main(void) { u8 byte; USART1_Init(115200); while (1) { while (RingBuffer_Read(rxBuf, byte)) { if (Frame_ParseByte(parser, byte)) { // 解析出一条完整帧处理它 ProcessFrame(parser.data, parser.dataLen); } } } }这样中断只管收主循环只管解析职责分明互不阻塞。5. 十六进制转十进制的实现与回传5.1 十六进制数据怎么变成十进制数值数据区里存的是一串十六进制字节比如1A 2B 3C。这里要先明确一个概念十六进制和十进制只是同一个数的不同表示法数值本身没变。0x1A就是十进制的260x2B是430x3C是60。所谓转十进制在程序里其实是把字节序列按某种规则组合成一个整数然后用十进制格式打印出来给人看。这里有两种常见理解取决于你的业务第一种把每个字节当成独立的十六进制数。1A 2B 3C就是三个数26、43、60。适合传感器上报多个独立通道的场景。第二种把多个字节拼成一个大整数。1A 2B 3C拼成0x1A2B3C 1715004。适合表示一个多字节的数值比如32位计数器。我两种都实现用参数区分。拼接时要注意字节序——大端是高位在前小端是低位在前。串口通信里大端更常见因为符合人的阅读习惯。// 方式一每个字节独立转十进制 void HexBytesToDec(u8 *data, u8 len) { printf(共%d个字节:\r\n, len); for (u8 i 0; i len; i) { printf( [%d] 0x%02X %d\r\n, i, data[i], data[i]); } } // 方式二拼成一个大整数大端 u32 HexToU32_BigEndian(u8 *data, u8 len) { u32 value 0; for (u8 i 0; i len; i) { value (value 8) | data[i]; } return value; }value (value 8) | data[i]这行是核心每来一个字节先把已有值左移8位腾出低位再把新字节或进去。这样第一个字节自然跑到最高位实现大端拼接。如果要做小端倒着遍历即可。5.2 printf重定向到串口标准库的printf默认输出到调试器要让它走串口得重定向fputcint fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (u8)ch); return ch; }USART_FLAG_TC是发送完成标志等它置位再发下一个字节否则会覆盖还没发出去的数据。另外要在Keil里勾选Use MicroLIB否则重定向不生效——这是新手最常卡住的地方代码没错但串口就是没输出八成是没勾MicroLIB。5.3 回传结果的格式设计回传也不能裸发最好也带个简单格式方便上位机识别。我用文本格式回传人直接能读RECV 3 bytes: 0x1A 0x2B 0x3C DEC: 26 43 60 U32: 1715004文本格式的好处是调试时用串口助手直接看不用解析。如果是对接程序可以再定义一套二进制回传帧格式和接收帧对称即可。处理函数void ProcessFrame(u8 *data, u8 len) { printf(RECV %d bytes: , len); for (u8 i 0; i len; i) { printf(0x%02X , data[i]); } printf(\r\n); printf(DEC: ); for (u8 i 0; i len; i) { printf(%d , data[i]); } printf(\r\n); if (len 4) { u32 val HexToU32_BigEndian(data, len); printf(U32: %lu\r\n, val); } }%lu对应u32别用%d否则大数会打印成负数。%02X里的02表示不足两位补零X表示大写十六进制这样输出对齐好看。6. 实测中踩过的坑和排查思路6.1 收到数据但解析不出帧第一次联调时串口助手明明发了AA 55 03 1A 2B 3C 84 0D但STM32毫无反应。排查步骤是这样的先确认底层收没收到。在中断里翻转一个LED或者把收到的字节原样回发。结果发现字节能收到说明底层没问题问题在解析。再检查状态机。把每个状态切换时打印出来发现卡在HEAD2。原因是串口助手发送时我勾了发送新行它在末尾自动加了0D 0A而我的帧尾是0x0D多出来的0x0A被当成下一帧的HEAD1虽然不影响本帧但让我误以为帧尾判断有问题。关掉发送新行后正常。这个坑的教训是上位机工具的默认行为会悄悄改你的数据。发送前一定确认有没有自动加回车换行、有没有按十六进制发送。6.2 校验总是差一位有次校验死活不过打印出来发现算出来的校验比实际多1。查了半天问题在长度字段。我一开始把校验写成从数据区开始累加但协议定义是从长度字段开始累加。长度字段本身也要参与校验这个很容易漏。改过来就对上了。所以定协议时校验范围一定要写清楚是从帧头算还是从长度算两边必须一致。我建议从长度字段开始因为帧头是固定的参与校验没意义。6.3 大数据量下丢包发单帧没问题连续快速发几十帧就开始丢。原因是主循环里printf太慢115200波特率下一个字符约87微秒打印一整行几十个字符要好几毫秒这期间中断还在收数据环形缓冲区虽然能缓一缓但发太快还是会满。解决办法有两个一是加大环形缓冲区到512或1024二是把回传也改成中断发送或者DMA发送别在主循环里死等。实际项目里如果数据吞吐大接收和发送都该上DMACPU几乎不参与这是进阶方向。6.4 中断优先级引发的怪问题如果你的项目里还有定时器中断、其他串口中断要注意优先级配置。串口接收中断优先级不能太低否则被高优先级中断长时间抢占同样会丢字节。我一般把串口接收中断设成中等优先级既不被无关中断频繁打断又能及时响应。另外中断里绝对不要调用printf。printf内部有缓冲和锁执行时间长且不可重入在中断里调用轻则丢数据重则死锁。要打印调试信息先把数据存到变量回主循环再打。7. 协议扩展与工程化建议7.1 加上地址和功能码现在这套协议是点对点的一条总线只能挂两个设备。如果要做一主多从就得加地址字段。在帧头后面加一个字节表示从机地址从机收到后先判断地址是不是自己不是就丢弃。功能码则用来区分读数据写参数复位等不同命令让一条协议能承载多种业务。扩展后的帧格式AA 55 [地址] [功能码] [长度] [数据] [校验] 0D。校验范围相应扩大到地址和功能码。改动不大但通用性提升一个档次。7.2 超时复位机制状态机有个潜在问题如果收到半截帧就断了比如AA 55 03 1A之后没下文状态机会一直停在DATA状态等后续字节。下次来新帧时新帧头会被当成数据吃掉导致连续错帧。解决办法是加超时。用一个定时器记录最后一次收到字节的时间超过比如50ms没新字节就强制把状态机复位到HEAD1。这个超时时间要大于一帧的正常传输时间又不能太长影响响应。115200下传10个字节约1ms设10到50ms都合理。7.3 用DMA彻底解放CPU前面说过高吞吐场景该上DMA。STM32的USART支持DMA接收配置成空闲中断DMA模式DMA默默把字节搬到缓冲区一帧数据收完总线空闲触发空闲中断在中断里一次性处理整块数据。这种方式CPU占用极低适合高速率、大数据量的场合。代价是配置复杂一些且要处理好DMA缓冲区的边界。等你把本文的中断方案跑熟再上DMA会顺理成章。7.4 代码分层方便复用最后给个工程化建议把串口驱动、环形缓冲区、协议解析分成独立的.c/.h文件互相之间只通过接口调用。这样你换个项目直接把这三个模块拷过去改改帧格式定义就能用。我自己的习惯是建一个protocol.c专门放状态机一个ringbuf.c放缓冲区usart.c只负责底层收发。分层清晰调试时也容易定位问题出在哪一层。这套东西我从智能台灯项目一直用到工业采集板帧格式改过好几版但状态机加环形缓冲的骨架从来没变过。你把这一套吃透以后遇到任何自定义串口协议都是换个帧格式定义的事。