1. 项目概述:为什么DMA是STM32性能提升的关键
如果你正在用STM32做项目,尤其是涉及到大量数据传输的场景,比如采集传感器数据、驱动屏幕、处理音频或者高速通信,那你一定遇到过CPU被数据搬运工作“累趴下”的情况。CPU吭哧吭哧地从外设(比如ADC、串口)把数据读到内存,或者从内存搬到外设(比如SPI、DAC),宝贵的计算资源都浪费在了这种“搬砖”的体力活上,真正需要复杂运算的任务反而被拖慢了。这时候,DMA(Direct Memory Access,直接存储器访问)就该登场了。它就像一个独立于CPU的“专职搬运工”,可以在不打扰CPU的情况下,高效地在内存与外设之间、内存与内存之间搬运数据。对于STM32开发者来说,掌握DMA不仅仅是学会一个外设,更是从“玩具级”应用迈向“工业级”或“高性能”应用的一道分水岭。这篇笔记,我就结合自己这些年踩过的坑和积累的经验,带你彻底搞懂STM32的DMA,从原理到配置,从常见应用到疑难杂症,手把手让你把DMA用起来、用好。
2. DMA核心原理与STM32中的实现架构
要理解DMA,首先要跳出“CPU中心论”。在传统的编程思维里,所有操作都依赖CPU执行指令。但DMA引入了一个新的“执行单元”——DMA控制器。它的工作流程可以类比为:你(CPU)要给朋友(外设A)寄一箱书(数据)。没有DMA时,你需要自己(CPU)一本本从书架上(内存)拿下来,打包,再跑去邮局(外设A)寄出,全程亲力亲为。有了DMA后,你只需要写一张“工作清单”(配置DMA通道),告诉你的私人助理(DMA控制器):从书架的这个位置开始,取多少本书,打包好送到哪个邮局的哪个柜台。然后你就可以去干别的了(CPU处理其他任务),助理会自动完成所有搬运工作,并在完成后通知你一声(触发中断)。
在STM32的体系结构中,DMA控制器是一个独立的外设模块。以常见的STM32F1/F4系列为例,它们通常有1到2个DMA控制器(DMA1, DMA2),每个控制器下辖多条“通道”(Channel)。你可以把通道理解为一条条专用的“传输管道”。每个通道在某一时刻只能服务于一个特定的“传输请求者”,这个请求者通常是某个外设(如ADC1、USART1_TX、SPI1_RX等)。通道的优先级可以配置,当多个传输请求同时发生时,高优先级的通道会先被服务。
DMA传输有三个核心概念:源(Source)、目标(Destination)和传输计数器(Data Number)。源和目标可以是外设寄存器地址(如&USART1->DR)或内存地址(如一个数组buffer)。传输模式主要分两种:
- 外设到内存模式:典型应用是ADC采集数据存入数组,或串口接收数据到缓冲区。
- 内存到外设模式:典型应用是从数组发送串口数据,或从内存缓冲区传输数据到DAC进行波形输出。
- 内存到内存模式:这是DMA2的专属功能(在F1系列上),可以在两个内存区域间快速拷贝数据,效率远高于CPU的
memcpy。
DMA传输的“粒度”由数据宽度(Data Width)决定,可以是字节(8位)、半字(16位)或字(32位)。传输可以配置为单次(传输完指定数量后停止)或循环模式(Circular Mode),后者在缓冲区填满后自动回到开头重新开始,是实现“环形缓冲区”(Ring Buffer)的硬件基础,对于连续数据流处理至关重要。
注意:配置源和目标的数据宽度时,必须确保两者一致,或者你清楚地知道不一致时硬件的行为(某些系列支持宽度不匹配的打包/解包,但通常不建议)。例如,从32位的ADC数据寄存器(源)传输到16位的内存数组(目标),如果不做特殊处理,可能会丢失高16位数据。
3. CubeMX图形化配置DMA的详细步骤与参数解读
对于初学者或快速原型开发,使用STM32CubeMX进行图形化配置是最高效的方式。它能直观地帮你完成时钟、引脚、外设和DMA的初始化代码生成。下面以STM32F407的ADC1多通道采集并通过DMA存入数组为例,详解配置过程。
3.1 外设基础配置首先,在Pinout & Configuration标签页下,激活你需要使用的外设。对于本例,我们需要:
- 在
Analog->ADC1中,启用IN0、IN1、IN2三个通道(假设对应PA0, PA1, PA2)。 - 在
Parameter Settings中,配置ADC为“扫描模式”(Scan Conversion Mode: Enabled)和“连续转换模式”(Continuous Conversion Mode: Enabled)。这样ADC会自动按顺序扫描所有启用的通道。 - 设置采样时间(Sampling Time),根据你的信号频率调整。
- 在
DMA Settings选项卡,点击Add,选择ADC1。此时会自动弹出DMA流/通道的选择(对于F4系列是Stream和Channel,F1系列只有Channel)。CubeMX通常会帮你选择一个可用的流,比如DMA2 Stream0,通道Channel 0(ADC1对应此通道)。
3.2 DMA参数深度解析点击添加的DMA行,进入详细配置。这里的每一个选项都至关重要:
- Direction:
Peripheral To Memory。这定义了数据从ADC数据寄存器流向内存数组。 - Priority: 可选
Low,Medium,High,Very High。如果你的系统中有多个DMA传输在竞争,优先级高的会优先得到服务。对于实时性要求高的数据采集,可以设为High。 - Mode:
Normal(普通模式):传输完设定的数据量后,DMA传输停止,需要软件重新使能才能进行下一次传输。Circular(循环模式):传输完设定的数据量后,DMA自动重置传输计数器并从头开始,周而复始。对于连续采集,必须选择此模式。
- Increment Address(地址自增):
Peripheral:通常设为Disable。因为ADC的数据寄存器地址是固定的(&ADC1->DR),我们总是从这个固定地址读取数据。Memory:必须设为Enable。因为我们希望采集到的数据依次存放到数组的连续元素中,内存地址需要自动增加。
- Data Width(数据宽度):
Peripheral:选择Word(32位)。因为STM32F4的ADC是12位精度,数据在数据寄存器中是右对齐或左对齐的16位数据,但寄存器本身是32位的。查看数据手册可知,ADC_DR寄存器是32位,低16位有效。为安全起见,通常按外设寄存器宽度配置。Memory:也选择Word。这样每次传输搬运32位(4字节)。如果你的数组定义为uint16_t,这里选Half Word(16位)也是正确的,但必须与源宽度匹配或理解其转换规则。
- Memory Burst和Peripheral Burst:通常用于高性能系列(如H7),用于配置突发传输,在F4上一般保持默认
Single。
3.3 生成代码与关键函数分析配置完成后,生成代码。CubeMX会在main.c中生成初始化代码,并在stm32f4xx_it.c中生成DMA中断服务函数(如果你使能了传输完成中断)。你需要手动完成以下几步:
- 定义缓冲区:在用户代码区定义一个足够大的数组,例如
uint32_t adc_buffer[BUFFER_SIZE];。BUFFER_SIZE需要是通道数的整数倍,因为DMA会按顺序填充所有通道的数据。 - 启动传输:在
main函数的初始化部分之后(while(1)之前),调用HAL库函数启动DMA传输。// 启动ADC的DMA采集,数据将存入adc_buffer,采集 BUFFER_SIZE 个数据后(循环模式下会从头开始) HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, BUFFER_SIZE); - 处理数据:在DMA传输完成中断(如果使能)或者半传输完成中断中处理数据。更常见的做法是使用循环模式,并结合“半传输完成”(HT)和“传输完成”(TC)中断来实现“双缓冲”(Ping-Pong Buffer)机制,实现数据处理的零等待。这是高效实时处理的关键技巧,我们稍后详细说明。
实操心得:CubeMX配置DMA时,最容易出错的地方是数据宽度和地址自增。务必对照数据手册,确认外设数据寄存器的实际有效宽度。一个快速验证方法是,先按你认为正确的配置生成代码,然后单步调试,观察DMA相关寄存器(如
CNDTR-剩余数据量,CMAR-内存地址,CPAR-外设地址)的值是否符合预期变化。
4. 标准外设库与HAL库下DMA的编程模式对比
虽然CubeMX和HAL库大大简化了开发,但理解底层寄存器操作和标准外设库(Standard Peripheral Library, SPL)的编程模式,能让你在遇到复杂问题或需要极致性能时,有更深的掌控力。此外,很多遗留项目仍在使用SPL。
4.1 标准外设库(SPL)模式SPL更接近硬件寄存器,需要手动配置大量结构体成员。以USART1发送数据到内存为例:
// 1. 定义DMA初始化结构体 DMA_InitTypeDef DMA_InitStructure; // 2. 开启DMA时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 3. 配置DMA通道参数(USART1_TX通常对应DMA1_Channel4) DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; // 外设地址 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)tx_buffer; // 内存地址 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 方向:内存到外设 DMA_InitStructure.DMA_BufferSize = buffer_size; // 传输数据量 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不增 DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; // 外设数据宽度 DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; // 内存数据宽度 DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; // 普通模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; // 优先级 DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 非内存到内存模式 DMA_Init(DMA1_Channel4, &DMA_InitStructure); // 4. 使能DMA通道 DMA_Cmd(DMA1_Channel4, ENABLE); // 5. 使能USART的DMA发送功能 USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);SPL的优势是直接、高效,代码量小,但可读性稍差,且需要开发者对寄存器有较好理解。
4.2 HAL库模式HAL库提供了高度抽象和封装的API,强调易用性和跨系列兼容性。同样的USART DMA发送:
// 1. 启动DMA传输(假设huart1和hdma_usart1_tx已在CubeMX中初始化) HAL_UART_Transmit_DMA(&huart1, tx_buffer, buffer_size); // 2. 传输完成回调函数(弱定义,需用户重写) void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { // 发送完成,处理后续逻辑,如点亮LED或准备下一包数据 } }HAL库将底层配置隐藏在MX_DMA_Init()函数中,并提供了丰富的回调函数(xxxCpltCallback,xxxHalfCpltCallback)来处理中断事件。其优点是开发速度快,但缺点是代码体积较大,执行效率相对SPL略有损耗,且其错误处理机制(锁机制)有时会在复杂中断场景下引入问题。
4.3 模式选择建议
- 新手、快速开发、项目复杂度不高:强烈推荐HAL库 + CubeMX。它能避免大量低级错误,让你聚焦业务逻辑。
- 追求极致性能、代码体积、对时序有严苛要求的老手:可以考虑SPL,甚至直接操作寄存器。
- 存量项目维护:遵循项目原有库的选择。 我个人在大多数应用场景下使用HAL库,但在对实时性要求极高的核心中断服务函数中,有时会混合使用寄存器操作来确保最短延迟。
5. 环形缓冲区与双缓冲机制:实现连续数据流无丢失处理
这是DMA应用中最精华、最体现设计水平的部分。很多初学者配置好DMA后,发现数据还是会丢,或者处理数据时CPU和DMA会“打架”,问题往往出在没有设计好数据缓冲区。
5.1 问题场景假设你用ADC以1MHz的速率通过DMA循环模式采集数据,缓冲区大小为1000个字。DMA不停地往这个数组里写数据。你的CPU需要读取这些数据并进行滤波等处理。如果你在DMA写入的同时去读取,可能会读到“半新半旧”的数据(撕裂问题)。如果你等DMA写满1000个后再一次性读取,那么在读取和处理这1000个数据的期间(假设耗时500us),DMA又在写入新的数据,这500us内新采集的数据就会覆盖掉你尚未读取的旧数据,造成丢失。
5.2 解决方案:双缓冲(Ping-Pong Buffer)双缓冲利用DMA的“半传输完成”(Half Transfer, HT)和“传输完成”(Transfer Complete, TC)中断,将一个大缓冲区在逻辑上分成两个等大的“子缓冲区”。
- 你定义一个大小为
2*N的缓冲区:uint32_t dma_buffer[2*N];。 - 在DMA循环模式下,使能HT和TC中断。
- 第一阶段:DMA从
dma_buffer[0]开始写,写满前N个数据后,触发HT中断。此时,dma_buffer[0]到dma_buffer[N-1](Buffer A)是“已满”状态,可供CPU安全读取;而DMA正在向dma_buffer[N]到dma_buffer[2N-1](Buffer B)写入数据。 - 在HT中断服务函数里,你通知主程序或任务去处理Buffer A的数据。
- 第二阶段:DMA继续写,直到写满后
N个数据(即整个2*N缓冲区),触发TC中断。此时,Buffer B“已满”,可供CPU读取;而DMA会自动将传输地址重置回缓冲区开头(dma_buffer[0]),开始覆盖Buffer A。 - 在TC中断服务函数里,你通知主程序去处理Buffer B的数据。
如此循环往复,CPU和DMA交替使用两个缓冲区,实现了“生产者(DMA)”和“消费者(CPU)”的完美解耦,只要CPU处理一个缓冲区数据的速度快于DMA填满另一个缓冲区的速度,数据就永远不会丢失。
5.3 代码实现示例(基于HAL库)
#define BUFFER_HALF_SIZE 500 uint32_t adc_dual_buffer[BUFFER_HALF_SIZE * 2]; // 双缓冲 volatile uint8_t buffer_ready = 0; // 标志位,0:无数据,1:半缓冲满,2:全缓冲满 // 重写ADC DMA半传输完成回调函数 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { buffer_ready = 1; // 标志前半部分缓冲区就绪 } // 重写ADC DMA传输完成回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { buffer_ready = 2; // 标志后半部分缓冲区就绪 } int main(void) { // ... 初始化代码,包括启动ADC DMA采集 HAL_ADC_Start_DMA(&hadc1, adc_dual_buffer, BUFFER_HALF_SIZE * 2); while(1) { if(buffer_ready == 1) { // 处理 adc_dual_buffer[0] 到 adc_dual_buffer[BUFFER_HALF_SIZE-1] process_data(&adc_dual_buffer[0], BUFFER_HALF_SIZE); buffer_ready = 0; // 处理完清除标志(需注意中断重入问题,实际可能用更安全的标志) } else if(buffer_ready == 2) { // 处理 adc_dual_buffer[BUFFER_HALF_SIZE] 到 adc_dual_buffer[2*BUFFER_HALF_SIZE-1] process_data(&adc_dual_buffer[BUFFER_HALF_SIZE], BUFFER_HALF_SIZE); buffer_ready = 0; } // ... 执行其他任务 } }重要提示:上述代码中的
buffer_ready作为在中断和主循环间共享的变量,必须考虑临界区保护。在更严谨的工程中,应使用原子操作、关中断、或者RTOS的信号量/消息队列来传递缓冲区就绪事件,避免竞态条件。
6. 常见外设的DMA应用场景与配置要点
掌握了核心原理和双缓冲技巧后,我们来看看几个最常用外设的DMA应用细节。
6.1 ADC多通道扫描 + DMA这是DMA最经典的应用。关键在于理解ADC的“扫描序列”如何映射到DMA的“内存地址递增”。
- 配置:如前所述,使能ADC扫描模式和连续转换模式,配置好通道序列和采样时间。
- 数据对齐:ADC结果可以左对齐或右对齐。右对齐时,12位数据存放在寄存器的低12位;左对齐时,存放在高12位。DMA传输的数据宽度需要与此匹配,否则会取错数据位。
- 缓冲区大小:如果你的缓冲区大小
N不是通道数M的整数倍,那么DMA写入的数据序列会错位。例如,3个通道,缓冲区大小为100。DMA写入顺序是Ch1, Ch2, Ch3, Ch1, Ch2, Ch3...。当你读取时,必须知道当前下标对应哪个通道的数据。通常建议设置N = M * K(K为整数),这样每次读取K个完整的扫描周期数据,处理起来更规整。
6.2 UART串口收发 + DMA串口DMA能极大解放CPU,特别是在高速或不定长数据通信时。
- 发送(UART TX DMA):配置为内存到外设模式。将待发送数据的数组地址和长度配置给DMA,启动传输即可。CPU可以立即去干别的,DMA会“悄无声息”地发完所有数据。注意:在发送完成中断(TC)触发前,不要修改发送缓冲区的内容,否则可能发送出错误数据。
- 接收(UART RX DMA):配置为外设到内存模式,并使用循环模式。这是实现不定长数据接收的“神器”。你只需要开辟一个足够大的环形缓冲区(比如256字节),并启动DMA循环接收。数据会源源不断地写入缓冲区,并自动覆盖旧数据。你需要额外维护一个软件读指针。当需要读取数据时,根据DMA当前写入位置(通过
__HAL_DMA_GET_COUNTER宏获取剩余传输计数,可以反算出写指针位置)和读指针,计算出未读数据的长度和位置进行处理。 - 空闲中断(IDLE)配合DMA接收:这是更高级的用法。使能串口的空闲线路中断(IDLE Line Interrupt)。当DMA在循环接收数据时,如果总线上一段时间没有新数据(产生IDLE中断),就意味着一帧数据结束了。在IDLE中断服务函数中,你可以根据DMA的写指针位置,精确地提取出刚刚接收到的完整一帧数据,效率极高。
6.3 SPI/I2S音频传输 + DMA驱动音频编解码器或数字麦克风时,需要持续不断的音频数据流。DMA是必选项。
- 双缓冲是标配:音频数据对实时性要求极高,必须使用双缓冲机制。一个缓冲区正在通过DMA发送给I2S外设播放,另一个缓冲区由CPU或解码器填充下一段音频数据。
- 数据宽度与对齐:I2S通常是16位或24位数据。注意STM32的I2S外设数据寄存器可能是16位或32位,需要根据数据手册和音频设备的要求,仔细配置DMA的数据宽度和传输模式。24位数据有时需要打包成32位进行传输。
- 主从模式与时钟:确保SPI/I2S的主从模式、时钟极性和相位配置正确,否则DMA传输的数据无法被正确识别。
7. DMA使用中的高级话题与疑难杂症排查
当你开始深入使用DMA,一定会遇到一些棘手的问题。这里分享几个典型的“坑”和排查思路。
7.1 内存对齐问题(Alignment Fault)这是一个隐蔽且常见的崩溃原因。Cortex-M内核(尤其是M3/M4/M7)对内存访问有对齐要求。例如,访问一个32位(4字节)数据,其地址必须是4的倍数。如果你定义了一个uint8_t数组,然后将其地址强制转换为uint32_t*并配置给DMA(数据宽度为Word),而该数组的起始地址不是4字节对齐的,那么当DMA试图以32位方式访问时,就会触发硬件错误(HardFault)。
- 解决方案:使用编译器指令来保证缓冲区的对齐。例如,在GCC或ARM Compiler中:
// 定义一个32位对齐的缓冲区 __attribute__((aligned(4))) uint8_t buffer[1024]; // 或者 uint32_t buffer[256]; // 本身就是4字节对齐的
7.2 缓存一致性问题(Cache Coherency)这个问题在带有数据缓存(D-Cache)的高性能MCU(如STM32H7系列)中极为突出。CPU和DMA共享同一片内存(SRAM)。CPU读写数据时会经过缓存,而DMA直接访问物理内存(绕过缓存)。这就可能导致:
- CPU写,DMA读:CPU修改了缓冲区数据(写在了Cache里),但未写回内存(Write-Back)。DMA读走的是内存中的旧数据。
- DMA写,CPU读:DMA将新数据写入了内存,但CPU的Cache中还是旧数据,导致CPU读到的是旧数据。
- 解决方案:
- 手动维护缓存:在DMA传输开始前或结束后,使用
SCB_CleanDCache_by_Addr(清理,将Cache数据写回内存)或SCB_InvalidateDCache_by_Addr(无效化,丢弃Cache数据,从内存重新加载)函数来维护缓存一致性。这是最常用和灵活的方法。 - 使用非缓存内存区域:将DMA缓冲区定义在SRAM中不被缓存(Non-Cacheable)的区域(如
D3域的SRAM或通过MPU配置)。这牺牲了性能换来了简单性。 - 使用带缓存维护的DMA API:一些HAL库的DMA驱动(特别是H7系列)在内部已经处理了部分缓存维护,但并非所有情况都覆盖,需要仔细阅读手册。
- 手动维护缓存:在DMA传输开始前或结束后,使用
7.3 DMA中断与CPU任务竞争在双缓冲应用中,中断服务函数(ISR)中设置标志位,主循环中处理数据。如果数据处理非常耗时,可能会发生:DMA已经写满了下一个缓冲区并再次触发中断,但主循环还没处理完上一个缓冲区。这会导致缓冲区被覆盖,数据丢失。
- 排查与解决:
- 优化处理算法:减少单次数据处理时间。
- 增大缓冲区:给CPU更长的处理时间窗口。
- 使用RTOS:将数据处理放在一个独立的高优先级任务中,并使用消息队列或信号量从ISR快速传递事件,让任务调度器来管理执行顺序。
- 使用三缓冲甚至多缓冲:提供更多的缓冲余地,但这会增加内存和逻辑复杂度。
7.4 DMA传输未启动或中途停止
- 检查列表:
- 时钟:DMA控制器(在AHB总线上)的时钟是否使能?
__HAL_RCC_DMA1_CLK_ENABLE()或__HAL_RCC_DMA2_CLK_ENABLE()。 - 外设DMA请求:是否使能了外设的DMA请求?例如,对于UART发送,除了配置DMA,还需要调用
__HAL_UART_ENABLE_IT(&huart1, UART_IT_DMATX)(HAL库通常在HAL_UART_Transmit_DMA内部处理了)。 - NVIC中断:如果使用了中断,NVIC是否配置并使能?
- 传输计数器(CNDTR):在启动传输前,这个寄存器必须被写入一个大于0的值。HAL库的启动函数会做这件事。如果传输中途停止,可以检查这个寄存器是否变成了0。
- 外设状态:确保外设本身已正确初始化并处于就绪状态(如UART已使能,ADC已校准并启动)。
- 时钟:DMA控制器(在AHB总线上)的时钟是否使能?
7.5 内存到内存传输的陷阱内存到内存传输在某些系列上(如F1)是DMA2的专属功能。使用时需注意:
- 它没有外设端的“流控制”,传输速度极快,会占用大量总线带宽,可能影响其他核心外设的访问。
- 源和目标的地址不能指向同一块内存区域的重叠部分,结果将是未定义的。如果需要移动重叠区域的数据,应使用CPU的
memmove函数。
8. 实战:构建一个基于DMA的实时数据采集与处理系统框架
最后,我们整合以上所有知识点,设计一个简单的、可扩展的实时数据采集系统框架。该系统使用ADC多通道扫描、DMA双缓冲、实时处理,并考虑在RTOS环境下的应用。
8.1 系统设计目标
- ADC以固定频率(如10kHz)采集4个通道的信号。
- 使用DMA循环模式+双缓冲,确保数据无丢失。
- 在主循环或RTOS任务中对采集到的数据进行实时滤波(如移动平均)和计算(如有效值)。
- 处理后的数据可通过串口DMA发送至上位机。
8.2 关键组件定义
// 定义双缓冲大小,每个缓冲区容纳10个完整的ADC扫描周期(4通道*10=40个数据) #define ADC_CHANNELS 4 #define SCAN_CYCLES_PER_BUFFER 10 #define ADC_BUFFER_HALF_SIZE (ADC_CHANNELS * SCAN_CYCLES_PER_BUFFER) // 40 // 双缓冲区和状态标志 __attribute__((aligned(4))) uint16_t adc_dma_buffer[ADC_BUFFER_HALF_SIZE * 2]; volatile uint8_t adc_buffer_index = 0; // 0: 前半部分就绪, 1: 后半部分就绪 // 处理后的数据缓冲区 float processed_data[ADC_CHANNELS];8.3 DMA与ADC配置(CubeMX+HAL)
- ADC配置:扫描模式、连续转换模式、使能DMA连续请求、配置好4个通道的采样顺序和时间。
- DMA配置:外设到内存、循环模式、高优先级、外设地址不自增(固定为ADC->DR)、内存地址自增、数据宽度均为半字(16位,对应ADC 12位数据右对齐)。
- 使能DMA的HT和TC中断。
8.4 中断回调与数据处理任务
// ADC DMA半传输完成回调 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { adc_buffer_index = 0; // 标志前半缓冲区就绪 // 如果使用RTOS,这里可以释放一个信号量或发送消息给处理任务 osSemaphoreRelease(adcDataReadySemaphore); // 示例 } // ADC DMA传输完成回调 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { adc_buffer_index = 1; // 标志后半缓冲区就绪 osSemaphoreRelease(adcDataReadySemaphore); // 示例 } // 数据处理任务函数 (RTOS环境示例) void adc_data_process_task(void const *argument) { uint16_t *buffer_to_process; while(1) { // 等待信号量,表示有缓冲区就绪 osSemaphoreWait(adcDataReadySemaphore, osWaitForever); // 根据标志确定处理哪个缓冲区 if(adc_buffer_index == 0) { buffer_to_process = &adc_dma_buffer[0]; } else { buffer_to_process = &adc_dma_buffer[ADC_BUFFER_HALF_SIZE]; } // 处理一个完整缓冲区的数据(40个数据,即10组扫描) for(int cycle = 0; cycle < SCAN_CYCLES_PER_BUFFER; cycle++) { for(int ch = 0; ch < ADC_CHANNELS; ch++) { uint16_t raw_value = buffer_to_process[cycle * ADC_CHANNELS + ch]; // 这里进行你的数据处理,例如累加用于后续平均 // sum[ch] += raw_value; } } // 完成10组扫描后,计算各通道的平均值 for(int ch = 0; ch < ADC_CHANNELS; ch++) { processed_data[ch] = (float)sum[ch] / SCAN_CYCLES_PER_BUFFER * 3.3f / 4095.0f; // 转换为电压 sum[ch] = 0; // 清零累加器 } // 可以在这里将 processed_data 通过串口DMA发送出去 // HAL_UART_Transmit_DMA(&huart1, (uint8_t*)processed_data, sizeof(processed_data)); } }8.5 系统启动在main函数中,初始化所有外设和RTOS后,启动ADC的DMA采集,并创建数据处理任务。
// 启动ADC DMA循环采集 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_HALF_SIZE * 2); // 创建信号量和任务 adcDataReadySemaphore = osSemaphoreNew(1, 0, NULL); osThreadNew(adc_data_process_task, NULL, &thread_attr);这个框架清晰地分离了数据采集(DMA中断驱动)和数据处理(任务),通过双缓冲和信号量机制实现了安全、高效的数据流。你可以在此基础上增加更复杂的算法、通信协议或用户接口。DMA的掌握,最终是为了让你能设计出这样优雅、高效的系统,让CPU专注于它该做的“思考”工作,而不是“搬运”工作。