ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/7 1:07:25 拓冰建站 浏览量
STM32F103串口不定长数据接收:DMA与空闲中断的高效实现 串口不定长数据接收这个需求做嵌入式的几乎都会遇到。早些年大家用最笨的办法——一个字节进一次中断把数据存到数组里等判断超时或者收到预定帧尾再处理。这个方法理论上没问题但波特率一高、主频一忙频繁进中断对CPU的消耗就很扎眼而且稍不注意丢一个字节整帧就废了。后来我开始用DMA就是让串口数据自己进内存CPU完全不用管这就把接收这件事从“CPU的负担”变成了“CPU的助理”。再配合STM32F103那特有的空闲中断线路空闲检测就能做到“数据来了就收收完一整帧再通知你”。这套方案既不挑协议也不依赖固定帧长非常适合用在设备调试、通信协议解析、传感器数据采集这类场景。这篇东西适合两类人刚把STM32F103跑通、正准备写串口协议的初学者以及已经用传统串口中断收过数据、想优化项目结构的老手。我不打算把标准库和HAL库全堆一遍而是以最常用的HAL库为例把思路、配置、代码、坑点一次讲透。读完之后你能直接抄走这套框架改成自己的协议也方便。1. 项目思路与方案选型为什么是DMA加空闲中断1.1 传统串口中断接收的问题在哪里先来掰扯掰扯传统做法。最常见的是这样配置串口接收中断一个字节进一次中断在回调函数里把数据存进缓冲区同时开一个定时器或者用代码里的计数来判断“多久没有新数据了”认为收到了一整帧。这套方案的缺点是显而易见的——每个字节都要经历“中断响应、压栈、保存现场、处理、恢复现场”这一整套流程。假设波特率是115200每秒大约能传11520个字符也就是每87微秒就会来一个字节。这个时间看着还行但如果你在中断里干了太多活比如拷贝数组、判断长度、复位状态很容易超过这个间隔下一个字节就覆盖了硬件寄存器丢数据就开始了。一旦主程序里再有Flash擦写、ADC连续采样、按键扫描这类耗时操作把串口中断一卡接收基本就废了。更麻烦的是不定长。如果协议帧长固定比如每一帧永远是8个字节那还可以用“收到8个字节就处理一下”的思路。但现实里很多协议是命令结构比如一帧数据里包含“帧头、长度字节、数据域、校验”长度本身是变动的。传统做法得自己在软件里搭状态机判断帧头、提取长度、等够字节数代码会越写越绕。1.2 DMA能帮我们省掉什么DMA的思路是把数据搬运这个动作从CPU的活外包给专门的硬件通道。STM32F103内部有DMA控制器它可以在外设寄存器比如USART的数据寄存器和内存比如一个缓冲区数组之间直接搬运数据搬完再通知CPU。具体到串口接收就是让DMA把USART收到的每个字节自动写进内存数组CPU全程不知道也不需要干预。只要配置好搬运的目标地址和搬运数量串口来多少字节DMA就默默地存多少字节。这样的话不管波特率是9600还是921600CPU都不会被串口数据打断主循环可以专心做自己的事。但这里有个新问题DMA是按照你预先设定的“搬运次数”来干活的比如我设定搬运100个字节那它必须收满100个字节才停下来通知CPU。可通信数据是随机的我根本不知道一帧到底是几个字节如果设置了100那收到10个字节后剩下的90个字节永远等不来DMA就卡死在半路上。这个矛盾怎么解答案就是STM32的串口空闲中断。1.3 为什么要用串口空闲中断所谓空闲中断指的是串口在接收数据的过程中检测到线路电平由活跃变成空闲也就是一段时间内没有新的起始位出现时硬件会产生一个中断请求。注意它是硬件行为只要总线上超过一个字节时间没有数据就会触发不需要软件去判断超时。这个特性和DMA配合起来堪称完美。假设我配置DMA接收长度为100字节实际对方发来10个字节那么DMA先收走这10个字节然后总线空闲了串口硬件立刻产生空闲中断。我在中断里一查发现DMA计数已经从100减到了90说明实际收到10字节立刻把这10字节取走处理再重新把DMA配置好等待下一帧。整个过程CPU只在帧中断唤醒一次处理一下完事。这比我最初用的“定时器超时判断法”要干净利落得多不需要额外占用一个定时器也不需要自己在中断里维护时间变量。所以这套方案的选型理由非常明确DMA解决“数据搬运消耗CPU”的问题空闲中断解决“不定长帧边界怎么确定”的问题两者一配合串口通信这件事的复杂度被硬件扛掉一大半。2. 把原理吃透USART、DMA、IDLE三者的角色划分2.1 USART数据通路明白硬件视角的“收到”想把这套方案调通脑子里得有清晰的数据通路概念。串口数据进来之后硬件会做几件事把起始位、数据位、校验位这些电气信号转换成8位或9位数据放进DR数据寄存器然后把RXNE标志位置起来表示“有一个数据到了快来读”。如果你开启了接收中断这个时候就会跳中断如果你开了DMA接收请求那么硬件会自动把DR寄存器里的数据送到总线上触发DMA搬运。这块容易混淆的点在于RXNE标志和DMA传输的关系。HAL库的串口接收DMA函数HAL_UART_Receive_DMA会同时打开USART的DMA接收请求使能硬件一收到数据RXNE变高之后就自动启动DMA。整个过程中你的代码什么都不用做数据自己走总线进内存。还有一个容易忽略的细节DMA搬运完成后硬件会把串口的DMA接收标志TC传输完成置位同时自动清除接收使能。但如果你只做了接收DMA没有处理TC中断那么DMA搬满预设长度后串口接收就停在那儿了。这就是为什么在不定长场景下我们不会把预设长度设得太小——搬满了就得重新初始化一轮很麻烦。2.2 空闲中断的触发条件和特性空闲中断IDLE是USART硬件上的一个状态检测结果。片上的接收线路在经历了一段连续的低电平起始位和高低电平跳变的数据位之后一旦检测到连续的高电平空闲状态并且持续时间超过一个完整字节的时间IDLE标志就会被置起来。这个检测是由硬件完成的精度和可靠性远高于我们在定时器里用软件模拟的超时判断。不过要注意空闲中断一旦产生如果软件不及时清除IDLE标志它不会自己恢复下次可能就无法再触发。这正是很多人配置完之后发现“只能收第一帧后面就再也不进中断”的根本原因。还有一个细节空闲中断和DMA是两条独立的路径。DMA负责数据搬运空闲中断是串口检测到总线空闲时给出的“帧结束”信号两者处理的是不同的事件。启用空闲中断不需要打开USART的RXNE中断只要打开IDLE中断即可。这也意味着你的接收流程本质上完全可以做到“帧间零打扰”。2.3 DMA传输计数怎么理解很多人一开始会被DMA的传输计数搞晕。在STM32F103的数据手册里DMA接收数据的时候NDTR寄存器保存的是“当前剩余的需要传输的数据个数”。假设你初始化时设置传输数量为100DMA每搬运一个字节这个数就减一。所以想知道收到多少字节用初始值减去当前NDTR值就行。拿到剩余计数的标准方法是__HAL_DMA_GET_COUNTER(hdma_usart1_rx)或者直接操作寄存器DMA1_Channel5-CNDTR。在空闲中断里执行这两步后就能拿到当前帧实际数据长度。要注意的是读取这个值应该在取走数据之前完成否则数据一动计数关系就乱了。等处理完数据重新配置一次DMA接收把传输数量恢复成缓冲区最大长度下一帧就可以继续走同样的流程。3. 环境准备与基于CubeMX的初始化配置3.1 搭建工程与基本参数设置我用的是STM32F103C8T6最小系统板开发环境是STM32CubeMX加Keil MDK。在用CubeMX生成工程时第一步是选中芯片型号然后配置时钟树。这里有一个老生常谈但必须强调的坑默认状态下HSE可能没有启用或者PLL参数没有配置导致系统时钟不是72MHz。串口波特率如果不准后面收发的数据就是乱码。我会把HSE设为外部晶振主频PLL倍频到72MHzAPB2总线时钟设为72MHz供给USART1使用。USART1的配置就简单了我选异步模式波特率先用115200数据位8位、停止位1位、无校验。收发引脚默认是PA9TX和PA10RX。注意如果板子上有板载USB转串口芯片通常连接的是这两个引脚所以不用改。没有的话外接USB转TTL模块时TX接RX、RX接TX这个接线关系也常有人搞反收到乱码后往往先怀疑配置实际上只是线接反了。3.2 DMA通道分配与中断优先级处理USART1的接收DMA默认挂在DMA1的Channel5上发送DMA挂在Channel4上。CubeMX里操作很简单在USART1的配置页打开DMA Settings选项卡点击Add添加USART1_RX通道选择DMA1 Channel5方向设为PeripheralToMemory也就是外设到内存再添加USART1_TX方向设为MemoryToPeripheral也就是内存到外设。模式那里接收端最关键要选Circular循环模式。循环模式会让DMA在一个缓冲区里一圈一圈地写数据永远不会因为计数归零而停止这对不定长接收来说非常重要。发送DMA的模式选Normal就行因为发送的数据都是一次性的不需要循环。内存地址增量两个方向都要开启外设地址不增量。数据宽度两边都设为Byte因为串口一个字节一个字节传输。这样配置完之后CubeMX会自动生成DMA初始化函数和串口DMA相关的定义但我们最终还是要手动加一些接收逻辑。然后是中断优先级。DMA接收和空闲中断都要用到NVIC配置。我在CubeMX里把USART1全局中断优先级设为抢占优先级1、子优先级0DMA1通道5中断也设为同样的优先级。有人喜欢让DMA优先级更高实际使用中差别不大但原则是不要让这两个中断被其他低优先级中断堵塞太久。调试WiFi模块或者外接高速传感器时如果主循环里有延时长的阻塞操作建议把串口相关中断优先级调得比它们高。3.3 关于接收缓冲区的规划定长接收可以任意指定缓冲区大小但不定长接收要把缓冲区设置成“最大可能帧长”。我的习惯是给接收缓冲区分配128字节或256字节。太少的话某些超长协议帧会在DMA还没处理完就被覆盖太多的话浪费RAM且NDTR计数范围也没必要拉满。缓冲区数组定义的时候要注意对齐和生命周期。因为DMA是硬件直接写内存数组不能是局部变量必须定义成全局变量。有些编译器还建议做特定对齐我用的Keil默认8字节对齐已经能满足需求。数组命名我用uint8_t rx_buf[256]。然后我再定义一个结构体或者几个全局变量来记录帧长度、处理状态等。这块可以根据项目风格随意点但核心是缓冲区必须常驻内存不能被优化掉或放在栈上。4. 核心代码实现每一段都掰开讲清楚4.1 启动接收DMA和空闲中断的初始化顺序初始化这件事顺序非常重要。我踩过一次坑先开了空闲中断再启用DMA接收结果第一帧数据到达时空闲中断触发了但读取NDTR计数居然为0数据丢失。原因是DMA还没真正进入接收状态串口硬件已经把数据收下来了。正确顺序是先把DMA接收启动起来再使能空闲中断。也就是说初始化阶段执行HAL_UART_Receive_DMA(huart1, (uint8_t *)rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);第一行让DMA开始工作硬件收到数据会直接往缓冲区里放第二行打开串口空闲中断这样每帧结束后有硬件通知。注意HAL_UART_Receive_DMA函数在HAL库里会把串口的DMA接收请求使能起来但它并不会把IDLE中断打开所以两行缺一不可。4.2 空闲中断服务函数解析帧长度接下来是重头戏串口中断服务函数。STM32F103的USART1中断向量是USART1_IRQHandler在HAL库里已经映射到了HAL_UART_IRQHandler这个函数会判断各种中断标志。但如果直接用HAL库你会遇到一个问题HAL库的默认中断处理流程里没有空闲中断的响应入口所以我们要自己写。我的做法是在USART1_IRQHandler里先判断IDLE标志是否置位如果置位就进入自己的处理逻辑否则再调用HAL库的标准处理函数。大致的代码框架是这样的void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t rx_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (rx_len 0) { // 标记一帧数据就绪记录长度交给主循环处理 g_rx_frame_len rx_len; g_rx_frame_ready 1; } // 重新启动接收准备下一帧 HAL_UART_Receive_DMA(huart1, (uint8_t *)rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }先解释清除IDLE标志这一行。F103的USART IDLE标志清除方式有些特殊正确做法是“先读SR寄存器再读DR寄存器”。在HAL库里__HAL_UART_CLEAR_IDLEFLAG宏帮我们做了这个事。如果你不把它清掉中断标志一直挂着后面再收到新数据时IDLE标志不会重新置位你的帧边界判断就会失效。这一行是真的不能省。然后获取DMA计数计算实际收到多少字节。__HAL_DMA_GET_COUNTER返回的是NDTR寄存器的剩余值用初始值RX_BUF_SIZE减去它得到的就是DMA已经搬进缓冲区里的字节数。这里我用了uint16_tNDTR是16位的最大值65535所以缓冲区不超过这个范围都没问题。接下来标记帧就绪这个是给主循环用的。把长度存到全局变量里置位标志位。主循环检测到这个标志为1就能带着长度去处理缓冲区。处理完记得把标志清掉。最后是重新启动DMA接收。关键点来了空闲中断触发时DMA并没有停止如果配置成循环模式它还在继续搬运。但是我们已经从DMA缓冲区取走了数据如果不重新配置一次下一次接收的数据会接着写到当前DMA位置。比如上一帧收到10字节下一帧又收到10字节新数据会写在缓冲区第10字节之后的位置导致数据错乱。重新调用HAL_UART_Receive_DMA本质是把写入位置重新指到缓冲区首地址同时也重置NDTR到满值下一帧就干干净净从头开始存。需要特别提醒的是HAL_UART_Receive_DMA这个函数里如果DMA正忙它会先调用一次HAL_DMA_Abort停止传输再重新配置。在某些极端情况下直接重新调用可能会和正在进行的接收冲突。如果在实测中发现偶尔丢帧或者卡死可以改成手动关DMA通道、清标志、再重新配置的方式这个问题在后面调试章节再展开。4.3 DMA发送一句话说清的思路接收那边处理完发送相对简单很多。所谓DMA发送就是把内存里一段数据交给DMA搬进USART的DR寄存器硬件自动把数据按位发出去。HAL库提供两个函数HAL_UART_Transmit_DMA(huart1, (uint8_t *)tx_buf, tx_len);以及带中断回调的版本HAL_UART_Transmit_DMA_IT(huart1, (uint8_t *)tx_buf, tx_len);我一般直接使用HAL_UART_Transmit_DMA配合传输完成回调函数来判断发送结束。HAL库的DMA发送完后会在HAL_UART_TxCpltCallback里通知你可以在这里置位发送完成标志。要注意的是发送缓冲区tx_buf如果在发送完成之前被主循环改写了发出的数据就是错的。所以我通常会把待发送的数据先拷贝到一段临时发送缓冲区再启动DMA发送。发送完成后回调里置标志主循环根据标志去更新这段缓冲区。如果嫌拷贝麻烦也可以用环形发送队列但这个不在本文范围下次再单独写。4.4 主循环的处理逻辑主循环这边不需要做什么复杂的事就是检测帧就绪标志然后处理数据最后清标志。帧处理和接收应该解耦否则一个耗时的解析操作会把下一帧的接收窗口堵住。while (1) { if (g_rx_frame_ready) { uint16_t len g_rx_frame_len; g_rx_frame_ready 0; // 在这里解析 rx_buf 的前 len 个字节 handle_frame(rx_buf, len); } }如果解析操作确实很耗时比如要解析JSON、下载到Flash里建议把接收到的数据先搬进另一个全尺寸副本缓冲区再把就绪标志清掉。这样下一帧到来时DMA可以放心地往原缓冲区写你手里拿的是上一帧的完整副本。动手能力强一点的话还可以搞双缓冲轮流切换但基础项目用副本拷贝就足够了。5. 调试实录最容易踩的五个坑以及定位方法5.1 只能收到第一帧之后就再也不进空闲中断这个坑非常经典原因就是在第一次空闲中断里没有正确清除IDLE标志。IDLE标志不消除硬件检测到下一次总线空闲时不会再次置位这个标志中断自然就不来了。解决办法就一句话在空闲中断服务函数里先清IDLE标志再处理数据顺序不要反。我见过有人把清标志的宏写在最后结果每次都还能进中断那是因为实际清掉的时机已经晚了恰好赶上数据处理的窗口。严谨的写法应该像上面代码那样一进中断立刻清标志。5.2 收到的是乱码DMA计数也对不上乱码原因很多最常见的是波特率不匹配。这时先不要怀疑DMA配置直接用一个简单的回环测试——把TX和RX短接发送一个字节看收不收得到。如果收发正常说明USART硬件基础没问题问题出在对端设备的波特率或者电平上。如果确定波特率正确还有乱码另一个常见原因是缓冲区数据宽度配置错成了半字或字。DMA配置里源地址和外设的数据宽度必须都是Byte时钟树如果APB2不是72MHz波特率也会有偏差这个在前面配置章节强调过了。5.3 主循环刚处理完一帧下一帧就把上一帧覆盖了这个问题的本质是缓冲区只有一份DMA一直在往里写而处理逻辑还没读完上一帧。在高速率或者大数据量传输下特别容易出现。我的解决方案是维护一个“帧副本”检测到g_rx_frame_ready 1时立刻把rx_buf里len长的数据拷贝到另一个专属解析缓冲区然后再清就绪标志。这样DMA可以立刻重新开始接收硬件不等待软件解析逻辑慢慢处理副本就行。代价是RAM多了点但换来的是接收的高可靠性和低延迟。5.4 在空闲中断里处理太多事情导致时序错乱有些人进空闲中断之后直接在里面解析协议、回发响应甚至调用延时函数。这在低速测试时看着没问题一旦数据接收节奏变快就会拖住整个接收链路。正确思路是中断服务函数里只做“取长度、置标志、重新启动DMA”这三件事剩下的都丢给主循环。中断里的代码越少越不会出错这个原则在串口场景下尤其重要。5.5 重新启动DMA时偶发卡死如何彻底规避前面提到过HAL_UART_Receive_DMA内部有停止动作在极短的帧间隔下两次调用可能产生冲突。我遇到过一次上位机持续高速发数据程序跑几秒就卡死调试发现是DMA的传输完成中断和多字节接收发生竞争。后来我把重新启动的方式改成先关通道、清标志再重新配置解决问题。关键代码如下__HAL_DMA_DISABLE(hdma_usart1_rx); __HAL_DMA_CLEAR_FLAG(hdma_usart1_rx, DMA_FLAG_TC | DMA_FLAG_HT | DMA_FLAG_TE); HAL_UART_Receive_DMA(huart1, (uint8_t *)rx_buf, RX_BUF_SIZE);第一行直接关DMA通道第二行把传输完成、半传输、传输错误几个标志清掉第三行再重新拉起接收。虽然多写两行但在高速场景下更稳。如果你只是做常规调试直接用HAL函数也没问题但知道这个兜底方案总有好处。6. 扩展与个人体会这套方案还能怎么用折腾完串口的DMA收发之后你会发现这套“DMA加标志中断”的思路其实不止用在USART上。SPI接收、ADC连续采样、外部存储器读写都是同样的套路硬件搬运数据到内存状态标志通知CPU处理。掌握了这套框架后面再碰到的外设驱动都会顺手很多。我自己在实际项目里还有几个习惯。一是在工程里把命令解析器和通信层分开串口回调里只管把原始字节投递给上层解析逻辑放进独立的模块这样换通信介质时上层代码不用动。二是调试阶段习惯用逻辑分析仪或者带时间戳的串口调试助手确认帧间隔特别是在修改波特率之后。三是如果项目对功耗有要求这套方案也天然优于轮询接收因为CPU大部分时间可以睡觉DMA搬运时不需要唤醒内核帧结束时才起来处理一下。最后再分享一个小技巧调试过程中可以临时把收到的原始字节以十六进制形式回显出来确认DMA的拷贝长度和内容符合预期再进入协议解析的开发。先用这套办法确认底层链路没问题再去调试上层逻辑会省掉很多无效的排查时间。把这几个要点记好串口不定长接收这条路基本就走稳了希望这篇东西能帮你少踩几个坑。