1. 为什么非要把 DMA 和空闲中断凑到一起
做过 STM32F103 串口通信的人几乎都经历过同一个阶段:先是用HAL_UART_Receive阻塞收,发现主循环被卡死;后来改用HAL_UART_Receive_IT单字节中断收,CPU 被频繁打断,波特率一高就开始丢数据;再后来听说有个"空闲中断",配上 DMA 一用,才第一次感觉串口接收这件事变得顺手了。我最早接触这套组合是在做一个多路传感器汇聚的项目,八路上位机同时往板子上怼数据,每帧长度还不固定,用单字节中断方案跑到 115200 就开始出错,换到 DMA + 空闲中断之后,同样的硬件、同样的线材,稳定性直接上了一个台阶。
这篇内容讲的就是这套方案在STM32F103上的完整落地过程:怎么用DMA负责数据搬运、用空闲中断负责判断一帧结束,从而实现USART 不定长收发。读完之后你应该能做到几件事:自己推算接收缓冲区要开多大、自己写出不会卡死的 IDLE 中断服务函数、自己把 DMA 发送封装成一个能复用的接口、以及在一帧数据莫名其妙少了半截的时候知道该去哪里找原因。
适合什么人看?手上有 F103 开发板、能点灯能跑串口、但接收这边还停留在"规定死长度"阶段的朋友,看完可以直接抄作业。已经用过这套方案但总在某些场景下翻车的,第 6 章和第 7 章应该能对上你的症状。纯小白也不用怕,我会在必要的地方把 DMA 和中断的底层行为掰开讲清楚,不会一上来就甩一堆寄存器。
有一点需要提前说明:下面的代码以 HAL 库为主,因为现在大部分 F103 工程起步都绕不开 CubeMX。但在关键的中断服务函数里,我会直接操作寄存器,因为 HAL 的状态机在 IDLE + DMA 这种组合场景下确实会给你添堵,直接动寄存器反而更干净、更好调试。用到标准库的工程,逻辑完全一样,只是函数名换成USART_ITConfig、DMA_Cmd那一套。
2. 先把原理吃透:DMA 和 IDLE 中断到底在干什么
2.1 DMA 解决的是"搬",不解决"切"
DMA 全称直接存储器访问,说白了就是一个独立于 CPU 的小搬运工。你告诉它源头在哪(外设数据寄存器)、目的地在哪(内存数组)、搬多少个字节,然后使能,它就会在后台一个字节一个字节地搬,每搬完一个自动更新计数器,CPU 完全不参与。STM32F103 的 USART 外设自带 DMA 请求能力,只要在USART_CR3里把DMAR位置 1,每收到一个字节,硬件就会向 DMA 控制器发一次请求,DMA 就把USART_DR里的数据搬到内存。
这里有个关键点很多人没想明白:DMA 只在"传满设定的数量"或者人为主观停止时才会告诉你它干完了。如果你设定了接收 256 字节,但对方只发了 12 个字节就停了,DMA 的计数器停在 244,它不会触发任何传输完成标志,也不会打断你。这就是为什么光有 DMA 是不够的——你根本不知道一帧什么时候结束。
至于通道选择,F103 的 DMA 通道映射是硬件定死的,不能随便换。常用的对应关系记一下:
| 外设方向 | DMA 控制器与通道 | 备注 |
|---|---|---|
| USART1_TX | DMA1_Channel4 | 最常用组合 |
| USART1_RX | DMA1_Channel5 | 最常用组合 |
| USART2_TX | DMA1_Channel7 | 与 TIM2_CH2 等共用请求线 |
| USART2_RX | DMA1_Channel6 | 与 TIM3_CH3 共用 |
| USART3_TX | DMA1_Channel2 | 与 SPI1_RX 共用 |
| USART3_RX | DMA1_Channel3 | 与 SPI1_TX 共用 |
| UART4_TX | DMA2_Channel5 | 只有大容量型号才有 |
| UART4_RX | DMA2_Channel3 | 只有大容量型号才有 |
共用通道意味着什么?意味着你如果同时开了 USART3 和 SPI1,那必须二选一,或者接受其中一个废掉。这一点在选型阶段就要算清楚,别画完板子才发现两个外设抢一个 DMA 通道。我见过一个项目为了省事把调试串口和 SPI Flash 挤在一块,结果只能在烧录固件和读 Flash 之间来回切换,非常别扭。
2.2 空闲中断的特殊之处:它不依赖数据内容
USART 的空闲中断(IDLE)有个很有意思的特性:它不是由某个特定字节触发的,而是由**总线上的"安静"**触发的。当接收线上连续出现一个完整字符时间的高电平(也就是停止位之后没有新的起始位),硬件就会把USART_SR的IDLE标志置 1。对 8N1 格式来说,一个字符时间等于 10 个 bit 的传输时间。
这个特性正好对上"帧间有间隔"的通信习惯。绝大多数串口协议,不管是自定义的也好、还是 Modbus 这类标准协议也好,字节之间的间隔是极短的(几乎是连续的),而两帧之间的间隔会明显大一些。IDLE 中断就是抓住了这个"明显大一些",把它当作帧结束的信号。
但反过来说,这也意味着一个坑:如果你的协议在帧内部就有超过一个字符时间的停顿,IDLE 就会提前把你这一帧切断。波特率越低,这个停顿窗口越宽,越容易被切。所以低波特率(比如 9600)反而比高波特率更容易出问题,这一点和直觉相反,后面第 6 章我会给具体数值。
2.3 三个数字千万别搞混
这套方案里最容易出错的地方,是把下面三个数字搅在一起:
- 缓冲区大小:你定义的数组长度,比如
uint8_t rx_buf[256],这是个常量。 - DMA 剩余计数:
DMA_Channel->CNDTR寄存器里的值,表示"还要搬多少字节才搬满",它是从缓冲区大小开始递减的。 - 实际接收长度:本次 IDLE 中断发生时,一共收到了多少个字节。
三者的关系是:实际接收长度 = 缓冲区大小 - DMA 剩余计数。这个公式是整个方案的核心,务必刻在脑子里。每次进 IDLE 中断,第一件事就是读CNDTR,然后做这个减法。
这里有个隐藏的假定:DMA 必须是从缓冲区起始地址开始搬的,而且本次接收之前计数器必须是满的。如果你用的是循环模式,DMA 搬满了会自动回绕,这个减法就失效了,得配合半满中断才能算出正确位置。我在第 4 章会讲循环模式怎么处理,先记住普通模式下的公式。
3. 工程搭建与初始化配置
3.1 时钟与外设参数怎么定
先定波特率。波特率的选择不只看对方,还要看你的接收缓冲区大小和主频。F103 常见主频 72MHz,115200 波特率下每个字节耗时约 86.8 微秒,也就是大约 6250 个 CPU 周期。这个时间里 CPU 能做不少事,所以 DMA 搬一个字节的开销完全藏得住。
数据格式我一般固定用 8 数据位、无校验、1 停止位(8N1)。不是因为它最好,而是因为它最通用、调试工具默认就是它。如果你的应用需要校验,用 8E1 也行,但要注意:加了校验位之后一个字符变成 11 个 bit,前面那些时间计算要跟着改。
硬件流控(RTS/CTS)在这套方案里通常不开。一是大部分模块不接这两根线,二是开了之后引脚占用变多,三是流控解决的是"对方发太快你处理不过来"的问题,而我们用 DMA 之后处理能力其实很充裕。真正需要流控的场景是高速率长距离传输,那种场合一般也不会用 F103 裸跑。
CubeMX 里的配置顺序我习惯这样走:先开 RCC 把 HSE 设成晶振、系统时钟配到 72MHz;然后开 USART1 设为 Asynchronous 模式,参数填 115200-8-N-1;接着在 USART1 的 DMA Settings 标签页里点 Add,选 USART1_RX,模式选 Normal,优先级选 Medium 或 High;最后在 NVIC 标签页里勾上 USART1 global interrupt,抢占优先级设 1 或 2,别设成 0——0 留给系统滴答或者更紧急的东西。
有一点需要特别注意:CubeMX 自动生成的初始化代码里,HAL_UART_MspInit会把 DMA 也一并初始化并把HAL_UART_Receive_DMA的准备工作做好,但它不会帮你使能 IDLE 中断。这个必须你自己在main里、或者初始化完成后手动加一行:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);不加这一行,你会发现 DMA 收得好好的,但中断服务函数压根不进,程序像死了一样安静。这是新手最高频的翻车点之一,我至少帮三个人排查过这个问题,最后都是漏了这一行。
3.2 缓冲区大小到底开多大
这个不能拍脑袋。计算方法是从协议反推:
先看单帧最大长度是多少。假设你的协议最长一帧是 200 字节(含帧头帧尾校验),那么缓冲区至少要能装下 200 字节。但只开 200 是不够的,因为 IDLE 中断有响应延迟,从"总线安静"到"你的代码真正读到计数器"之间,理论上还可能有新数据进来(虽然概率很低)。更重要的是,中断服务函数里你要做数据拷贝或者解析,如果拷贝的过程中 DMA 还在跑,就会踩到正在被写的区域。
我的经验值是:缓冲区大小 = 单帧最大长度 × 2,然后向上取到 2 的整数次幂。200 字节的话取 512。为什么取 2 的幂?一是方便后面做环形缓冲区的取模运算,二是很多 DMA 相关的对齐要求也是按 2 的幂来的,省心。RAM 紧张的型号(比如 C8T6 只有 20KB)开 512 字节完全负担得起,但如果你的应用开了好几路串口,就得算总账了。
还有一种更保守的做法:开一个大的 DMA 缓冲区(比如 1024),然后在 IDLE 中断里把数据拷贝到一个独立的解析缓冲区。这样 DMA 区域和业务区域完全隔离,互不干扰。代价是多一次内存拷贝和一块额外 RAM。对 F103 这种主频不高、RAM 不多的芯片,我会根据实际帧长做取舍——帧短就用前者,帧长且业务复杂就用后者。
3.3 不用 CubeMX 时的手写初始化
有些老项目或者教学工程不允许用 CubeMX,得手写。核心就三块:GPIO 和 USART 初始化、DMA 初始化、NVIC 配置。关键几行如下(以 USART1 + DMA1_Channel5 为例):
/* DMA1_Channel5 配置,USART1_RX */ DMA1_Channel5->CPAR = (uint32_t)&(USART1->DR); /* 外设地址 */ DMA1_Channel5->CMAR = (uint32_t)rx_buf; /* 内存地址 */ DMA1_Channel5->CNDTR = RX_BUF_SIZE; /* 传输数量 */ DMA1_Channel5->CCR = DMA_CCR_MINC /* 内存地址自增 */ | DMA_CCR_TCIE /* 传输完成中断,可选 */ | DMA_CCR_EN; /* 使能通道 */ /* USART1 使能 DMA 接收请求 */ USART1->CR3 |= USART_CR3_DMAR; /* 使能 IDLE 中断 */ USART1->CR1 |= USART_CR1_IDLEIE;注意DMA_CCR_MINC这一位。内存地址自增必须开,否则 DMA 会把每个字节都写到数组的第 0 个位置,你收到的永远是最后一个字节。外设地址自增(DMA_CCR_PINC)绝对不能开,因为USART_DR永远只有那一个寄存器地址,开了就跑到别的寄存器上去了,那是灾难性的。
还有一个细节:CNDTR寄存器在通道使能状态下是只读的,要改必须先关通道(清DMA_CCR_EN),改完再开。这一点在重新挂载 DMA 的时候非常关键,第 4 章会详细说。
4. 接收侧代码:从 IDLE 中断到一帧完整数据
4.1 中断服务函数的正确写法
先上代码,然后逐行解释:
void USART1_IRQHandler(void) { /* 先判断是不是 IDLE 触发 */ if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { /* 清除 IDLE 标志:先读 SR 再读 DR,这是硬件要求的规定动作 */ __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 关掉 DMA 通道,防止搬运过程中数据被改 */ DMA1_Channel5->CCR &= ~DMA_CCR_EN; /* 算本次收到多少字节 */ uint16_t recv_len = RX_BUF_SIZE - DMA1_Channel5->CNDTR; if (recv_len > 0) { /* 把数据交给上层解析,注意这里最好只做拷贝或置标志 */ frame_handler(rx_buf, recv_len); } /* 重新装填计数器并重启通道 */ DMA1_Channel5->CNDTR = RX_BUF_SIZE; DMA1_Channel5->CCR |= DMA_CCR_EN; } /* 其他中断交给 HAL 处理,比如错误中断 */ HAL_UART_IRQHandler(&huart1); }几个要点必须说清楚。
第一,清除 IDLE 标志必须是"读 SR 再读 DR"这个序列。这是 STM32 硬件层面的规定,只读 SR 不清、只写 CR 也不清。HAL 提供的__HAL_UART_CLEAR_IDLEFLAG宏内部就是这个序列,用它最省事。如果你自己写,顺序错了标志清不掉,中断会反复进入,程序直接卡死在这个函数里出不来。我碰到过一次,现象是串口一收到数据整个系统就没响应了,用调试器一停,发现停在中断里出不去,就是标志没清干净。
第二,为什么要先关 DMA 再读计数器。如果不关,从你读CNDTR到你处理完数据这段时间里,DMA 可能又搬了几个字节进来,导致你算出来的长度和实际缓冲区里的内容对不上,处理时就会读到半新半旧的脏数据。虽然时间窗口很短,但在高速率下(比如 921600)这个窗口足够塞进好几个字节。关通道这个动作只需要一条指令,成本极低,没有理由不做。
第三,中断里不要做重活。上面代码里的frame_handler我建议只做两件事:把数据 memcpy 到一个独立的解析缓冲区,然后置一个"有新帧"的标志位。真正的协议解析、校验、业务处理全部放到主循环里做。中断里耗时太长会拖累整个系统的实时性,而且如果解析函数里有浮点运算或者除法,时间更不可控。我见过有人在中断里直接跑 Modbus CRC 校验加解析加回包,结果主循环里的定时任务全部抖动,最后查了半天才发现问题在中断里。
第四,HAL_UART_IRQHandler要不要调用。如果你的工程里还使能了接收错误中断(ORE、FE、NE、PE),那必须调用,让 HAL 去处理错误标志,否则一旦出现溢出错误,HAL 库会把串口锁死,后续再也收不到数据。但如果你的工程完全不用 HAL 的串口中断机制,全部自己写,那可以不调用,改成自己清错误标志:
if (USART1->SR & (USART_SR_ORE | USART_SR_FE | USART_SR_NE | USART_SR_PE)) { volatile uint32_t tmp = USART1->SR; /* 读 SR */ tmp = USART1->DR; /* 读 DR 清标志 */ (void)tmp; }我个人的习惯是保留HAL_UART_IRQHandler调用,但把错误处理单独拎出来做,因为 HAL 的错误回调里可以打日志,排查问题时很有用。
4.2 重新挂载 DMA 的两种写法与取舍
上面代码里我用的是直接改寄存器的方式重装 DMA。还有一种写法是用 HAL 的 API:
HAL_UART_DMAStop(&huart1); uint16_t recv_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); frame_handler(rx_buf, recv_len); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);看起来更"正规",但实际用起来有两个坑。
第一个坑:HAL_UART_DMAStop内部会检查gState和RxState,如果当前状态不是预期的值,它可能什么都不做就返回了,导致 DMA 根本没停。而HAL_UART_Receive_DMA在启动前会检查RxState是否为HAL_UART_STATE_READY,如果不是(比如还在HAL_UART_STATE_BUSY_RX),直接返回HAL_BUSY并且什么都不干。结果就是:第一次接收正常,第二次开始就收不到了。
第二个坑:HAL 的 DMA 停止流程会连带把USART_CR3的DMAR位也清掉,重新启动时再置回来。这个过程中如果正好有数据到达,那一个字节就丢了。虽然概率不高,但在连续通信的场景下迟早会遇到。
所以我的建议是:要么全用寄存器,要么全用 HAL,别混着来。混用的时候状态机会变得非常难推理,出了问题也很难定位。我自己现在固定用寄存器直操的写法,代码短、行为确定、调试时看寄存器一目了然。
如果你非要用 HAL 的 API,那重装之前一定要手动把状态改回来:
huart1.RxState = HAL_UART_STATE_READY; huart1.gState = HAL_UART_STATE_READY; HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);这两行看着很脏,但确实管用。不过我更推荐另一条路:根本不用HAL_UART_Receive_DMA启动,而是在初始化时直接用寄存器把 DMA 配好、把DMAR位置上,然后HAL_UART_Receive_DMA一次都不调用。这样 HAL 的状态机永远停在 READY,也就不会给你找麻烦。
4.3 环形缓冲区:接收长度超过缓冲区怎么办
上面的方案有一个硬性限制:单帧长度不能超过缓冲区大小。如果对方突然发了一帧 600 字节,而你的缓冲区只有 512,DMA 会在收满 512 时触发传输完成中断(如果你开了的话),剩下 88 字节会覆盖到缓冲区开头——如果你用的是循环模式的话;如果是普通模式,DMA 停在 512 就不动了,剩下的字节全部丢失。
解决这个问题有两条路。
第一条路是把缓冲区开得足够大,大到任何可能的帧都装得下。这是最简单的办法,代价是 RAM。如果你的协议明确规定了最大帧长(绝大多数工业协议都有),那直接按最大帧长 × 2 开就行,不用纠结。
第二条路是用循环模式 + 半满中断 + 空闲中断三者配合。循环模式下 DMA 搬满会自动回绕到起点继续搬,永远不会停。你需要额外使能半满中断(DMA_CCR_HTIE),在搬满一半的时候也进一次中断,然后根据当前CNDTR和上次记录的位置差来算本次收到多少字节。这套逻辑比较绕,我把它写成伪代码:
/* 每次进中断(IDLE 或 HT 或 TC)都执行 */ uint16_t curr = RX_BUF_SIZE - DMA1_Channel5->CNDTR; uint16_t len; if (curr >= last_pos) len = curr - last_pos; else len = RX_BUF_SIZE - last_pos + curr; /* 发生了回绕 */ last_pos = curr; /* len 就是这一段的长度,交给上层 */循环模式的好处是永不丢数据、缓冲区利用率高;坏处是逻辑复杂、一旦算错位置就会出现"数据重复"或者"数据缺失",而且这种错误往往不是每次都出现,极难复现。我的建议是:除非你的帧长真的可能超过缓冲区,否则别上循环模式。普通模式 + 足够大的缓冲区,稳定性高一个数量级。
还有一个折中方案值得提一下:用普通模式,但把缓冲区开到 2048 甚至 4096,同时在 DMA 传输完成中断里做"缓冲区满"的告警。这样正常帧走 IDLE 中断,异常长帧走完成中断,两条路都有兜底。F103 的 RAM 一般够,ZET6 有 64KB,开 4KB 完全无所谓。C8T6 只有 20KB,就得省着点,2KB 是我见过比较常见的折中值。
5. 发送侧代码:DMA 发送与忙等待的取舍
5.1 三种发送方式的真实差异
发送这边比接收简单,但也有讲究。常见三种方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
阻塞发送HAL_UART_Transmit | 代码最简单,CPU 全程等待 | 只发几个字节、系统实时性要求不高 |
中断发送HAL_UART_Transmit_IT | 逐字节中断,CPU 参与度高 | 中等长度数据、不想占 DMA 通道 |
| DMA 发送 | CPU 零参与,一次配置搬完 | 长数据、高频率发送、多串口并发 |
很多人觉得阻塞发送"太低端",其实在发几条调试日志的场景下,阻塞发送反而是最省心的。115200 下发 20 个字节大约 1.7 毫秒,这点延迟对大多数应用无所谓。真正需要 DMA 发送的是这几种情况:一帧几百字节的图像数据或者固件分包、多个串口同时高频发送、发送期间 CPU 有硬实时任务要跑。
我用 DMA 发送踩过最典型的一个坑是数据覆盖。DMA 发送的原理是把缓冲区地址告诉 DMA,然后立刻返回,DMA 在后台慢慢搬。如果你给的是栈上的局部数组:
void send_frame(void) { uint8_t tmp[64]; /* ...填充 tmp... */ HAL_UART_Transmit_DMA(&huart1, tmp, 64); /* 危险! */ }函数一返回,栈上的tmp就失效了,可能被下一个函数调用覆盖。DMA 还在慢慢搬,搬到的就是被改过的垃圾数据。这个 bug 的表现是"发出去的数据偶尔错几个字节",而且越是函数调用层次深的时候越容易出现,非常难查。
正确的做法有二:要么给一个静态的发送缓冲区,要么用"发送完成回调里再释放"的方式。我一般用一个静态的环形发送队列:
static uint8_t tx_buf[512]; static volatile uint8_t tx_busy = 0; void uart_send(const uint8_t *data, uint16_t len) { if (len > sizeof(tx_buf)) return; uint32_t primask = __get_PRIMASK(); __disable_irq(); if (!tx_busy) { memcpy(tx_buf, data, len); tx_busy = 1; __set_PRIMASK(primask); DMA1_Channel4->CCR &= ~DMA_CCR_EN; DMA1_Channel4->CMAR = (uint32_t)tx_buf; DMA1_Channel4->CNDTR = len; DMA1_Channel4->CCR |= DMA_CCR_EN; USART1->CR3 |= USART_CR3_DMAT; } __set_PRIMASK(primask); }发送完成之后在 DMA 的传输完成中断里清tx_busy并关掉DMAT位:
void DMA1_Channel4_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_TCIF4) { DMA1->IFCR = DMA_IFCR_CTCIF4; DMA1_Channel4->CCR &= ~DMA_CCR_EN; USART1->CR3 &= ~USART_CR3_DMAT; tx_busy = 0; } }注意这里tx_busy的判断和置位放在临界区里,否则两个任务同时调用uart_send会互相踩。__disable_irq的代价很小,但要注意别在临界区里做耗时操作,也别在里面再调可能阻塞的函数。
如果你确实需要排队发送多帧,那就得用真正的环形队列,加上读写指针和队列满的处理。这种情况下我建议直接用现成的轻量队列实现,别自己造轮子,出错的成本比省下的时间高得多。
5.2 printf 重定向的那些坑
几乎每个工程都会把printf重定向到串口。标准做法是重写fputc:
int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_SR_TXE)); USART1->DR = (uint8_t)ch; return ch; }这段代码是阻塞的,每发一个字符都要等 TXE。如果串口正在被 DMA 占用,printf和 DMA 会同时往USART_DR里写,输出就乱了。所以一旦用了 DMA 发送,printf必须改成走同一个发送队列,或者干脆在 DMA 发送期间禁用printf输出。
还有一个细节:重定向之后输出中文容易乱码。不是编码问题,而是printf的格式串里如果混了%d和中文,Keil 的 MicroLIB 在某些版本下处理多字节字符会有边界问题。我的做法是调试信息全部用英文和数字,真要输出中文就自己写个uart_puts直接发字节流,绕开printf那套格式化。
另外 MicroLIB 默认不开启,要在 Keil 的 Target 选项里勾上 Use MicroLIB,否则printf重定向会因为半主机模式卡死。这个坑新手几乎必踩,现象是程序一跑到printf就停在BKPT指令上不动了。GCC 环境则是实现_write函数。
6. 实测数据与性能边界
6.1 不同波特率下的帧间隔窗口
前面说过,IDLE 中断的触发条件是"连续一个字符时间的高电平"。这个时间随波特率变化,我实测了一组数据(8N1 格式,主频 72MHz):
| 波特率 | 单字符时间 | 单字节耗时 | 缓冲区 512 的填满时间 |
|---|---|---|---|
| 9600 | 1042 µs | 1042 µs | 533 ms |
| 19200 | 521 µs | 521 µs | 267 ms |
| 57600 | 174 µs | 174 µs | 89 ms |
| 115200 | 86.8 µs | 86.8 µs | 44 ms |
| 460800 | 21.7 µs | 21.7 µs | 11 ms |
| 921600 | 10.8 µs | 10.8 µs | 5.5 ms |
这张表能读出几件事。
第一,低波特率下帧内停顿的容忍度更高,但也更容易被误切。9600 波特率下,只要帧内出现 1 毫秒以上的间隔,IDLE 就会触发。而很多上位机软件在发送长数据时,由于操作系统的调度,字节之间出现 1 毫秒间隔是很正常的。这就是为什么有人用 9600 测试没问题,换到 115200 反而更稳定(因为窗口变窄了)。
第二,高波特率下留给中断响应的时间很短。921600 时,从 IDLE 触发到你进中断,只有 10.8 微秒,72MHz 下大约 780 个周期。如果此刻有更高优先级的中断在跑,或者你开了什么耗时长的中断,就很容易错过窗口——虽然 IDLE 标志会一直保持,不会丢,但 DMA 会继续接收后面的数据,导致两帧数据粘在一起。所以高波特率下一定要控制中断嵌套深度。
第三,DMA 填满缓冲区的时间可以用来反推你的处理预算。115200 下 512 字节要 44 毫秒,如果你的主循环处理一帧需要超过这个时间,就要考虑加大缓冲区或者用循环模式。
6.2 我做过的一组压力测试
测试条件:F103C8T6,72MHz,USART1 115200,一帧 64 字节,帧间隔随机在 0.2ms 到 5ms 之间,连续发送 10 万帧,统计出错率。
结果是:普通模式 + 512 字节缓冲区,10 万帧里错了 3 帧,都是帧间隔小于 0.3ms 的时候两帧粘连。把缓冲区加到 1024 并稍微优化中断处理(去掉中断里的 memcpy,改成只置标志 + 记录长度),错误降到 0。循环模式 + 半满中断的方案,10 万帧也是 0 错误,但代码复杂度明显上升。
另外测了一组"极限"场景:把发送方改成不间断连续发送,每帧之间只留 1 个字符时间的间隔。这种情况下 IDLE 中断的触发就非常依赖时序,实测在 115200 下大约有 2% 的帧会被合并。这不是代码问题,是协议设计问题——如果你的协议本身不保证帧间隔,就不该用 IDLE 来切帧,得靠帧头帧尾或者长度字段来解析。
7. 常见问题排查速查表
7.1 一帧都收不到
这是最常见的求助场景。按下面顺序查,基本能定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无反应,中断不进 | 忘了使能 IDLE 中断 | 查USART_CR1的 IDLEIE 位 |
| 中断进了但长度为 0 | DMA 没使能,或 DMAR 位没置 | 查DMA_CCR_EN和USART_CR3_DMAR |
| 长度总是 1 或固定小值 | IDLE 标志没清干净,反复进中断 | 用调试器看 SR 寄存器 |
| 收到数据但全是 0 | DMA 内存地址配错,或 MINC 没开 | 查DMA_CMAR和CCR的 MINC 位 |
| 第一次正常,之后全废 | HAL 状态机卡在 BUSY | 查huart1.RxState |
第二项和第五项我要多说两句。
DMAR 位是很多人漏掉的。CubeMX 生成的HAL_UART_Receive_DMA内部会置这个位,但如果你是自己用寄存器配的,就必须手动置。现象是:DMA 通道使能了、计数器也设了、中断也能进,但CNDTR永远是满值,说明一个字节都没搬。原因就是 USART 根本没往外发 DMA 请求。
HAL 状态机这个问题我在 4.2 节提过。表现是第一次接收完美,第二次开始HAL_UART_Receive_DMA一直返回HAL_BUSY,什么也不做。用调试器看huart1.RxState会发现它停在HAL_UART_STATE_BUSY_RX(值为 0x22)。根源是 IDLE 中断发生时 DMA 传输并没有完成,HAL 认为还在忙,而你的代码又直接动了 DMA 寄存器把它停了,HAL 完全不知道,状态就对不上了。解决办法就是前面说的:要么全寄存器,要么手动改状态。
7.2 数据错乱、缺字节、粘连
这类问题比"收不到"难查,因为它是概率性的。我整理了几个我实际遇到过的原因:
原因一:中断里 memcpy 和目标缓冲区重叠。如果frame_handler里的目标缓冲区不够大,或者长度算错了(最常见的是把CNDTR的剩余值当成了已收值),memcpy 就会越界,把别的变量踩掉。表现是"偶发某个全局变量莫名其妙变了"。查这个问题最有效的方法是在目标缓冲区前后各加一个魔数标记,处理完检查标记是否还在。
原因二:重新装填 DMA 时窗口期的数据丢失。从关 DMA 到重新使能,中间有几个指令周期。如果对方正好在这几个周期里发数据,那个字节就丢了。115200 下一个字节 86 微秒,丢的概率很低但存在。要彻底避免就得用循环模式,因为循环模式下 DMA 永不停,不存在这个窗口。或者用双缓冲:准备两个缓冲区,处理 A 的时候 DMA 往 B 里写,处理完切换。
原因三:IDLE 和 DMA 传输完成中断打架。如果一帧长度刚好等于缓冲区大小,DMA 的传输完成中断和 IDLE 中断都会触发,你的代码可能处理两次,第二次长度算出 0 或者算出满值,导致重复数据。解决办法是在传输完成中断里把CNDTR重装并置一个标志,IDLE 中断里看到这个标志就跳过。或者干脆不开传输完成中断,只靠 IDLE——但如果帧长等于缓冲区,IDLE 会在 DMA 停下的那一刻触发,CNDTR是 0,长度算出来是满值,逻辑上其实是对的,只要你不重复处理就行。
原因四:波特率误差导致误码。F103 的 USART 波特率是分频得到的,某些波特率下误差会超过 3%,长期跑就会偶发误码。115200 在 72MHz 下误差约 0.16%,很安全;但比如 921600 误差就到了 1.7% 左右,接近临界。用外部晶振比内部 RC 稳得多,内部 RC 温漂会让误差进一步放大。这个原因导致的错误往往表现为"偶尔某个字节变成了 0x00 或 0xFF",因为误码通常发生在采样点偏移的时候。
7.3 死机、卡在中断里出不来
卡在 IDLE 中断里循环,几乎百分之百是标志没清。清除 IDLE 必须是"读 SR、读 DR"这个顺序,用__HAL_UART_CLEAR_IDLEFLAG最保险。如果你自己写的清除代码是先读 DR 再读 SR,或者只读了 SR,都会失败。
HardFault,常见原因是 DMA 的内存地址指向了非法区域,或者数组越界踩到了栈。还有个隐蔽的原因是CNDTR设成了 0——DMA 计数器为 0 时通道使能的行为在手册里写得不清楚,实测会导致传输完成标志立刻置位,如果中断里又重装 0,就是死循环。
串口突然不工作了,但程序还在跑,多半是 ORE 溢出错误把 HAL 锁住了。HAL 库在检测到错误标志时会把gState设为HAL_UART_STATE_ERROR,之后所有 HAL 的收发 API 都返回错误,但不会自动恢复。解决办法是在错误回调或者中断里手动清标志并重置状态:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(huart); huart->RxState = HAL_UART_STATE_READY; huart->gState = HAL_UART_STATE_READY; /* 重新挂载 DMA 接收 */ } }ORE 在不定长接收场景下出现的概率不低,尤其是在你处理上一帧的时候新数据就来了。所以这个错误回调一定要实现,不能省。
提示:调试这类问题时,最有效的手段不是加打印,而是用调试器在 IDLE 中断入口打一个断点,然后看
DMA_CNDTR、USART_SR、huart1.RxState这三个值的组合。不同的组合对应不同的故障模式,比瞎猜快得多。
8. 几个踩坑之后才明白的道理
关于缓冲区,我现在的原则是"宁可浪费 RAM,不要省那几百字节"。F103C8T6 只有 20KB,看着紧,但一个 512 字节的接收缓冲区加上一个 512 的发送队列,总共 1KB,占 5%。为了省这 1KB 去搞循环模式、搞双缓冲切换,代码复杂度和出错概率上升的代价远大于那点 RAM。真正 RAM 吃紧的时候,该优化的是那些开了没用的大数组和没必要的全局变量。
关于中断优先级,IDLE 中断的优先级不要设得太高也不要太低。太高会打断其他重要外设的时序,太低会被其他中断拖住导致帧粘连。我给的位置是中等偏上,抢占优先级 1 或 2,和系统滴答同级或者略高。如果你的系统里有电机控制、编码器采样这类对时序敏感的东西,把串口中断排在它们后面。
关于代码可移植性,我把这套逻辑封装成了两个函数:uart_dma_init()和uart_dma_send(),接收部分通过一个回调函数指针把数据往外抛。这样换串口、换芯片型号的时候,只需要改初始化和通道号,上层业务代码一行不动。封装的时候刻意不去依赖 HAL 的UART_HandleTypeDef,直接操作寄存器,反而让移植变得简单——F1 到 F4 的 DMA 寄存器布局不太一样,但 USART 的 IDLE 机制是完全一致的。
最后一个想说的是:这套方案不是万能的。它依赖"帧间有间隔"这个前提,如果你的协议是连续流式的(比如某些传感器一上电就不停地吐数据,帧与帧之间没有间隔),那 IDLE 根本切不开帧,必须靠帧头、长度字段或者固定帧长来解析。选型阶段就要想清楚协议长什么样,别等代码写完才发现前提不成立。我见过一个项目就是因为传感器是流式输出,硬套 IDLE 方案,最后只能靠软件定时器轮询来判断帧边界,绕了一大圈。