资讯动态

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

发布时间:2026/10/27 18:10:05 来源:尧图企业网站定制
1. 为什么我最终选了 DMA 空闲中断这套组合STM32F103 这颗芯片玩过的人都知道USART 几乎是每个项目都绕不开的外设。但真正把串口收发做到稳、快、不丢包其实没那么简单。我最早做串口接收的时候用的是最原始的方式在USART1_IRQHandler里一个字节一个字节地读USART_ReceiveData然后塞进一个环形缓冲区。波特率低的时候9600、19200还能凑合一旦上到 115200 甚至 460800CPU 基本就被串口中断吃满了主循环里干别的事情明显卡顿。后来我换成了 DMA 接收情况好了很多。但新的问题又来了DMA 是搬运工它只管把数据从 USART 数据寄存器搬到内存里它不知道一帧数据什么时候结束。如果我用固定长度接收那遇到不定长协议比如 Modbus RTU、自定义的 AT 指令、JSON 字符串就抓瞎了。这时候空闲中断IDLE Interrupt就成了关键——总线空闲一个字节时间后触发中断正好用来判断这一帧收完了。所以这套方案的核心逻辑就是DMA 负责搬数据不占 CPU空闲中断负责判断帧结束及时通知应用层处理。两者配合既解决了高速率下的 CPU 占用问题又解决了不定长数据的边界识别问题。我实测下来115200 波特率下连续接收 1KB 数据CPU 占用几乎可以忽略而且帧与帧之间的间隔只要大于一个字节传输时间就能被准确切分。这套方案适合谁如果你正在用 STM32F103 做串口通信协议是不定长的数据量不算特别小或者你对 CPU 占用有要求那这套方案基本就是标准答案。标准外设库SPL和 HAL 库都能实现我下面以 SPL 为主来讲因为 F103 时代 SPL 的代码更直观寄存器操作一目了然排查问题也方便。2. 核心原理拆解DMA 和空闲中断到底怎么配合2.1 DMA 接收的搬运逻辑与配置要点DMA 的全称是 Direct Memory Access直译就是直接内存访问。你可以把它想象成一个专门负责搬箱子的工人CPU 是老板。老板告诉工人从 USART1 的数据寄存器地址固定搬数据搬到内存里的rx_buffer数组搬多少个我告诉你。然后老板就可以去干别的事了工人每搬一个箱子计数器减一搬完了或者中途有特殊情况工人再通知老板。在 STM32F103 上USART1 的 RX 对应 DMA1 的通道 5TX 对应通道 4。这个对应关系是硬件固定的不能随便改。配置 DMA 接收的时候有几个参数必须搞清楚外设地址USART1-DR注意是数据寄存器不是别的。内存地址你的接收缓冲区首地址比如rx_buffer。传输方向外设到内存Peripheral to Memory。缓冲区大小RX_BUFFER_SIZE这个值决定了 DMA 最多搬多少个字节后触发传输完成中断。外设地址是否增量不增量因为 USART 只有一个数据寄存器。内存地址是否增量增量这样数据才能依次填入数组。数据宽度外设和内存都是 8 位Byte。模式这里有个关键选择——普通模式还是循环模式。我一开始用的是普通模式DMA 搬完指定长度就停了需要手动重新配置。后来发现循环模式更省事DMA 搬完一圈自动回到起点继续搬缓冲区变成一个环形队列。但循环模式也有坑后面讲问题排查的时候会细说。// DMA1 通道5 配置USART1_RX DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); 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_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE);这段代码里DMA_Mode_Normal和DMA_Mode_Circular的选择很关键。普通模式下DMA 搬完RX_BUFFER_SIZE个字节后DMA_GetCurrDataCounter会变成 0传输完成标志置位。如果你想继续接收必须重新设置DMA_SetCurrDataCounter并重新使能通道。循环模式则不需要但你需要自己维护读写指针否则新数据会覆盖旧数据。2.2 空闲中断的触发机制与时间窗口空闲中断是 USART 自带的一个功能当 RX 线上连续空闲没有起始位达到一个字节的传输时间后硬件自动置位USART_FLAG_IDLE。注意这个一个字节时间是跟波特率相关的。比如 115200 波特率下一个字节10 位1 起始 8 数据 1 停止的时间大约是 86.8 微秒。也就是说如果总线空闲超过 86.8 微秒空闲中断就会触发。这个时间窗口非常有用。它意味着只要两帧数据之间的间隔大于一个字节时间空闲中断就能把它们分开。对于大多数协议来说帧间隔至少是几个字节时间所以完全够用。但如果你的协议是连续流式发送帧与帧之间没有间隔那空闲中断就分不开了这时候只能靠协议里的长度字段或者固定帧头帧尾来解析。空闲中断的清除方式有点特殊。在 F103 的 SPL 里你需要先读USART_GetFlagStatus(USART1, USART_FLAG_IDLE)然后读一次USART_ReceiveData(USART1)才能清除 IDLE 标志。这个顺序不能反否则中断会一直触发。我踩过这个坑当时中断服务函数里只清了标志没读数据结果程序一直卡在中断里出不来。void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 清除 IDLE 标志先读 SR再读 DR USART_ReceiveData(USART1); // 读 DR 清除标志 // 计算本次接收到的数据长度 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); } }上面这段代码是普通模式下的典型处理流程。每次空闲中断触发先算出 DMA 已经搬了多少个字节RX_BUFFER_SIZE - 剩余计数器这就是本帧的长度。处理完数据后把 DMA 关掉、重置计数器、再打开准备接收下一帧。2.3 为什么不用接收中断而用空闲中断有人可能会问既然已经有 DMA 了为什么还要开中断直接用 DMA 传输完成中断不行吗答案是DMA 传输完成中断只在搬完指定长度后触发它不知道帧边界。如果你的缓冲区设成 256 字节但实际一帧只有 20 字节DMA 不会触发完成中断数据就一直躺在缓冲区里应用层不知道什么时候该去读。接收中断RXNE则是每收到一个字节就触发一次虽然能实时知道数据来了但又回到了 CPU 被频繁打断的老路。空闲中断正好折中它不关心每个字节只关心总线什么时候闲下来也就是一帧结束的时刻。这样 CPU 只在帧结束时被唤醒一次效率最高。我做过一个粗略的对比测试同样接收 1000 帧、每帧 32 字节的数据115200 波特率下接收方式CPU 中断次数主循环可用时间占比RXNE 中断约 32000 次约 40%DMA 空闲中断约 1000 次约 95%这个差距非常明显。尤其是当你主循环里还有 ADC 采样、屏幕刷新、电机控制等任务时RXNE 中断方案基本不可接受。3. 完整实操从零搭建 DMA 空闲中断收发工程3.1 硬件准备与最小系统搭建先说一下硬件。我用的是 STM32F103C8T6 最小系统板也就是常说的蓝板或核心板。这块板子便宜、资料多拿来验证串口通信足够了。需要注意几个点供电最小系统板一般有 5V 和 3.3V 引脚。如果你用 ST-Link 供电注意 ST-Link 的 3.3V 输出电流有限带不动太多外设。我一般单独接一个 5V 电源板子上的 AMS1117-3.3 会转成 3.3V 给 MCU。串口电平F103 的 USART 引脚是 3.3V 电平不能直接接 5V 的串口设备。如果你用的是 USB 转 TTL 模块确保它的电平是 3.3V 或者可以切换。我见过有人直接把 5V 的串口接到 PA9/PA10 上结果 IO 口烧了。接线USART1 的 TX 是 PA9RX 是 PA10。USB 转 TTL 模块的 TX 接 PA10RX 接 PA9GND 对 GND。千万别接反接反了收不到数据。如果你用的是 STM32F103 5V 转 3.3V 电路自己搭的板子记得在 USART 引脚附近加 TVS 或者至少加个限流电阻防止热插拔时浪涌打坏 IO。3.2 时钟与 GPIO 初始化时钟配置是 F103 开发的第一步。USART1 挂在 APB2 总线上DMA1 挂在 AHB 总线上GPIOA 也挂在 APB2 上。所以使能时钟的时候要分别处理// 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);GPIO 配置方面PA9 是复用推挽输出TXPA10 是浮空输入或上拉输入RX。我一般用上拉输入这样总线空闲时电平稳定不容易受干扰误触发。GPIO_InitTypeDef GPIO_InitStructure; // 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);这里有个细节TX 的 GPIO 速度我设成了 50MHz其实 115200 波特率用 2MHz 就够了。但如果你以后想超频到 1M 波特率50MHz 更保险。RX 设成上拉输入是因为串口空闲时是高电平上拉可以保证没有设备连接时不会读到乱码。3.3 USART 参数配置与中断优先级USART 的配置比较标准波特率 115200、8 位数据、1 位停止、无校验、无硬件流控。中断方面只需要开空闲中断IDLE不需要开 RXNE 中断因为数据由 DMA 搬。USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate 115200; 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_IDLE, ENABLE); // 使能 USART USART_Cmd(USART1, ENABLE);NVIC 配置要注意优先级分组。F103 的中断优先级分组一般用NVIC_PriorityGroup_2这样抢占优先级和响应优先级各有 2 位。空闲中断的优先级不用太高因为它只是通知帧结束不涉及实时性极强的操作。我一般设成抢占优先级 1、响应优先级 1。NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); 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);3.4 DMA 发送配置与 printf 重定向发送这边DMA 的配置和接收类似但方向相反内存到外设。USART1_TX 对应 DMA1 通道 4。发送模式我一般用普通模式因为发送是主动发起的发完就结束不需要循环。void USART1_DMA_Tx_Init(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)tx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize TX_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_Medium; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel4, DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }发送函数这样写void USART1_Send_Data(uint8_t *data, uint16_t len) { while(DMA_GetCmdStatus(DMA1_Channel4) ! DISABLE); // 等待上一次发送完成 DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA1_Channel4-CMAR (uint32_t)data; // 重新设置内存地址 DMA_Cmd(DMA1_Channel4, ENABLE); }注意DMA1_Channel4-CMAR这一行直接操作寄存器重新设置内存地址。因为 SPL 的DMA_Init只在初始化时设置一次地址后续发送不同缓冲区时需要手动改。这个坑我踩过当时发送固定数组没问题一换成动态缓冲区就发的是旧数据。printf 重定向到 USART 也很简单实现fputc就行。但如果你用了 DMA 发送fputc里不能直接调 DMA 发送因为fputc是逐字节调用的每次调 DMA 效率极低。我的做法是fputc先把数据攒到一个缓冲区遇到\n或者缓冲区满了再一次性 DMA 发出去。或者更简单直接用阻塞发送反正 printf 一般用于调试不追求极致效率。int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }3.5 空闲中断服务函数的完整实现中断服务函数是整个方案的核心。我把它分成几个步骤判断空闲标志、清除标志、计算长度、拷贝数据、重启 DMA、通知应用层。#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint8_t rx_frame[RX_BUFFER_SIZE]; volatile uint16_t rx_frame_len 0; volatile uint8_t rx_frame_ready 0; void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 清除 IDLE 标志 USART_ReceiveData(USART1); // 计算接收长度 uint16_t len RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if(len 0 len RX_BUFFER_SIZE) { // 拷贝数据到帧缓冲区 memcpy(rx_frame, rx_buffer, len); rx_frame_len len; rx_frame_ready 1; } // 重启 DMA DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }主循环里轮询rx_frame_ready标志处理完数据后清零。这种中断置标志、主循环处理的模式可以避免在中断里做耗时操作保证中断响应速度。4. 踩坑实录那些让我熬夜排查的问题4.1 DMA 计数器不归零导致的数据错位这个问题我遇到过两次症状是第一帧数据正常第二帧开始数据错位有时候还会丢字节。排查了半天最后发现是 DMA 计数器没有正确重置。在普通模式下DMA 搬完指定长度后DMA_GetCurrDataCounter返回 0通道自动关闭。但如果你在空闲中断里只重新使能了通道没有重新设置计数器那 DMA 会认为我已经搬完了不会再搬数据。正确的做法是先DMA_Cmd(DISABLE)再DMA_SetCurrDataCounter(RX_BUFFER_SIZE)最后DMA_Cmd(ENABLE)。顺序不能乱否则计数器设置不进去。还有一个更隐蔽的问题如果你在 DMA 还在搬运的时候就去读DMA_GetCurrDataCounter读到的值可能是不准确的。因为 DMA 和 CPU 是并行工作的计数器在实时变化。所以计算长度的时候最好先关掉 DMA 通道再读计数器读完再打开。虽然这样会丢失几个字节的接收窗口但对于帧间隔较大的协议来说影响不大。4.2 空闲中断标志清除不彻底前面提过IDLE 标志的清除需要先读 SR再读 DR。但 SPL 的USART_GetITStatus函数内部已经读了一次 SR所以你只需要再读一次 DR 就行。我见过有人用USART_ClearITPendingBit(USART1, USART_IT_IDLE)来清除这个函数在 F103 上对 IDLE 标志无效因为 IDLE 不是通过写寄存器清除的而是通过读 DR 清除的。如果你发现中断一直在触发程序卡在中断里出不来大概率就是 IDLE 标志没清掉。这时候可以在中断里加一句USART_ReceiveData(USART1)问题基本就解决了。4.3 高速率下的数据覆盖问题当波特率很高比如 460800 或 921600而你的主循环处理速度跟不上时会出现数据覆盖。具体表现是DMA 缓冲区是 256 字节但一帧数据有 300 字节DMA 搬完 256 字节后如果还没触发空闲中断新的数据就会从缓冲区头部开始覆盖。解决这个问题有两个思路一是加大缓冲区比如 512 或 1024 字节确保一帧数据不会超过缓冲区大小二是用循环模式 双缓冲区DMA 搬一半的时候触发中断应用层处理前半部分DMA 继续搬后半部分。第二种方案更复杂但能处理任意长度的数据帧。我一般用第一种方案因为大多数协议的帧长度不会超过 256 字节。如果你确实需要处理长帧建议用循环模式并且在 DMA 传输完成中断里也做处理不要只依赖空闲中断。4.4 常见问题速查表现象可能原因排查方法解决方案收不到任何数据TX/RX 接反用示波器看 PA10 是否有波形交换 TX/RX 接线第一帧正常后续错位DMA 计数器未重置在中断里打印计数器值关通道→设计数器→开通道中断一直触发IDLE 标志未清除在中断里翻转 GPIO 用示波器看读一次 DR 清除标志数据偶尔丢失缓冲区太小被覆盖加大缓冲区观察增大 RX_BUFFER_SIZE 或用循环模式发送数据不完整DMA 发送未等待完成检查 DMA_GetCmdStatus发送前等待上一次完成波特率误差大时钟配置不对检查 RCC 和 USARTDIV 计算确认系统时钟和波特率寄存器5. 进阶优化让这套方案更稳更快5.1 双缓冲区交替接收如果你的数据量很大或者帧间隔很短单缓冲区可能会在应用层处理数据时被新数据覆盖。双缓冲区的思路是准备两个缓冲区 A 和 BDMA 先往 A 里搬空闲中断触发后应用层处理 A同时 DMA 切换到 B 继续接收。这样应用层处理数据的时间窗口就大了一倍。实现上可以在空闲中断里切换 DMA 的内存地址if(current_buffer 0) { DMA1_Channel5-CMAR (uint32_t)rx_buffer_b; current_buffer 1; process_buffer(rx_buffer_a, len); } else { DMA1_Channel5-CMAR (uint32_t)rx_buffer_a; current_buffer 0; process_buffer(rx_buffer_b, len); }注意切换地址后要重新设置计数器并重新使能通道。这个方案我实测在 460800 波特率下连续接收 10KB 数据零丢包。5.2 DMA 发送的完成判断与超时处理DMA 发送虽然不占 CPU但如果你连续调用发送函数而前一次发送还没完成就会出问题。我一般加一个超时机制uint8_t USART1_Send_Data_Timeout(uint8_t *data, uint16_t len, uint32_t timeout) { uint32_t tickstart GetTick(); while(DMA_GetCmdStatus(DMA1_Channel4) ! DISABLE) { if((GetTick() - tickstart) timeout) { return 0; // 超时 } } // ... 启动 DMA 发送 return 1; }超时时间根据波特率和数据长度估算。比如 115200 波特率下发送 256 字节理论时间约 22 毫秒超时设 100 毫秒足够了。5.3 用定时器做接收超时补充空闲中断虽然好用但它有个前提总线必须空闲一个字节时间。如果对方发送完一帧后立刻又发下一帧中间没有空闲空闲中断就不会触发。这时候可以用一个定时器做超时补充每收到一个字节就重置定时器如果定时器超时了还没收到新字节就认为一帧结束。这个方案需要开 RXNE 中断来重置定时器会增加一些 CPU 开销但能处理连续流式数据。我一般只在协议帧间隔极短的情况下才用普通场景空闲中断足够了。6. 实测数据与性能对比我在 115200 波特率下做了一组对比测试发送端用 PC 串口助手连续发送 1000 帧、每帧 64 字节的数据接收端分别用三种方案处理方案丢包数CPU 占用代码复杂度RXNE 中断 环形缓冲区0约 55%中等DMA 普通模式 空闲中断0约 8%中等DMA 循环模式 空闲中断 双缓冲0约 5%较高从数据可以看出DMA 空闲中断的方案在 CPU 占用上有压倒性优势。循环模式 双缓冲虽然占用更低但代码复杂度上升明显除非你的项目对 CPU 占用极其敏感否则普通模式 空闲中断已经足够。另外我还测了不同波特率下的最大吞吐量。在 921600 波特率下DMA 空闲中断方案可以稳定接收CPU 占用约 15%。但这时候帧间隔必须大于 10 微秒左右否则空闲中断可能来不及响应。如果你需要更高的速率建议考虑用 SPI 或者 USB 接口。7. 工程移植与代码组织建议这套代码我后来移植到了好几个项目里总结了一些组织上的经验。首先把 USART 和 DMA 的初始化封装成独立的usart_dma.c和usart_dma.h对外只暴露USART1_Init()、USART1_Send_Data()、USART1_Get_Frame()这几个接口。中断服务函数放在stm32f1xx_it.c里但具体的处理逻辑调用usart_dma.c里的函数保持中断函数的简洁。缓冲区的大小根据你的协议来定。如果协议帧最长不超过 128 字节RX_BUFFER_SIZE设 256 就够了。如果协议里有长数据包建议设 512 或 1024。但注意 F103C8T6 只有 20KB RAM缓冲区别设太大否则其他变量没地方放。最后如果你用的是 Keil MDK记得在工程选项里勾选Use MicroLIB否则 printf 重定向会报错。如果你用的是 STM32F103 Pack 包确保版本和你的芯片型号匹配C8T6 和 RCT6 的启动文件不一样选错了会跑不起来。这套方案我在实际项目中用了两年多从简单的调试串口到复杂的 Modbus 主站稳定性一直很好。唯一需要注意的是如果你的应用场景电磁干扰比较强建议在 USART 引脚上加一些滤波电容或者在软件里做校验和重传硬件层面的问题软件只能缓解不能根治。

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

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

免费获取报价 →
↑