资讯动态

STM32F103串口DMA+空闲中断实现不定长数据高效接收

发布时间:2026/10/30 1:13:34 来源:尧图企业网站定制
1. 为什么串口接收总是不完整从轮询到DMA的必然选择做过STM32F103项目的人几乎都经历过这样一个阶段串口接收数据用轮询主循环里死等RXNE标志位收到一个字节处理一个字节。数据量小的时候没问题一旦对上位机连续发送几十上百字节或者波特率拉到115200以上丢包、错位、数据粘包就全来了。更麻烦的是主循环被串口接收彻底绑架别的任务根本没机会执行。这个问题的根源在于串口接收本质上是异步事件。发送方什么时候发、发多长接收方事先并不知道。轮询方式要求CPU时刻盯着中断方式虽然解放了主循环但每来一个字节就进一次中断115200波特率下每秒最多11520次中断CPU光在进出中断上就要消耗大量时钟周期。如果同时还有ADC采集、定时器任务、SPI通信系统响应会明显变慢。DMA加空闲中断的组合正是为了解决这个矛盾而生的。DMA负责把串口数据寄存器里的字节自动搬运到内存缓冲区全程不需要CPU干预空闲中断负责在总线安静下来的时候告诉CPU“这一帧数据收完了”。两者配合CPU只需要在整帧数据到达后处理一次中间过程完全托管给硬件。STM32F103的USART外设支持DMA请求这是硬件层面的能力。具体来说USART的RX通道可以产生DMA请求每收到一个字节DMA控制器就自动执行一次从USART_DR到内存的传输。而空闲中断IDLE的触发条件是在收到最后一个字节后总线保持空闲状态超过一个字节的传输时间。这个特性天然适合处理不定长数据帧。我实测过一组对比数据在115200波特率下连续接收200字节的数据帧轮询方式CPU占用率接近100%中断方式大约15%而DMA加空闲中断方式不到2%。这个差距在低功耗场景或者多任务系统里是决定性的。注意STM32F103的DMA通道和USART有固定的映射关系USART1_RX对应DMA1_Channel5USART1_TX对应DMA1_Channel4选错了通道DMA根本不会触发。2. 硬件链路与时钟配置那些手册上不会强调的细节2.1 最小系统板的串口引脚与电平匹配STM32F103C8T6最小系统板上USART1的TX是PA9RX是PA10。这两个引脚是3.3V电平如果直接和电脑的RS232串口连接电平不匹配会烧芯片。常见的做法是用CH340或者CP2102这类USB转TTL模块模块输出的是3.3V或5V可选电平记得把跳线帽插到3.3V档位。有些板子上标着5V tolerant的引脚PA9和PA10确实属于5V容忍引脚但这不意味着可以长期接5V。5V容忍指的是引脚可以承受5V输入而不损坏但输出高电平仍然是3.3V。如果对方设备要求5V高电平才能识别中间需要加电平转换电路。我见过有人直接用5V的USB转串口模块接PA10短时间能用时间长了RX引脚漏电流增大通信误码率明显上升。2.2 时钟树配置对波特率精度的影响STM32F103的USART波特率计算公式是Tx/Rx Baud fCK / (16 * USARTDIV)其中fCK是USART的时钟源通常挂载在APB2上USART1或APB1上USART2/3。系统时钟72MHz时APB2为72MHzAPB1为36MHz。以115200波特率为例USARTDIV 72000000 / (16 * 115200) 39.0625。整数部分39小数部分0.0625对应BRR寄存器的值为0x271。这里有个容易忽略的点小数部分的精度直接影响误码率。0.0625正好是1/16的整数倍所以误差为0。但如果波特率是9600USARTDIV 468.75小数部分0.75对应12/16也能精确表示。真正容易出问题的是那些除不尽的情况比如某些非标准波特率。我一般建议用STM32CubeMX的时钟配置工具自动计算BRR值它会给出实际波特率和误差百分比误差超过2%就要重新考虑时钟源。2.3 GPIO模式选择的讲究PA9作为TX配置为复用推挽输出GPIO_Mode_AF_PP速度建议50MHz。PA10作为RX配置为浮空输入GPIO_Mode_IN_FLOATING或者上拉输入GPIO_Mode_IPU。浮空输入在总线空闲时引脚电平不确定容易受干扰产生噪声上拉输入可以保证空闲时为高电平更稳定。我一般用上拉输入除非外部已经有上拉电阻。提示如果串口引脚同时接了其他外设比如SPI的MOSI要确保没有引脚冲突。STM32F103的引脚复用功能是固定的不能随意重映射除非使用AFIO的remap功能。3. DMA通道与空闲中断的底层机制拆解3.1 DMA请求映射与传输模式选择STM32F103有DMA1和DMA2两个控制器DMA1有7个通道DMA2有5个通道只有大容量型号才有DMA2。USART1的RX请求固定在DMA1_Channel5TX固定在DMA1_Channel4。这个映射关系在参考手册RM0008的DMA请求映射表里有明确说明不能更改。DMA的传输模式有几种关键配置配置项推荐值说明数据传输方向外设到存储器USART_DR到内存缓冲区外设地址增量禁止USART_DR地址固定存储器地址增量使能缓冲区地址递增外设数据宽度字节8位USART_DR是8位存储器数据宽度字节8位缓冲区用uint8_t传输模式普通模式收完一帧后重新配置优先级中或高根据系统需求循环模式禁止配合空闲中断使用这里重点说普通模式和循环模式的区别。循环模式下DMA传输完指定数量后自动重装载适合固定长度的连续采集。但不定长接收必须用普通模式因为每次帧长度不一样需要在空闲中断里重新设置传输长度。3.2 空闲中断的触发条件与清除方法空闲中断的触发条件是USART在检测到RX引脚上出现一个字节时间的空闲电平后如果此时IDLE标志位被置起就产生中断。一个字节时间取决于波特率115200下大约是86.8微秒。关键点在于IDLE标志的清除方式。STM32F103的IDLE标志不能直接写0清除必须先读USART_SR寄存器再读USART_DR寄存器这个序列才能清除IDLE标志。很多人只读SR不读DR结果中断反复触发程序卡死在中断里。void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 必须先读SR再读DR volatile uint32_t temp; temp USART1-SR; temp USART1-DR; (void)temp; // 计算接收到的数据长度 uint16_t recv_len RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 处理数据... // 重新配置DMA准备下一次接收 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }3.3 接收缓冲区的设计策略缓冲区大小需要根据实际数据帧的最大长度来定。如果协议规定单帧不超过256字节缓冲区可以设为256或512。设大一点的好处是防止DMA溢出但太大会浪费RAM。STM32F103C8T6只有20KB RAM缓冲区设1KB已经不小了。我通常用双缓冲区方案一个缓冲区用于DMA接收另一个用于数据处理。空闲中断触发后先切换缓冲区指针再处理数据这样不会因为处理时间过长导致下一帧数据覆盖。具体做法是定义两个数组DMA指向A区空闲中断里把A区数据拷贝到B区处理同时把DMA重新指向A区。如果处理时间可能超过一帧的间隔就需要更复杂的队列机制。4. 从零搭建工程SPL库下的完整实现步骤4.1 工程框架与文件组织用SPL标准外设库开发时我习惯把串口DMA相关的代码单独放在一个文件里比如usart_dma.c和usart_dma.h。这样移植到其他项目时直接拷贝这两个文件改一下引脚和通道定义就行。工程目录结构建议Project/ ├── CMSIS/ ├── STM32F10x_StdPeriph_Driver/ ├── User/ │ ├── main.c │ ├── usart_dma.c │ ├── usart_dma.h │ └── stm32f10x_it.c └── Output/usart_dma.h里定义缓冲区大小、超时时间等宏#define RX_BUFFER_SIZE 512 #define TX_BUFFER_SIZE 5124.2 USART初始化与DMA配置的代码实现初始化顺序很重要先开时钟再配GPIO然后配USART最后配DMA。顺序错了外设不工作。void USART1_DMA_Init(uint32_t bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 开时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // TX PA9 复用推挽 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); // RX PA10 上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // USART配置 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); // DMA接收配置 DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); // 使能DMA和USART的DMA请求 DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); // 使能空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // NVIC配置 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }这段代码里有个细节DMA_InitStructure.DMA_MemoryBaseAddr要填缓冲区的地址缓冲区必须是全局数组或者静态数组不能是局部变量否则DMA访问的是栈上的临时地址数据会乱。4.3 空闲中断服务函数的完整处理逻辑中断服务函数里要做三件事清标志、算长度、重启DMA。顺序不能乱。void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 清IDLE标志先读SR再读DR volatile uint32_t clear USART1-SR; clear USART1-DR; (void)clear; // 停止DMA以获取准确长度 DMA_Cmd(DMA1_Channel5, DISABLE); // 计算接收长度 uint16_t len RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if(len 0 len RX_BUFFER_SIZE) { // 把数据拷贝到处理缓冲区 memcpy(process_buffer, rx_buffer, len); process_len len; frame_ready 1; } // 重启DMA DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }注意DMA_GetCurrDataCounter返回的是剩余未传输的数据量不是已传输量。已传输量 缓冲区大小 - 剩余量。这个减法顺序别搞反了。4.4 主循环中的数据处理与printf重定向主循环里检测frame_ready标志处理完清标志。处理时间要尽量短如果数据处理复杂建议用状态机分步处理不要在中断里做耗时操作。printf重定向到USART1的代码int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }如果发送量也很大建议TX也用DMA。TX的DMA配置和RX类似只是方向反过来通道用DMA1_Channel4。发送前把数据拷到发送缓冲区启动DMA等传输完成中断或者查询TC标志。5. 实测中暴露的五个典型问题与排查过程5.1 第一帧数据总是丢失现象上电后第一次发送数据接收端收不到之后正常。排查过程用逻辑分析仪抓PA10引脚发现第一帧数据确实到了引脚上但DMA没有搬运。检查DMA配置发现DMA_Cmd在USART_Cmd之前执行了。DMA使能时USART还没使能DMA请求信号无效。调整顺序后问题解决。根因USART的DMA请求使能位在USART_CR3寄存器里USART没使能时这个位虽然可以写但不会产生实际的DMA请求。必须先USART_Cmd(USART1, ENABLE)再USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE)。5.2 空闲中断反复触发导致死机现象串口收到数据后程序卡死调试器显示一直在USART1_IRQHandler里。排查过程在中断入口和出口加GPIO翻转用示波器看波形发现中断频率极高。检查清标志代码发现只读了SR没读DR。IDLE标志的清除需要“读SR读DR”这个序列缺一不可。修复在中断里加两行读操作问题消失。这个坑在ST的参考手册里有说明但很多人看手册时跳过了这段。5.3 DMA传输长度计算错误现象接收到的数据长度总是比实际少1或者多1。排查过程在中断里打印DMA_GetCurrDataCounter的值发现空闲中断触发时最后一个字节可能还在移位寄存器里没有完全搬到DR。DMA搬运的是DR里的数据如果最后一个字节还没进DRDMA自然没搬。解决在空闲中断里加一个短暂延时几个微秒等最后一个字节进入DR后再读DMA计数器。或者更稳妥的做法是在中断里先关DMA再读计数器然后手动把DR里可能残留的字节读出来。5.4 高波特率下数据错位现象波特率115200时正常拉到460800后数据错位。排查过程用示波器测量PA10上的波形发现上升沿和下降沿有振铃信号质量差。检查硬件发现杜邦线太长且没有加匹配电阻。解决缩短连线到10cm以内在RX引脚附近加一个100欧姆的串联电阻振铃明显改善。另外检查BRR寄存器的误差460800下USARTDIV 72000000/(16*460800) 9.765625小数部分0.765625对应12.25/16无法精确表示实际误差约0.1%在可接受范围内。5.5 DMA和CPU同时访问同一块内存的冲突现象偶尔出现数据前半段正确后半段乱码。排查过程怀疑是DMA正在写缓冲区时CPU也在读。检查代码发现主循环里直接读rx_buffer而DMA可能正在搬运新数据。解决引入双缓冲区机制DMA只写A区CPU只读B区空闲中断里切换。这样彻底避免了竞争。如果RAM紧张至少要在读之前关DMA读完再开。6. 性能优化与进阶玩法6.1 DMA发送的零拷贝实现接收用双缓冲区解决了竞争发送也可以用类似思路。定义一个发送环形缓冲区printf和业务代码往环形缓冲区里写DMA从环形缓冲区里读。当DMA空闲时启动一次传输。这样发送大量数据时CPU不会阻塞在while(TCRESET)上。环形缓冲区的实现要点维护读指针和写指针写指针追上读指针时表示缓冲区满需要等待或者丢弃。DMA传输完成中断里更新读指针并检查是否有新数据需要发送。6.2 空闲中断超时机制的补充空闲中断依赖总线空闲如果发送方发完一帧后立刻发下一帧中间没有空闲时间空闲中断不会触发两帧数据会粘在一起。解决办法是加一个定时器做超时检测每收到一个字节就重置定时器定时器溢出表示一帧结束。这个方案比纯空闲中断更可靠但需要额外占用一个定时器。我通常的做法是空闲中断和超时定时器二选一。如果协议有明确的帧头帧尾用空闲中断就够了如果协议是纯数据流没有帧边界必须加超时机制。6.3 用DMA测速评估系统余量DMA测速是个很实用的调试手段。具体做法是让DMA以最高速度搬运数据比如从内存到内存测量单位时间内搬运的字节数再对比理论带宽。STM32F103的DMA1在72MHz下理论带宽是72MB/s实际测试通常在30-50MB/s之间取决于总线仲裁和优先级配置。如果串口DMA接收时发现丢包可以用DMA测速看看是不是DMA带宽被其他外设占满了。比如同时开了ADC_DMA和SPI_DMA三个DMA通道竞争总线优先级低的通道可能来不及搬运串口数据。6.4 从SPL迁移到HAL的注意事项现在新项目大多用HAL库或者LL库从SPL迁移时要注意几个差异对比项SPLHALDMA配置手动填寄存器结构体用DMA_HandleTypeDef空闲中断手动清标志用__HAL_UART_CLEAR_IDLEFLAG中断处理直接写IRQHandler用HAL_UART_IRQHandler回调传输长度DMA_GetCurrDataCounter__HAL_DMA_GET_COUNTERHAL库的空闲中断处理有个坑HAL_UART_IRQHandler内部会调用UART_Receive_IT如果没开接收中断空闲中断可能被意外清除。我一般直接用寄存器操作绕过HAL的中断处理函数只保留HAL的初始化部分。7. 不定长协议解析的工程化落地7.1 帧头帧尾加长度字段的协议设计不定长接收只是第一步收到数据后怎么解析才是关键。我常用的协议格式是[帧头0xAA][帧头0x55][长度高字节][长度低字节][命令字][数据...][校验和]长度字段表示数据段的长度不包括帧头和校验。解析时先找帧头再读长度然后根据长度取数据最后校验。如果校验失败丢弃这一帧从下一个字节重新找帧头。这种协议的好处是解析逻辑简单不需要超时机制。缺点是帧头可能出现在数据段里造成误判解决办法是用转义字符或者更复杂的帧头模式。7.2 环形缓冲区在协议解析中的应用接收到的数据先写入环形缓冲区解析器从环形缓冲区里逐字节读取。环形缓冲区的好处是解耦了接收和解析的速度差异。DMA接收是突发的解析可能是慢速的环形缓冲区起到缓冲作用。环形缓冲区的容量建议是最大帧长的2-4倍。如果最大帧长256字节环形缓冲区设1KB比较合适。读写指针用uint16_t缓冲区大小取2的幂次这样可以用位与运算代替取模提高效率。7.3 状态机解析器的实现状态机是解析不定长协议最可靠的方式。定义几个状态等待帧头1、等待帧头2、等待长度、等待数据、等待校验。每来一个字节根据当前状态决定下一步动作。typedef enum { STATE_HEADER1, STATE_HEADER2, STATE_LEN_H, STATE_LEN_L, STATE_DATA, STATE_CHECKSUM } parse_state_t; void parse_byte(uint8_t byte) { static parse_state_t state STATE_HEADER1; static uint16_t data_index 0; static uint16_t data_len 0; switch(state) { case STATE_HEADER1: if(byte 0xAA) state STATE_HEADER2; break; case STATE_HEADER2: if(byte 0x55) state STATE_LEN_H; else state STATE_HEADER1; break; case STATE_LEN_H: data_len byte 8; state STATE_LEN_L; break; case STATE_LEN_L: data_len | byte; if(data_len MAX_DATA_LEN) { state STATE_HEADER1; break; } data_index 0; state STATE_DATA; break; case STATE_DATA: frame_buffer[data_index] byte; if(data_index data_len) state STATE_CHECKSUM; break; case STATE_CHECKSUM: if(checksum(frame_buffer, data_len) byte) { // 帧有效提交处理 frame_process(frame_buffer, data_len); } state STATE_HEADER1; break; } }这个状态机每来一个字节调用一次执行时间固定不会阻塞。放在主循环里轮询环形缓冲区或者放在空闲中断里批量处理都可以。7.4 错误恢复与异常帧处理实际现场环境中干扰、波特率偏差、对方设备异常都会导致帧错误。状态机要有超时重置机制如果超过一定时间没有收到新字节强制回到STATE_HEADER1。这个超时可以用定时器实现也可以用字节计数实现。另外如果数据段长度超过缓冲区容量直接丢弃并重置状态机不要试图截断处理。截断的数据比没有数据更危险可能导致上层逻辑误判。8. 我在实际项目中积累的几条硬核经验第一条DMA缓冲区的地址必须4字节对齐。虽然STM32F103的DMA支持字节对齐但不对齐的地址在某些情况下会导致传输效率下降。用__align(4)修饰数组或者用uint32_t数组再强制转换。第二条空闲中断里不要做浮点运算和内存分配。中断上下文里做这些操作栈空间可能不够而且浮点运算会保存FPU寄存器如果用了带FPU的型号增加中断延迟。把数据拷贝出来在主循环里处理。第三条波特率高于256000时建议用外部晶振。内部RC振荡器的精度在1%左右高波特率下累积误差会导致误码。8MHz外部晶振加PLL到72MHz精度可以做到0.1%以内。第四条调试串口和业务串口分开。调试信息用USART2业务数据用USART1这样调试打印不会干扰业务数据的DMA接收。如果只有一个串口调试打印用查询方式发送业务接收用DMA加空闲中断。第五条定期检查DMA的传输错误标志。DMA传输错误TE、直接模式错误DME、帧错误FE这些标志位在调试阶段要打开正式发布时可以关闭。我遇到过因为存储器地址越界导致DMA传输错误但错误标志没开查了半天才发现是数组越界。这套方案在STM32F103C8T6上跑了一年多对接过PLC、传感器、上位机等多种设备115200波特率下连续运行72小时无丢包。后来移植到GD32F103和HC32F460上只需要改DMA通道映射和时钟配置核心逻辑完全复用。如果你正在做串口通信相关的项目建议先把这套框架搭起来后面加协议、加功能都会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑