ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32串口多字节接收实战:空闲中断+DMA与状态机解析

STM32串口多字节接收实战:空闲中断+DMA与状态机解析 简介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 ErrorORE。标准外设库默认ORE置位后后续的接收就会停止。很多人看到的只收一次其实是第一次收了一大包数据后触发了ORE接收器悄悄罢工了。解决方案是在中断里主动检测OREvoid 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通信思路都是相通的。本文还有配套的精品资源点击获取
返回列表