做串口通信,尤其是 STM32F103 这类资源不算宽裕的 MCU,最烦人的往往不是协议解析,而是“不知道对方什么时候把数据发完”。如果你还在用轮询方式读 USART,或者每来一个字节就进一次中断,那在 115200 甚至更高波特率下,CPU 大概率会被收发逻辑拖死。而 DMA 加空闲中断这套组合,就是解决“不定长、高速率、低 CPU 占用”接收需求的最实用方案。这篇文章我尽量把自己实际调试过程中踩过的坑讲透,从方案选型、CubeMX 配置,到完整 HAL 库代码和常见排错,适合刚把串口调通、准备做正经通信协议的开发者参考。
1. 方案选型:为什么串口接收要上 DMA + 空闲中断
1.1 先算算轮询和单字节中断的账
很多人一开始都是这么写串口的:HAL_UART_Receive(&huart1, &byte, 1, timeout)放在主循环里轮询,或者开HAL_UART_Receive_IT(&huart1, &byte, 1)一个字节一个字节收。这两种方式在调试阶段没问题,但一旦数据量上来,立刻露怯。
拿 115200bps 来说,1 个字节约 86.8us。如果用轮询,主循环必须保证在 86.8us 内回来读一次数据寄存器,否则就会 ORE 溢出。主循环里写点 Flash、刷个屏、跑个算法,很容易就超时。用单字节中断稍微好一点,但每收一个字节就要进一次中断,CPU 要现场保护、跳转、处理、恢复,一个中断没个 20~50us 出不来。115200 还好,到了 460800、921600 波特率时,每个字节只有 10.8us、5.4us,中断处理开销占比相当可观。
不做生意不知道,一算账吓一跳。比如 921600 波特率下,一帧 512 字节的数据,光接收时间约 5.6ms,如果用单字节中断,意味着这 5.6ms 里 CPU 要处理 512 次中断,基本上其他任务全部停摆。这就是典型的“能跑但不敢用”。
1.2 DMA 批量搬运解决了什么
DMA 的价值不是“省电”,而是把逐字节搬运这件事从 CPU 手里拿走了。外设的 RDR 寄存器收到一个完整字节后,DMA 控制器直接把它搬到你指定的内存缓冲区,搬满一个字节计数一次。整个过程 CPU 完全不参与,既不进中断也不读寄存器,直到一整个缓冲区被填满或触发我们自定义的事件。
这意味着同样 512 字节的一帧数据,CPU 可能只在最后处理一次,中间这段时间可以安心跑自己的主循环。对 STM32F103 这种主频 72MHz、资源有限的老选手来说,DMA 几乎是必须的。
这里要多说一句,很多人以为 DMA 只能做定长接收,实际上配合串口外设的 IDLE 空闲中断,DMA 可以做到非常优雅的不定长接收。数据量小、帧长短的项目,甚至可以把接收缓冲区设成 256 字节,一帧接一帧地收,效果爽到不行。
1.3 IDLE 空闲中断就是“一帧结束”的裁判
不定长接收的核心矛盾在于:接收方不知道对方这次会发多少个字节,也不知道数据什么时候结束。常见方案有三种:协议里固定包头包尾、数据包里带长度字段、接收方用超时判断。
IDLE 空闲中断属于第三种,而且它是在硬件层面帮你判断空闲。串口线在空闲状态下是高电平,当一个字节传输结束后,如果 RX 线上超过一个字节时间内没有新的起始位,硬件就会把 IDLE 标志位置 1。换句话说,一帧数据发完后,只要链路静默一小段时间,IDLE 中断就会通知你,“这一帧大概结束了”。
为什么说大概?因为 IDLE 标志是“最后一次接收后空闲了一个字节时间”的指示,它不能区分你是收到了完整的帧,还是在帧中间发生了超时。所以实际使用时,业务协议里最好还是定义最大帧长、超时容错或者包尾校验,纯靠 IDLE 做严格可靠的分帧是不行的。但作为“不定长接收的触发信号”,IDLE 的性价比极高,因为它不需要额外硬件、不占用定时器,打开标志位就行。
1.4 和定时器超时、固定长度接收的对比
既然 IDLE 只是“空闲一个字节时间”的判断,那有人会问:我直接用定时器输入捕获或者干脆开个基础定时器,收到字节后重置计数,超时了也当作一帧结束,行不行?当然行,这就是软件超时方案。
但软件超时有一个致命问题:它占资源。一个字节一个字节地接收数据,每次都要把定时器计数清零,如果主循环在忙别的事,定时器中断处理不及时,很容易出现误判。而 IDLE 是串口硬件自己监测的,你只管在标志位置位那一刻去读取 DMA 计数,不需要主动跟踪每个字节,实现简单得多。
还有固定长度接收。如果通信双方协议固定,比如每帧固定 8 字节、64 字节,那直接HAL_UART_Receive_DMA等接收完成中断就可以。但实际产品中,命令帧、应答帧、日志帧长短不一的情况极多,定长接收会浪费带宽或者拆包,维护起来也痛苦。DMA+IDLE 不定长接收,既保留了 DMA 的低开销,又解决了变长数据的分帧问题,属于这个场景下的最优解。
2. 硬件与 CubeMX 配置细节
2.1 最小系统和 5V 转 3.3V 电平匹配
STM32F103 的 USART1 默认引脚 PA9/PA10。用最小系统板的时候,最容易被坑的是电平匹配。F103 是 3.3V 供电,但很多外设模块,比如老式 GPS、蓝牙模块、串口屏,常常是 5V 逻辑。如果直接用 5V 的 TX 接到 PA10,轻则读不到数据,重则把引脚搞坏。
我实际使用的做法是:对方 5V 输出接一个 10K 电阻分压到 3.3V,或者用 TXS0108 这类电平转换芯片。如果只是单向传给 STM32,两个电阻分压就够;如果要双向交互,老老实实上电平转换芯片。还有人问我能不能直接用 5V 兼容引脚,F103 绝大多数 GPIO 不是 5V 容忍的,别赌。
另外,如果外设模块可以先从 3.3V 供电,很多 5V 模块也能工作,但通信电平标准会变,容易造成误码。稳妥的做法还是控制在 3.3V 逻辑电平下通信。
2.2 CubeMX 时钟与串口参数
时钟配置建议直接让系统跑满 72MHz。在 CubeMX 的 RCC 里选 HSE,外部晶振 8MHz,PLL 倍频 9 倍,得到 SYSCLK 72MHz,APB1 36MHz,APB2 72MHz。USART1 挂 APB2 上,波特率计算准确,配置 115200 时实际误差很小。
串口参数基本上是模版套用:波特率 115200、数据位 8、停止位 1、无校验、无流控。这里有一点很关键:如果你计划用 DMA 收发,模式先选 Asynchronous,然后下方的 DMA Settings 里添加 DMA 请求,而不是在 GPIO 设置里纠结。
CubeMX 生成的MX_USART1_UART_Init函数会把外设初始化好,但默认不会开启 DMA 接收。开启 DMA 接收的完整流程在代码里写,不在 CubeMX 里搞,这样重新生成代码时不容易丢配置。
2.3 DMA 通道配置:方向、数据宽度与模式
USART1 的 DMA 请求在 F103 上有固定的映射关系:发送是 DMA1 通道 4,接收是 DMA1 通道 5。CubeMX 里添加请求时,通常会自动分配到正确的通道。别自己随手改通道,改完大概率收不到数据或者发不出去。
参数配置上,方向分为 MemoryToPeripheral 和 PeripheralToMemory。发送用前者,接收用后者,这个一般不会错。容易踩的是数据宽度:外设数据宽度和数据存储器数据宽度都必须设成 Byte,因为串口寄存器是 8 位,缓冲区是 uint8_t 数组。如果把外设或内存宽度配成 HalfWord、Word,DMA 会把两个字节拼成一个半字来操作,数据直接乱掉。
模式这里要仔细想。很多新手直接默认 Normal,接收方向配 Normal 的含义是:DMA 搬运完缓冲区大小的数据后自动停止,不会再自动开始下一轮。如果你用空闲中断来分帧,Normal 模式正好:每接收完一帧(空闲中断触发),你手动停止 DMA、处理数据、再重新启动 DMA。如果没处理完就有人发下一帧,数据可能会被覆盖或丢失。
如果使用 Circular 循环模式,DMA 在传输完缓冲区大小后,计数器会自动重装载,继续往里写。这种模式适合做环形缓冲,但需要额外管理读写指针,复杂度高不少。对大多数场景,我建议先上 Normal 模式,跑通再考虑 Circular 优化。
2.4 中断优先级与生成代码的坑
串口全局中断需要打开:在 NVIC Settings 里勾选 USART1 global interrupt。DMA 中断要不要开?如果接收使用 Normal 模式,缓冲区大小大于最大帧长时,理论上永远走不到 DMA 传输完成中断,所以可以不勾选接收 DMA 中断,只靠 IDLE 触发。发送 DMA 的完成中断建议勾上,因为你需要知道一包数据发完了,才能发下一包。
优先级设置上,串口全局中断优先级可以设高一点,比如抢占优先级 1、子优先级 0。DMA 发送完成中断优先级比串口略低也没关系,反正是异步的。但要注意,如果你同时使用串口接收的 IDLE 中断,它的分发入口和串口中断是在一起的,优先级设置成和串口一样。
CubeMX 生成代码后有个常见的坑:它不会自动调用HAL_UART_Receive_DMA,也不会自动使能 IDLE 中断。必须在main.c的初始化部分,或者在串口外设初始化完成后手动加代码。还有一些旧版本 HAL 库,UART_IT_IDLE的宏名可能略有差异,但 CubeMX 配的 HAL 库版本一般不会有这问题。
2.5 printf 重定向的小细节
调试阶段离不开 printf。不少人重定向完 printf 后发现,用 DMA 发送时程序会卡死或者丢数据。原因很简单:stdio 的 printf 是同步调用,它调fputc一个字节一个字节地往串口写。如果你在fputc里用 DMA 发送单字节,就会有一堆并发问题。
比较稳妥的做法是,调试打印用阻塞式发送,业务数据再用 DMA。fputc里直接操作寄存器等待 TXE 标志,写 DR 寄存器,十个字节以内的日志一点问题没有。具体代码在下一节说。注意使用 Keil 时要勾选 MicroLIB,否则半主机相关的底层会拖慢甚至导致程序跑飞,这个是老生常谈但真的容易忘。
3. 代码实现:DMA 发送 + 空闲中断不定长接收
3.1 接收缓冲区与全局状态定义
写代码前,先把数据结构和状态标志定义好。我的习惯是定义一个缓冲区、一个长度变量,还有一个“收到一帧”的标志。缓冲区大小建议取最大帧长的 1.5 倍以上,但不要大到浪费内存。STM32F103C8T6 有 20KB RAM,256 字节缓冲区很安全。
#define RX_BUF_SIZE 256 uint8_t rx_buff[RX_BUF_SIZE]; // DMA 接收缓冲区 volatile uint16_t rx_len = 0; // 当前接收到的数据长度 volatile uint8_t rx_complete = 0; // 一帧接收完成标志这里的volatile不能省。rx_len和rx_complete都在中断里被修改,主循环读取,编译器如果不加 volatile,优化时可能缓存值,导致主循环读不到最新数据。嵌入式的“玄学问题”很多就是这样来的。
全局变量命名不建议用太通用的rx_buff加在库代码里,容易和你其他模块冲突。规范一点,可以在前面加模块前缀,比如uart1_rx_buff。
3.2 启动 DMA 接收的标准流程
CubeMX 初始化外设后,必须手动调用一次HAL_UART_Receive_DMA,并且打开 IDLE 中断。很多新手把这两行的顺序搞反,结果第一帧数据来了 IDLE 不会触发,或者 DMA 根本没在接收。
我通常封装成一个函数:
void UART1_StartRxDMA(void) { // 先清掉可能的接收错误标志,防止 DMA 启动即失败 __HAL_UART_CLEAR_OREFLAG(&huart1); __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止旧 DMA 接收,再重新开始 HAL_UART_DMAStop(&huart1); HAL_UART_Receive_DMA(&huart1, rx_buff, RX_BUF_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }初始化时调用一次即可。为什么先DMAStop再Receive_DMA?因为 HAL 库里如果上一次 DMA 接收处于 BUSY 状态,直接调用HAL_UART_Receive_DMA会返回HAL_BUSY并且什么都不干。加上这一句能保证每次调用都干净。
__HAL_UART_CLEAR_OREFLAG这个宏很多人不熟悉,但它很重要。如果 DMA 还没启动,串口外设已经在接收数据,RDR 没人读,溢出标志 ORE 会置位,之后 DMA 无论如何也收不到新数据,而且这个标志不一定被 HAL 自动清除。所以在启动前清一次是安全的。
3.3 空闲中断处理与长度计算
CubeMX 生成的串口中断处理函数USART1_IRQHandler默认只调用了库自带的HAL_UART_IRQHandler,而 IDLE 中断不是标准 HAL 库帮你处理的类型,需要你自己加代码。
推荐这样改:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); UART1_IDLE_RxHandler(); } }IDLE 中断真正干活的函数,是读取 DMA 当前计数,计算已经接收的数据长度。STM32F103 的 DMA 计数器NDTR保存的是“还剩多少字节没搬”,所以已经收到的数据长度是缓冲区大小 - 当前 DMA 计数器。
void UART1_IDLE_RxHandler(void) { uint16_t remain; uint16_t len; remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); len = RX_BUF_SIZE - remain; if (len > 0) { rx_len = len; rx_complete = 1; // 通知主循环处理 // 手动停止 DMA,防止后面的数据继续写入缓冲区 HAL_UART_DMAStop(&huart1); } }这里有三个细节。
第一,计算长度用 DMA 计数器,而不是自己维护一个累加变量。因为 DMA 控制器在后台搬运,CPU 并不知道实际搬了多少,只有计数器最可靠。第二,为什么要DMAStop?因为如果 DMA 还开着,下一帧数据来了会继续写到缓冲区头,把没处理完的数据覆盖掉,所以必须停止,等主循环处理完再重新启动。第三,rx_complete置 1 后,主循环要尽快处理,否则 DMA 是停着的,后续数据会溢出丢失。
3.4 主循环消费一帧数据
主循环的写法其实很简单:检查rx_complete,处理完一帧,然后重启 DMA 接收。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); UART1_StartRxDMA(); while (1) { if (rx_complete) { rx_complete = 0; // 这里做你的业务解析 ProcessFrame(rx_buff, rx_len); // 处理完后重新开启 DMA UART1_StartRxDMA(); } // 其他任务... } }注意,进入if处理业务时,DMA 是停止状态,所以即使此时有串口数据进来,也只会被串口外设接收,不写入缓冲区,同时 ORE 溢出标志可能会置位。因此我在UART1_StartRxDMA里清溢出标志。这个流程属于“单缓冲 + 串行处理”模型,优点是简单、安全,缺点是一帧处理期间无法同时接收下一帧。如果你的业务处理很耗时,就需要看后面第五节的进阶方案。
3.5 DMA 发送与发送完成管理
发送部分比接收简单,核心是防止调用HAL_UART_Transmit_DMA时上一次发送还没结束。HAL 库如果发现串口状态不是 READY,会直接返回HAL_BUSY,你的数据就丢了。
一个轻量做法是维护发送状态标志:
static volatile uint8_t uart1_tx_busy = 0; void UART1_SendDMA(uint8_t *data, uint16_t len) { while (uart1_tx_busy) // 等待上次发送完成 { // 可以在这里加超时保护 } uart1_tx_busy = 1; HAL_UART_Transmit_DMA(&huart1, data, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart1_tx_busy = 0; } }while (uart1_tx_busy)在极端情况下可能死等,所以生产环境最好加一个超时计数器。不过串口发送本身很快,一包几百字节在 115200 波特率下也就十几毫秒,应用层通常可以接受简单等待。
有一点要提醒:HAL_UART_Transmit_DMA如果传入的数组是局部变量,函数返回时数组已经不在栈上,DMA 却还在后台读取这些内存,数据就乱了。DMA 发送必须保证源数据在整个发送期间有效,要么用静态数组,要么等待发送完成再修改数据。
3.6 重定向 printf 的可靠写法
调试打印我不用 DMA,直接用寄存器操作写fputc,这样不会触发 DMA 发送的并发问题,而且速度也够。
int fputc(int ch, FILE *f) { while (!(USART1->SR & UART_FLAG_TXE)) { } USART1->DR = ch; return ch; }这个写法依赖 F103 的 USART1 寄存器映射。在 Keil 里勾选 MicroLIB,然后在main.c里写printf("hello\r\n");就正常输出了。这里把 SR 的 TXE 位当成“发送数据寄存器空”的标志,写 DR 后由发送器取走,逻辑上完全够用。
有人会问,为什么不用HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 100)?也可以用,但 HAL 函数每次都要做状态检查、波特率判断、超时计算,频繁调用时开销大。直接寄存器操作最省事,也比 HAL 不容易被误调用搞乱。当然,如果你想统一接口,用 HAL 阻塞式也行,只是别和 DMA 发送混用同一个串口就行。
4. 常见问题与排错实录
4.1 首帧丢失或完全收不到数据
出现这个问题的概率非常高,相信我。一般先查三处。
第一,你启动 DMA 接收了吗?CubeMX 不会在你调用HAL_UART_Receive_DMA之前开始接收。很多人初始化完直接跑主循环,发现串口没反应,就是漏了这一步。
第二,IDLE 中断有没有真的被使能?__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)必须放在串口初始化之后,且USART1_IRQHandler里要有对应的处理逻辑。还有,不要在HAL_UART_IRQHandler返回之前处理 IDLE,我试过有些 HAL 版本会抢占中断标志,导致明明有数据却以为没有。
第三,DMA 启动前,串口有没有 ORE 溢出标志?如果启动 DMA 之前串口已经收到过数据,RDR 没被读,ORE 置位,后续 DMA 接收可能直接失效。启动函数里先清一次__HAL_UART_CLEAR_OREFLAG能避免这个坑。
另外,还有人用HAL_UART_Receive_DMA(&huart1, rx_buff, RX_BUF_SIZE)后,立刻在回调里接收数据,发现首帧长度不对。实际上,DMA 计数器更新有一个微小的延迟,IDLE 标志刚触发时 DMA 计数器还没完全减到最终值。如果首帧长度总是差 0~1 个字节,不一定是 DMA 算错,可能是你处理得太快。稳妥做法是等一个短暂延时,或者在 IDLE 中断里先什么都不做,等下一次中断再处理。我个人的习惯是:如果帧之间有空隙,IDLE 中断处理时 DMA 已经稳定,长度基本准确;如果确实差,就在协议层做容错。
4.2 帧长度算错、多一字节少一字节
不定长接收最烦的就是长度不准。首先要分清用的 DMA 计数器是哪一个。如果你有两个 DMA 通道,一个发送一个接收,取计数器时别拿错。我见过一个兄弟__HAL_DMA_GET_COUNTER(&hdma_usart1_tx)去算接收长度,结果当然是错的。
其次,检查是不是没有先停 DMA 再读计数器。DMA 还在运行的时候,计数器每收到一个字节都会变,你读到的可能是个中间值。正常流程是 IDLE 标志置位后,立刻停 DMA,再读计数器,这样长度才是稳定的。
最后,如果用的是 Circular 模式,长度计算逻辑完全不同。Circular 模式下 DMA 计数器从大小减到 0 后会重新装载,如果你还按RX_BUF_SIZE - counter来算,在循环覆盖后会出现很大的跳变。要么用双缓冲写在协议层维护写指针,要么直接用 Normal 模式,别上来就搞 Circular,除非你确实需要连续接收。
4.3 IDLE 中断频繁触发与 ORE 溢出
有朋友反映,板子运行一段时间后,串口就像抽风一样不停进 IDLE 中断,或者出现一次 ORE 溢出后,后续数据全丢。这多半是因为 DMA 停止后串口还在工作。串口外设收到新数据,但没有人把 RDR 里的数据搬走,硬件无法处理下一个字节,ORE 标志就会置位,之后接收完全停滞。
所以每次重启 DMA 前,务必清 ORE 标志。另外,如果主循环处理一帧业务用了很长时间,DMA 一直处于停止状态,这段时间内串口数据进来就会触发 ORE。这不是中断逻辑错误,而是“单缓冲串行处理”架构的固有缺陷。解决办法是缩短临界区时间,或者用双缓冲、环形缓冲让 DMA 始终处于工作状态。
还有一点,IDLE 标志是“置位后需要手动清除”的类型,如果不清除,它会反复触发。在USART1_IRQHandler里我已经用__HAL_UART_CLEAR_IDLEFLAG清掉了,但如果你的代码在别处也开了中断,同时有两个地方清除或读取,会造成重复处理。建议在整个模块里只保留一个入口处理 IDLE。
4.4 Normal 和 Circular 选错导致数据错乱
如果 DMA 模式选了 Normal,缓冲区大小正好等于一帧数据长度,那么 DMA 搬完后会自动停止,并且触发传输完成中断。此时 IDLE 标志可能还没到,你会在 DMA 完成中断里拿到一帧数据。如果选成 Circular,DMA 不会停止,传输完成标志变成半传输/全传输回调,而你的空闲中断处理也可能在同一时刻触发,两边数据逻辑打架,帧就乱了。
实际工程里,我倾向于让缓冲区大小明显大于最大帧长,比如最大帧 100 字节,缓冲区设 256 字节,这样 DMA 正常模式下永远不会在空闲中断之前搬满缓冲区,IDLE 始终是唯一的“帧结束”信号。这个设计干净。
如果缓冲区实在小,或者协议允许大于缓冲区的单帧数据,那就得提前处理 DMA 接收完成中断,在里面把缓冲区前半段搬运到业务队列,再重新初始化 DMA。这就是“分片传输”了,代码复杂度明显上升,建议先确认需求再动手。
4.5 发送与接收 DMA 通道的混淆
在 F103 上,USART1_TX 的 DMA 通道是 DMA1 通道 4,USART1_RX 是 DMA1 通道 5。有些开发板可能把某个 DMA 通道用在了 ADC 或者 SPI 上,你分配串口 DMA 时需要留意冲突。比如 SPI1_TX 和 USART1_TX 的 DMA 请求可能落在同一条 DMA1 通道的不同外设请求上,如果你同时启用,通道会互相抢占,串口或者 SPI 就不正常。
CubeMX 一般会自动安排好,但手动写代码的人容易搞错。调试时可以用调试器看 DMA 通道的CCR寄存器,确认外设请求映射是否对。F103 的参考手册 RM0008 里有完整 DMA 请求映射表,查一下 30 秒的事。
4.6 常见问题速查表
| 现象 | 可能原因 | 建议处理 |
|---|---|---|
| 完全收不到数据 | 未调用 HAL_UART_Receive_DMA | 初始化后手动启动 DMA 接收,并开启 IDLE 中断 |
| 首帧长度不对 | DMA 计数器未稳定 / 开启接收前有残留标志 | 启动前清 IDLE 和 ORE;必要时稍等再取长度 |
| 数据正常但偶发丢字节 | ORE 溢出 / 主循环处理太慢 | 缩短处理时间;处理完立即重启 DMA;清 ORE |
| IDLE 中断反复进入 | IDLE 标志未清除 | 每次进入中断后__HAL_UART_CLEAR_IDLEFLAG |
| DMA 发不出数据 | 通道映射错误 / DMA 模式配置异常 | 查 RM0008 的 DMA 请求表,核对通道 |
| DMA 发送只发一次 | 未处理 TxCpltCallback 或发送状态未复位 | 发送完成回调里清忙标志 |
| 用 DMA 重定向 printf 后打印乱 | DMA 异步和 stdio 同步冲突 | 业务通信用 DMA,调试打印用阻塞式 fputc |
这张表基本覆盖了我自己在项目里踩过的坑,尤其是前三条,几乎每次换平台都要重新确认一遍。
5. 进阶思路:多帧连续接收与系统整合
5.1 双缓冲乒乓接收
如果主循环需要花时间去解析协议,而串口数据来得又快,单缓冲肯定顶不住。一个简单的优化是双缓冲乒乓:两块缓冲区,DMA 往 A 缓冲写,CPU 处理 B 缓冲;下一帧 DMA 写 B,CPU 处理 A。这样 DM 从不停下来,CPU 处理一帧的同时,下一帧还在被 DMA 接收。
乒乓切换的关键是重新指定 DMA 目标地址。在 F103 上,修改 DMA 目标地址后要重新使能通道。具体流程:IDLE 中来时,先停 DMA,取完长度,切换到另一块缓冲区,重新启动 DMA,然后把刚才那帧交给业务层处理。切换到哪块缓冲区由状态机维护,用一个uint8_t active_buf_index翻转即可。
好处是接收连续性大大提高;代价是代码里要对“当前处理的是哪块缓冲区”保持时刻清醒,不然数据错位的 Bug 会找上门。我建议至少要在处理函数里加一个字段,把缓冲区索引和长度一起传下去,不要用全局变量传播。
5.2 环形缓冲与 Circular 模式
环形缓冲是另一种更通用的方案。配合 DMA 的 Circular 模式,DMA 摄像头永远在写,环形缓冲区维护一个读指针和写指针,当 DMA 计数器变化时,根据差值计算可读长度。这种方式能支撑更长的数据流,但 F103 上环形缓冲配合 HAL 库需要自己维护,稍不留神就会读重复或读丢。
如果仅仅是“不定长帧”场景,我不建议一上来就用环形缓冲+Circular,因为 IDLE 分帧已经很自然。只有当你的数据流是连续字节流、没有明确帧边界时,环形缓冲才有优势。比如 RTU 协议、自定义流协议等。
5.3 和 FreeRTOS 队列或信号量配合
如果你跑的是 FreeRTOS,IDLE 中断里只做一件事:把接收缓冲区指针和长度放进队列,或者给任务发送一个信号量,然后立刻重启 DMA 接收。这样中断服务时间极短,业务任务在等待队列时自然阻塞,不会占用 CPU。
举个例子:
BaseType_t xHigherPriorityTaskWoken = pdFALSE; xQueueSendFromISR(rx_queue_handle, &frame_info, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken);在 STM32F103 上,中断里调用xQueueSendFromISR这种带 FromISR 后缀的 API 没问题。但注意,队列中存放的frame_info最好复制缓冲区地址和长度,不要复制整个缓冲区,因为缓冲区会被 DMA 重新写,复制整块数据很浪费时间。比较好的做法是双缓冲配合队列,把缓冲区地址发给任务,任务处理完后归还缓冲区。
5.4 性能实测参考
我做过一个粗略测试,F103 跑 72MHz,115200 波特率,开启 DMA 接收和 IDLE 中断,主循环每秒处理 200 帧、每帧 128 字节的协议。CPU 占用增加不多,接收中断平均开销微乎其微,大部分时间还是花在协议解析上。如果换成 921600 波特率,DMA 方案依然能接住,只是主循环解析频率要适当控制。
这个结果的另一面是:CPU 占用低不代表系统无敌,因为 DMA 只是解决了搬运,协议层处理不及时照样会丢包。所以做项目时,要先把“接收路径”和“处理路径”分开看。接收路径用 DMA+IDLE 做,处理路径尽量用状态机分步解析,不要在中断里做重活,也不要在主循环里一次性做几百毫秒的阻塞操作。
说到这,我自己的习惯是:能用 DMA+IDLE 解决的串口接收,就不碰定时器超时;能用双缓冲解决 CPU 处理瓶颈,就不轻易上复杂的环形缓冲。这套方案在实际项目里跑了很多种场景,从简单传感器数据采集到多机通信都扛得住。如果后续遇到更复杂的协议需求,再考虑在 IDLE 分帧基础上叠加 CRC 校验和重传机制,整个框架不用推翻重来。