1. 这篇文章真正要解决的问题
当你在嵌入式开发中,尤其是涉及高速数据采集、图像处理或音频流传输时,是否遇到过这样的困境:CPU 被频繁的中断和内存拷贝操作所拖累,导致系统整体性能瓶颈,无法充分利用高速外设的带宽?或者,在编写驱动时,面对复杂的内存地址管理和数据传输同步,感到力不从心?如果你正在为这些问题寻找一个高效、底层的解决方案,那么DMA(直接内存访问)就是你必须掌握的核心技术。
本文要解决的,正是如何从零开始,透彻理解并实践DMA-16这一特定模式。网络上关于 DMA 的概念文章很多,但往往停留在“CPU不参与数据传输”的浅层描述,对于“31 DMA 31DMA-16”这类具体型号或模式的深入实操解析却很少。这导致很多开发者只知道 DMA 好,却不知道如何针对自己的硬件(比如某些特定编号的 DMA 控制器)进行正确的配置、启动和调试,最终在项目集成时踩坑无数。
本文将从一个明确的判断出发:DMA 技术的精髓不在于“免除了CPU负担”这个结果,而在于对“传输发起者、传输路径、传输仲裁”这一整套流程的精细控制。我们将以“DMA-16”为例(通常指数据位宽为16位的DMA传输),拆解其完整的工作流程、寄存器配置、以及在实际编码中如何避开那些教科书上不会写的“坑”。读完本文,你将能够:
- 清晰区分不同DMA模式(如Memory-to-Memory, Peripheral-to-Memory)的应用场景。
- 独立完成一个DMA-16传输通道的初始化、配置和启动代码编写。
- 掌握DMA传输完成中断的处理与缓冲区管理策略。
- 理解并规避多通道DMA仲裁、传输宽度不对齐等常见问题。
2. 基础概念与核心原理
在深入代码之前,我们必须建立几个关键认知,否则后续的配置就像在黑暗中摸索。
DMA 的本质是什么?DMA 是一个独立的硬件子系统,它像是一个高度专业化的“数据搬运工”。它的核心能力是在不经过CPU核心的情况下,直接在两个实体之间搬运数据。这两个实体通常是:
- 外设(Peripheral):如 ADC(模数转换器)、SPI/I2S(通信接口)、SDMMC(存储卡接口)等。
- 内存(Memory):如 SRAM、SDRAM。
为什么需要 DMA?以一个典型的音频播放场景为例:音频数据存放在内存中,需要持续不断地发送到 I2S 接口。如果没有 DMA,CPU 需要反复执行“从内存读一个数据 -> 写入 I2S 数据寄存器”的指令。这不仅占用大量 CPU 周期,而且因为 CPU 可能被其他高优先级任务打断,极易导致音频流卡顿或断裂。DMA 接管后,CPU 只需告诉 DMA:“从这块内存地址开始,连续送 N 个数据到 I2S 数据寄存器,送完了告诉我一声”,然后就可以去处理其他任务,由 DMA 硬件保证数据流的稳定和及时。
关键概念解析:
- 通道(Channel):一个 DMA 控制器通常有多个独立的通道,每个通道可以处理一条独立的数据流。通道之间可以设置优先级进行仲裁。“31 DMA”可能指一个拥有多达31个通道的DMA控制器。
- 传输宽度(Transfer Width):指单次传输操作的数据位宽,常见的有 8位(字节)、16位(半字)、32位(字)。“DMA-16”即指配置为16位宽度的传输模式。这必须与源地址和目标地址的对齐方式、外设数据寄存器的宽度相匹配。
- 传输模式:
- 外设到内存(Peripheral-to-Memory):例如,ADC 转换完成的数据自动存入内存。
- 内存到外设(Memory-to-Peripheral):例如,将内存中的图像数据发送到 LCD 的显存接口。
- 内存到内存(Memory-to-Memory):纯内存拷贝,通常用于大数据块搬运,某些DMA控制器支持。
- 仲裁器(Arbiter):当多个DMA通道同时请求传输时,由仲裁器根据预设优先级(固定优先级或循环优先级)决定哪个通道先被服务。
- FIFO:一些高级DMA控制器内置FIFO缓冲区,用于暂存数据,可以平滑数据流,处理突发(Burst)传输。
“31 DMA 31DMA-16”的可能含义:根据常见命名习惯,这很可能指的是一个支持31个通道,且当前示例或配置聚焦于16位数据宽度传输的DMA控制器。在具体的芯片数据手册中,你会看到类似DMA1_Channel1这样的标识。理解这个编号体系对查找正确的寄存器至关重要。
3. 环境准备与前置条件
在开始编程前,请确保你的开发环境已就绪。本文的示例将以 ARM Cortex-M 系列微控制器(如 STM32)的通用 DMA 控制器为背景,但其原理和步骤具有普适性。
- 硬件平台:一块支持DMA的评估板(如 STM32F4 Discovery, NXP LPC系列等)。确保你手头有该板子的原理图和数据手册。
- 软件开发环境:
- IDE:Keil MDK, IAR Embedded Workbench, 或 STM32CubeIDE。
- 开发库/框架:标准外设库(Standard Peripheral Library)、HAL库(Hardware Abstraction Layer)或直接寄存器操作。本文示例将结合 HAL 库(因其可读性高)和必要的寄存器概念进行讲解。
- 调试器:J-Link, ST-Link 等,用于下载程序和单步调试。
- 关键文档:
- 芯片数据手册(Datasheet):了解芯片的基本参数。
- 参考手册(Reference Manual):这是最重要的文档!其中“DMA controller”章节详细描述了寄存器功能、工作模式、时序图。你必须学会查阅它。
- 工程准备:在IDE中创建一个新的工程,正确配置芯片型号、系统时钟(尤其是为DMA提供时钟的总线,如AHB),并启用你计划使用的外设(例如ADC1、SPI2等)及其对应的DMA通道。
4. DMA-16 核心流程拆解
配置一次完整的 DMA-16 传输,可以分解为以下六个核心步骤。理解每一步的目的,是写出健壮代码的关键。
步骤 1:时钟使能与外设绑定DMA 控制器作为一个独立的外设,其时钟默认可能是关闭的(为了省电)。第一步是开启它的时钟。同时,你需要明确是哪个外设(如 ADC1、USART1_TX)触发这次DMA传输,这个绑定关系通常是硬件固定的(即某个外设的DMA请求只能由特定的DMA通道处理),需要在参考手册中查表确认。
步骤 2:通道参数配置(核心)这是最核心的一步,你需要初始化一个DMA通道参数结构体,关键字段包括:
- 源地址(Source Address):数据从哪里来。如果是外设到内存,这里就是外设数据寄存器的地址(如
&ADC1->DR)。 - 目标地址(Destination Address):数据到哪里去。如果是外设到内存,这里就是内存中数组的首地址(如
adc_buffer)。 - 传输方向(Direction):选择
PERIPH_TO_MEM或MEM_TO_PERIPH。 - 数据宽度(Width):这里配置为16位(
HALF_WORD)。必须确保源和目标的数据宽度设置一致,否则可能导致数据错位。 - 地址递增模式(Increment):对于内存地址,通常需要设置为递增,以便连续存储/读取数据。对于外设地址(通常是固定的数据寄存器),则设置为不递增。
- 传输模式(Mode):
- 普通模式:传输指定数量的数据后停止。
- 循环模式(Circular):传输完成后自动重置地址和计数器,重新开始,用于处理连续数据流(如音频)。
- 优先级(Priority):当多个通道竞争时,决定谁先被服务。
步骤 3:中断配置(可选但重要)如果你需要在传输完成、传输一半或发生错误时得到通知,以进行后续处理(如处理半满的缓冲区),则需要使能对应的DMA传输完成中断(TCIE)、半传输中断(HTIE)或错误中断(TEIE),并在NVIC(嵌套向量中断控制器)中配置该DMA通道中断的优先级。
步骤 4:启动传输将配置好的参数通过HAL_DMA_Start()或类似函数装载到DMA硬件,并启动通道。此时DMA控制器就处于等待状态。
步骤 5:外设触发DMA不会自动开始搬数据!它需要由外设的事件来触发。例如,你需要使能ADC的连续转换模式,并使能ADC的DMA请求(调用HAL_ADC_Start_DMA())。此后,每次ADC转换完成,都会产生一个DMA请求,DMA控制器随即执行一次16位的数据搬运。
步骤 6:传输完成处理在中断服务程序(ISR)或通过轮询标志位,检测传输完成状态。在完成中断里,你可以处理数据、重置标志、或准备下一次传输。对于循环模式,你可能只需要在半传输和传输完成中断中交替处理两个缓冲区(双缓冲技术),以实现无缝数据流。
5. 完整示例与代码实现
下面我们以一个具体的场景为例:使用 DMA-16 模式,将 ADC1 的转换结果(12位精度,右对齐,实际有效数据在低12位,但寄存器是16位宽)连续搬运到内存中的一个数组中。
5.1 外设与 DMA 时钟使能
首先,在系统初始化阶段,确保时钟正确开启。
// 文件:main.c (初始化部分) int main(void) { HAL_Init(); SystemClock_Config(); // 这个函数需要配置AHB、APB等总线时钟,确保DMA时钟(通常挂在AHB下)被使能。 // 使能 ADC1 时钟 (挂在 APB2 上) __HAL_RCC_ADC1_CLK_ENABLE(); // 使能 DMA2 时钟 (假设ADC1的DMA请求连接到DMA2的Stream0,具体需查手册) __HAL_RCC_DMA2_CLK_ENABLE(); // ... 其他初始化 }5.2 ADC 与 DMA 通道配置
我们使用 STM32 HAL 库来简化配置。
// 文件:adc_dma.c #include "adc_dma.h" // 定义用于接收ADC数据的缓冲区,大小为100个16位数据 #define ADC_BUFFER_SIZE 100 uint16_t adc_buffer[ADC_BUFFER_SIZE]; // 声明DMA和ADC的句柄 DMA_HandleTypeDef hdma_adc; ADC_HandleTypeDef hadc1; void ADC_DMA_Init(void) { // --- 1. 配置DMA --- hdma_adc.Instance = DMA2_Stream0; // 根据芯片手册确定Stream/Channel hdma_adc.Init.Channel = DMA_CHANNEL_0; // 根据芯片手册确定Channel hdma_adc.Init.Direction = DMA_PERIPH_TO_MEMORY; // 方向:外设到内存 hdma_adc.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增 hdma_adc.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 外设数据对齐:半字 (16位) hdma_adc.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; // 内存数据对齐:半字 (16位) hdma_adc.Init.Mode = DMA_CIRCULAR; // 模式:循环模式,持续传输 hdma_adc.Init.Priority = DMA_PRIORITY_HIGH; // 通道优先级:高 hdma_adc.Init.FIFOMode = DMA_FIFOMODE_DISABLE; // 本例禁用FIFO // 注意:FIFO相关配置在启用时需要额外设置,这里简化处理 // 初始化DMA流 if (HAL_DMA_Init(&hdma_adc) != HAL_OK) { Error_Handler(); } // 将DMA句柄与ADC句柄关联起来 __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc); // --- 2. 配置ADC --- hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; // ADC精度12位 hadc1.Init.ScanConvMode = DISABLE; // 单通道,非扫描模式 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换模式 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE; // 软件触发 hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件启动 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 数据右对齐,读16位寄存器时,数据在低12位 hadc1.Init.NbrOfConversion = 1; hadc1.Init.DMAContinuousRequests = ENABLE; // 使能DMA连续请求 hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; // 每次转换结束产生EOC if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); } // --- 3. 配置ADC通道 --- ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; // 使用通道0 sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES; if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) { Error_Handler(); } }关键点解释:
PeriphDataAlignment和MemDataAlignment都设置为DMA_PDATAALIGN_HALFWORD,这就是DMA-16模式的核心配置,告诉DMA控制器按16位宽度搬运数据。Mode = DMA_CIRCULAR设置为循环模式,传输完ADC_BUFFER_SIZE个数据后,会自动回到adc_buffer开头继续传输,非常适合连续采样。__HAL_LINKDMA宏是关键,它建立了ADC句柄和DMA句柄之间的内部关联。
5.3 启动传输与中断处理
配置完成后,启动ADC的DMA传输。
// 文件:adc_dma.c (续) void ADC_DMA_Start(void) { // 启动ADC的DMA传输。 // 参数:ADC句柄,目标内存地址,传输数据长度(单位:数据项个数) if (HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE) != HAL_OK) { Error_Handler(); } // 此后,ADC会开始连续转换,每次转换完成都通过DMA将16位数据写入adc_buffer // 缓冲区写满后,由于是循环模式,会覆盖开头继续写入。 } // 文件:stm32f4xx_it.c (中断服务程序) // DMA传输完成中断服务程序 void DMA2_Stream0_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_adc); // 调用HAL库的通用中断处理函数 } // HAL库的中断回调函数 // 当传输完成时,此函数会被 HAL_DMA_IRQHandler 调用 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { // 传输完成回调:此时 adc_buffer 中充满了最新的 ADC_BUFFER_SIZE 个样本 // 你可以在这里处理数据,例如计算平均值、发送到上位机等。 // 注意:在循环模式下,此回调在缓冲区第一次被填满时触发一次, // 之后除非传输被停止再重启,否则不会再次触发。 process_adc_data(adc_buffer, ADC_BUFFER_SIZE); } } // 更实用的方式是使用“半传输完成”和“传输完成”回调来实现双缓冲。 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 当DMA传输到缓冲区一半时触发(即前50个数据就绪) // 可以处理 adc_buffer[0..49] process_adc_data(adc_buffer, ADC_BUFFER_SIZE/2); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 当DMA传输完整个缓冲区时触发(即后50个数据就绪) // 可以处理 adc_buffer[50..99] process_adc_data(&adc_buffer[ADC_BUFFER_SIZE/2], ADC_BUFFER_SIZE/2); }关键点解释:
HAL_ADC_Start_DMA函数一次性完成了三件事:启动ADC、启动DMA、并关联两者。- 在循环模式+双缓冲回调的策略下,
HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback会交替被调用,为你提供了两个“逻辑缓冲区”,可以实现无锁、连续的数据处理,这是高效实时系统的常用技巧。
6. 运行结果与效果验证
如何验证你的 DMA-16 传输是否正常工作?
软件验证(调试器观察):
- 在
ADC_DMA_Start()后设置断点。 - 运行程序,在调试器的“Memory”或“Watch”窗口中观察
adc_buffer数组。 - 给ADC输入一个变化的电压(例如用电位器),然后全速运行。
- 稍后暂停程序,你应该能看到
adc_buffer数组中的值在随着输入电压变化,并且数值在0-4095(12位ADC)范围内。这证明数据正在被DMA持续更新。
- 在
硬件验证(逻辑分析仪/示波器):
- 如果你有逻辑分析仪,可以探测ADC的转换完成信号(如EOC)和DMA请求/应答信号。你会看到每次ADC转换完成,都伴随一次DMA活动。
- 更直观的方法是,将处理后的数据通过另一个DMA通道(如内存到USART)发送到串口助手,在PC上绘制波形,观察是否连续、无丢失。
性能对比验证:
- 写一个不使用DMA的版本,用CPU轮询ADC状态寄存器并读取数据。
- 在两个版本中,都在ADC数据就绪时点亮/熄灭一个GPIO引脚。
- 用示波器测量该GPIO的波形。DMA版本的脉冲会非常短(仅处理中断开销),而轮询版本的脉冲宽度会包含整个数据读取和存储的CPU执行时间。在高速采样下,轮询方式可能导致波形占空比极大,甚至无法响应其他任务,而DMA版本则游刃有余。
7. 常见问题与排查思路
DMA配置相对复杂,容易出错。下表列出了典型问题及解决方法:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| DMA根本启动不了,初始化失败 | 1. DMA或外设时钟未使能。 2. 使用的Stream/Channel编号错误,与所选外设不匹配。 | 1. 检查__HAL_RCC_xxx_CLK_ENABLE()是否调用。2.仔细查阅芯片参考手册的“DMA请求映射”表格。 | 1. 确保所有相关外设时钟已开启。 2. 根据手册更正 Instance和Channel的赋值。 |
| 传输一次后就停止,不循环 | 1. DMA模式配置为DMA_NORMAL(普通模式)。2. 外设的DMA连续请求未使能。 | 1. 检查hdma.Init.Mode。2. 检查外设初始化结构体中的 DMAContinuousRequests或类似字段。 | 1. 如需连续传输,改为DMA_CIRCULAR。2. 使能外设的连续DMA请求。 |
| 数据错位或全是0/最大值 | 1.数据宽度(Data Alignment)配置错误,这是DMA-16最易出错点。 2. 源地址或目标地址递增模式错误。 3. ADC数据对齐方式(左对齐/右对齐)与DMA读取方式不匹配。 | 1. 核对PeriphDataAlignment和MemDataAlignment。2. 确认 PeriphInc和MemInc。3. 核对ADC的 DataAlign与DMA期望的数据格式。 | 1. 确保源和目标宽度一致(本例均为HALF_WORD)。 2. 内存地址通常递增,外设地址不递增。 3. 对于12位右对齐ADC数据,DMA按16位读取是OK的(取低12位)。若ADC是左对齐,则需注意数据在16位中的位置。 |
| 只能传输部分数据,计数器不减少 | 1. DMA传输计数器(NDTR)配置过大,超过缓冲区实际大小。 2. 外设没有正确产生传输请求。 | 1. 检查HAL_ADC_Start_DMA的第三个参数(数据长度)。2. 检查外设是否已启动(如ADC是否开始转换)。 | 1. 确保传输长度参数正确。 2. 确保外设已正确初始化和启动,并能产生触发事件(如ADC的EOC信号)。 |
| 进入DMA传输错误中断 | 1. 访问了非法内存地址(如地址未对齐到数据宽度)。 2. 传输过程中源/目标地址被意外修改。 3. 寄存器配置冲突。 | 1. 检查adc_buffer数组地址是否对齐(通常编译器会处理,但需注意)。2. 检查是否有其他代码(或中断)修改了DMA配置寄存器或源/目标地址。 3. 查看DMA状态寄存器错误标志位。 | 1. 确保数组地址是字对齐的(对于32位系统)。可以使用__ALIGNED(4)等属性修饰数组。2. 在DMA传输期间,避免操作相关的内存区和配置。 3. 根据错误标志位(如TEIF:传输错误)查阅手册。 |
| 多通道DMA时,低优先级通道一直得不到服务 | DMA仲裁器优先级设置问题。 | 检查各通道的Priority设置。 | 如果希望公平,可将所有通道设为相同优先级(如果支持循环仲裁),或根据业务重要性合理分配优先级。 |
8. 最佳实践与工程建议
掌握了基础操作后,以下建议能帮助你在实际项目中更稳健地使用DMA。
双缓冲与环形缓冲区:
- 对于连续数据流,务必使用双缓冲技术(利用半传输和传输完成中断)。这能保证你处理数据时,DMA正在向另一个缓冲区写入,实现零等待。
- 更通用的方案是结合DMA循环模式和软件管理的环形缓冲区。DMA持续向一个大的物理环形内存写入,软件从中读取。这需要处理好读/写指针的同步。
内存对齐与数据宽度:
- 严格遵守对齐规则。如果配置为16位传输,源和目标地址都应该是2字节对齐的。对于32位传输,则需要4字节对齐。使用
__attribute__((aligned(4)))(GCC)或__ALIGNED(4)(ARM/Keil)来确保全局数组或缓冲区的对齐。 - 当外设数据宽度与内存数据宽度不一致时(例如8位外设到32位内存),需要仔细配置DMA的打包(Packing)或解包(Unpacking)功能(如果支持),或考虑使用FIFO。
- 严格遵守对齐规则。如果配置为16位传输,源和目标地址都应该是2字节对齐的。对于32位传输,则需要4字节对齐。使用
FIFO的合理使用:
- 现代DMA控制器的FIFO不是简单的缓存。它可以将多次单次传输(Single Transfer)合并为一次突发传输(Burst Transfer),大幅提升总线利用效率,尤其是在内存一侧。
- 启用FIFO时,需要正确设置FIFO阈值(Threshold)。通常,对于外设到内存,如果外设数据宽度较小,建议将FIFO阈值设为
1/4或1/2,以允许DMA积累一定数据后再发起一次高效的内存写入突发。
DMA与Cache的协同:
- 在带有数据缓存(D-Cache)的高性能MCU(如Cortex-M7)中,DMA操作的内存区域必须进行正确的缓存维护。因为DMA直接访问物理内存,而CPU访问的是缓存中的数据副本,两者不一致会导致数据错误。
- 解决方案:将DMA缓冲区所在的内存区域配置为非缓存(Non-Cacheable),或者在进行DMA传输前后,使用
SCB_CleanDCache_by_Addr()和SCB_InvalidateDCache_by_Addr()等函数手动清洗和无效化缓存。
资源管理与节能:
- 传输完成后,及时停止DMA通道(
HAL_DMA_Stop)并关闭外设的DMA请求,以节省功耗。 - 在复杂系统中,规划好DMA通道的使用,避免冲突。可以制作一个通道分配表。
- 传输完成后,及时停止DMA通道(
错误处理与鲁棒性:
- 务必使能DMA传输错误中断(TEIE),并在错误回调函数(
HAL_DMA_ErrorCallback)中记录日志或进行安全恢复(如重置DMA通道)。 - 在启动传输前,检查DMA通道是否处于忙碌状态。
- 务必使能DMA传输错误中断(TEIE),并在错误回调函数(
通过将DMA-16的配置流程拆解为清晰的步骤,并结合具体的代码示例和问题排查指南,你应该能够跨越从“知道概念”到“实现功能”的鸿沟。DMA是释放嵌入式系统性能潜力的关键工具,理解并熟练运用它,是迈向高级嵌入式开发的必经之路。建议你根据手头的具体芯片手册,将本文中的通用逻辑映射到实际的寄存器操作上,动手实践一遍,感受数据如何如流水般在系统中自动穿梭。