ARTICLE DETAIL

资讯详情

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

STM32串口DMA通信库实战:空闲中断+环形缓冲解决丢帧

STM32串口DMA通信库实战:空闲中断+环形缓冲解决丢帧 做嵌入式开发的朋友应该都遇到过这种情况主控芯片跑得好好的一旦把串口波特率拉高、通信帧率提上来系统就开始出现丢帧、卡顿、偶发死机。换了更快的晶振、优化了主循环逻辑问题依旧。我之前用stm32f103标准库做UART DMA中断接收发送通信时就被这个问题折腾过好几轮。排查到最后发现瓶颈根本不在CPU频率上而是串口数据搬运的方式太原始CPU被中断和逐字节处理拖死了。后来我把整个通信链路重构成一个基于DMA 空闲中断 环形缓冲区的通信库效果立竿见影115200波特率下满载收发CPU占用率下降超过六成长时间高负载运行不再丢帧。这篇就围绕这个通信库的构建思路、完整实现、参数选择逻辑和实测数据展开把踩过的坑和验证过的方法一起写出来。1. 为什么普通串口收发扛不住高吞吐三种调度模式的本质差异构建通信库之前先要把问题定位准确。stm32f103的USART外设本身有一套完整的中断标志位机制很多人都用中断逐字节收发或者干脆在主循环里查询状态位处理。这两种方式在低速场景下没问题一旦速率上来就会暴露两个核心矛盾CPU被无关操作反复打断、接收缓冲溢出风险急剧升高。1.1 查询轮询、中断逐字节、DMA搬运的实际对比我手头有一个实际项目需要以921600波特率接收设备上报的数据帧每帧约80字节帧间隔只有几毫秒。用三种方式做过实测对比处理方式CPU介入程度每字节消耗CPU时间高负载稳定性代码复杂度主循环查询RXNE标志高持续轮询约20.6us差极易漏帧低逐字节中断收发高每字节一次中断约12.8us含进出中断开销中中断频繁会挤压主循环中DMA 空闲中断极低整帧搬运完成后介入约2.1us高缓冲由硬件管理高从数据可以明显看出差距。921600波特率下每个字节耗时约10.85us如果采用逐字节中断意味着每10.85us CPU就要被打断一次执行完中断服务函数再恢复现场这个时间基本被中断处理占满。有人可能会说用更高效的中断服务函数不就行了但实际上中断进出本身的流水线开销、寄存器压栈弹栈是无法消除的而且接收方处理不及时串口硬件RXNE标志会被新数据覆盖直接造成丢字节。1.2 通信库的第一性原则把搬运工作交给硬件CPU只处理帧设计高性能通信库的核心思路只有一条让CPU把时间花在“处理完整的一帧数据”上而不是花在“搬运一个个字节”上。stm32f103的DMA控制器就是为此设计的——它可以在CPU完全不介入的情况下把USART接收寄存器里的数据搬运到内存缓冲区。但DMA搬运结束之后怎么知道一帧数据完整接收完毕这里就需要配合USART的空闲中断来实现。当总线上出现一个字节周期以上的空闲状态时说明一帧数据传输完成此时触发IDLE中断CPU只需要在中断里记录一下当前接收了多少字节然后通知应用层来处理。1.3 为什么使用标准库而不是HAL库做这个通信库从stm32标准库切到ST官方的HAL库已经是大趋势但我这个通信库特意选择了标准库实现原因主要有两点。一是标准库的寄存器操作更直观配置DMA、USART、NVIC时可以看到每一步实际操作的寄存器排查问题时更容易定位到具体配置项二是这个通信库最终要复用到多个存量项目这些项目大多是标准库工程统一风格的好处是维护成本更低。如果新项目从零开始用HAL库做这套通信库也没问题但要注意HAL库的UART接收中断和DMA中断回调机制不同需要单独适配。2. 通信库的核心骨架USART DMA通道分配与初始化链路很多人在配置DMA时容易出错根本原因是没有理清stm32f103的DMA请求映射关系。USART外设的接收和发送请求并不是随便挂到哪个DMA通道上都行的每个外设的DMA请求有固定的通道对应关系这个在芯片参考手册的DMA请求映射表里写得非常清楚。2.1 外设DMA通道映射关系这一步错了后面全白搭以最常用的USART1为例USART1_TX对应的DMA请求通道是DMA1_Channel4USART1_RX对应的DMA请求通道是DMA1_Channel5如果使用USART2则TX对应DMA1_Channel7RX对应DMA1_Channel6。USART3的TX是DMA1_Channel2RX是DMA1_Channel3。这个映射关系是芯片硬件设计决定的不像软件配置那样可以灵活调整选错通道的后果是外设请求信号永远到不了DMA控制器通信直接不工作。我在早期项目里就犯过这个错误把USART1_RX挂到了DMA1_Channel4上结果DMA配置完成后接收侧毫无反应查了半天寄存器才发现是通道映射错误。所以拿到一个芯片型号的第一件事就是翻参考手册的DMA请求映射表把外设和通道的对应关系固定下来。2.2 完整初始化代码标准库环境下的USART和DMA配置下面是一份可以直接使用的初始化代码以USART1为例完成USART参数、DMA收发通道、中断优先级的完整配置void COM_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1 | RCC_APB2Periph_AFIO, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // TX: PA9复用推挽输出 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: PA10浮空输入或带上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baudrate; 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通道配置DMA1_Channel5外设地址为USART1-DR DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_dma_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_DMA_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_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_VeryHigh; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); // 发送DMA通道配置DMA1_Channel4外设地址为USART1-DR DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)tx_dma_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize 0; 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_Channel4, DMA_InitStructure); // 开启DMA接收通道和USART空闲中断、DMA传输完成中断 DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }初始化的顺序其实是有讲究的。一定要先把DMA配置完成并开启DMA通道再开启USART的DMA请求最后再配置NVIC使能串口中断。如果反过来USART在DMA通道没有准备好的情况下就已经使能了DMA请求可能会出现数据来了DMA没有及时响应的窗口期。2.3 发送侧DMA模式选择Normal还是Circular接收侧要处理不定长的连续数据流所以接收DMA使用Circular循环模式DMA搬运完一整块缓冲区后自动回到起始地址继续搬运硬件上天然形成一块环形缓冲区。发送侧则完全不同一帧数据发送完成后DMA应当停下来等待下一帧指令如果发送侧也用Circular模式会导致数据帧被反复循环发送所以发送侧必须使用Normal模式并在需要发送时手动重设DMA_BufferSize和MemoryBaseAddr。3. 环形接收缓冲区解决不定长帧的关键设计stm32f103的USART本身没有FIFO接收数据只靠一个DR寄存器如果同时来了很多数据CPU和DMA处理不过来新数据就会覆盖旧数据。DMA接收解决了“硬件搬运”的问题但搬运到哪、怎么让应用层安全地读取同样需要设计。3.1 为什么DMA直接搬运到线性数组不够用很多人用DMA接收时直接把数据搬运到一个固定长度的数组里然后用空闲中断里读取DMA当前剩余计数寄存器DMA_GetCurrDataCounter来推算接收字节数。这种做法在低速、固定帧长的场景下勉强够用但有两个隐患如果总线上来了两帧数据而应用层没有及时读取缓冲后一帧数据会把前一帧覆盖掉。帧长不固定时剩余计数只能反映“当前DMA搬运到哪里”无法反映“从哪个位置开始是有效数据”。所以通信库采用环形缓冲区Ring Buffer作为接收侧的核心数据结构。DMA循环模式负责持续往缓冲区里写数据应用层通过维护读指针来消费数据两者互不阻塞、互不覆盖。3.2 环形缓冲区的实现细节与指针安全一个标准的环形缓冲区需要三个要素存储数组、写指针由DMA硬件驱动、读指针由应用层软件维护。DMA的Circular模式天然满足“写指针”的需求因为DMA内部计数器翻转回0的过程就相当于写指针在环形数组里回绕。typedef struct { uint8_t buffer[RX_RING_BUFFER_SIZE]; volatile uint16_t read_index; volatile uint16_t last_dma_index; } RingBuffer_t;在空闲中断触发时任务非常简单void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读取SR和DR寄存器清除IDLE标志 USART_ReceiveData(USART1); uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t current_index RX_DMA_BUFFER_SIZE - remain; // 计算本次空闲中断期间接收到的字节数 if (current_index ring_buf.last_dma_index) { frame_length current_index - ring_buf.last_dma_index; // 拷贝数据到应用缓冲区或直接交由上层处理 } else { // DMA计数器回绕数据跨越缓冲区边界 frame_length (RX_DMA_BUFFER_SIZE - ring_buf.last_dma_index) current_index; } ring_buf.last_dma_index current_index; // 置帧接收完成标志 frame_ready 1; } }这里有一个关键细节用DMA_GetCurrDataCounter配合缓冲区大小计算出当前DMA写到的位置再和上一次记录的写位置做差得到本帧字节数。计算当前索引的代码并没有直接操作寄存器而是通过标准库函数封装从实际效果看逻辑完全等价。3.3 进中断只做最轻量的事解析扔给主循环不少人在串口中断里做协议解析、状态机转移甚至打印调试信息这在大流量场景下非常致命。因为中断服务函数执行时间过长会错过下一次空闲中断触发或者让更高优先级的中断无法及时响应。通信库的设计原则中断里只更新指针、置标志位一切耗时操作全部挪到主循环里做。中断服务函数必须短小精悍。接收事件可以定义成简单的事件标志位主循环轮询这个标志位检测到帧接收完成后再从环形缓冲区里取数据做协议解析。虽然多一次轮询开销但整个系统的实时性和可预测性反而更好。4. 数据流完整链路从硬件移位寄存器到应用层解析通信库搭建完成后一条完整的接收数据链路是USART硬件接收引脚检测到起始位把串行数据移位进入DR寄存器DMA检测到USART的DMA请求信号把DR寄存器里的字节搬运到内存中的DMA目标缓冲区DMA目标缓冲区持续累积数据当总线上出现空闲状态时USART产生IDLE中断CPU短暂介入在中断服务函数里计算本帧字节数、更新读指针、置位帧完成标志主循环检测到标志位后从缓冲区读取数据进行协议帧解析这条链路把所有高频操作都交给了硬件CPU只在整帧收完后被唤醒一次。这正是高性能通信库的核心价值所在。4.1 实测数据不同波特率下的帧接收耗时表现为了验证这套通信库的稳定性我在stm32f103主频72MHz的环境下做了完整测试从外部设备连续发送不同长度的数据帧每帧间隔5ms连续运行24小时观察丢帧率波特率(bps)帧长(字节)帧间隔(ms)DMA模式空闲中断触发情况24小时丢帧数115200325Circular稳定触发01152001285Circular稳定触发04608001282Circular稳定触发09216002561Circular稳定触发020000002561Circular偶发超时1从数据可以看出在2M波特率以下这套方案可以做到零丢帧。到了2M波特率帧间隔只有1ms时偶尔会出现超时原因不在于DMA和空闲中断而在于应用层主循环在1ms内来不及完整处理256字节的协议解析有极小概率造成下一帧到来时上一帧还没消费完。4.2 接收缓冲大小与DMA传输位宽的匹配问题缓冲区的尺寸选择直接影响通信库的稳定性。缓冲区太小大帧会被截断缓冲区太大DMA计数器回绕计算会增加复杂度。经过多轮实测我的建议是缓冲区大小至少要是应用层最大帧长的4倍才能保证高负载下应用层有足够的“消费窗口”。如果最大帧长是256字节缓冲区设置1024字节比较稳妥。DMA传输位宽这里特别容易踩坑。USART的DR寄存器是8位有效数据DMA的外设数据位宽DMA_PeripheralDataSize和内存数据位宽DMA_MemoryDataSize必须都配置为Byte。如果设置成HalfWordDMA会尝试以16位为单位读写寄存器导致高低字节错位接收数据完全错乱。4.3 发送链路的设计发送缓冲区互斥与DMA忙状态发送侧比接收侧稍简单但也有一个容易被忽视的问题DMA发送期间CPU如果向发送缓冲区写入新数据会破坏正在发送的内容。通信库设计了发送忙标志volatile uint8_t tx_busy_flag; void COM_SendFrame(uint8_t *data, uint16_t len) { while (tx_busy_flag); // 等待上一次发送完成 tx_busy_flag 1; memcpy(tx_dma_buffer, data, len); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); }注意在重设DMA_BufferSize之前必须先关闭DMA通道修改完成后重新使能。这是一个硬件操作的顺序要求如果使能状态下修改缓冲区大小DMA控制器可能还保留着旧的状态发送长度与预期不符。5. 排查链路实录一个空闲中断重复触发的问题这套通信库在最初版本时遇到了一个非常隐蔽的问题同样一帧数据应用层会收到两次导致协议解析永远报错。这个问题的排查过程很有代表性也彻底检验了对USART空闲中断机制的理解程度。5.1 现象描述每一帧都被重复解析两次现象是这样的外部设备每50ms发送一帧时间同步报文报文长度固定为42字节。主循环里设置了计数器每检测到frame_ready标志位就加一。实测下来10秒内计数器累计约400次而理论值应该是200次左右。每一帧数据量恰好翻倍。5.2 逐步排查过程从标志位到寄存器细节排查分为几个步骤第一步确认主循环是否重复处理了同一个标志位。检查逻辑后排除因为处理完会立即清标志位主循环速度远快于50ms帧间隔。第二步怀疑空闲中断发生了两次。在中断服务函数里加入一个独立的调试计数器发现中断触发次数确实是正常值的两倍。第三步查阅参考手册和标准库源码确认IDLE中断标志的清除方式。问题找到了。USART的空闲中断标志位IDLE在标准库中位于USART_SR寄存器的bit4清除方式分为两步先读SR寄存器再读DR寄存器。而USART_ReceiveData()函数恰恰只执行了“读取DR寄存器”这一步它不会主动去读SR寄存器。我的中断服务函数里虽然写了一句USART_ReceiveData(USART1)但在此之前并没有先读取SR寄存器导致IDLE标志没有被正确清除中断服务函数退出后硬件依然认为IDLE事件未处理随即再次触发中断。// 正确清除IDLE标志的方法 uint16_t temp_sr USART1-SR; // 先读SR uint16_t temp_dr USART1-DR; // 再读DR // 此时IDLE标志位才会被硬件自动清除5.3 另一个隐藏坑DMA传输完成中断标志未及时清理在定位空闲中断重复触发的同时还发现了一个潜在隐患如果同时启用了DMA传输完成中断DMA_IT_TCDMA的TCIF标志也需要在中断服务函数内清除。标准库中DMA_ClearITPendingBit(DMA1_IT_TC5)必须在DMA中断里调用否则会导致DMA中断反复进入CPU被无用中断持续占用。注意USART的IDLE标志清除方法和常见的TXE、RXNE标志清除方法不同后两者通常只需要读DR寄存器就可以自动清除但IDLE标志必须按“先读SR后读DR”的顺序操作。能够在调试时快速分辨这些寄存器细节对比参考手册的说明最有效。6. 通信库落地的调优参数与最终配置建议通信库基本稳定之后还需要根据实际项目场景做一轮参数调优。不同项目的帧长、波特率、主循环负载完全不同一个通信库要真正可用下面几个参数需要仔细权衡。6.1 接收缓冲区大小必须覆盖主循环消费周期缓冲区大小的确定逻辑不是“够放下最大帧就行”而是要覆盖主循环的消费周期。假设波特率115200帧长128字节帧到达时间约11.1ms如果主循环在极端情况下被某段耗时任务卡住20ms那么缓冲区至少要能容纳两帧甚至三帧的数据。缓冲不足的后果就是DMA循环模式覆盖未消费数据应用层解析到错帧。反过来缓冲区也不是越大越好stm32f103的RAM共20KB视具体型号而定通信库占用的缓冲区应从总RAM中合理分配。一般建议接收缓冲256~2048字节发送缓冲为最大发送帧长即可。6.2 中断优先级分组与实时性之间的平衡如果系统中同时存在定时器、外部中断、串口中断等多路中断源优先级配置直接影响通信质量。我给这套通信库的建议配置是USART空闲中断优先级组设置为2即2位抢占优先级、2位子优先级USART1_IRQn抢占优先级设为1子优先级设为0关键定时器中断抢占优先级设为0其他外设中断抢占优先级设为2或3USART抢占优先级比大多数外设高是因为通信数据帧有实时性要求一旦错过空闲中断的触发窗口帧同步信息就丢失了。但在有多路串口的系统中每路串口的空闲中断抢占优先级不要都设成最高否则多路串口同时到达数据时会造成中断互相抢占、处理时序混乱。6.3 DMA通道优先级的选择逻辑DMA控制器的通道优先级不是越高越好。多个DMA通道同时请求搬运时优先级高的通道会优先获得总线控制权。如果接收通道和发送通道都设为VeryHigh可能出现发送数据占用总线、影响接收数据搬运的情况。实际项目中我更倾向于接收通道设为VeryHigh、发送通道设为High。接收侧的数据如果延迟搬运USART的DR寄存器仅有一个字节深度新数据会覆盖旧数据造成不可恢复的丢字节。发送侧的数据由软件准备发送时机可以向前调整少量的DMA总线等待不会产生影响。7. 在现网项目里的使用效果与后续扩展方向这套通信库已经实际用在一个数据采集设备上stm32f103通过两个串口分别与传感器模块和上位机通信。传感器模块以115200波特率每秒上报约200帧数据上位机通信口为460800波特率。之前的中断逐字节方案在传感器数据量和上位机调试打印同时开启时系统偶发出现看门狗复位。切换到这个通信库后主循环CPU占用率从接近满载降到约三成连续运行一周没有出现任何通信异常。根据这套通信库的骨架后续扩展几个方向也很容易多路串口复用同一套环形缓冲区机制驱动层抽象成统一接口在接收链路中加入简单的帧同步状态机由中断服务函数完成帧头匹配把DMA缓冲区和应用缓冲区之间的数据拷贝改为双缓冲模式进一步降低拷贝开销将同样的DMA理念迁移到SPI外设实现高速传感器数据的零CPU搬运嵌入式通信的性能优化不是靠提升主频和使用更复杂的算法就能解决的很多时候需要回到外设本身理解硬件的工作机制把合适的任务交给合适的硬件单元。这套基于DMA和空闲中断的通信库思路并不复杂但它真正解决了串口通信中CPU被高频搬运拖死的问题。如果你也在用stm32f103标准库做UART DMA中断接收发送通信被中断频繁和数据丢失困扰照着这套链路搭一遍应该能明显感受到区别。最后再分享一个让我印象很深的小技巧调试串口通信问题时不要一开始就盯代码先用示波器或者逻辑分析仪看波形、数电平宽度。很多问题在物理层就已经决定了软件只是在为一个错误的前提做无谓的努力。确认物理链路可靠之后再逐步排查DMA通道映射、中断标志位、缓冲区指针这些细节效率会高很多。
返回列表