☰
STM32F103 DMA+USART空闲中断实现串口不定长收发
2026/11/3 13:37:20 网站建设 项目流程

串口不定长数据帧的处理,几乎是每个做 STM32F103 项目的人绕不过去的一道坎。协议帧长度不固定,短的三五个字节,长的两三百字节,帧与帧之间有明显间隔——这个场景用逐字节接收中断去扛,115200 波特率下跑一会儿就开始丢包;用定长 DMA 去接,又会因为长度对不上而反复踩坑。我这次要拆解的,就是STM32F103 上用 DMA 配合 USART 空闲中断(IDLE)实现不定长收发这一套组合方案。它解决的核心问题是:让 CPU 从"每来一个字节中断一次"里解放出来,只在完整一帧结束时被打断一次,同时还能准确知道这一帧有多长。这篇文章面向刚接触串口 DMA 的新手,也面向已经在用但被"只收到第一帧""长度算不对""跑几小时就死机"折腾过的老手,我会把寄存器层面的机制、可直接复制的代码、以及我实际调试中踩过的坑一并摊开讲。

1. 为什么这套组合拳能解决不定长接收

1.1 逐字节接收中断的实际开销测算

先把账算清楚,才知道为什么要换方案。115200 波特率、8 数据位、1 停止位、无校验,一个字节在线路上占 10 个位时间,传输时间约 86.8 微秒。如果采用"每收一个字节进一次中断"的做法,中断频率就是 1/86.8us ≈ 11.5 kHz。这意味着 CPU 每秒要被打断一万一千多次,每次进中断至少要做现场保护、读 SR、读 DR、存缓冲区、清标志、退中断这一套动作,保守估计 2 到 5 微秒。折算下来,光是串口接收就吃掉 3% 到 6% 的 CPU 时间,看起来不多,但问题在于中断是突发的:当一帧 200 字节的数据连续涌进来,CPU 会被这 200 次中断密集打断,如果此时主循环里正好有一段不允许被打断太久的逻辑(比如软件模拟时序、或者精确的定时任务),时序就会被拉偏。

更麻烦的是丢包风险。中断响应存在延迟,如果同时有更高优先级的中断在跑,或者主循环里关了一会儿中断,RXNE 标志没被及时读走,下一个字节就到了,硬件置位 ORE 溢出错误,这个字节直接丢失,而且如果不清 ORE,后续接收会一直异常。我在一个带 LCD 刷屏的项目里就遇到过这个现象:屏幕刷新时串口必丢字节,查了半天才定位到是刷屏函数里有一段临界区把中断关了将近 100 微秒。所以说,逐字节中断不是不能用,而是它的可靠性高度依赖你整个工程的实时性设计,一旦工程变复杂,它就是最先崩的那一环。

1.2 定长DMA的适用范围与短板

那换成 DMA 呢?DMA 的好处很直接:串口每收到一个字节,硬件自动发 DMA 请求,DMA 控制器直接把 DR 里的数据搬到内存,全程不经过 CPU,也不需要中断。CPU 该干嘛干嘛,只在 DMA 搬够指定数量后触发一次传输完成中断(TC)。这个模式在"我知道要收多少字节"的场景下非常好用,比如读取某个传感器的固定 8 字节响应,或者向上位机请求一段固定长度的数据。

但不定长场景就不行了。假设你把 DMA 长度设成 256,实际来了一帧 20 字节的数据,DMA 只搬了 20 个,剩下的 236 个还没搬,TC 中断不会触发,你的程序就干等着,完全不知道数据已经到齐了。反过来,如果把长度设小一点比如 8,那长帧就会被截断成好几段,还要自己拼接。更隐蔽的一个坑是:DMA 在普通模式下,CNDTR 递减到 0 之后就自动停止传输了,如果你没有在 TC 中断里重新装填计数,后续数据会全部丢掉,而且不会报任何错——现象就是"跑了一阵子突然不收了",第一次遇到能让人怀疑人生。

所以定长 DMA 的短板不在于快慢,而在于它缺少一个"帧边界"的判断依据。CPU 无法从 DMA 的状态里知道"这一帧已经结束了",只知道"搬了多少个"。要解决这个问题,就必须引入一个能表征"线路空闲"的硬件信号。

1.3 DMA + IDLE 的判定逻辑与边界条件

USART 外设里恰好有这么一位:IDLE 标志位。它的置位条件很明确——接收线上在检测到一个完整字节(含停止位)之后,持续保持高电平达到一个字节时间(8 数据位配置下是 10 个位时间),硬件就把 IDLE 置起来。这本质上就是硬件在告诉你:"线路上没动静了,刚才那一串数据应该是一帧。"

把这两个机制拼起来,逻辑就闭环了:DMA 负责把数据默默搬进内存,一个字节都不吵 CPU;IDLE 中断负责在帧结束时喊一声,告诉 CPU"这帧完了"。CPU 在中断里只需要做两件事——算出这帧有多少字节,然后把 DMA 重新准备好接下一帧。整个过程中,CPU 在每帧只被打断一次,200 字节的帧和 2 字节的帧都是同样的开销。

但这套方案有一个必须提前说清的边界条件:IDLE 只在"线路出现空闲"时才触发。如果你的数据流是完全连续的、字节与字节之间没有间隙,那 IDLE 永远不会置位,这帧数据你永远等不到结束信号。所以它天然适合"帧与帧之间有间隔"的协议,比如 Modbus RTU 那种靠 3.5 字符间隔分帧的、或者自定义的"一问一答"式通信。如果是对付持续不断的音频流、日志流,那就得换思路,靠协议层的帧头帧尾去切,或者干脆用循环 DMA 加半传输中断。这一点在方案选型阶段就要想明白,否则代码写完才发现协议不匹配,返工很痛苦。

2. 硬件层面的两个关键机制

2.1 IDLE标志的置位条件与清除序列

IDLE 相关的寄存器只有两个地方要关心:状态寄存器 USART_SR 的 bit4 是 IDLE 标志,控制寄存器 USART_CR1 的 bit4 是 IDLEIE 中断使能。要让 IDLE 中断真正进得来,这两个位都得照顾到,再加上 NVIC 里的中断使能,少一个都不行。

置位的时机值得再细说一遍。参考手册里的描述是:当接收线上检测到空闲线路时,IDLE 位被置 1;如果在接收一个字节之后出现了空闲,也会置位。这个"一个字节时间"是硬件自己数的,不需要软件干预。我实测过,115200 下从最后一个停止位结束到 IDLE 置位,大约就是 87 微秒,非常准时。

真正容易翻车的是清标志。IDLE 的清除序列是:先读 USART_SR,再读 USART_DR。注意,是"读"不是"写",也不需要往 DR 里写 0。这两个读操作缺一不可,只读 SR 的话标志位纹丝不动,中断会反复进入,现象就是程序卡在中断里出不来,主循环完全不跑。而且这个序列跟 RXNE、ORE 的清除序列是同一套,读 SR 再读 DR 能一次性把它们全清掉。

在 HAL 库里,这个动作被封装成了宏。但这里有个坑要提前预警:早期版本的 HAL 库里压根没有__HAL_UART_CLEAR_IDLEFLAG这个宏,直接调用会编译不过。稳妥的做法是自己写一段:

__IO uint32_t tmp; tmp = huart1.Instance->SR; /* 第一步:读 SR */ tmp = huart1.Instance->DR; /* 第二步:读 DR */ (void)tmp; /* 防止编译器优化掉 */

用__IO修饰是必须的,不然开了 -O2 优化后,这两次读很可能被编译器判定为"读了没用"而直接删掉,然后你就得到一个永远清不掉的标志位,debug 的时候会怀疑是自己脑子出了问题。

注意:读 DR 这个动作本身会把 DR 里的数据取走。如果此时 DMA 还没把最后一个字节搬完,这个读操作就会让最后一个字节永久丢失。实际使用中这种概率极低——IDLE 的触发时间点比 DMA 搬运延迟晚得多,DMA 早就搬完了。但如果你的系统里 DMA 优先级被压得很低,或者总线上有大量竞争,那就得留个心眼,可以在中断里先禁用 DMA 通道冻结计数,再清标志。

2.2 用CNDTR反推已接收字节数

DMA 通道里有一个寄存器叫 CNDTR(在 HAL 里通过hdma->Instance->CNDTR访问),它的含义是"还剩下多少个数据要搬"。启动时你把它设成缓冲区大小 N,每搬一个字节它就减 1,减到 0 就停(普通模式)或者重装成 N 继续(循环模式)。

于是接收长度就能直接算出来:已接收字节数 = N - CNDTR。这个计算非常轻量,一次减法就完事,没有任何遍历或者计数器的开销。

举个具体例子:缓冲区设 256 字节,启动后 CNDTR = 256。来了一帧 37 字节,DMA 搬完后 CNDTR 变成 219,那么256 - 219 = 37,正好对上。这个算法在普通模式和循环模式下都成立,区别只在于循环模式下 CNDTR 减到 0 会自动重装,所以算出来的值需要配合读写指针来理解,这个后面第 5 节会展开。

读取 CNDTR 的时机很关键。如果在 DMA 还在搬运的过程中去读,可能读到一个"半路"的值。所以我的习惯是:先在中断里禁用 DMA 通道,让 CNDTR 冻结,再读它。禁用通道只是清 CCR 寄存器的 EN 位,不会影响已经搬完的数据,代价也就一个总线周期。这样读出来的值一定是确定的,不会出现长度差 1 的诡异现象。

__HAL_DMA_DISABLE(&hdma_usart1_rx); /* 冻结计数 */ uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len = RX_BUF_SIZE - remain;

顺带提一句,如果用的是寄存器直操方式,__HAL_DMA_DISABLE展开就是hdma->Instance->CCR &= ~DMA_CCR_EN,__HAL_DMA_SET_COUNTER展开就是hdma->Instance->CNDTR = 值,自己写和用宏没区别,用宏可读性更好。

2.3 DMA通道映射与冲突排查

STM32F103 的 DMA 是"通道绑定外设请求"的结构,不是任意外设能随便选通道。每个 USART 的 TX 和 RX 请求固定挂在某一个 DMA 通道上,接错了就是完全没反应。这个表我建议直接贴在工程注释里,省得每次都要翻手册:

串口发送请求通道接收请求通道
USART1DMA1_Channel4DMA1_Channel5
USART2DMA1_Channel7DMA1_Channel6
USART3DMA1_Channel2DMA1_Channel3

冲突是这个表带来的经典问题。比如你的工程里同时用了 USART2 的接收(Channel6)和 I2C1 的发送(也是 Channel6),这两个外设抢同一个通道,硬件层面没法同时工作,只能分时复用,或者改换方案。我见过一个项目里 SPI1_RX(Channel2)和 USART3_TX(Channel2)打架,现象是串口发送一开,SPI 读回来的数据就全是 0xFF,查了两天才定位到通道冲突。排查思路很简单:把所有用到的外设列出来,对照这张表看有没有重复,有重复就换串口、换 SPI 编号,或者干脆用软件模拟其中一路。

还有一点容易忽略:DMA1 有 7 个通道,通道之间的优先级是可编程的。如果几个通道同时有请求,优先级高的先响应。串口这种对时序敏感的,建议把接收通道的优先级设成 High 或者 Very High,别用默认的 Low,不然在大批量数据传输时会因为排队延迟而丢字节。

3. 工程配置:从时钟到中断的完整清单

3.1 时钟树与GPIO配置

时钟这块没什么花活,但顺序不能乱。USART1 挂在 APB2 上,DMA1 挂在 AHB 上,GPIOA 也在 APB2。三个时钟必须都打开:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

用 HAL 的话通常 CubeMX 已经帮你生成了,但如果你是自己搭工程,漏掉 DMA1 的时钟是个高频错误——现象是 DMA 通道怎么配都不动,初始化函数返回值正常,但 CNDTR 就是不变,因为外设时钟没开,写寄存器全是无效操作。

GPIO 配置上,TX 要配成复用推挽输出(GPIO_Mode_AF_PP),RX 配成浮空输入或者上拉输入(GPIO_Mode_IN_FLOATING/GPIO_Mode_IPU)。这里我建议 RX 配上拉,虽然标准做法是浮空,但配上拉能在总线悬空时把电平拉到高,避免收到一堆 0x00 的假数据。速度等级选 50MHz 就够,115200 用不着高速档。

提示:如果外接的是 RS485 或者长线传输,RX 端最好外加一个上拉电阻,内部上拉阻值偏大(大概 40kΩ),抗干扰能力一般。我在一个工业现场的项目里,电机一启动串口就疯狂收乱码,最后是靠外部 4.7kΩ 上拉加磁珠才解决的。

3.2 串口参数与DMA通道参数

串口参数就那几个:波特率 115200、8 位数据、1 位停止、无校验、无硬件流控、收发模式都开。这里要特别提醒一句:校验位会改变有效数据位数。如果你把校验设成 9 位数据加 1 位校验,那实际每帧包含的数据字节数还是按 8 位算,但 USART 的 DR 寄存器读出来是 9 位,最高位是校验位,如果你的协议要求 8 位数据,解析时得把最高位掩掉,否则数据全是错的。

DMA 通道的配置有六项,我逐项说一下为什么这么选:

配置项取值理由
外设地址&USART1->DR固定不变
内存地址接收缓冲区首地址每个字节往后挪
传输方向外设到内存接收场景
缓冲区大小512大于最大帧长,留余量
外设地址自增禁止DR 地址固定
内存地址自增使能一个字节占一个内存位置
数据宽度字节串口一次搬 8 位
模式普通(Normal)每帧结束后手动重启
优先级高避免被其他通道挤掉

外设地址自增必须关掉,这是新手最容易配错的一项。DR 寄存器只有一个地址,如果让它自增,第二次搬运就会去读一个不存在的地址,结果是数据错乱或者直接触发总线错误。内存地址自增必须打开,否则 512 个字节全挤在缓冲区第一个位置上,只有最后一个字节有效。

还有一个隐藏开关:USART 的 CR3 寄存器里有一位 DMAR(DMA 接收使能),必须置 1,DMA 请求才会从串口发出来。HAL 库在调用HAL_UART_Receive_DMA时会自动帮你置位,但如果你是自己写寄存器版本,漏了这一步就会得到一个"初始化全对但数据一个不来"的诡异现象。

USART1->CR3 |= USART_CR3_DMAR; /* 使能串口 DMA 接收请求 */

3.3 NVIC优先级安排

中断优先级这块,要开两个:USART1 的全局中断(IDLE 走这个入口)和 DMA1_Channel5 的中断(如果要用 TC 中断的话)。抢占优先级我一般这样安排:串口空闲中断给到比较高的优先级(比如 1),DMA 通道中断低一档(比如 2),但都高于普通的定时器和 GPIO 中断。

理由是:IDLE 中断里要做数据拷贝和 DMA 重装,这段代码越快越好,如果被别的中断抢占,重装延迟就会变大,极端情况下上一帧还没处理完下一帧就来了。当然也不能给到最高(比如 0),否则会抢占一些真正紧急的硬件事件。用 HAL 的话:

HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART1_IRQn);

关于分组,STM32F103 的 NVIC 有 4 位优先级,分组方式决定几位给抢占、几位给子优先级。我的建议是用 NVIC_PriorityGroup_2(2 位抢占 + 2 位子优先级),这样抢占级别有 4 档,够用又不至于太粗。如果整个工程都用默认的 Group_0(0 位抢占,全给子优先级),那抢占机制就形同虚设,高优先级中断没法打断低优先级,串口这种实时性要求高的就吃亏了。

4. 核心代码:接收链路的完整实现

4.1 缓冲区与状态变量

缓冲区怎么划分,直接决定了后面代码好不好写。我的习惯是分两层:一层是 DMA 直接写的原始缓冲区,一层是给主循环用的帧缓冲区。两者分开的好处是,DMA 在后台随便写,主循环在处理上一帧的时候不会被覆盖。

#define RX_DMA_SIZE 512 #define RX_FRAME_SIZE 512 static uint8_t rx_dma_buf[RX_DMA_SIZE]; /* DMA 直写区 */ static uint8_t rx_frame_buf[RX_FRAME_SIZE]; /* 交给主循环的帧 */ static volatile uint16_t rx_frame_len = 0; static volatile uint8_t rx_frame_ready = 0;

volatile这两个字千万别省。中断里改、主循环里读的变量,如果不加volatile,编译器在做优化时可能把它缓存到寄存器里,主循环永远不会察觉到中断的修改,现象就是"数据明明收到了,但主循环不处理"。这个坑我在早期的工程里踩过,当时对着逻辑分析仪看波形,数据确实进了内存,代码逻辑也没问题,最后是反汇编才看出来变量被优化没了。

缓冲区大小怎么定?我的经验公式是缓冲区 ≥ 最大帧长 × 1.5。如果协议规定最大帧 300 字节,那就开 512。留余量的原因有两个:一是防止超长帧导致 DMA 计数归零后停摆,二是给协议解析留出错缓冲,万一收到畸形数据也不会立刻溢出。代价只是多占几百字节 RAM,F103 的 20KB RAM 完全扛得住。

4.2 初始化与启动接收

初始化的顺序有讲究:先配 DMA,再配串口,最后启动接收。如果顺序反了,串口在 DMA 还没准备好的时候就开始收数据,很可能丢掉开头几个字节。完整的启动函数:

void UART1_Rx_Start(void) { /* 1. 启动 DMA 接收,长度 = 缓冲区大小 */ HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_DMA_SIZE); /* 2. 打开 IDLE 中断 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }

就这两行,但每一行都有讲究。HAL_UART_Receive_DMA内部会做三件事:配置 DMA 通道、把 CNDTR 装成 512、置位 USART 的 DMAR 位。它执行完之后,DMA 就开始待命了,只要有字节进来就自动搬。第二行打开 IDLEIE 位,让 IDLE 标志能触发中断。

这里有个初始化顺序的细节值得说:HAL_UART_Receive_DMA必须在串口初始化(HAL_UART_Init)之后调用,否则 huart 句柄里的 Instance 还没绑定好,会往一个空指针里写配置。而在主循环里重启接收时,就只需要重复调用这两行即可,不需要再动串口配置。

还有个小坑:如果初始化完成后立刻调用了UART1_Rx_Start,而此刻 RX 引脚悬空或者一直为高,IDLE 标志会马上置位,进一次中断,此时 len 算出来是 0。所以在中断处理里必须判断len > 0,否则会给主循环塞一堆长度为 0 的空帧,让上层解析逻辑莫名其妙。

4.3 中断服务函数的正确写法

这段是整个方案的核心,我把它拆开逐段讲。

void USART1_IRQHandler(void) { /* 第一步:确认是 IDLE 触发的 */ if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { /* 第二步:先冻结 DMA 计数 */ __HAL_DMA_DISABLE(&hdma_usart1_rx); /* 第三步:算长度 */ uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len = RX_DMA_SIZE - remain; /* 第四步:清 IDLE 标志,读 SR 再读 DR */ __IO uint32_t tmp; tmp = huart1.Instance->SR; tmp = huart1.Instance->DR; (void)tmp; /* 第五步:把数据交给主循环 */ if (len > 0 && len <= RX_FRAME_SIZE) { memcpy(rx_frame_buf, rx_dma_buf, len); rx_frame_len = len; rx_frame_ready = 1; } /* 第六步:重装计数并使能 */ __HAL_DMA_SET_COUNTER(&hdma_usart1_rx, RX_DMA_SIZE); __HAL_DMA_ENABLE(&hdma_usart1_rx); return; /* IDLE 处理完就返回,不走 HAL 的通用处理 */ } /* 非 IDLE 中断交给 HAL,比如错误中断、发送完成 */ HAL_UART_IRQHandler(&huart1); }

为什么要先冻结 DMA 再算长度?如果 DMA 还在跑,你在读 CNDTR 的那一刻,它可能正好在减 1,读出来的值就处于一个中间态,长度可能差 1。虽然概率不高,但一旦发生,上层解析就会因为少一个字节而校验失败,而且这种问题极难复现。先禁用再读,代价微乎其微,收益是逻辑确定性。

为什么最后要return?因为HAL_UART_IRQHandler内部会检查多种标志位,包括 RXNE、TC、ORE 等。我们用的是 DMA 模式,RXNE 由 DMA 自动清,但如果此刻正好有一个错误标志置位(比如 ORE),HAL 会走错误回调,把 huart 的状态机置成错误态,后续的HAL_UART_Receive_DMA就会返回 BUSY 而失败。在 IDLE 分支里提前返回,可以避免这次不必要的状态机变更。

第六步的重装顺序也不能反。__HAL_DMA_SET_COUNTER只是写寄存器,写完必须再__HAL_DMA_ENABLE才会真正工作。如果先使能再写计数,DMA 会拿旧的 CNDTR 值去搬,搬着搬着就发现数字对不上,行为很诡异。

注意:如果你用的是 HAL 库并在别处也调用了HAL_UART_Receive_DMA来重启,那在中断里就不要用寄存器直操的方式重装,两套机制会打架。HAL 的 huart 状态机会认为 UART 还在接收状态,再次调用会返回 HAL_BUSY。要么全程用 HAL 封装(HAL_UART_DMAStop+HAL_UART_Receive_DMA),要么全程寄存器直操,别混着用。

4.4 主循环处理与重启时机

中断里只做最小动作:算长度、拷贝、置标志。真正的协议解析、数据入库、应答组包全部放在主循环。这样中断停留时间可以压到几微秒,不会被后续的高频中断挤掉。

while (1) { if (rx_frame_ready) { rx_frame_ready = 0; Protocol_Handle(rx_frame_buf, rx_frame_len); } /* 其他任务 */ }

用标志位而不是在中断里直接处理,还有一个好处是方便调试。你可以在主循环里打个断点,看看 rx_frame_len 到底是多少,而不会因为断点把整个中断时序打乱。我调试串口问题的时候,第一件事就是把 len 和缓冲区前几个字节打印出来,90% 的问题看一眼这个就能定位。

关于重启时机,我把它放在中断里而不是主循环,是有考虑的。如果放在主循环,从 IDLE 触发到主循环处理完,中间这段时间 DMA 是停着的,新来的数据会丢。放在中断里,处理完立刻重装,空窗期只有几微秒,基本可以忽略。当然这也意味着中断里做了 memcpy,如果帧长特别大(比如 512 字节),拷贝耗时大概十几个微秒,这时如果波特率很高,还是有可能丢一点。极端的场景就得用下一节讲的循环 DMA 方案。

5. 进阶方案:循环DMA + 环形缓冲

5.1 普通模式重启的时序空窗

普通模式下的工作循环是:启动 DMA → 收一帧 → IDLE 中断 → 关 DMA → 拷贝 → 重装 → 重新使能。这一串动作里,从"关 DMA"到"重新使能"之间的这段时间,串口是完全不接收的。按我的实测,这段代码在 72MHz 主频下大约耗时 8 到 15 微秒,具体取决于 memcpy 的字节数。

15 微秒是什么概念?115200 波特率下一个字节约 87 微秒,所以 15 微秒还不到一个字的时间,正常情况下不会丢。但如果对端是连续发帧、帧间隔极短,或者主频降到 8MHz 内部 RC 振荡,这段空窗就可能跨过一整个字节,导致下一帧的第一个字节丢失。这种"偶尔丢首字节"的问题特别难查,因为大部分时候是好的,压力大了才暴露。

还有个更隐蔽的问题:如果某一帧的长度超过缓冲区大小,CNDTR 递减到 0 后 DMA 就停了,TC 标志置位但如果你没开 TC 中断,就完全不知道。后续数据全部丢失,直到下一次 IDLE 触发——但问题是 DMA 已经停了,CNDTR 是 0,算出来的 len 是满缓冲区,而且新数据根本没进来。这个现象就是"收到第一帧就再也不收了"。

5.2 循环模式下的读写指针计算

循环模式(Circular)能根治上述两个问题。DMA 配成循环后,CNDTR 减到 0 会自动重装成初始值继续搬,永远不会停。数据像一个环一样不断往里写,CPU 只需要维护一个读指针,读指针追上写指针的位置就是新数据。

#define RING_SIZE 1024 static uint8_t ring_buf[RING_SIZE]; static volatile uint16_t rd_pos = 0; /* IDLE 中断里 */ uint16_t wr_pos = RING_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t avail; if (wr_pos >= rd_pos) avail = wr_pos - rd_pos; else avail = RING_SIZE - rd_pos + wr_pos; /* 处理 ring_buf[rd_pos] 开始的 avail 个字节,注意跨越末尾时要分两段 */ /* ... */ rd_pos = wr_pos;

这段逻辑的关键在于理解 wr_pos 的含义:它不是"总共收到了多少字节",而是"当前写到了环的哪个位置"。CNDTR 从 RING_SIZE 递减,递减的量就是这一轮写了多少,所以RING_SIZE - CNDTR就是当前的写位置。当 CNDTR 减到 0 重装回 RING_SIZE 时,wr_pos 也就归零,环闭合。

读写指针的回绕处理是这段代码的核心。当 wr_pos 大于等于 rd_pos 时,说明这一轮没有跨过环的末尾,数据是从 rd_pos 连续到 wr_pos;当 wr_pos 小于 rd_pos 时,说明跨过了末尾,数据分成两段:rd_pos 到 RING_SIZE-1,以及 0 到 wr_pos。处理的时候要分开拷贝,不能简单用一个 memcpy 搞定,否则会越界读到环的开头去。

有一点必须注意:循环模式下,如果写指针追上读指针,新数据就会覆盖还没处理的老数据,而且不会有任何报错。所以要么保证处理速度跟得上接收速度,要么在中断里检测avail是否接近 RING_SIZE 并置一个溢出标志。我的习惯是开 1024 大小的环,正常情况下单帧最多 300 字节,处理一帧的耗时远小于接收一帧的时间,不会溢出。

5.3 半传输中断的配合使用

循环 DMA 还有一个特性可以利用:它有两个中断,半传输完成(HT)和全传输完成(TC)。当 CNDTR 减到 RING_SIZE/2 时触发 HT,减到 0 时触发 TC。这两个中断给了你一个"周期性检查点",即使没有 IDLE(比如连续数据流),也能每半个环处理一次。

组合起来的用法是:IDLE 中断负责"帧结束时立刻处理",HT/TC 中断负责"定期清理,防止环被写满"。两套机制不冲突,因为处理逻辑都是基于读写指针的,谁先来谁处理,处理完指针都更新到同一个位置。这种设计在高速数据采集场景下特别有用,比如串口一直以 921600 波特率往里灌数据,中间没有间隔,IDLE 永远不触发,这时候就完全靠 HT/TC 来续命。

不过要提醒一句,HT/TC 中断里不要再调用HAL_UART_IRQHandler,因为那是给串口中断用的。DMA 通道有自己的中断入口DMA1_Channel5_IRQHandler,里面要自己调用HAL_DMA_IRQHandler(&hdma_usart1_rx),然后在HAL_UART_RxHalfCpltCallback之类的回调里处理。这个回调链路容易绕晕,建议直接在 DMA 中断里写自己的逻辑,别走 HAL 的回调。

6. 发送方向:DMA发送与回调处理

6.1 发送DMA的启动与完成判断

接收讲完了,发送其实简单得多,因为没有"长度未知"的问题——你要发多少是自己定的。配置上唯一的区别是方向改成内存到外设,外设地址不自增,内存地址自增。

HAL_UART_Transmit_DMA(&huart1, tx_buf, tx_len);

调用这一行就启动了,函数立即返回,数据在后台慢慢发。发完之后会触发 DMA 的 TC 中断,HAL 在中断里调用HAL_UART_TxCpltCallback。如果你需要在发送完成后做点什么(比如拉高 RS485 的方向控制脚、释放缓冲区),就在这里写。

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { RS485_SwitchToRx(); /* 发送完成,切回接收 */ tx_busy = 0; } }

判断"发送是否完成"这件事,很多人喜欢用while(HAL_UART_GetState(&huart1) != HAL_UART_STATE_READY);来死等。这种写法在简单场景能用,但在带 RTOS 或者有实时性要求的工程里就是灾难,因为它会把整个主循环卡住。正确做法是用状态机配合回调,或者至少用HAL_UART_GetState做非阻塞轮询。

6.2 缓冲区被覆盖的典型场景

发送 DMA 最大的坑是缓冲区生命周期。DMA 启动后只是把内存地址记下来,真正往外发是在后台异步进行的。如果你在HAL_UART_Transmit_DMA返回后立刻修改 tx_buf 的内容(比如复用一个全局缓冲区发下一条),很可能出现"发出去的数据前半段是第一条的,后半段是第二条的"这种灵异现象,因为 DMA 正在读那块内存,你改到一半它读到的就是新值。

我遇到过一次:上位机发指令,下位机连续回三条响应,代码里用同一个 buf 组装。现象是偶尔第一条响应的尾部会混进第二条响应的内容。查了半天以为是串口硬件问题,最后发现是 buf 被复用了。解决方案有三个,按推荐程度排序:给每条响应分配独立的缓冲区;在发送完成回调里再复用自己的 buf;或者发送前先 memcpy 到 DMA 专用缓冲区。我一般用第三种,虽然多一次拷贝,但逻辑最清晰,不会因为回调延迟而出问题。

提示:如果开了 DMA 发送中断但没写对应的 IRQHandler,会进默认的死循环(HardFault_Handler)。F103 的启动文件里所有中断向量默认都指向一个 while(1),名字就叫 Default_Handler。第一次调试 DMA 发送时如果发现程序跑飞,先检查 NVIC 里有没有使能 DMA 通道中断,以及对应的中断函数名有没有拼错(注意是DMA1_Channel4_IRQHandler,不是DMA1_IRQHandler)。

7. 问题排查速查表

7.1 完全收不到数据

这一类问题的排查顺序是自下而上的:先看硬件,再看寄存器,最后看代码逻辑。

现象可能原因排查方法
一个字节都收不到DMA1 时钟没开检查 RCC_AHBPeriphClockCmd
同上

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询