STM32F103C8T6 DMA配置全解析:从原理到实战应用

📅 2026/7/31 13:10:12 👁️ 阅读次数 📝 编程学习
STM32F103C8T6 DMA配置全解析:从原理到实战应用

1. 从“搬运工”到“甩手掌柜”:DMA在STM32中的角色转变

如果你玩过STM32F103C8T6,或者任何一款STM32,肯定对“CPU占用率”这个词不陌生。当你用轮询方式去读一个串口数据,或者用ADC连续采集时,主循环就像被堵住了一样,其他任务根本动不了。中断(IT)是个好办法,它让CPU可以“随叫随到”,但每次中断来临,CPU都得停下手中的活,去保存现场、处理数据、恢复现场,对于高速、大批量的数据传输,频繁的中断开销依然可观。

这时候,DMA就该登场了。你可以把它想象成一个极其专业、且不知疲倦的“数据搬运工”。它的全称是直接存储器访问,核心思想就一句话:让数据在存储器和外设之间“自动”搬家,完全不需要CPU的干预。CPU只需要在搬家前,告诉DMA搬运工:“从A地址搬N个数据到B地址,搬完了叫我一声”,然后就可以潇洒地去执行其他代码了。等DMA搬完了,通过一个中断通知CPU:“老板,活干完了,数据在B地址,您查收一下。”

对于STM32F103C8T6这颗经典的“蓝桥杯神器”来说,理解并用好DMA,是项目性能从“能用”到“高效”的关键一步。无论是做四轴飞控需要高速读取IMU传感器数据,还是做示波器需要ADC连续采样,或是做无线通信需要处理大量串口数据,DMA都能大幅解放CPU,让你的程序响应更迅速、架构更清晰。接下来,我们就深入这个“超级搬运工”的内部,看看它是怎么工作的,以及如何在C8T6上把它用起来。

2. STM32F103C8T6的DMA架构与通道资源剖析

STM32F103C8T6内部集成了两个DMA控制器:DMA1和DMA2。但需要注意的是,C8T6只有DMA1。DMA2是更高端型号(如F103xC/D/E)才具备的。所以,我们的所有讨论都基于DMA1。

DMA1拥有7个独立的通道(Channel 1~7)。每个通道都可以被配置为服务于一个特定的外设请求。这里“外设请求”是关键,它意味着DMA的传输是由外设触发的。比如,当ADC转换完成、串口收到数据、定时器产生更新事件时,这些外设会向DMA控制器发出一个“请求信号”,DMA控制器收到后,就会指挥对应的通道开始一次数据传输。

通道与外设的映射关系是固定的,不能随意指派。这是硬件设计时决定的。对于DMA1,其通道分配大致如下(这是你需要牢记的):

  • 通道1:ADC1, TIM2_CH3, TIM4_CH1
  • 通道2:SPI1_RX, USART3_TX, TIM1_CH1, TIM2_UP, TIM3_CH3
  • 通道3:SPI1_TX, USART3_RX, TIM1_CH2, TIM3_CH4, TIM3_UP
  • 通道4:SPI2_RX, USART1_TX, TIM1_CH4, TIM3_CH1, TIM4_CH2
  • 通道5:SPI2_TX, USART1_RX, TIM1_UP, TIM4_CH3
  • 通道6:USART2_RX, TIM1_CH3, TIM3_CH2, TIM4_UP
  • 通道7:USART2_TX, TIM2_CH1, TIM2_CH2, TIM4_CH4

注意:这个列表并非完全详尽,且不同型号的STM32F1可能会有细微差别。最权威的参考永远是芯片的参考手册(Reference Manual)中的DMA章节。以USART1为例,它的发送(TX)固定使用DMA1的通道4,接收(RX)固定使用通道5。如果你试图用通道1去服务USART1_TX,硬件上是不工作的。

为什么通道资源如此重要?因为它是稀缺的。C8T6只有7个通道,但你可能同时需要ADC多通道DMA采集、串口收发、SPI读写Flash等。这就涉及到通道冲突的问题:一个通道在同一时间只能服务于一个外设。例如,如果你同时使用了USART1_TX(通道4)和SPI2_RX(也是通道4),那就产生了冲突,必须分时复用或重新设计数据流。

DMA传输的三大要素:无论配置哪个通道,你都需要明确告诉DMA三件事:

  1. 源地址:数据从哪里来?是某个外设的数据寄存器(如USART1->DR),还是内存中的一个数组?
  2. 目标地址:数据到哪里去?是到外设的数据寄存器,还是到内存?
  3. 传输数量:要搬多少数据?这个数量会随着传输的进行而递减。

DMA控制器内部有专门的寄存器来存储这些信息,并且在传输过程中自动管理地址的递增或递减。

3. DMA工作模式详解:单次、循环与存储器到存储器的奥秘

理解了通道,下一步就是配置DMA的工作模式。这是DMA灵活性的体现,主要分为以下几种:

3.1 外设到存储器 / 存储器到外设模式

这是最常用的模式,也是DMA设计的初衷。

  • 外设到存储器:典型应用是ADC多通道DMA采集。ADC转换完成的数据寄存器(ADC1->DR)是源地址(外设),你定义在内存中的一个数组adc_buffer[ ]是目标地址。DMA会在每次ADC转换完成后,自动把数据搬到数组里。
  • 存储器到外设:典型应用是串口DMA发送。你要发送的数据存在内存数组tx_buffer[ ]里(源地址),DMA会逐个搬移到串口的数据寄存器(USART1->DR)中,由串口硬件发送出去。对于发送大量数据(如发送一帧图片数据到串口屏)特别有用。

在这两种模式下,地址指针的递增行为需要仔细配置:

  • 外设地址:通常不递增。因为像USART1->DR这种寄存器,你总是往同一个地址读写数据。
  • 存储器地址:通常递增。因为你希望数据依次存放到数组的连续位置,或者从数组的连续位置取出。

3.2 循环模式 vs 单次模式

这是控制DMA传输“轮次”的关键。

  • 单次模式:DMA在完成指定数量的传输后,会自动关闭通道(EN位被清零)。如果你想再次传输,必须由软件重新使能DMA通道。适用于你知道确切传输量的场景,比如发送一个固定长度的数据包。
  • 循环模式:DMA在完成指定数量的传输后,会自动将传输计数器地址指针(根据配置是否回绕)重新加载为初始值,然后开始新一轮传输。这个过程完全由硬件完成,无需软件干预。这是实现连续、无间断数据流的利器。
    • ADC连续采集:必须使用循环模式。配置一个足够大的缓冲区(比如adc_buffer[1000]),设置传输数量为1000,并使能循环模式。DMA就会在缓冲区里循环填充ADC数据,即使CPU很久才来读取一次,最新的数据也永远在缓冲区里,不会丢失。
    • 串口DMA接收不定长数据:这是一个经典组合“串口空闲中断 + DMA循环接收”。将DMA接收配置为循环模式,指向一个环形缓冲区。串口每收到一个字节,DMA就自动存到缓冲区并更新指针。当一帧数据结束,串口产生空闲中断,在中断服务程序里,通过计算DMA当前传输剩余值,就能推算出这一帧收到了多少数据,然后进行处理。这种方法几乎零CPU开销,且能处理任意长度的数据帧。

3.3 存储器到存储器模式

这是DMA1的一个特殊功能,允许数据在两个内存区域之间直接搬运,比如从一个数组复制到另一个数组。配置时,需要将其中一个内存地址配置为“外设地址”(虽然它并不是外设)。

重要限制:在STM32F103的DMA1上,只有通道1~3支持存储器到存储器模式。通道4~7不支持。如果你需要此功能,务必选择正确的通道。

使用场景:当需要复制大量数据时(比如图像缓冲区的复制),使用DMA比用CPU的memcpy函数效率高得多,因为不占用CPU时间和总线带宽(DMA使用自己的总线矩阵)。

4. 核心配置流程与寄存器/库函数操作指南

无论是使用标准外设库(SPL)还是HAL库,配置DMA的思路都是一致的。这里我们以HAL库为例,因为它更通用,且与STM32CubeMX工具无缝衔接。我们以实现“USART1使用DMA发送不定长数据”和“ADC1多通道循环DMA采集”两个典型场景来讲解。

4.1 场景一:USART1 DMA发送

目标:将内存中的一段数据通过USART1发送出去,期间CPU不参与数据搬运。

CubeMX图形化配置

  1. Connectivity中使能USART1,模式选择Asynchronous
  2. DMA Settings标签页,点击Add,选择USART1_TX。方向(Direction)自动为Memory To Peripheral
  3. 配置DMA参数:
    • Mode:Normal(单次模式)。如果发送连续流数据可选Circular
    • Priority:Medium。根据系统实时性要求调整。
    • Increment Address: 对于Memory侧选Enabled(地址递增),Peripheral侧选Disabled

代码实现关键点

// 1. 定义发送缓冲区和长度 uint8_t tx_data[] = "Hello DMA!\r\n"; uint16_t tx_len = sizeof(tx_data) - 1; // 去掉字符串结尾的'\0' // 2. 启动DMA传输 HAL_UART_Transmit_DMA(&huart1, tx_data, tx_len); // 调用此函数后,CPU立即返回,后续工作由DMA接管 // 3. (可选)查询或中断通知发送完成 // 方式A:轮询查询 if(HAL_DMA_GetState(&hdma_usart1_tx) == HAL_DMA_STATE_READY) { // 发送完成 } // 方式B:使用发送完成中断(需在CubeMX中使能DMA全局中断) // 在stm32f1xx_it.c中,找到DMA1_Channel4_IRQHandler,HAL库会自动调用回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { // USART1 DMA发送完成,可以准备下一包数据或进行其他操作 } }

避坑经验

  • 数据一致性:在DMA传输进行中,绝对不要修改源数据缓冲区(tx_data)的内容。因为DMA可能才搬了一半。如果需要修改,要么等传输完成回调触发后再操作,要么使用双缓冲区(Ping-Pong Buffer)机制。
  • 总线冲突:DMA和CPU都要访问内存和总线。如果CPU和DMA频繁访问同一块内存区域(尤其是紧挨着的地址),可能会导致性能下降。合理安排数据布局可以缓解。
  • 中断优先级:如果系统中有多个中断(如定时器中断、ADC中断等),需要合理设置DMA传输完成中断的优先级,避免高优先级任务被阻塞。

4.2 场景二:ADC1多通道循环DMA采集

目标:连续采集ADC1的通道0(PA0)和通道1(PA1)的电压值,并存放到数组中。

CubeMX图形化配置

  1. Analog中使能ADC1
  2. Parameter Settings中:
    • Scan Conversion Mode:Enabled(扫描模式,用于多通道)。
    • Continuous Conversion Mode:Enabled(连续转换模式)。
    • DMA Continuous Requests:Enabled(使能DMA连续请求,这是循环采集的关键)。
    • Number Of Conversion: 设置为2。
    • Rank中配置Channel 0Channel 1,并设置采样时间。
  3. DMA Settings标签页,点击Add,选择ADC1。方向自动为Peripheral To Memory
  4. 配置DMA参数:
    • Mode:Circular(循环模式)。
    • Increment Address:MemoryEnabledPeripheralDisabled
    • Data Width: 根据ADC分辨率选择Word(12位ADC结果用16位存储即可,但对齐为半字或字)。

代码实现关键点

// 1. 定义ADC采集缓冲区 // 假设采集2个通道,缓冲区深度为100组数据 #define ADC_CHANNEL_NUM 2 #define ADC_BUFFER_DEPTH 100 uint16_t adc_buffer[ADC_BUFFER_DEPTH][ADC_CHANNEL_NUM]; // 二维数组,便于理解 // 2. 启动ADC的DMA采集 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_CHANNEL_NUM * ADC_BUFFER_DEPTH); // 第二个参数是目标地址,第三个参数是总的数据单元数量(通道数*深度) // 3. 此后,adc_buffer会被DMA自动、循环地填充 // 例如,adc_buffer[0][0]是第0次采集的通道0数据,adc_buffer[0][1]是第0次采集的通道1数据 // adc_buffer[1][0]是第1次采集的通道0数据,以此类推。 // 4. 在程序任何需要的地方,直接读取adc_buffer即可获得最新数据 // 为了知道当前DMA写到了哪个位置,可以使用DMA的当前剩余数据寄存器(CNDTR)来推算 uint32_t remaining = __HAL_DMA_GET_COUNTER(&hdma_adc1); // 获取剩余传输次数 uint32_t index = (ADC_CHANNEL_NUM * ADC_BUFFER_DEPTH - remaining) / ADC_CHANNEL_NUM; // index 指示了当前最新写完的一组数据的索引(可能不是最新的,因为DMA在循环) // 更稳健的做法是使用双缓冲区或记录“写指针”。

高级技巧:双缓冲区(Ping-Pong Buffer)对于实时性要求高的处理(如音频处理、数字滤波),可以在DMA传输完成一半和全部完成时都触发中断,实现双缓冲区交换。这样,当DMA在填充缓冲区A时,CPU可以处理缓冲区B的数据,实现并行处理,几乎零等待。HAL库提供了HAL_ADC_ConvHalfCpltCallback(半传输完成)和HAL_ADC_ConvCpltCallback(全传输完成)两个回调函数来支持此模式。

5. 常见问题排查与实战调试心得

即使配置看起来正确,DMA也常常会“沉默”,不工作。以下是我在项目中总结的排查清单和调试方法。

5.1 DMA不启动或数据不传输

  1. 时钟检查:DMA控制器本身需要时钟。在STM32F1中,DMA时钟在AHB总线上。使用CubeMX配置时通常会自动开启,但如果是手动初始化,务必确认RCC_AHBPeriph_DMA1时钟已使能。

    __HAL_RCC_DMA1_CLK_ENABLE(); // HAL库 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 标准库
  2. 通道绑定错误:这是最常见的问题。反复核对参考手册中DMA请求映射表,确认你使用的外设请求(如USART1_TX)是否真的映射到了你初始化的DMA通道(DMA1_Channel4)上。用错了通道,DMA永远收不到外设的触发信号。

  3. 外设DMA请求未使能:仅仅配置了DMA还不够,必须在外设内部开启DMA功能。

    • 对于USART:需要设置USART_CR3寄存器中的DMAT(发送DMA使能)或DMAR(接收DMA使能)位。HAL库中HAL_UART_Transmit_DMA函数内部会做这个操作。
    • 对于ADC:需要设置ADC_CR2寄存器中的DMA位(对于ADC1)或DDS位(对于DMA连续请求)。HAL库中HAL_ADC_Start_DMA会处理。
    • 对于SPI/I2C:同样有对应的DMA使能位。
  4. 传输完成中断(TCIF)未清除:如果上一次传输完成后,传输完成中断标志没有被清除,新的传输请求可能会被忽略。在标准库中,需要在中断服务程序里手动清除标志位(DMA_ClearITPendingBit)。在HAL库中,中断处理框架通常会帮你清除,但如果你是自己写的底层驱动,这一点务必检查。

5.2 数据错位、重复或丢失

  1. 地址递增配置错误:这是导致数据错位的元凶。仔细检查DMA_CCRx寄存器中的PINC(外设地址递增)和MINC(存储器地址递增)位。对于外设到存储器,通常是PINC=0MINC=1。如果MINC设成了0,所有ADC采样值都会堆积在缓冲区的第一个地址,覆盖之前的数据。

  2. 数据宽度不匹配:源地址和目标地址的数据宽度必须匹配。例如,ADC是12位分辨率,结果寄存器是16位(低12位有效)。如果你配置DMA的数据宽度为Byte(8位),那么一次传输只能搬走低8位,数据就错乱了。通常配置为HalfWord(16位)即可。

  3. 缓冲区溢出(Overrun):在循环模式下,如果CPU读取数据的速度跟不上DMA写入的速度,新数据就会覆盖还未被处理的老数据。解决方案:一是加大缓冲区深度;二是提高CPU处理数据的频率(例如使用定时器中断定期处理);三是采用双缓冲区机制。

  4. 外设FIFO与DMA的协同问题:某些外设(如USART)有自己的小容量FIFO。当DMA传输速度极快时,需要关注外设的FIFO状态,避免数据被覆盖。STM32F103的USART FIFO很浅,通常不是问题,但在更高端的系列或高速通信时需要留意。

5.3 调试利器:活用调试器

  1. 查看DMA寄存器:在IDE(如Keil MDK)的调试模式下,打开Peripherals -> DMA -> DMA1窗口,可以实时查看每个通道的控制寄存器(CCR)、数据数量寄存器(CNDTR)、外设/存储器地址寄存器(CPAR/CMAR)的值。这是判断DMA是否被正确使能、传输是否在进行、计数器是否在递减的最直接方法。

  2. 观察内存数据:在Memory窗口中,输入你定义的DMA缓冲区地址(如&adc_buffer),可以实时看到数据是否被正确写入。结合单步执行,可以判断是DMA没写,还是CPU误写了。

  3. 断点与中断:在DMA传输完成中断的服务函数里设置断点。如果传输完成但断点从未触发,说明中断未正确配置或使能。如果断点触发,但内存数据不对,问题可能出在数据传输环节。

我个人最深刻的教训:曾经在一个项目中,ADC DMA采集的数据总是偶尔跳变。排查了很久,最后发现是电源噪声。模拟部分(ADC参考电压、传感器供电)的轻微波动,被高速采样的ADC捕捉到,再经过DMA放大,在数据上就表现为随机跳变。所以,当DMA硬件层面排查无误后,如果数据仍有问题,别忘了回头检查你的模拟电路、电源质量和接地。DMA只是一个忠实的搬运工,它不生产数据,它只是数据的搬运工。如果源头“污染”了,搬出来的也只能是“垃圾”。