☰
STM32自定义串口协议实战:状态机解析与十六进制转十进制
2026/9/25 6:35:04 网站建设 项目流程

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个字符时间的静默间隔来分帧。这种方式对定时器精度要求高,实现起来比本文的显式帧头方案复杂。而像NMEA(GPS常用)用$开头、换行结尾,靠文本分隔符分帧,适合人读不适合机读效率。

本文这套"帧头+长度+校验+帧尾"的方案,是嵌入式里最通用的自定义协议骨架。它的好处是:不依赖定时器、不依赖特殊字符转义、接收逻辑清晰。你以后接任何自定义设备,先看它帧格式,基本都能套进这个模型。

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:等待帧头第一个字节0xAA
  • STATE_HEAD2:等待帧头第二个字节0x55
  • STATE_LEN:接收长度字段
  • STATE_DATA:接收数据区
  • STATE_CHECK:接收校验字节
  • STATE_TAIL:接收帧尾0x0D

4.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就是十进制的26,0x2B是43,0x3C是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默认输出到调试器,要让它走串口,得重定向fputc:

int 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发送,别在主循环里死等。实际项目里,如果数据吞吐大,接收和发送都该上DMA,CPU几乎不参与,这是进阶方向。

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只负责底层收发。分层清晰,调试时也容易定位问题出在哪一层。

这套东西我从智能台灯项目一直用到工业采集板,帧格式改过好几版,但状态机加环形缓冲的骨架从来没变过。你把这一套吃透,以后遇到任何自定义串口协议,都是换个帧格式定义的事。

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

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

立即咨询