ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

串口接收不掉数据:空闲中断+DMA+FIFO完整方案

串口接收不掉数据:空闲中断+DMA+FIFO完整方案

简介:面向STM32 F7开发者的串口高效通信实现方案,基于HAL库整合空闲中断、DMA与FIFO,解决大数据量、高实时性传输中CPU占用高与数据易丢失的问题。资源包共1198个文件,大小9.2MB,以C源文件与头文件为主体,辅以IAR/Keil工程文件、CMSIS DSP库、链接脚本及ioc等配置,并含有ModBus RTU工程入口,构成一套可直接编译运行的完整工程源码。已有3500余人学习下载,适合需要掌握HAL库串口DMA收发与空闲中断联调的中高级嵌入式开发者。该源码包含串口参数初始化、DMA通道分配、空闲中断服务、FIFO深度与触发级别配置等完整实现,并展示了传输完成回调与错误处理逻辑;通过分析工程结构,可快速理解中断与DMA的状态协同,直接参考或移植到实际项目中,有效提升串口通信的实时性与可靠性,适用于工业控制、物联网网关等对实时性要求较高的场景。

1. 串口接收还在掉数据?空闲中断加 DMA 和 FIFO 才是正经解法

做嵌入式这几年,串口接收是我见过翻车率最高的外设之一。用 HAL 库的HAL_UART_Receive_IT一个字节一个字节收,数据一多就进中断,CPU 被拖死不说,稍微一忙就丢字节;用HAL_UART_Receive_DMA定长接收,又得提前知道协议长度,遇到不定长帧直接抓瞎。直到我换成 HAL 库串口空闲中断 + DMA + FIFO 这套组合,才算把串口接收从"玄学"变成"工程"。这套方案的核心就一句话:让 DMA 把数据搬运到内存,空闲中断告诉你"这一帧收完了",FIFO 负责把数据暂存起来交给业务层慢慢消费。它适合所有不定长串口协议、日志采集、传感器数据上报场景,新手按步骤能跑通,老手照着能调出稳定参数。

2. 先搞懂三个部件:空闲中断、DMA 与 FIFO 各自扮演什么角色

2.1 为什么定长接收满足不了真实业务

大多数串口协议设计成不定长帧,比如 AT 指令以回车结尾,Modbus RTU 以 3.5 个字符静默区分帧,GPS 报文更是长短不一。如果只用 DMA 定长接收,你必须提前知道每次会来多少字节,这在绝大多数场景里是做不到的。之前我用过HAL_UART_Receive_IT加状态机,每收到一个字节就进一次中断,波特率 115200 时每字节间隔只有 87 微秒,CPU 在这段时间里还要处理主循环任务,一个调度抖动就是丢数据。后来尝试HAL_UART_Receive_DMA配合 DMA 传输完成回调,但它只告诉你"缓冲区满了",不会告诉你"帧结束了",半包、粘包问题依然无解。

空闲中断恰好补上这个缺口:串口接收线在收到一个字节后,如果持续一个完整字节时间没有新数据,硬件就会产生空闲中断。也就是说,它天然就是"帧结束"的信号。配合 DMA 连续接收,数据从外设到内存的搬运完全由硬件完成,CPU 只在帧结束时被中断一次。这套组合的逻辑结构是:DMA 负责"搬运",空闲中断负责"分帧",FIFO 负责"缓存"。三者各司其职,才能做到任意时刻都有空闲缓冲区可用。

2.2 HAL 库串口底层的调用链,你必须心里有数

HAL 库把串口封装成UART_HandleTypeDef结构体,里面有个UART_StateTypeDef gState和RxState,它们决定了当前串口处于什么状态。很多人直接用HAL_UART_Receive_DMA(&huart1, buf, size)启动了一次性接收,然后发现接收完一次就停了,因为 HAL 库默认的这种模式是一次性的,传输完成回调里如果你不重新调用启动函数,DMA 不会再接收后续数据。

更关键的是,HAL 库的中断处理函数HAL_UART_IRQHandler内部会判断各种中断标志,包括UART_FLAG_IDLE。但 HAL 库默认只处理错误、接收完成等几个中断,空闲中断需要你自己在HAL_UART_IRQHandler之前或之后手动处理。我常用的做法是写一个自己的中断服务函数,先判断空闲标志,再调用 HAL 库的中断处理函数。这样做的好处是不动库文件,升级 HAL 库也不怕冲突。调用链大概是:串口全局中断 → 自拟的UART_IRQHandler→ 处理空闲标志 → 调用HAL_UART_IRQHandler→ 处理字节接收和错误。

2.3 FIFO 设计:环形缓冲区才是真正的"数据港口"

DMA 直接写入的缓冲区是线性的,业务层如果直接读这个缓冲区,读一半的时候 DMA 又往里面写,数据就会互相覆盖。所以必须引入 FIFO。我一般用经典的环形缓冲区实现:头指针head表示写入位置,尾指针tail表示读取位置,当head == tail时为空,当(head + 1) % size == tail时为满。这里有个工程细节:环形缓冲区必须浪费一个字节来区分空和满,否则无法判断head == tail到底是空还是满。

FIFO 的容量选择是有讲究的。假如波特率 115200,每字节约 87 微秒,一帧 100 字节约 8.7 毫秒。你希望业务层最坏情况在 10 毫秒内来取一次数据,那 FIFO 至少能装下 2 帧才能防抖,也就是 200 字节。但 DMA 的缓冲区是另一回事,它由空闲中断触发,DMA 缓冲区至少要能装下一帧最大长度。所以两个缓冲区要分开:DMA 缓冲区做"硬件搬运暂存",FIFO 做"软件缓存"。我通常把 DMA 缓冲区定为 256 字节,FIFO 定为 1KB,这样既能吸收突发数据,又不会占用太多 RAM。如果芯片 RAM 紧张,可以把 FIFO 减到 512 字节,但 DMAX 缓冲区不建议小于最大帧长加 16。

3. 从零搭建工程:CubeMX 配置与代码生成

3.1 串口引脚与时钟配置的三个关键点

在 CubeMX 里配置串口时,第一步是把 UsartX 的 Mode 选为 Asynchronous,波特率按你的外设协议要求填。这里有个容易忽略的点:Word Length要选8 Bits还是9 Bits,大多数场景用 8 位,但如果你接的是带奇偶校验的工业总线,就得选 9 位——这个选项直接决定 DMA 搬运的字节单位。第二步是打开串口的全局中断,NVIC 里把USARTx global interrupt优先级设为 5 或 6 都可以,但千万不要和 DMA 中断优先级一样,会造成竞争。我一般把串口中断设为 5,DMA 中断设为 6,这样即使极端情况也只丢回调不丢数据。第三步是 DMA 的配置:在 DMA Settings 里添加USARTx_RX,方向是 Peripheral to Memory,数据宽度和内存数据宽度都选 Byte,模式选 Circular。

这里特别说明一下为什么使用 Circular 模式。定长接收用 Normal 模式,一次传输完就停止;但空闲中断+DMA 的方案需要 DMA 一直在接收,Circular 模式在到达缓冲区末尾时会自动回卷到开头继续搬运,这样硬件层面就形成了一个循环队列。你可能会问:那 FIFO 还有必要存在吗?有。因为 DMA 的循环写入地址是固定的区间,业务层如果不及时读走,新数据会覆盖旧数据,而 FIFO 把数据从 DMA 缓冲区搬走之后,DMA 缓冲区始终只作为"新数据的临时落脚点",两者职责分离,才能保证不丢帧。

3.2 串口与 DMA 中断的配置顺序

这是一个血泪经验:初始化的顺序必须严格,否则第一步就翻转。正确顺序是先初始化 DMA,再初始化串口。原因在于串口外设一旦使能,外部数据可能马上到来,如果 DMA 还没有准备好接收通道,数据就会因为无接收目标而丢弃。CubeMX 生成的代码里,MX_DMA_Init()和MX_USART1_UART_Init()的顺序在main()里是按字母序生成的,DMA 通常排在串口前面,但如果你手动调整过函数顺序,务必确认。另外,DMA 初始化里有一步__HAL_RCC_DMAx_CLK_ENABLE(),这个时钟使能必须在串口时钟使能之前。我建议统一在main()开头调用MX_DMA_Init(),然后MX_USART1_UART_Init(),最后再打开串口中断和 DMA 中断。

还有一个坑:CubeMX 生成的串口初始化里,最后会调用HAL_UART_MspInit(),这里面默认会在使能串口之前先把 DMA 通道配置好。如果你在代码里手动调换了初始化顺序,就会导致HAL_UART_MspInit()找不到 DMA 句柄,直接返回错误。这时候不要强行改 HAL 库底层,而是检查你把MX_DMA_Init()放在哪里了。

3.3 启动接收的完整代码与参数说明

配置好 CubeMX 后,在代码里启动接收的代码并不复杂,但很多新手挂在第一次调用上。下面是我常用的一段初始化代码,放在main()里while(1)之前的串口初始化之后。

// 全局定义:DMA缓冲区与FIFO #define RX_BUF_SIZE 256 uint8_t dma_rx_buf[RX_BUF_SIZE]; FIFO_TypeDef uart1_fifo; // 启动接收:开始DMA循环接收,并使能空闲中断 void UART1_Start_Receive(void) { // 1. 复位FIFO FIFO_Reset(&uart1_fifo); // 2. 启动DMA循环接收,数据从外设直接搬到dma_rx_buf HAL_UART_Receive_DMA(&huart1, dma_rx_buf, RX_BUF_SIZE); // 3. 使能空闲中断(关键步骤) __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }

这里HAL_UART_Receive_DMA的第三个参数是接收长度,因为使用 Circular 模式,这个长度代表缓冲区大小,不是一次性要收多少字节。启动后 DMA 会一直搬运数据,搬满 256 字节后自动回卷。__HAL_UART_ENABLE_IT是 HAL 库提供的宏,用来单独打开空闲中断位。注意:这个宏必须在HAL_UART_Receive_DMA之后调用,因为后者内部会清掉一些中断标志,而且它本身可能会重置状态。如果你先开空闲中断再启动 DMA,有些芯片上会导致启动瞬间的空闲误中断,连续触发多次。这段时间我调试过不下十个型号,稳妥的写法就是上面这个顺序。

4. 核心实现:空闲中断回调、FIFO 读写与边界处理

4.1 在中断里区分空闲与普通接收

空闲中断并不是独立的中断源,它挂在串口全局中断里。所以你要在串口中断服务函数里手动判断标志。看下面这段代码,我直接写了一个独立的中断服务函数,在调用 HAL 库的HAL_UART_IRQHandler之前先处理空闲标志,这样不会干扰 HAL 库自身的状态机。

void USART1_IRQHandler(void) { uint32_t isr_flags = READ_REG(huart1.Instance->SR); uint32_t cr1_val = READ_REG(huart1.Instance->CR1); // 1. 检查空闲标志且空闲中断已使能 if ((isr_flags & UART_FLAG_IDLE) && (cr1_val & UART_IT_IDLE)) { // 先清标志,避免连续进入 __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算刚才这一帧的数据量和搬运位置 uint16_t rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 核心:把这帧数据从DMA缓冲区写入FIFO // 但要注意环形边界:DMA缓冲区可能回卷,需要分段拷贝 if (rx_len > 0) { // 起点是DMA当前写入位置减去收到的长度 uint8_t *start_ptr = &dma_rx_buf[RX_BUF_SIZE - rx_len]; FIFO_Write(&uart1_fifo, start_ptr, rx_len); } } // 2. 让HAL库继续处理其他的串口中断(如错误中断) HAL_UART_IRQHandler(&huart1); }

这里的核心是__HAL_DMA_GET_COUNTER,它返回 DMA 当前还剩多少个字节没搬运。用缓冲区总长减去剩余计数,就是你这一帧实际收到的数据长度。但有个边界问题:如果 DMA 已经回卷过一次,比如缓冲区大小为 256,这一帧从第 200 字节开始,经过 300 字节后落到第 56 字节,这时候不能简单地从头算。这个场景我在量产中遇到过两次,解决办法是记录上次空闲中断时 DMA 的写入位置,但代码会复杂一些。对于大部分帧长小于缓冲区容量的场景,上面这个"仅处理未回卷"版本是可用的,因为你把缓冲区设置的比最大帧长还大,一帧内不会发生回卷。

4.2 FIFO 写入与读取的完整实现

FIFO 要提供两个 API:一个给中断里写入,一个给主循环读取。中断写入时要关闭中断保护,但主循环读时如果也关闭中断,容易在读取中途被串口中断打断导致指针不一致。我采用的是"临界区只保护指针更新"的办法。看实现:

typedef struct { uint8_t *buffer; uint16_t size; uint16_t head; // 写入索引 uint16_t tail; // 读取索引 } FIFO_TypeDef; uint16_t FIFO_Write(FIFO_TypeDef *fifo, uint8_t *data, uint16_t len) { uint16_t free_space = fifo->size - 1 - (fifo->head - fifo->tail + fifo->size) % fifo->size; if (len > free_space) { len = free_space; // 写不下就截断,实际工程中要记录溢出次数 } for (uint16_t i = 0; i < len; i++) { fifo->buffer[fifo->head] = data[i]; fifo->head++; if (fifo->head >= fifo->size) fifo->head = 0; } return len; } uint16_t FIFO_Read(FIFO_TypeDef *fifo, uint8_t *buf, uint16_t len) { uint16_t data_count = (fifo->head - fifo->tail + fifo->size) % fifo->size; if (len > data_count) len = data_count; for (uint16_t i = 0; i < len; i++) { buf[i] = fifo->buffer[fifo->tail]; fifo->tail++; if (fifo->tail >= fifo->size) fifo->tail = 0; } return len; }

注意head和tail都声明为uint16_t,放在中断和主循环共享的地方,需要保持原子性。在主流 32 位芯片上,uint16_t读写是单条指令,理论上不被打断,但如果你用uint8_t拼两个字节,或者用uint32_t的指针,就可能在读高字节和低字节之间被中断插一脚,导致读取错数据。这是翻车的重灾区,后面避坑章节会再强调。

4.3 处理 DMA 半满与全满中断的边界

如果你把 DMA 缓冲区设得很大,比如 512 字节,那一帧数据可能只占了 10 个字节就空闲了,完全用不到半满中断。但如果你面对的是连续不间断的数据流,比如 GPRS 模块主动上报数据,一次可能持续塞几百字节,这时候数据分帧的逻辑就不是靠空闲中断了,而是靠协议解析层去切帧。DMA 的半满和全满中断此时就派上用场:半满中断可以帮你及时把前半缓冲区移入 FIFO,避免后半写入时覆盖前半。

我习惯在 DMA 的传输半满和完成回调里把数据搬到 FIFO,这样空闲中断只负责标注"这里有帧边界"。具体写法是复用HAL_UARTEx_RxEventCallback,在回调里判断收到的字节数。但有个前提:你必须调用HAL_UARTEx_ReceiveToIdle_DMA这个 API,它内部会同时开启 DMA 循环接收和空闲中断,并且自动处理半满和全满事件。这是 ST 后来在 HAL 库里专门为这种场景封装的函数。如果你用的是旧版 HAL 库,可能没有这个函数,就只能手动管理。我的经验是:如果你只需要定长或不定长帧接收,用HAL_UARTEx_ReceiveToIdle_DMA就够;如果你还想做双缓冲持续收发,就得自己把半满中断打开。

5. 避坑指南:串口接收的 5 个经典翻车现场

5.1 现象:接收数据错位、乱码

这个现象通常表现为:第一帧数据正常,第二帧开始前几个字节是上一次的残留,或者某个字节总是莫名其妙的变成 0xFF。原因是 DMA 缓冲区回卷后,空闲中断计算数据长度时没有把回卷的起点偏移计算进去。解决方案很直接:确保你的最大帧长小于 DMA 缓冲区大小,并且在空闲中断里增加一个"剩余计数大于缓冲区一半就视为回卷"的判断。我做过一个模拟项目,把 DMA 缓冲区设成 128,实际帧长 64,回卷概率很低,但协议里加了一个 80 字节的扩展帧,就出现了这个现象。后来把缓冲区改成 256,问题消失。所以不要把缓冲区调得刚好,至少留 30% 余量。

5.2 现象:DMA 传输完成但空闲中断不触发

代码逻辑看起来没问题,DMA 一直在接数据,但空闲中断就是不进。原因大概率是__HAL_UART_CLEAR_IDLEFLAG宏在清除标志时把 CR1 里的空闲中断使能位也清了。这个宏在不同 HAL 库版本里实现不同,有些版本是直接修改 SR 寄存器,有些版本要写 CR1。解决方法是:在清标志之后再调用一次__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE),确保使能位恢复。我之前就在一次 HAL 库升级之后踩了这个坑,现象是上电第一次空闲中断正常,之后永远不触发。建议每次升级 HAL 库后,都实际跑一下连续收发的压力测试。

5.3 现象:FIFO 溢出导致丢帧

业务层解析耗时长,比如你要查表,或者往 Flash 写日志,这时候 FIFO 满了,新数据进不来。体系上要先区分"硬件可靠性丢帧"和"业务来不及消费丢帧"。后者是正常的,解决办法是提高 FIFO 容量,或者把解析任务放到 DMA 通道更宽裕的优先级上。我做的一个某跨平台系统项目里,串口收到传感器数据后要马上转发到另一路串口,中间 FIFO 只有 256 字节,转发波特率比接收波特率低,必然溢出。解决方法是改成双 FIFO,接收 FIFO 和发送 FIFO 分开,并且发送 FIFO 阻塞写,保证不丢一帧。这里的经验是:FIFO 不能盲目加大,要算波特率和处理时间的匹配关系。

5.4 现象:低功耗模式下串口唤醒异常

设备进入 STOP 模式以后,串口用外部中断唤醒,但空闲中断和 DMA 中断在低功耗模式下优先级不同,导致帧边界丢失。原因是低功耗模式下 DMA 时钟可能被关闭,空闲中断虽然能唤醒 CPU,但 DMA 没有搬运数据,所以醒过来之后看到的缓冲区是空的。正确做法是用串口的接收引脚电平触发唤醒中断,唤醒后再启动 DMA 并恢复空闲中断。注意在恢复 DMA 之前,要先清掉串口残留的标志位,避免一使能就误进空闲中断。这个坑我是在做低功耗传感器节点时遇到的,仿真器连上一切正常,拔掉仿真器就丢帧,就是低功耗下 DMA 时钟的问题。

5.5 现象:中断与主循环竞争导致数据被撕裂

主循环里读取 FIFO 时,如果正好来了一帧数据,中断里写 FIFO 的头指针,主循环读尾指针,可能会读到半帧。比如一帧数据写着"ABCDEF",主循环读到了"ABC",这时中断又写入新数据 "GHIJK",FIFO 里就混成了 "ABCGHIJK"。这是我见过最多人栽的地方。原因是共享变量的读写没有做成原子操作。解决方法是:在读取 FIFO 前先关闭串口中断,读完了再打开,如果数据量大,禁用中断的时间不能太长,否则会丢后面的数据。另一种做法是把 FIFO 的读操作和写操作都做成带临界区的函数,利用硬件提供的__disable_irq()和__enable_irq()包裹关键区域,这样一来即使发生竞争,也不会读到不一致的数据。

6. 进阶:多串口共用 DMA 与一套可复用的调试手段

当你需要同时接收两路或更多串口数据时,如果每个串口都分配一个 DMA 通道,小容量芯片很快就枯竭。常见做法是让多个串口共用同一个 DMA 控制器,只要通道号不冲突就行。比如 UART1 用 DMA1 通道 4,UART2 用 DMA1 通道 5,互不影响。但要注意,有些芯片同一 DMA 通道不能同时服务两个外设,必须在 CubeMX 里确认外设请求映射表。我在某公司项目里用过类似布局,一共四个串口,每个串口配一个 DMA 缓冲区,全部挂在一个 DMA 控制器下,中断优先级统一,并没有出现资源竞争。关键是每个串口的 DMA 中断要独立打开,并在各自的回调里只处理自己的 FIFO。

调这种多串口方案时,不要只依赖串口助手看数据。我习惯在板上预留一个空闲的 GPIO,用逻辑分析仪测量每次空闲中断之间的时间间隔,用来估算 CPU 占用率。做法是:在空闲中断里拉高一个 GPIO,在处理完 FIFO 后拉低,用逻辑分析仪抓这段高电平的宽度。高电平时间越长,说明这一帧数据被 CPU 搬移和解析的时间越长。如果高电平接近帧间隔,说明你的代码性能已经到瓶颈了。这时候优先考虑优化 FIFO 拷贝,比如用memcpy替代逐字节循环,或者把解析逻辑挪到更低的优先级任务里。

这套方案真正成熟的标准是:连续跑 24 小时不丢一帧,任意时刻断点后重启都能恢复。我自己的习惯是每次改完代码都做一次压力测试,用串口助手发送随机长度、随机间隔的数据帧,间隔从 1 毫秒到 100 毫秒变化,持续跑。如果连续 1 万帧无错,才敢说"稳定"。这个习惯救了我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进