1. 先搞清楚“DMA三件套”到底是什么,以及它解决了什么问题
如果你是第一次接触嵌入式开发,拿到一块开发板或者一个项目资料包,里面有个叫“DMA三件套”的东西,大概率会有点懵。这名字听起来像是什么神秘工具包,其实它指的就是围绕DMA(Direct Memory Access,直接存储器访问)这个核心功能,进行串口(USART/UART)通信时,最常用、最经典的三个技术组合:DMA发送、DMA接收(配合空闲中断或不定长接收)、以及DMA中断处理。
它解决的核心问题,就是把CPU从繁重的数据搬运工作中解放出来。想象一下,你的单片机(比如STM32)要通过串口不停地接收或发送大量数据(比如传感器数据包、图像数据、长文本指令)。如果不用DMA,每收到或发送一个字节,CPU都得停下手中的活,去处理这个字节的搬运,效率极低,CPU大量时间被“打杂”占用,无法处理更重要的逻辑计算。DMA就像一个专职的“快递员”,CPU只需要告诉它:“把这堆数据从内存A搬到串口发送器”,或者“把串口接收器来的数据存到内存B”,然后CPU就可以去干别的了,等DMA搬完了再通知CPU一声。
所以,这个“三件套”的价值非常直接:实现高效、稳定的串口大数据量通信,让系统响应更实时,资源利用率更高。这篇文章就是给刚拿到相关代码或教程的“萌新”准备的,我会带你理解这三个部分分别是什么,怎么把它们拼装起来,以及实际调试时最容易在哪里“翻车”。
2. 安装准备:不是装软件,而是理解环境和配置
首先纠正一个概念,“安装DMA三件套”不是像安装电脑软件那样点下一步。在嵌入式开发里,“安装”指的是在工程中正确配置DMA外设、编写驱动代码、并集成到你的应用程序中。整个过程依赖于你的开发环境。
你需要准备的东西很明确:
- 硬件平台:一块支持DMA的MCU开发板,比如STM32F1/F4/H7系列、GD32系列等。文章开头热词里提到的STM32H7、STM32F103C8T6、STM32F407都是典型代表。
- 开发环境:通常是Keil MDK、IAR Embedded Workbench或者STM32CubeIDE。STM32CubeMX(一个图形化配置工具)会极大简化初始化工作,也是热词里频繁出现的。
- 工程基础:一个能编译下载的空白工程或点灯工程。确保你的开发环境能正常编译代码并下载到板子上。
- 原理图或引脚分配:知道你的串口(USART1/2/3…)连接在哪两个GPIO引脚上(比如USART1是PA9/PA10)。
对于新手,我强烈建议从STM32CubeMX + HAL库开始。它通过图形化界面勾选配置,自动生成初始化代码,能帮你避免很多底层寄存器配置的坑。热词中的stm32cubemx-stm32f103c8t6-usart dma程序指的就是这种流程。
3. 拆解“三件套”:发送、接收与中断
现在我们来拆解这个“三件套”。你可以把它想象成组装一个自动化流水线。
3.1 第一件:DMA发送 —— 让数据自动“流”出去
DMA发送是最容易理解的一环。CPU的任务是准备好要发送的数据(比如一个数组tx_buffer),然后启动DMA传输,之后就不用管了。
核心步骤:
- 配置DMA通道:在CubeMX里,找到你的串口(如USART1),在“DMA Settings”标签页添加一个DMA请求。方向选择“Memory To Peripheral”(内存到外设)。模式通常选“Normal”(普通模式,发完一次就停),如果需要连续发送,可选“Circular”(循环模式,但小心使用)。
- 配置内存与外设地址:DMA需要知道从哪里搬(源地址:
tx_buffer的地址),搬到哪里去(目标地址:串口发送数据寄存器的地址,如&huart1.Instance->DR)。这些HAL库在初始化时已经设置好了。 - 启动传输:在你的代码中,调用
HAL_UART_Transmit_DMA(&huart1, tx_buffer, size)。这个函数不会阻塞,调用后立即返回,CPU可以去执行其他任务。 - 判断发送完成:这是关键。怎么知道数据发完了?热词里
dma传输最后一个字节后 怎么判断串口已发送完成?和串口dma发送完成中断指的就是这个问题。有两种常用方式:- 查询DMA传输完成标志位:检查
__HAL_DMA_GET_FLAG(&hdma_usart1_tx, DMA_FLAG_TC1)。但更优雅的方式是使用中断。 - 使用发送完成中断(TC):在CubeMX中使能DMA通道的传输完成中断。然后在代码中实现DMA发送完成回调函数
HAL_UART_TxCpltCallback()。当DMA搬完所有数据,就会触发这个中断,你可以在回调函数里进行下一步操作,比如点亮一个LED或者准备下一包数据。
- 查询DMA传输完成标志位:检查
注意:串口本身的“发送完成”和DMA的“传输完成”是两回事。DMA完成只表示数据从内存搬到了串口的数据寄存器(DR),串口硬件还会一位一位地把数据从DR发送到线上。对于判断“所有数据已物理发出”,通常DMA TC中断就够了,因为DR一旦被搬空填入新数据,发送就会持续。更精确的判断需要查询串口的“传输完成”标志位(TC),但大多数应用场景不需要这么精确。
3.2 第二件:DMA接收(配合空闲中断)—— 高效接收不定长数据
这是“三件套”的精华,也是新手最容易出错的地方。普通串口接收需要知道数据长度,但现实中很多数据包是不定长的(例如以回车符结尾的指令)。DMA接收配合串口空闲中断(Idle Interrupt)完美解决了这个问题。热词中的hal dma idle中断 原理和uart 不定长就是针对这个。
工作原理:
- DMA配置为循环模式:在CubeMX中,为串口接收添加DMA请求,方向为“Peripheral To Memory”(外设到内存),模式选择“Circular”(循环)。这样DMA会像一个环形缓冲区一样,持续地把串口收到的数据搬到指定的内存数组
rx_buffer中,从头到尾,周而复始。 - 使能串口空闲中断:在CubeMX的串口配置里,使能“Idle Interrupt”。当串口线上超过一个字节的时间没有收到新数据时,硬件就会产生空闲中断。
- 流程联动:
- 上电后,你调用
HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE)启动DMA循环接收。DMA就开始默默地在后台搬数据。 - 当一帧数据发送完毕,发送方停止,串口线进入空闲状态。
- 空闲中断触发,进入
HAL_UART_IRQHandler,最终会调用你重写的HAL_UART_IdleCpltCallback()回调函数。 - 在回调函数里计算接收到的数据长度:这是最关键的一步。长度 = 当前DMA的写入位置(
BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx))。因为DMA是循环的,所以还需要处理缓冲区回绕的情况。 - 处理数据:拿到数据和长度后,你就可以解析这帧数据了。处理完后,记得重新启动DMA接收(如果需要继续接收)。
- 上电后,你调用
优势:CPU完全不参与单个字节的接收过程,只有在一整帧数据到达后,才被中断唤醒一次来处理,效率极高,且能轻松处理任意长度的数据帧。
3.3 第三件:DMA中断处理 —— 系统的“通知机制”
中断是DMA与CPU协同工作的“通信员”。前面提到的发送完成中断(TC)、空闲中断(Idle)都属于中断处理范畴。你需要正确配置和处理它们。
常见的中断类型:
- 传输完成中断(TC):DMA搬运完预设数量的数据后触发。用于发送完成通知或定长接收完成通知。
- 半传输完成中断(HT):在循环缓冲区和双缓冲区模式下有用,当搬运到一半时触发,可以实现“乒乓操作”,一边处理前半部分数据,一边接收后半部分数据。
- 传输错误中断(TE):DMA传输过程中发生错误时触发。
- 串口空闲中断(Idle):如前所述,用于不定长接收。
在代码中的体现:在STM32 HAL库中,你不需要直接编写复杂的中断服务函数(ISR)。你只需要:
- 在CubeMX中使能所需的中断(NVIC设置)。
- 在
stm32fxx_it.c文件中,HAL库已经为你写好了中断服务函数,它们会调用HAL_UART_IRQHandler或HAL_DMA_IRQHandler。 - 在你的主程序文件(如
main.c)中,重写(Weak Function)对应的回调函数。例如:// 发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 发送完成,可以做下一步操作 } } // 空闲中断回调 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 计算接收长度并处理数据 uint16_t len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (len > 0) { process_received_data(rx_buffer, len); // 处理完后,可以重新设置DMA指针(如果需要) // 或者直接使用循环缓冲区,注意数据覆盖问题 } } }
4. 从CubeMX配置到代码集成的完整流程
现在我们走一遍从零开始的完整流程,以STM32F103C8T6的USART1为例。
4.1 使用STM32CubeMX进行图形化配置
- 新建工程,选择你的芯片型号。
- 配置系统核心(SYS):Debug选“Serial Wire”(如果要用ST-Link调试)。
- 配置时钟(RCC):HSE选择“Crystal/Ceramic Resonator”,确保系统有时钟源。
- 配置USART1:
- 模式选择“Asynchronous”(异步)。
- 波特率、字长、停止位、校验位根据你的通信设备设置(常用115200-8-N-1)。
- 最重要的是,打开“DMA Settings”标签页。
- 配置DMA:
- 点击“Add”,为USART1_TX添加一个DMA流(Channel)。方向“Memory To Peripheral”,优先级“Low/Medium”。模式先选“Normal”。
- 再次点击“Add”,为USART1_RX添加一个DMA流。方向“Peripheral To Memory”,优先级“Low/Medium”。模式必须选“Circular”。
- 对于RX的DMA,建议将“Data Width”都设置为“Byte”(字节),因为串口是按字节通信的。
- 配置中断(NVIC):
- 在NVIC设置中,找到USART1全局中断,勾选启用(Enabled)。
- 找到对应的DMA通道中断(如DMA1 Channel4 for USART1_TX, DMA1 Channel5 for USART1_RX),勾选启用。这里主要启用“Transfer Complete interrupt”即可。
- 关键一步:在USART1的配置页面,找到“NVIC Settings”子标签,勾选“USART1 global interrupt”和“Idle interrupt”。
- 生成代码:设置好工程名、路径、IDE(MDK-ARM V5),然后点击“GENERATE CODE”。
4.2 在生成的代码基础上编写应用逻辑
CubeMX生成了所有外设的初始化代码(MX_USART1_UART_Init(),MX_DMA_Init()),并在main()中调用了它们。你的工作是在合适的地方启动DMA收发,并实现回调函数。
- 定义缓冲区:
#define TX_BUFFER_SIZE 256 #define RX_BUFFER_SIZE 512 uint8_t tx_buffer[TX_BUFFER_SIZE]; uint8_t rx_buffer[RX_BUFFER_SIZE]; - 在
main()函数初始化后启动DMA接收:int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); // ... 其他初始化 // 启动DMA循环接收,这是实现不定长接收的关键 if (HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE) != HAL_OK) { Error_Handler(); } // 使能空闲中断(CubeMX生成的代码可能已使能,但最好确认) __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); while (1) { // 主循环,CPU可以自由处理其他任务 // 例如,当需要发送数据时: // memcpy(tx_buffer, "Hello World\r\n", 13); // HAL_UART_Transmit_DMA(&huart1, tx_buffer, 13); // 发送完成后,会在 HAL_UART_TxCpltCallback 中收到通知 } } - 实现空闲中断和发送完成回调函数(在
main.c的/* USER CODE BEGIN 4 */区域):// 空闲中断回调函数 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 暂时关闭空闲中断,防止在处理数据时被再次打断 __HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE); // 计算本次接收到的数据长度 // __HAL_DMA_GET_COUNTER 获取DMA通道中剩余未传输的数据量 uint16_t receive_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (receive_len > 0) { // 处理数据,例如将数据拷贝到另一个解析缓冲区 // process_data(rx_buffer, receive_len); // 处理完成后,需要重新设置DMA的存储器地址和计数器 // 因为DMA处于循环模式,它还在继续接收,但我们需要将“读指针”重置到有效数据的末尾 // 一种简单的方法是:重新启动DMA接收,指向同一个缓冲区 // 但更严谨的做法是计算新的内存地址,避免数据覆盖,这里演示简单重启 HAL_UART_DMAStop(&huart1); // 先停止DMA // 可以在这里将已处理的数据从rx_buffer移走或做标记 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); // 重新启动 } // 重新使能空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } } // 发送完成回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 发送完成,可以释放缓冲区或准备下一次发送 // 例如,点亮一个LED指示发送完成 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }
5. 调试与排坑:新手最容易遇到的几个问题
代码写好了,下载到板子,却发现没反应或者数据不对?别急,按以下顺序排查。
5.1 问题一:根本没进中断或回调函数
- 检查NVIC配置:在CubeMX和生成的代码中,确认USART全局中断和DMA通道中断已经使能。可以查看
MX_NVIC_Init函数或HAL_UART_MspInit函数。 - 检查中断优先级:如果程序中有其他更高优先级的中断长时间占用,可能导致串口或DMA中断无法及时响应。确保中断优先级设置合理。
- 检查回调函数名:确保你重写的回调函数名完全正确,例如是
HAL_UART_TxCpltCallback而不是HAL_UART_TransmitCpltCallback。HAL库的回调函数是弱定义(__weak),你的强定义必须一字不差。 - 使用调试器:在Keil/IAR中设置断点,看程序是否能跑到中断服务函数(
USART1_IRQHandler或DMA1_Channel4_IRQHandler)和你写的回调函数里。
5.2 问题二:DMA接收数据不全或混乱
- 缓冲区溢出:这是循环模式下的经典问题。如果数据接收太快,而你的主程序或空闲中断处理太慢,新数据可能会覆盖还未处理的旧数据。解决方案是使用双缓冲区(Double Buffer)或增加缓冲区大小。HAL库的DMA支持双缓冲区模式,可以在传输一半和全部完成时产生中断,实现“乒乓操作”。
- 空闲中断误触发:总线上的短暂噪声可能被误判为空闲状态。可以在空闲中断回调函数中加入数据长度有效性判断,比如长度必须大于0且小于某个最大值,才认为是有效帧。
- DMA计数器与缓冲区索引计算错误:计算接收长度
RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma)是标准做法。但要特别注意,在重新启动DMA接收前,要处理好缓冲区指针。上面的示例代码中简单重启DMA会丢失之前接收到的数据。更稳健的做法是使用一个软件读写指针来管理环形缓冲区。 - 数据覆盖处理:在
HAL_UART_IdleCpltCallback中,如果你只是简单重启DMA,那么rx_buffer中从0开始的位置会被新的数据覆盖。你应该将有效数据复制出来,然后根据DMA的当前写入位置(CNDTR寄存器值)来调整下一次DMA接收的起始地址,形成一个真正的环形队列。这是进阶内容,但对稳定性至关重要。
5.3 问题三:DMA发送卡住或只发一次
- 模式选择错误:如果你需要连续发送,但DMA模式选了“Normal”,那么发完一次后DMA就停止了。连续发送应使用“Circular”模式,但要注意CPU和DMA对缓冲区的访问冲突(需要同步机制)。
- 发送未完成就修改缓冲区:调用
HAL_UART_Transmit_DMA后,DMA立即开始从tx_buffer搬数据。如果在DMA搬运完成前,你的程序修改了tx_buffer的内容,那么发送出去的数据可能就是混乱的。确保在发送完成回调触发后,再准备下一包数据或修改发送缓冲区。 - 发送函数返回HAL_BUSY:上一次DMA传输还没结束,你又调用了发送函数。你需要等待上一次发送完成(通过标志位或回调函数),或者实现一个发送队列来管理发送请求。
5.4 问题四:资源冲突与硬件连接
- DMA通道冲突:一个DMA通道/流在同一时间只能服务于一个外设。确保你的USART1_RX和USART1_TX使用的DMA通道是不同的(CubeMX通常会自动分配好)。也要检查其他外设(如ADC、SPI)是否配置了相同的DMA通道。
- 串口引脚复用:确认USART的TX/RX引脚是否正确配置为复用推挽输出和浮空输入。用CubeMX配置通常没问题,但自己写代码时要检查GPIO初始化。
- 物理连接与波特率:最基础也最容易忽略。用USB转TTL工具连接板子的串口,确保地线(GND)连接,TX/RX交叉连接。用串口助手软件(如XCOM、Putty)查看数据,双方波特率、数据位、停止位、校验位必须完全一致。
6. 进阶与优化:从“跑通”到“好用”
当基本的发送接收稳定后,可以考虑以下优化,让你的“三件套”更健壮。
- 双缓冲区(乒乓操作):针对高速数据流。配置DMA为双缓冲区模式,并开启半传输完成(HT)和传输完成(TC)中断。当DMA写满半个缓冲区(HT中断)时,CPU处理前半部分数据;当写满整个缓冲区(TC中断)时,CPU处理后半部分数据。这样几乎可以实现零丢失的数据接收。
- 环形缓冲区软件管理:对于DMA接收,在应用层实现一个环形缓冲区。DMA硬件负责往物理缓冲区
rx_buffer里循环写,你的软件维护一个读指针。在空闲中断里,根据DMA的当前写入位置和软件的读指针,计算出未读取的数据长度进行处理。这比每次重启DMA更高效,数据不易丢失。 - 超时机制:不是所有设备都发送规整的数据帧。可以为空闲中断增加一个软件超时定时器。如果一段时间内没有收到完整帧(即未触发空闲中断),则强制将当前已接收的数据作为一帧进行处理,防止数据滞留。
- 错误处理:在DMA和串口的中断回调函数中,除了完成回调,还有错误回调函数(如
HAL_UART_ErrorCallback)。实现它们,并在其中处理溢出错误、噪声错误、帧错误等,增加程序的鲁棒性。 - 与RTOS结合:在FreeRTOS、RT-Thread等实时操作系统中,DMA回调函数通常是一个中断上下文。绝对不能在中断回调中进行长时间操作或调用可能阻塞的API。正确的做法是:在回调函数中释放一个信号量、发送一个消息队列或触发一个任务通知,让一个专门的数据处理任务(线程)去完成解析工作。热词中的
rt-thread就是一款流行的国产RTOS。
最后,回到最初的问题:“萌新收到DMA三件套怎么安装?” 答案已经清晰:它不是安装,而是理解原理(CPU减负、效率提升)、配置硬件(CubeMX勾选)、编写驱动(启动DMA、实现回调)、最后集成测试。最有效的学习路径不是直接复制一大段代码,而是从CubeMX生成一个最小工程,先让DMA发送跑起来,再加入空闲中断接收,每一步都用调试器和串口助手验证。遇到问题,就按照“硬件连接 -> 基础配置 -> 中断使能 -> 缓冲区管理”这个顺序层层排查。把这个流程走通一遍,你对嵌入式系统里这个高效“快递员”的理解,会比读十篇理论文章都深刻。