1. 项目概述为什么DMA是STM32性能释放的关键如果你正在用STM32F103C8T6做项目无论是驱动LCD屏、高速采集ADC数据还是处理串口通信一旦数据量上来是不是总觉得CPU被各种数据搬运的杂活拖累得喘不过气程序跑起来卡顿中断响应不及时明明是一颗72MHz主频的Cortex-M3性能却感觉没完全发挥出来。问题的核心往往就在于数据搬运的方式。传统上我们依赖CPU通过软件指令一个字节一个字节地在内存和外设寄存器之间“搬砖”这占用了大量宝贵的CPU周期。而DMA正是为了解决这个痛点而生的“专职搬运工”。DMA全称直接存储器访问是STM32内部一个独立于CPU的硬件模块。它的核心思想非常简单却极其高效由这个硬件模块来接管数据在内存与内存、内存与外设之间的批量传输任务。在整个传输过程中CPU只需要在开始时配置好DMA告诉它从哪里搬、搬到哪里、搬多少然后就可以去执行其他更重要的任务比如算法处理、逻辑判断。传输完成后DMA会通过中断通知CPU“活儿干完了”。这种机制将CPU从繁重的重复性I/O操作中解放出来实现了真正的并行处理是提升系统整体效率和实时性的关键技术。对于STM32F103C8T6这款经典的“蓝桥杯”神器、学生项目常客来说深入理解并熟练运用DMA意味着你能轻松处理更复杂的任务。无论是实现流畅的液晶显示、稳定高速的多通道ADC采样还是实现串口的不定长数据高效收发DMA都是你从单片机“玩具级”应用迈向更复杂、更可靠嵌入式系统的必修课。接下来我将结合自己多年的踩坑经验带你彻底拆解STM32F103C8T6的DMA原理与实战。2. STM32F103C8T6的DMA架构深度解析2.1 DMA控制器结构与通道映射STM32F103C8T6内部集成了两个DMA控制器DMA1和DMA2。但需要注意的是C8T6这款芯片只包含了DMA1。DMA1拥有7个独立的通道Channel 1-7每个通道都可以配置为服务于一个特定的外设请求。理解通道与外设的固定映射关系是正确使用DMA的第一步。这个映射是硬件决定的不能随意更改。例如如果你想让USART1的发送TX使用DMA你必须使用DMA1的Channel 4。这个信息在数据手册的“DMA请求映射”表格中可以查到但为了方便我整理了一个C8T6常用的映射速查表DMA1 通道支持的外设请求优先级从高到低Channel 1ADC1, TIM2_CH3, TIM4_CH1Channel 2SPI1_RX, USART3_TX, TIM1_CH1, TIM2_UP, TIM3_CH3Channel 3SPI1_TX, USART3_RX, TIM1_CH2, TIM3_CH4, TIM3_UPChannel 4SPI2_RX, USART1_TX, I2C2_TX, TIM1_CH4, TIM1_TRIG, TIM1_COM, TIM4_CH2Channel 5SPI2_TX, USART1_RX, I2C2_RX, TIM1_UP, TIM2_CH1, TIM4_CH3Channel 6USART2_RX, I2C1_TX, TIM1_CH3, TIM3_CH1, TIM3_TRIGChannel 7USART2_TX, I2C1_RX, TIM2_CH2, TIM2_CH4, TIM4_UP注意上表中“TX”通常对应外设的发送DMA请求“RX”对应接收请求。同一个通道可能被多个外设复用因此在实际配置时除了选择通道还需要在DMA初始化结构中指定具体是哪个外设通过DMA_PeripheralInc等参数间接确定或CubeMX中直接选择。每个通道都像一个独立的“传输管道”拥有自己的一套配置寄存器如源地址、目标地址、传输数量和状态标志。7个通道之间存在着固定的优先级通道1最高通道7最低。当多个通道的请求同时到来时优先级高的先被响应。你也可以通过软件配置将某个通道的优先级设置为“高”、“中”、“低”、“非常高”这会在同级别硬件通道号优先级的基础上进行二次调整。2.2 三种数据传输模式与方向DMA传输有三个核心要素源数据从哪里来、目标数据到哪里去、方向。根据源和目标的组合DMA传输主要分为三种模式外设到存储器模式这是最常用的模式之一例如ADC采集数据到内存数组或者串口接收数据到缓冲区。此时外设寄存器地址是源地址内存数组地址是目标地址。DMA会不断地将外设数据寄存器如ADC-DR或USART-DR的值“搬运”到你指定的内存数组中。存储器到外设模式同样极其常用例如将内存中的一段数据如图像缓冲区、待发送的字符串通过串口或SPI发送出去。此时内存数组地址是源地址外设数据寄存器地址是目标地址。存储器到存储器模式这是DMA1所有通道都支持的模式。用于在两个内存区域之间快速复制数据例如复制一个大数组或者进行数据块的搬移、填充操作。在这种模式下CPU的参与度降到最低效率非常高。需要注意的是存储器到存储器模式不能与循环模式同时使用。传输方向就是由上述的源和目标决定的。在配置时你需要清晰地设定DMA_DIR参数告诉DMA控制器数据传输的方向是从外设到内存还是从内存到外设亦或是内存到内存。2.3 关键配置参数数据宽度、地址增量与循环模式配置DMA时有几个参数至关重要它们决定了数据传输的“粒度”和“行为”数据宽度指单次传输操作的数据位宽。STM32的DMA支持字节8位、半字16位和字32位。你必须确保外设数据寄存器宽度、内存缓冲区数据类型宽度与DMA配置的传输宽度三者匹配。例如ADC1是12位精度但其数据寄存器ADC1-DR是一个16位的半字寄存器。如果你定义的内存缓冲区是uint16_t adc_buf[100]那么DMA的传输宽度就应该设置为半字16位。如果设置为字节会导致数据错乱设置为字则可能浪费带宽或引发对齐问题。实操心得我习惯将缓冲区数据类型与DMA传输宽度、外设寄存器宽度严格统一。对于ADC就用uint16_t对于32位的外设如某些定时器的捕获/比较寄存器就用uint32_t。这样可以避免很多难以调试的数据对齐问题。地址增量指每次传输后DMA是否自动增加源地址和目标地址。对于外设寄存器地址如USART-DR通常是不递增的因为始终是同一个寄存器。对于内存数组地址则必须递增以便将数据顺序存放到数组的连续位置。DMA_PeripheralInc外设地址递增使能。通常禁用DISABLE。DMA_MemoryInc存储器地址递增使能。通常使能ENABLE除非你想把所有数据都塞到同一个内存地址某些特殊场景下有用。循环模式这是实现“环形缓冲区”或连续采集的关键。当循环模式使能时DMA在完成一轮指定数据量的传输后会自动将传输计数器CNDTR重载为初始值并从头开始新一轮传输周而复始。应用场景ADC连续采集、串口DMA接收不定长数据配合空闲中断。循环模式避免了频繁重新配置DMA实现了“设置一次永久运行”。重要限制如前所述存储器到存储器模式不支持循环模式。传输计数器这是一个非常重要的寄存器DMA_CNDTRx它指定了要传输的数据项数量注意是“项”的数量不是字节数。例如数据宽度设置为半字传输计数器设置为100则意味着DMA会传输100个半字即200字节。在传输过程中该计数器值会递减软件可以读取它来了解还剩多少数据未传输。3. 核心外设的DMA应用实战与配置详解理论讲得再多不如一行代码。下面我们以STM32F103C8T6最常用的几个外设为例拆解其DMA配置的核心步骤和避坑要点。我将以STM32标准外设库为例进行说明HAL库的思想是相通的。3.1 ADC多通道扫描与DMA连续传输这是DMA最经典的应用之一。假设我们需要用ADC1连续扫描采集通道0PA0、通道1PA1、通道2PA2三个点的电压值。设计思路配置ADC1为扫描模式、连续转换模式。配置DMA1通道1因为ADC1请求映射到通道1模式为循环模式、外设到存储器、数据宽度半字。开启ADC的DMA请求ADC_DMACmd。启动DMA传输然后启动ADC转换。之后ADC每完成一次多通道扫描得到3个值就会触发一次DMA请求DMA自动将这3个值依次搬运到指定的内存数组中。关键配置代码片段与解析#define ADC_CHANNEL_NUM 3 uint16_t ADC_ConvertedValue[ADC_CHANNEL_NUM]; // DMA目标缓冲区 void ADC1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; // 1. 开启时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置DMA DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(ADC1-DR); // 源地址ADC数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)ADC_ConvertedValue; // 目标地址内存数组 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 方向外设-存储器 DMA_InitStructure.DMA_BufferSize ADC_CHANNEL_NUM; // 传输数据项数3个半字 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不增 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 存储器地址递增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; // 外设数据宽度半字(16位) DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; // 存储器数据宽度半字 DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; // 非存储器到存储器模式 DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); // 使能DMA通道 // 3. 配置ADC省略部分GPIO和通道规则组配置 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode ENABLE; // 扫描模式 ADC_InitStructure.ADC_ContinuousConvMode ENABLE; // 连续转换模式 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 软件触发 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel ADC_CHANNEL_NUM; ADC_Init(ADC1, ADC_InitStructure); // ... 配置ADC通道和采样顺序 ... ADC_DMACmd(ADC1, ENABLE); // 使能ADC的DMA请求 ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 启动转换 }配置完成后ADC_ConvertedValue[0]、[1]、[2]就会自动、实时地更新为通道0、1、2的转换结果完全无需CPU干预。避坑指南数据对齐确保ADC_ConvertedValue数组在内存中的地址最好是半字对齐的地址是2的倍数。虽然DMA能处理非对齐访问但可能损失性能。定义全局数组通常由编译器自动对齐但如果你用malloc动态分配需要注意。缓冲区溢出在循环模式下DMA会不停地覆盖缓冲区。如果你的主程序读取数据的速度慢于ADC采集的速度就会发生新数据覆盖旧数据的情况。对于实时性要求高的应用可以考虑使用双缓冲区Ping-Pong Buffer机制配置两倍大小的缓冲区利用DMA的半传输完成和传输完成中断在中断中切换CPU读取和DMA写入的缓冲区区域。采样率计算ADC的连续转换速率由ADC时钟分频和采样周期决定。DMA的搬运速度必须跟上ADC的产出速度。对于STM32F103ADC时钟最高14MHz在1.5个周期的采样时间下理论最高采样率约为1MHz。如果设置多通道总采样率需要除以通道数。3.2 USART串口DMA收发与不定长数据处理串口配合DMA可以极大减轻CPU负担尤其是在高速或大数据量通信时。发送相对简单接收并处理不定长数据是难点。USART1 DMA发送TX配置要点通道使用DMA1_Channel4查表可知。方向存储器到外设。源地址发送数据缓冲区地址。目标地址(uint32_t)USART1-DR。通常不开启循环模式发送完指定长度数据后停止等待下次发送任务。需要发送时更新源地址和传输计数器重新使能通道。USART1 DMA接收RX配置要点配合空闲中断实现不定长接收 这是非常经典且实用的方案。思路是将DMA接收配置为循环模式指向一个足够大的线性缓冲区。使能串口的空闲中断IDLE。当一帧数据到来DMA会自动将数据顺序存入缓冲区。当一帧数据结束串口总线进入空闲状态触发空闲中断。在中断服务函数中我们可以通过计算DMA当前剩余传输计数器值推算出本次已经接收了多少个字节从而进行帧处理。关键步骤配置USART1和DMA1_Channel5RX为循环模式、外设到存储器。使能USART1的空闲中断USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)。在USART1的中断服务函数中判断是否是空闲中断。在空闲中断处理中if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 读一次DR以清除IDLE标志位非常重要 // 计算本次接收数据长度 // DMA配置的缓冲区总大小 - DMA当前剩余传输计数 已接收数据长度 uint16_t remain_cnt DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t recv_len RX_BUFFER_SIZE - remain_cnt; if(recv_len 0) { // 处理 recv_len 长度的数据... // 例如将数据从DMA缓冲区复制到处理缓冲区 } // 注意由于是循环模式DMA会继续接收覆盖旧数据。 // 如果处理速度慢需要考虑双缓冲区或更复杂的机制。 }避坑指南清除IDLE标志必须在空闲中断中先读取一次USART-DR寄存器才能清除IDLE中断标志否则会连续进入中断。这是STM32的一个硬件特性。缓冲区大小循环接收缓冲区要足够大至少要能容纳一帧最大的可能数据量否则会发生数据覆盖。如果数据帧很长要考虑使用更大的内存或更高效的流控协议。数据一致性在计算recv_len和复制数据的过程中DMA可能仍在后台写入新的数据。对于要求严格的应用可以在处理前暂时关闭DMA通道或使用双缓冲区。3.3 SPI与I2S的DMA应用以驱动LCD为例对于驱动像SPI接口的LCD屏如ST7789、ILI9341使用DMA发送显存数据可以极大提高刷屏效率实现流畅的动画效果。设计思路将整个屏幕的帧缓冲区Frame Buffer放在内存中例如一个uint16_t fb[240][320]的数组。配置SPI和对应的DMA通道例如SPI1_TX用DMA1_Channel3为存储器到外设模式。当需要刷新屏幕时将帧缓冲区的起始地址和总像素数如240*320配置给DMA启动传输。DMA会自动将帧缓冲区中的每一个16位像素数据通过SPI数据寄存器发送出去。CPU在此期间可以准备下一帧的图像数据。关键配置与优化技巧数据宽度SPI数据寄存器通常是8位或16位需要根据LCD控制器要求设置。如果LCD支持16位数据一次发送则配置DMA为半字传输效率更高。内存到外设流控SPI的发送速度必须与DMA的读取速度匹配。要确保SPI的时钟配置正确并且DMA请求的优先级足够高避免SPI等待数据而出现“断流”。使用传输完成中断在DMA传输完成中断中可以置位一个标志通知主程序“一帧数据已发送完毕”此时可以安全地修改帧缓冲区准备下一帧避免撕裂效应。对于更复杂的GUI如LVGLLVGL库本身支持Frame Buffer和DMA加速。你需要为LVGL配置一个专用的显示缓冲区并实现flush_cb回调函数。在这个回调函数里启动DMA将LVGL的显示缓冲区数据搬运到LCD。这样LVGL负责渲染DMA负责搬运CPU占用率极低。4. DMA高级应用与疑难问题排查实录掌握了基础应用后我们来看看更高级的用法和那些让人头疼的常见问题。4.1 双缓冲区乒乓缓冲区机制在ADC连续高速采集或音频处理等场景为了避免处理数据时被新数据覆盖需要使用双缓冲区。原理分配两倍大小的内存区BufferA和BufferB。DMA配置为循环模式但传输总数据量设置为单个缓冲区大小。利用DMA的“半传输完成中断”HT传输到一半时触发和“传输完成中断”TC全部传输完时触发。当DMA填充BufferA到一半时实际上开始填充BufferB触发HT中断此时CPU可以安全地处理整个BufferA因为DMA正在写BufferB。当DMA填满BufferB即完成一轮传输时触发TC中断此时CPU可以安全地处理整个BufferB因为DMA会循环回来开始写BufferA。 如此往复实现了数据生产和消费的无冲突并行。配置关键DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_BufferSize BUFFER_SIZE; // 单个缓冲区大小 // 使能中断 DMA_ITConfig(DMA1_Channel1, DMA_IT_HT | DMA_IT_TC, ENABLE);在中断服务程序中根据DMA_GetITStatus判断是HT还是TC从而确定当前可读的缓冲区是A还是B。4.2 DMA与中断的协同与优先级管理DMA虽然减轻了CPU负担但并非完全不用CPU。DMA的传输开始、停止、状态查询、错误处理以及双缓冲区中的数据交换都需要CPU介入通常通过中断进行。中断源DMA通道有4个主要中断事件传输完成TC、半传输完成HT、传输错误TE、FIFO错误FE。需要根据需求使能。优先级冲突如果DMA服务的外设如ADC、UART本身也有中断而DMA传输完成中断的优先级低于该外设中断可能会发生问题。例如ADC转换完成触发DMA请求DMA搬运数据。如果ADC的EOC转换结束中断优先级很高它可能频繁打断DMA传输或后续处理中断影响系统实时性。策略通常将DMA传输完成中断的优先级设置为高于其服务的外设中断优先级确保DMA能及时响应请求并完成搬运。在NVIC中仔细规划中断优先级分组和子优先级。4.3 常见问题排查技巧速查表以下是我在多年调试中总结的DMA相关问题及排查思路问题现象可能原因排查步骤与解决方案DMA根本不传输数据1. 外设时钟或DMA时钟未开启。2. 外设的DMA请求未使能如ADC_DMACmd、USART_DMACmd。3. DMA通道未使能DMA_Cmd。4. 传输计数器CNDTR为0。1. 检查RCC_AHBPeriphClockCmd和对应外设的时钟函数是否调用。2. 仔细检查外设库函数确认已使能DMA请求。3. 确认DMA_Cmd在正确时机被调用。4. 在启动前检查并正确设置DMA_InitStructure.DMA_BufferSize。数据错乱高低字节颠倒、数据不对1. 数据宽度配置错误外设、内存、DMA三者不匹配。2. 地址递增配置错误内存地址应递增外设地址通常不递增。3. 内存缓冲区地址不对齐。1. 核对数据手册确认外设寄存器宽度。确保DMA_PeripheralDataSize、DMA_MemoryDataSize和缓冲区数据类型一致。2. 检查DMA_PeripheralInc和DMA_MemoryInc。3. 确保数组或内存地址符合数据宽度的对齐要求。对于半字地址末位应为0对于字地址末两位应为00。只能传输一次不能循环1. 循环模式未使能DMA_Mode未设置为Circular。2. 在传输完成中断中错误地禁用了DMA通道。1. 检查DMA_InitStructure.DMA_Mode。2. 检查中断服务程序除非必要不要在TC中断里禁用通道。循环模式下TC中断后DMA会自动重载计数器继续运行。传输不完整提前停止1. 传输过程中发生了DMA错误如FIFO错误、总线错误。2. 被更高优先级的DMA通道或中断长时间抢占。1. 使能DMA错误中断在中断中检查标志位。2. 检查NVIC优先级配置确保当前DMA通道优先级合理。对于高速连续传输可适当提高其优先级。使用存储器到存储器模式无效1. 存储器到存储器模式需要软件触发。2. 未正确设置软件触发并使能。1. 配置DMA_InitStructure.DMA_M2M DMA_M2M_Enable。2. 调用DMA_Cmd使能通道后还需要设置DMAx-CCRx寄存器中的EN位通常DMA_Cmd已包含但更重要的是需要手动设置软件触发对于DMA1通过DMA_Cmd使能后即开始传输这里需注意标准库中M2M模式使能通道后自动开始。HAL库可能需要调用HAL_DMA_Start。仔细阅读库函数说明。一个最隐蔽的坑外设寄存器访问冲突DMA在搬运数据时会直接通过总线访问外设的数据寄存器如ADC-DR。与此同时如果CPU也去读取这个寄存器就会发生访问冲突可能导致数据读取错误或DMA传输异常。因此在DMA传输期间应避免通过CPU代码直接访问被DMA占用的外设数据寄存器。对于ADC如果要读取结果请直接从DMA的目标内存数组中读取。这是一个非常重要的编程纪律。5. 从标准库到HAL库与CubeMX的DMA配置迁移现在很多新项目使用STM32CubeMX和HAL库进行开发。其DMA配置的核心思想与标准库完全一致只是操作接口不同。在CubeMX中配置DMA的直观优势图形化通道选择在USART、ADC等外设的配置页直接勾选“DMA”标签页CubeMX会自动列出可用的DMA通道你只需要点击“Add”并选择方向RX/TX即可无需记忆通道映射表。参数可视化配置在弹出的DMA配置窗口中可以直观地设置模式Normal/Circular、优先级、数据宽度、地址递增等减少了手动编写初始化结构体的出错概率。代码生成生成代码后HAL库已经为你初始化好了DMA句柄并关联了外设。你只需要调用相应的HAL函数来启动传输。HAL库DMA启动函数示例USART发送// 假设已通过CubeMX配置好UART1的TX DMA uint8_t tx_data[] Hello DMA!; // 启动DMA传输 if (HAL_UART_Transmit_DMA(huart1, tx_data, sizeof(tx_data)-1) ! HAL_OK) { // 错误处理 } // 传输完成后会进入HAL_UART_TxCpltCallback回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart-Instance USART1) { // 发送完成可进行下一步操作 } }HAL库的注意事项状态管理HAL库有严格的状态机。在DMA传输完成前再次调用HAL_UART_Transmit_DMA可能会返回HAL_BUSY。需要做好状态判断。回调函数充分利用xxxCpltCallback完成回调、xxxHalfCpltCallback半完成回调等弱定义函数在其中添加你的应用逻辑保持代码整洁。中断处理HAL库将DMA中断和外设中断进行了整合处理用户一般不需要直接操作DMA或外设的中断服务程序只需重写回调函数。从标准库切换到HAL/CubeMX最大的变化是从直接操作寄存器结构体转变为使用封装度更高的句柄和API。理解底层DMA原理即本文前面所讲的内容能让你在使用HAL库时更加得心应手遇到问题也能快速定位到底层原因而不是停留在API调用层面。最后关于国产替代芯片如GD32的DMA其基本原理和STM32高度相似寄存器设计和库函数也大多兼容。但在细微之处如时钟树、某些外设的DMA请求映射、FIFO深度等方面可能存在差异。在迁移代码时务必仔细阅读新芯片的参考手册和数据手册重点关注“DMA控制器”和具体外设章节中关于DMA请求的说明并进行针对性测试。掌握了STM32F103C8T6的DMA精髓你就能快速地将这份知识迁移到几乎任何一款现代MCU上。