简介:STM32F103C8T6多字节收发程序是一份面向STM32初学者的串口通信示例工程,重点演示USART1的中断接收与printf重定向发送。程序将接收到的数据通过重定义的printf回传至电脑,并预留用户处理入口,便于在此基础上扩展数据解析、协议应答或传感器数据转发等功能。压缩包共196个文件,以C源文件、头文件、汇编启动文件为主,同时包含Keil工程配置文件、编译生成的hex/axf可执行文件及map映射文件,整体约7.1MB,文件组织便于直接对照学习。目前已有2700余人学习这一资源,代码基于标准外设库编写,串口配置、中断服务函数和printf重定向三者间的调用关系清晰,可帮助读者快速掌握STM32串口多字节收发的常规流程与调试方法。
1. 先别急着写代码:单字节收发到多字节收发到底差在哪
做STM32F103C8T6开发的人,很多都是从点灯开始,然后第二步就是碰串口。我第一次写串口程序是在一个温湿度采集项目里,需要在OLED上实时显示传感器数据,同时把数据通过USART1发到上位机。刚开始的想法特别简单:printf重定向到串口不就行了吗?结果真把电路板接上去,才发现问题的关键不在发送,而在接收。
单字节收发很好理解:一个字节一个字节地进中断,来了就存、就处理。但真正到了多字节收发,比如你要接收一条完整的GPS语句($GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.03,61.7,M,55.2,M,,*76),或者要接收ESP01S模块返回的一串AT指令应答,你就必须解决三个核心问题:
- 数据边界:串口是一个字节一个字节来的,你怎么知道这一串数据什么时候算"一帧"?
- 数据完整性:多字节数据中间被打断、被别的任务抢占,你怎么保证拼出来的是完整的一帧?
- 数据处理方式:是每个字节都中断一次,还是一批数据到达后再统一处理?
这三个问题,才是多字节收发的本质。如果你只是把单个字节的收发代码重复调用,一定会踩到"接收一两次之后就卡死"、"收到一堆乱码"、"数据粘连分不清哪帧是哪帧"这些坑。所以真正动手之前,得先把方案想清楚。
2. 三套主流接收方案,按项目场景选型
多字节接收的常见思路,我总结下来有三套:单字节中断+状态机、空闲中断+DMA批量接收、环形缓冲区+定时器超时判断。它们各有适用场景,没有绝对的谁比谁强,关键看你手里的是什么类型的数据。
| 方案 | 适用场景 | 占用资源 | 优点 | 缺点 |
|---|---|---|---|---|
| 单字节中断+状态机 | 自定义协议帧、指令交互频繁 | RAM极小,无额外外设占用 | 实时性高,逻辑可控 | 每字节进一次中断,CPU占用高 |
| 空闲中断+DMA | 不定长数据(GPS、4G模块、蓝牙模块) | RAM较大,需DMA通道 | CPU几乎零负担,接收效率极高 | 依赖USART的IDLE事件,F103上需要正确配置 |
| 环形缓冲区+定时器超时 | 无空闲中断的场合、多串口复用 | 需要1个定时器 | 通用性强,代码可移植 | 超时时间要反复调,误判率高 |
用生活场景来类比一下:单字节中断就像餐厅门口一个一个地接待客人,来一个招呼一个;空闲中断+DMA就像包间直接放了个大圆桌,客人陆续入座,等服务生看一眼"没人再来了"就统一上菜;环形缓冲区+定时器超时则像是在入口放了个感应门,最后一个人进门后等5秒没人才关门,但到底等几秒,得看客人是不是还在楼道里磨蹭。
如果你只是做一块小板子,接收的数据长度基本固定(比如上位机固定发来8字节的 PID 参数),那状态机方案最舒坦。但如果你的板子要接ESP01S、GPS、AT指令模块这种"废话很多"的设备,我强烈建议上空闲中断+DMA,这也是我最常用的方案。下面两章我分别把这两套方案的完整实现写出来,标准外设库和HAL库我都覆盖到。
3. 实战一:空闲中断+DMA实现不定长接收
这套方案的原理一句话讲清楚:DMA自动把串口收到的每个字节搬运到内存缓冲区,当串口线上出现空闲(也就是一串数据发完了),IDLE中断触发,这时去读DMA的剩余计数寄存器,算出这次到底收到了多少个字节。
先看标准外设库版本的实现。前提是用CubeMX或寄存器初始化了USART1和DMA1的Channel5(对应USART1_RX),波特率、IO复用这种基础设施我就不赘述了,直接上核心代码。
/* 缓冲区与标志位 */ #define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len = 0; // 本次接收到的有效字节数 volatile uint8_t rx_complete_flag = 0; // 一帧接收完成标志 /* 启动一次DMA接收 */ void uart1_dma_rx_start(void) { DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); } /* 中断处理:USART1_IRQHandler */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 这一步必须做:读DR清除IDLE标志 DMA_Cmd(DMA1_Channel5, DISABLE); rx_len = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); rx_complete_flag = 1; DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }我在第一次写这套代码时,就漏了USART_ReceiveData(USART1)这一行。结果现象特别诡异:程序一启动,就会反复触发IDLE中断,DMA根本没办法正常工作。原因是IDLE标志位的清除条件是"先读SR寄存器,再读DR寄存器",不读DR,标志永远挂在那里。这个细节在参考手册的勘误说明里专门提过,但新人很难注意到。
如果你用的是HAL库,逻辑是一样的,只是回调函数的位置不同:
/* CubeMX配置时使能USART1全局中断,DMA设置成Normal模式 */ /* DMA接收一半/完成回调:这里处理一帧接收完成 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { rx_len = Size; rx_complete_flag = 1; /* 重新启动下一次接收,HAL库必须显式再调一次 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE); } }用HAL库时有个几乎人人都踩的坑:HAL_UARTEx_ReceiveToIdle_DMA只能触发一次,第二次调用必须在回调里手动完成。很多人以为DMA是循环模式就能一直收,但HAL库的这套接口默认不是这样设计的。所以回调尾部那行重启代码,绝对不能省。
这套方案最关键的优势在于,你完全不需要知道数据帧的长度就能完整接收。缓冲区最多能缓存256字节,超过的话DMA会停在缓冲区末尾,数据从尾部开始继续覆盖,所以如果你的数据帧可能超过256字节,记得把RX_BUF_SIZE调大。
4. 实战二:帧头帧尾+状态机解析的稳定协议栈
空闲中断+DMA在做"不定长接收"时很厉害,但有一个软肋:它只能告诉你"来了一堆数据",不能告诉你"这堆数据是不是完整的一帧"。如果发送端的数据里恰好有两个包粘连在一起,或者中间插入了一个干扰字节,DMA方案是识别不出来的。这种情况下,要靠状态机给数据帧画边界。
我比较推荐的自定义帧结构是这样:
帧头(2字节) 长度(1字节) 数据(N字节) 校验(1字节) 0xAA 0x55 LEN DATA SUM帧头用来同步,LEN告知后面跟了多少数据的长度,SUM是前面所有字节的累加和(只保留低8位)。配上状态机,无论数据流多乱,都能快速恢复同步。
typedef enum { FRAME_STATE_WAIT_HEAD1, FRAME_STATE_WAIT_HEAD2, FRAME_STATE_WAIT_LEN, FRAME_STATE_WAIT_DATA, FRAME_STATE_WAIT_CHECK, } frame_state_t; frame_state_t frame_state = FRAME_STATE_WAIT_HEAD1; uint8_t frame_buf[128]; uint16_t frame_index = 0; uint8_t frame_len = 0; uint8_t frame_sum = 0; void uart_byte_parser(uint8_t byte) { switch (frame_state) { case FRAME_STATE_WAIT_HEAD1: if (byte == 0xAA) frame_state = FRAME_STATE_WAIT_HEAD2; else frame_state = FRAME_STATE_WAIT_HEAD1; // 重新等待 break; case FRAME_STATE_WAIT_HEAD2: if (byte == 0x55) frame_state = FRAME_STATE_WAIT_LEN; else frame_state = FRAME_STATE_WAIT_HEAD1; // 没匹配上,回到起点 break; case FRAME_STATE_WAIT_LEN: frame_len = byte; frame_index = 0; frame_sum = 0; frame_state = (frame_len > 0) ? FRAME_STATE_WAIT_DATA : FRAME_STATE_WAIT_CHECK; break; case FRAME_STATE_WAIT_DATA: frame_buf[frame_index++] = byte; frame_sum += byte; if (frame_index >= frame_len) frame_state = FRAME_STATE_WAIT_CHECK; break; case FRAME_STATE_WAIT_CHECK: if (frame_sum == byte) { /* 校验通过,一帧完整收到,此时可处理frame_buf */ handle_valid_frame(frame_buf, frame_len); } frame_state = FRAME_STATE_WAIT_HEAD1; break; default: frame_state = FRAME_STATE_WAIT_HEAD1; break; } }这段代码的思路,说穿了就是"宁可错过,不可错收"。一旦帧头对不上,就立刻回到WAIT_HEAD1重新开始同步。无论串口线路上来了多少个字节,状态机都能在下一个0xAA 0x55出现时找到正确的边界。
实际使用时,我把这个解析函数放在串口接收中断里直接调用,每收到一个字节就跑一次。即使没有DMA、没有空闲中断,只用一根USART中断线,也能稳定处理多字节协议。这套代码我在好几个项目里复用,包括一个通过ESP-01S做远程继电器控制的小东西,实测跑了大半年没有出现过一帧解析错乱。
要注意的是,frame_buf最大支持128字节的数据段。如果你的协议数据可能更长,需要同步调大这个数组,并且在WAIT_DATA状态里加上溢出保护,防止frame_index越界写入导致硬件错误(HardFault)。溢出保护很简单,在写入前判断一下:
case FRAME_STATE_WAIT_DATA: if (frame_index < sizeof(frame_buf)) { frame_buf[frame_index++] = byte; frame_sum += byte; } else { frame_state = FRAME_STATE_WAIT_HEAD1; // 超长,直接丢弃 } if (frame_index >= frame_len) frame_state = FRAME_STATE_WAIT_CHECK; break;5. 中断里"只收一次"的根因排查链路
"串口中断接收只收一次"这个问题,在STM32F103C8T6相关的检索词里出现频率高得离谱。我自己也中过招,而且那次排查花了我一整个晚上。把排查链路完整写出来,你下次遇到就能直接对照。
现象描述:板子第一次给串口发数据,中断能进,数据能收到;但发第二次、第三次,就再也没反应了。重启后又恢复正常,然后又是只收一次。
第一步:检查中断中是否重新启动了下一次接收
对于HAL库,这个概率最高。HAL_UART_Receive_IT或HAL_UARTEx_ReceiveToIdle_DMA是一次性的,进入回调后如果不重新调用,接收链就断了。我看到太多人写的代码是这样:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 在这处理数据 // 忘了重新启动接收! }结果就是串口像是"死"了一样。解决方案是在回调末尾重新调用一次接收函数。
第二步:检查中断标志位是否清除
标准外设库的老写法里,很多人会手动调USART_ClearITPendingBit(USART1, USART_IT_RXNE)来清标志。但RXNE标志在读取DR寄存器后会自动清零,手动清反而可能把新来的数据给弄丢。还有更隐蔽的:IDLE中断标志必须通过"读SR再读DR"来清除,只调用USART_ClearITPendingBit根本不管用。如果中断服务函数里没清干净,就会不断重进中断,卡死整个程序。
第三步:检查ORE溢出标志
这个坑非常经典。当串口一帧数据很长,或者主循环被某个耗时操作(比如printf重定向到串口、软件延时)阻塞太久,接收寄存器里的数还没被取走,新的数据又到了,就会触发Overrun Error(ORE)。标准外设库默认ORE置位后,后续的接收就会停止。很多人看到的"只收一次",其实是第一次收了一大包数据后触发了ORE,接收器悄悄罢工了。
解决方案是在中断里主动检测ORE:
void USART1_IRQHandler(void) { if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) { USART_ReceiveData(USART1); // 读一次,清掉ORE } // 正常RXNE/IDLE处理... }第四步:检查全局变量有没有加volatile
中断里修改的rx_len、rx_complete_flag,主循环里读取时如果没有加volatile修饰,编译器可能会优化掉对变量的重复读取,导致主循环一直读到旧值,看起来像是"中断没再进来"。这个问题最坑,因为万用表量波形都正常,但逻辑就是不对。正确的写法就是我在代码里写的:
volatile uint8_t rx_complete_flag;第五步:检查中断优先级配置
如果USART中断优先级低于某个长时间运行的中断(比如定时器中断里处理大量数据),串口数据就会被延迟响应,造成ORE。这是系统性设计问题,不是在中断服务函数里能补回来的。把USART中断优先级提到足够高,同时把耗时操作从高优先级中断里挪到主循环,才是正解。
这套五步排查法,我后来几乎是照着给人答疑用。十个"只收一次"的案例里,至少七个是第一步,两个是第三步,剩下一个分散在其他因素。
6. 数据验证与工程化补全:别让程序死在实验室里
写完接收代码,不代表你的多字节收发程序就靠谱了。我见过太多在串口助手里手动敲数据一切正常,一接真实模块就乱七八糟的项目。问题出在验证方法太随意。
建议一:用循环压力测试代替手动发送
不要只在串口助手里点一下"发送"。把这个操作写成一个脚本(或者用串口助手的定时发送功能),每50ms发一包随机长度的数据,连续发几百次。观察有没有丢包、粘包、错误帧。这个压力测试大概花10分钟,能发现的问题比跑一天现场还多。
建议二:一定在接收端做回环验证
如果你在写协议,最好加一条回环测试的动作:上位机发一帧,板子解析成功后在串口回发一个ACK帧,上位机统计ACK是否正确、数量是否对得上。靠人来盯屏幕,盯不了几分钟就会疲劳,统计才是可靠的。
建议三:缓冲区只存放数据,不处理数据
无论是DMA缓冲区还是环形缓冲区,我强烈建议中断里只做"搬运"和"标志位置位",真正的解析、校验、业务处理放到主循环里。中断里做太多事,一方面会拉低实时性,另一方面调试起来会非常痛苦,因为你不知道当前中断和主循环到底是哪个在抢资源。
建议四:预留调试串口
F103C8T6有3个USART,如果主串口被业务占满,完全可以用第二个串口作为调试口,把它接一个USB转TTL,随时打内部变量。调试信息用printf走VCP,业务数据走USART1,两者互不干扰。这样排查问题的时候,不用反复猜测缓冲区里存的到底是什么。
我自己的习惯是:帧解析必须独立成一个模块,不绑定具体的外设。uart_byte_parser(uint8_t byte)这个函数,你可以从USART中断里喂给它字节,也可以从DMA缓冲区里喂给它字节,甚至可以从调试串口或者无线模块喂给它字节。模块化做好之后,换一个通信链路,接收解析代码一行都不用改。
多字节收发看起来是嵌入式的入门话题,但真把它写稳定,里面装的都是项目中积累下来的细节。DMA接收、状态机解析、排查中断问题的方法,这三样东西学会了,再往后的Modbus协议、AT指令解析、甚至CAN通信,思路都是相通的。
本文还有配套的精品资源,点击获取