1. 从“轮询”到“DMA”:为什么串口通信需要解放CPU?
如果你用过STM32、GD32这类MCU的串口,大概率是从HAL_UART_Transmit和HAL_UART_Receive这两个函数开始的。发送一个字符串,接收一个字节,代码简单明了。但当你需要以115200甚至更高的波特率连续收发数据,或者MCU同时还要处理复杂的算法、刷新屏幕时,问题就来了:你会发现CPU的时间几乎被串口的收发操作“吃”掉了。那个经典的while(!__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TXE))轮询等待发送完成的循环,本质上就是让CPU在原地“空转”,什么也干不了,直到硬件完成一个字节的搬运。这在低速率、间歇性通信的场景下尚可忍受,但在需要高吞吐、低延迟或低功耗的应用中,就成了性能瓶颈。
这时,DMA就该登场了。DMA,全称Direct Memory Access,直接存储器访问。它的核心思想非常直接:让一个专有的硬件控制器,在内存(比如你的数组变量)和外设(比如串口的发送/接收数据寄存器)之间直接搬运数据,而无需CPU的介入。你可以把CPU想象成一个公司的CEO,而DMA则是一个高效的物流部门。CEO(CPU)只需要下达一个指令:“把仓库A(内存数组)里的这100箱货(数据),搬到码头B(串口发送寄存器)去,搬完了告诉我一声。” 然后CEO就可以去处理其他战略决策(运行主循环、执行算法)了,具体的搬运工作全部由物流部门(DMA)的叉车自动完成。只有当100箱货全部搬完,物流部门才会发个消息(触发中断)通知CEO:“任务完成了。”
对于串口来说,使用DMA带来的好处是立竿见影的:
- 极低的CPU占用率:数据搬运过程零CPU干预。CPU仅在配置DMA传输(设置源地址、目标地址、数据量)和传输完成中断中消耗极少量资源。
- 支持高带宽连续传输:非常适合发送一帧长的数据(如Modbus协议帧、JSON字符串)或连续接收数据流(如传感器数据流、GPS NMEA语句)。
- 实现精确的时序控制:当DMA与定时器触发联动时,可以产生精确定时(如每1ms发送一个字节),这对于某些同步通信协议至关重要。
- 有利于低功耗设计:CPU可以在等待DMA传输完成期间进入低功耗模式(如Sleep或Stop模式),由DMA和串口硬件自主工作,数据搬完后通过中断唤醒CPU,从而大幅降低系统平均功耗。
网络上搜索“串口DMA”时,常伴随“环形缓冲”、“不定长数据接收”、“DMA中断”等关键词,这恰恰说明了大家在使用DMA时遇到的真实场景和痛点:如何高效、可靠地管理持续不断的数据流。接下来,我们就深入这些细节。
2. 核心配置:打通内存与串口寄存器的数据高速公路
配置串口DMA,本质上是为CPU、DMA控制器、串口外设这三者建立一条清晰、可控的数据通路。我们以STM32的HAL库为例,拆解关键的配置步骤和其背后的硬件原理。虽然不同厂商(如GD32、HC32)的库函数名称可能略有差异,但核心思想和配置流程是相通的。
2.1 硬件连接与初始化顺序
在代码层面,第一步永远是正确的初始化顺序。一个常见的错误是,先使能了串口或DMA,再配置参数,导致硬件状态混乱。
正确的顺序应该是:
- 初始化DMA控制器时钟:任何外设使用前,必须先开启其时钟。对于DMA,通常是
__HAL_RCC_DMA1_CLK_ENABLE()或__HAL_RCC_DMA2_CLK_ENABLE(),具体取决于你的芯片和使用的DMA流(Stream/Channel)。 - 配置并初始化串口:设置波特率、数据位、停止位、校验位等。此时不要开启串口的发送或接收使能。
- 配置DMA通道:这是最核心的一步,将DMA通道与特定的串口收发请求关联起来。例如,USART1的TX请求可能映射到DMA1的Channel4(具体映射需查芯片数据手册)。
- 初始化DMA:调用
HAL_DMA_Init(),填充DMA_HandleTypeDef结构体。关键参数包括:Direction:传输方向,MEMORY_TO_PERIPH(内存到外设)用于发送,PERIPH_TO_MEMORY(外设到内存)用于接收。PeriphInc和MemInc:地址自增模式。外设地址(串口数据寄存器)通常固定,设为DISABLE;内存地址(你的数组)需要连续访问,设为ENABLE。PeriphDataAlignment和MemDataAlignment:数据宽度对齐。必须与串口的数据位宽一致(通常为DMA_PDATAALIGN_BYTE)。Mode:模式选择。NORMAL(普通模式)传输指定数量后停止;CIRCULAR(循环模式)传输完成后自动从头开始,用于构建“环形缓冲区”,是实现连续接收的基石。
- 关联DMA与串口句柄:通过
__HAL_LINKDMA(&huart, hdmatx, &hdma_tx)宏,将DMA句柄链接到串口句柄的发送或接收成员上。这样HAL库就知道该用哪个DMA通道来处理这个串口的请求。 - 使能串口的DMA请求:调用
HAL_UART_Transmit_DMA()或HAL_UART_Receive_DMA()。这个函数内部会做三件事:a) 根据参数设置DMA传输的源/目标地址和长度;b) 使能串口对应的DMA请求(如USART_CR3寄存器的DMAT位);c) 启动DMA传输。
注意:很多初学者会忽略
__HAL_LINKDMA这一步,导致后续的DMA传输函数无法正确关联硬件,程序卡死。务必检查。
2.2 发送与接收的配置差异
发送和接收的DMA配置,在思路上有微妙但重要的区别。
对于发送(TX DMA):通常使用NORMAL模式。你调用HAL_UART_Transmit_DMA(&huart, pData, Size)启动一次传输。DMA会忠实地将pData开始的Size个字节逐个搬移到串口的发送数据寄存器(TDR)。当Size个字节全部搬完,DMA会产生一个传输完成中断(TC)。此时,串口可能还在发送最后一个字节(因为从TDR寄存器到TX引脚的电平转换需要时间)。所以,安全的做法是:在DMA传输完成中断(TC)中,不要立即关闭串口或操作缓冲区,而应该等待串口本身的发送完成中断(TXE或TC)来确认所有数据已真正从引脚发出。不过,HAL库的HAL_UART_TxCpltCallback()回调函数被调用时,通常意味着DMA传输已完成,你可以在此回调中安全地复用发送缓冲区或进行下一步操作。
对于接收(RX DMA):这是更具挑战性,也是网络热词中“不定长数据接收”、“环形缓冲”等问题的焦点。推荐使用CIRCULAR模式。
- 初始化时启动循环接收:在程序初始化阶段,调用
HAL_UART_Receive_DMA(&huart, pBuffer, BufferSize),并将BufferSize设置为你的环形缓冲区大小(例如256字节)。DMA会以循环方式,持续将串口接收到的数据写入pBuffer。 - 如何知道收到了数据?由于是循环模式,DMA不会在收满缓冲区后停止,因此不会有“传输完成”中断。我们需要依赖串口的空闲中断(Idle Interrupt)。使能串口空闲中断(
__HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE))。当串口接收线上超过一个字符帧的时间(具体时间取决于波特率)没有新数据时,硬件会产生空闲中断。 - 在空闲中断中计算数据长度:在空闲中断服务函数或回调函数中,关键是通过DMA的当前计数器
__HAL_DMA_GET_COUNTER(&hdma_rx)来推算接收到的数据量。公式为:ReceivedLength = BufferSize - __HAL_DMA_GET_COUNTER(&hdma_rx)。这个值就是从上次处理数据到本次空闲之间,新接收到的字节数。结合缓冲区的读写指针管理,就能实现高效的不定长数据帧提取。
2.3 关键参数详解:数据对齐与传输宽度
PeriphDataAlignment和MemDataAlignment这两个参数看似简单,设置错误却会导致数据错乱或硬件错误。它们必须与串口的数据位宽严格匹配。
- 如果串口配置为
8位数据位,无校验,那么这两个参数都应设为DMA_PDATAALIGN_BYTE(字节对齐)。 - 如果串口配置为
9位数据位(常用于带校验位模式),虽然数据寄存器可能是16位访问,但有效数据仍是9位。此时,DMA的数据宽度通常仍设置为BYTE,但需要仔细查阅芯片参考手册,看硬件是否支持9位数据的DMA传输,或者是否需要以16位方式传输并忽略高7位。一个安全且通用的做法是:串口数据位宽是多少位,DMA就设置为多少位的对齐方式。当不确定时,设置为BYTE在大多数8位/9位数据场景下都是可行的。
Mode模式的选择直接影响程序架构:
NORMAL模式:逻辑简单,适合已知长度的单次发送或接收。每次传输都需要重新配置和启动DMA。CIRCULAR模式:逻辑稍复杂,但一劳永逸。特别适合持续不断的接收数据流,是构建软件环形缓冲区(或直接利用DMA硬件环形)的必备选项。这也是实现高效、可靠串口数据流处理的核心技术。
3. 实战陷阱与排坑指南:从“跑不通”到“跑得稳”
配置完成后,代码编译通过,下载到板子,却发现数据没发出去、收不到、或者数据错乱。以下是几个最常见的坑点和排查思路。
3.1 数据发送不完整或错位
现象:调用发送函数后,用逻辑分析仪或串口助手查看,发现只发送了部分数据,或者数据中间出现了奇怪的间隔、错位。
排查步骤:
- 检查缓冲区生命周期:这是最隐蔽的坑。如果你这样写:
函数返回后,局部数组void send_message(void) { char temp_buf[] = "Hello DMA"; HAL_UART_Transmit_DMA(&huart1, (uint8_t*)temp_buf, strlen(temp_buf)); }temp_buf的内存空间可能被释放或覆盖,而DMA传输是异步的,它可能还在从这块已失效的内存中读取数据,导致发送乱码。必须确保DMA传输期间,源数据缓冲区(对于发送)或目标缓冲区(对于接收)的内存是有效且稳定的。通常使用全局数组或静态数组,或者动态分配但在传输完成前不释放。 - 检查DMA和串口中断优先级:如果DMA传输完成中断(TC)或串口发送完成中断的优先级被配置得太低,可能会被其他高优先级中断长时间打断,导致后续处理延迟。确保关键通信中断具有合适的优先级。
- 验证物理连接与波特率:使用示波器或逻辑分析仪测量TX引脚波形,确认波特率是否准确。一个9600波特率的偏差,在115200下就会导致大量误码。
- 清空标志位:在启动一次新的DMA传输前,确保之前的传输已完成,并且相关标志位(如DMA的TCIF、串口的TC)已被清除。HAL库函数内部通常会处理,但如果在中断中手动操作寄存器,这一点至关重要。
3.2 接收数据丢失与“环形缓冲区”溢出
现象:使用循环DMA接收,数据量大时,发现较早的数据被新数据覆盖,或者部分数据帧丢失。
根因分析:这通常是软件处理速度跟不上硬件接收速度导致的。即使DMA可以无CPU干预地将数据搬到内存,但如果你的主程序或中断服务程序从环形缓冲区中读取、解析数据的速度太慢,缓冲区就会被新数据覆盖,造成“覆写”丢失。
解决方案与设计要点:
- 增大缓冲区:这是最简单粗暴但有效的方法。根据你的最大数据包长度和预估的数据吞吐率,将DMA循环接收缓冲区(
pBuffer)设置得足够大。例如,对于115200波特率(约11.5KB/s),如果主循环处理一次数据可能需要10ms,那么这10ms内可能收到115字节,你的缓冲区至少应大于这个值,并留有裕量。 - 实现双缓冲区(Ping-Pong Buffer):这是一种更高级的策略。准备两个大小相同的缓冲区A和B。DMA配置为循环模式,但缓冲区大小设置为A+B的总和。在软件层面,维护两个逻辑指针。当DMA的当前写入位置从一个缓冲区切换到另一个时(例如从A的末尾跳到B的开头),触发一个半传输完成(HT)中断或通过计算得知,在中断中处理刚刚被填满的那个缓冲区(A),而DMA继续向另一个缓冲区(B)写入。这相当于为数据处理争取了一整个缓冲区的处理时间。
- 优化数据处理逻辑:将耗时的数据处理(如复杂的协议解析、浮点运算)从串口空闲中断中移出。中断服务函数(ISR)只做最核心的工作:计算数据长度、设置标志位、拷贝数据到另一个安全队列(如RTOS的消息队列)。具体的数据解析工作放在主循环或低优先级任务中完成。中断快进快出是保证系统实时性的黄金法则。
- 使用DMA传输暂停/恢复功能:一些高级的MCU(如STM32H7系列)的DMA支持流控制。当软件检测到接收缓冲区快满时,可以暂时禁用DMA请求,防止溢出,等处理完一部分数据后再重新使能。但这需要更精细的控制。
3.3 不定长数据帧的可靠提取
这是串口通信的经典问题,结合DMA和空闲中断,可以给出一个优雅的解决方案。以下是基于STM32 HAL库的一个实现框架:
// 定义环形缓冲区及相关变量 #define UART_RX_BUF_SIZE 256 uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; // DMA循环接收缓冲区 volatile uint16_t uart_rx_read_pos = 0; // 软件读指针 volatile uint16_t uart_rx_write_pos = 0; // 软件写指针(由DMA当前位置推导) // 串口初始化函数中 void uart_init(void) { // ... 初始化串口和DMA ... // 启动DMA循环接收 HAL_UART_Receive_DMA(&huart1, uart_rx_buf, UART_RX_BUF_SIZE); // 使能串口空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } // 串口空闲中断服务函数(或在HAL_UART_IRQHandler中调用回调) void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); } // HAL库的空闲中断回调函数 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { uint16_t dma_remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 获取DMA未传输计数 uart_rx_write_pos = UART_RX_BUF_SIZE - dma_remaining; // 计算DMA当前写入位置 // 计算本次空闲中断期间接收到的数据长度 uint16_t received_len = 0; if (uart_rx_write_pos >= uart_rx_read_pos) { received_len = uart_rx_write_pos - uart_rx_read_pos; } else { // 发生了缓冲区回绕 received_len = (UART_RX_BUF_SIZE - uart_rx_read_pos) + uart_rx_write_pos; } if (received_len > 0) { // 设置数据接收完成标志,通知主循环或任务进行处理 // 注意:不要在这里进行复杂的数据解析! uart_rx_flag = 1; // 可以在这里将数据拷贝到另一个处理队列 } // 更新读指针,指向当前写指针,为下一帧数据做准备 uart_rx_read_pos = uart_rx_write_pos; // 注意:无需手动清除空闲中断标志,HAL库已处理 } }关键点:
__HAL_DMA_GET_COUNTER获取的是DMA剩余传输次数。在循环模式下,它从BufferSize开始递减,减到0后又重置为BufferSize,如此循环。- 计算
write_pos的公式BufferSize - remaining是理解循环DMA位置的核心。 - 处理缓冲区回绕(wrap-around)是必须的,否则当
write_pos从255变为0时,长度计算会出错。 - 中断回调中只做标志位设置和指针管理,真正的数据处理应放在后台。
4. 进阶应用:DMA与其他外设的联动与性能优化
当你掌握了基础的串口DMA收发后,可以探索更强大的应用模式,这些模式在网络热词中也有所体现,如“ADC多通道扫描循环采样DMA”、“通过双DMA实现脉冲输出”等。
4.1 DMA与ADC的“天作之合”:实现高速、无CPU干预的数据采集
这是DMA最经典的应用场景之一。以STM32的ADC多通道扫描为例:
- 配置ADC:设置为扫描模式(Scan)、连续转换模式(Continuous),并选择多个通道(CH1, CH2, CH3...)。
- 配置DMA:方向为
PERIPH_TO_MEMORY,外设地址为ADC的数据寄存器(DR),内存地址为你定义的一个数组adc_values[],模式为CIRCULAR,数据宽度与ADC分辨率对齐(如12位ADC对应半字DMA_PDATAALIGN_HALFWORD)。 - 启动:使能ADC的DMA请求,然后启动ADC和DMA。
一旦启动,ADC会按照预设的顺序自动循环转换多个通道,每转换完一个通道的数据,硬件就会触发一次DMA请求,DMA控制器自动将这个数据搬运到adc_values数组的对应位置。整个过程完全无需CPU参与。你只需要在需要的时候(例如定时器中断中),去读取adc_values这个数组,里面就已经是实时更新、按通道顺序排列的最新ADC采样值了。这实现了真正意义上的“后台”数据采集。
4.2 双缓冲(Double Buffer)与链表传输(Linked List)
对于极其严苛的、不允许任何数据丢失的连续流传输,高级的DMA控制器(如STM32的DMA或DMA2D,以及很多厂商的“增强型DMA”)支持更复杂的模式。
- 双缓冲模式:硬件层面提供两个缓冲区。DMA在向缓冲区A写入时,CPU可以安全地读取处理缓冲区B的数据;当A写满,硬件自动切换至B,同时产生中断通知CPU处理A。这避免了软件实现双缓冲时切换瞬间的竞争风险。
- 链表传输(或称为Scatter-Gather):DMA可以读取一个预先配置好的“描述符”链表,每个描述符定义了下一段传输的源地址、目标地址和长度。DMA完成当前描述符的传输后,自动加载下一个描述符并继续,无需CPU重新配置。这对于需要传输分散在内存不同位置的数据块(如视频帧的多个片段)非常高效。
4.3 调试技巧与性能考量
- 使用调试器观察DMA寄存器:在IDE(如Keil, IAR, STM32CubeIDE)的调试模式下,可以实时查看DMA控制器的状态寄存器(如
ISR)、当前剩余传输次数(CNDTR)、当前内存地址(CMAR)等。这对于诊断DMA是否正常工作、是否发生传输错误(如TEIF传输错误标志)至关重要。 - 测量CPU负载:在DMA传输大量数据时,用一个GPIO引脚在任务开始和结束时拉高拉低,用示波器测量高电平脉宽,可以直观对比使用轮询和DMA时CPU的繁忙程度。也可以使用系统滴答定时器(SysTick)来粗略计算任务执行时间。
- 内存与总线带宽:当系统中有多个主设备(如CPU、DMA1、DMA2、以太网MAC)同时访问内存或外设时,可能会遇到总线仲裁和带宽瓶颈。如果发现使用DMA后系统性能提升不明显,甚至其他任务变慢,需要检查芯片的总线矩阵(Bus Matrix)架构,考虑将频繁访问的数据放在访问速度更快的内存区域(如CCM RAM、DTCM),或者错开高带宽外设的访问时序。
- 电源管理:如前所述,DMA是实现超低功耗的关键。在设计低功耗应用时,可以规划让CPU在大部分时间处于睡眠模式,由DMA配合定时器、ADC、串口等外设周期性地采集数据并存入内存。只有当数据积累到一定量,或特定事件(如DMA传输完成、串口收到特定指令)发生时,才通过中断唤醒CPU进行批量处理。这种“事件驱动+批量处理”的模式能极大降低平均功耗。
串口DMA的使用,从简单的替代轮询,到构建高效稳定的数据流处理管道,再到与其他外设协同实现复杂的系统功能,其内涵远比一个配置函数要丰富。理解其硬件机制,避开常见的软件陷阱,并善用其高级特性,能让你的嵌入式系统在通信性能、实时响应和功耗控制上提升一个档次。它不再是一个可选的“优化项”,而是处理现代嵌入式系统中并发、实时数据流的“必需品”。