STM32 DMA实战:ADC连续采样与UART发送的完整配置指南
2026/8/4 11:59:37 网站建设 项目流程

最近在嵌入式开发中,经常需要处理大量数据在内存与外设(如ADC、DAC、UART)之间的搬运。如果全靠CPU搬运,会严重消耗其算力,导致系统响应变慢。DMA(直接存储器访问)技术就是为了解决这个痛点而生的,它允许外设与内存之间直接进行数据传输,无需CPU干预,从而解放CPU去处理更复杂的任务。

本文将以STM32系列微控制器为例,深入讲解DMA的工作原理、配置流程,并提供一个从零开始的完整实战案例:使用DMA实现ADC连续采样并通过串口DMA发送数据。无论你是刚接触STM32的新手,还是希望优化现有项目性能的开发者,都能通过本文掌握DMA的核心配置技巧和避坑指南。

1. DMA核心概念与工作原理

1.1 什么是DMA?

DMA,全称Direct Memory Access,即直接存储器访问。它是一种允许特定硬件子系统(外设)独立于中央处理器(CPU)直接读写系统内存的技术。你可以把它想象成一个高效的“数据搬运工”。

在没有DMA的情况下,当ADC转换完成一个数据,CPU需要执行一系列指令:从ADC数据寄存器读取值 -> 将这个值存入某个内存变量 -> 判断是否发送 -> 将变量值写入UART数据寄存器。这个过程每一步都占用CPU周期。

而启用DMA后,你只需要告诉DMA控制器:“请把ADC数据寄存器(源地址)的数据,自动搬运到内存中的数组(目标地址),每搬运完一个就告诉我一声”。之后,ADC每转换一次,DMA就自动完成一次搬运,CPU完全不用管,可以继续执行主循环或其他中断服务程序。

1.2 DMA在STM32中的架构与通道

在STM32中,DMA控制器是一个独立的外设。以STM32F1系列为例,它通常有1-2个DMA控制器(DMA1, DMA2),每个控制器有多个通道(Channel)。

  • 通道(Channel):每个通道关联到一个或多个特定的外设(如ADC1、USART1_TX、TIM2_CH1等)。通道是DMA传输的执行单元,一次传输只能使用一个通道。
  • 仲裁器(Arbiter):当多个通道同时请求DMA传输时,仲裁器根据优先级(软件可配置)来决定哪个通道先执行。
  • 数据流(Stream):在更高级的系列(如STM32F4/F7/H7)中,引入了“数据流(Stream)”的概念,一个数据流可以映射到多个通道,提供了更灵活的配置。

一次DMA传输涉及三个关键地址:

  1. 外设地址:数据来源或去向的外设数据寄存器地址(如&ADC1->DR)。
  2. 存储器地址:数据在内存中的地址(如一个数组adc_buffer的地址)。
  3. 传输数量(NDTR):需要传输的数据项数量。

1.3 DMA传输模式与方向

DMA传输主要有以下几种模式,理解它们对正确配置至关重要:

  • 传输方向

    • 外设到存储器:例如,ADC采样数据存入内存数组。
    • 存储器到外设:例如,从内存数组发送数据到UART。
    • 存储器到存储器:例如,在两个内存区域(如数组间)快速拷贝数据。注意,不是所有型号的DMA都支持此模式。
  • 传输模式

    • 单次传输(Single):DMA在传输完指定数量的数据后自动停止,需要重新使能才能进行下一次传输。
    • 循环传输(Circular):DMA在传输完指定数量的数据后,自动重置传输计数器并重新开始,形成一个“乒乓”缓冲区,非常适合连续数据流(如音频采集、持续ADC采样)。
  • 数据宽度与对齐:需要配置外设和存储器的数据宽度(8位、16位、32位),两者可以不同,DMA会自动处理数据打包/解包,但需注意地址对齐问题。

  • 增量模式:决定每次传输后,外设地址和/或存储器地址是否自动增加。例如,从ADC读数据,外设地址(ADC数据寄存器地址)通常固定;而写入内存数组时,存储器地址需要递增。

2. 开发环境与硬件准备

2.1 硬件平台与软件工具

  • 主控芯片:本文以STM32F103C8T6(蓝桥杯常用核心板)为例,其DMA1有7个通道。原理同样适用于其他STM32系列,但寄存器或HAL库函数名可能略有不同。
  • 开发环境
    • IDE:Keil MDK-ARM (uVision5) 或 STM32CubeIDE。
    • 开发库:本文采用STM32CubeMX图形化配置工具结合HAL库进行开发,这种方式直观且可移植性强。
    • 调试工具:ST-LINK V2 或 J-Link。
  • 外设使用
    • ADC1:用于模拟信号采样,使用通道0(PA0)。
    • USART1:用于打印采样数据,TX引脚为PA9,RX引脚为PA10。
    • DMA1:使用通道1服务于ADC1,通道4服务于USART1_TX。

2.2 使用STM32CubeMX创建工程

  1. 新建项目:打开STM32CubeMX,选择MCU型号STM32F103C8Tx
  2. 配置时钟:在RCC配置中,将高速外部时钟(HSE)设置为Crystal/Ceramic Resonator。然后在Clock Configuration标签页,将系统时钟(SYSCLK)配置为72MHz(这是F103的最高主频)。
  3. 配置ADC
    • Analog下拉菜单中,找到ADC1
    • 启用IN0通道(对应PA0)。
    • 在右侧参数设置中:
      • Scan Conversion Mode:Enabled(扫描模式,为多通道预留)。
      • Continuous Conversion Mode:Enabled(连续转换模式)。
      • DMA Continuous Requests:Enabled(使能DMA连续请求)。
      • End of Conversion Selection:EOC flag at the end of single conversion
    • Rank中设置采样时间,例如Cycles 55.5以获得较好精度。
  4. 配置DMA for ADC
    • 点击DMA Settings->Add
    • 选择ADC1
    • 配置模式:
      • Mode:Circular(循环模式,实现连续采样)。
      • Direction:Peripheral To Memory
      • Increment Address:MemoryYes(内存地址递增),PeripheralNo(外设地址固定)。
      • Data Width:PeripheralMemory都选Word(32位,因为ADC是12位,HAL库用32位变量存储)。
  5. 配置USART
    • Connectivity中选择USART1
    • 模式选择Asynchronous(异步通信)。
    • 参数默认(波特率115200, 8位数据,无校验,1停止位)。
  6. 配置DMA for USART_TX
    • 再次进入DMA Settings->Add
    • 选择USART1_TX
    • 配置模式:
      • Mode:Normal(单次模式,发送完指定数量数据后停止)。
      • Direction:Memory To Peripheral
      • Increment Address:MemoryYesPeripheralNo
      • Data Width:Byte(UART通常按字节发送)。
  7. 生成代码
    • Project Manager标签页设置项目名称、路径、IDE(MDK-ARM V5)。
    • Code Generator中,选择“为每个外设生成独立的.c/.h文件”。
    • 点击GENERATE CODE生成工程。

3. HAL库DMA关键函数与配置解析

生成的代码基于HAL库,它封装了底层寄存器操作。以下是几个核心的DMA相关HAL函数:

3.1 启动DMA传输的函数

// 启动ADC的DMA传输 // hadc: ADC句柄指针 // pData: 目标内存缓冲区地址 // Length: 传输数据长度(单位取决于数据宽度) HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef* hadc, uint32_t* pData, uint32_t Length); // 启动UART的DMA发送 // huart: UART句柄指针 // pData: 源内存缓冲区地址 // Size: 发送数据长度(字节) HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef* huart, uint8_t* pData, uint16_t Size);

3.2 DMA传输回调函数

DMA传输完成、半传输完成或发生错误时,会触发中断,并调用相应的回调函数。我们需要在用户代码中重写(Weak)这些函数以添加自己的处理逻辑。

// DMA传输完成回调(在stm32f1xx_hal_adc.c中已定义为__weak) void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc); void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc); // 半传输完成 // UART DMA发送完成回调(在stm32f1xx_hal_uart.c中) void HAL_UART_TxCpltCallback(UART_HandleTypeDef* huart);

循环模式下的缓冲区管理:当ADC DMA配置为循环模式时,HAL_ADC_ConvHalfCpltCallbackHAL_ADC_ConvCpltCallback会交替被调用。这是一种高效的“乒乓缓冲区”策略。例如,你定义一个大小为N的数组adc_buf[N]

  • 当DMA填满前半部分(adc_buf[0]adc_buf[N/2-1])时,触发半传输完成中断,你可以安全地处理这前半部分数据。
  • 当DMA继续填满后半部分(adc_buf[N/2]adc_buf[N-1])时,触发传输完成中断,你可以处理后半部分数据。
  • 与此同时,DMA会自动回到数组开头重新开始填充,如此循环。这保证了数据处理和数据采集的并发进行,没有数据丢失。

4. 完整实战:ADC DMA采样与UART DMA发送

接下来,我们实现一个经典场景:使用DMA循环采集ADC数据,并在主循环中定时通过DMA将一批数据发送到串口助手显示。

4.1 定义全局变量与缓冲区

main.c文件开始处,用户代码区域添加以下定义:

/* USER CODE BEGIN PV */ #define ADC_BUFFER_SIZE 100 // ADC DMA缓冲区大小 uint32_t adc_dma_buffer[ADC_BUFFER_SIZE]; // ADC DMA目标缓冲区 uint8_t uart_tx_buffer[ADC_BUFFER_SIZE * 5]; // UART发送缓冲区,需要将数值转换为字符串 volatile uint8_t adc_data_ready = 0; // 数据就绪标志 uint32_t adc_data_index = 0; // 用于标记已处理的数据位置 /* USER CODE END PV */

4.2 启动ADC DMA采集

main函数的/* USER CODE BEGIN 2 */区域,启动ADC的DMA采集:

/* USER CODE BEGIN 2 */ // 启动ADC,并指定DMA传输的目标地址和长度 if (HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE) != HAL_OK) { Error_Handler(); // 启动失败,进入错误处理 } /* USER CODE END 2 */

4.3 实现ADC DMA传输完成回调

main.c文件中找到/* USER CODE BEGIN 4 */区域,添加回调函数。这里我们使用传输完成回调来设置标志位。

/* USER CODE BEGIN 4 */ /** * @brief ADC转换完成回调函数(DMA模式) * @param hadc: ADC句柄 * @retval None */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc->Instance == ADC1) { adc_data_ready = 1; // 设置数据就绪标志 // 注意:在循环模式下,这个回调会在缓冲区被完全填满时调用。 // 此时DMA已经回到缓冲区开头继续填充,所以我们需要尽快处理数据或拷贝数据。 } } /* USER CODE END 4 */

4.4 主循环处理与UART DMA发送

main函数的while (1)循环中,我们检查标志位,处理数据并发送。

/* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ if(adc_data_ready) { adc_data_ready = 0; // 清除标志 // 1. 将ADC原始值(0-4095)转换为电压值字符串(假设Vref=3.3V) // 为了演示,我们只处理前10个数据并发送 int offset = 0; for(int i = 0; i < 10 && i < ADC_BUFFER_SIZE; i++) { float voltage = (adc_dma_buffer[i] * 3.3f) / 4095.0f; offset += sprintf((char*)&uart_tx_buffer[offset], "ADC[%d]: %.3fV\r\n", i, voltage); } offset += sprintf((char*)&uart_tx_buffer[offset], "----\r\n"); // 2. 通过DMA发送字符串到UART // 注意:HAL_UART_Transmit_DMA是非阻塞的,函数调用后立即返回。 // 在上一次DMA发送完成前,不能再次调用此函数发送同一个huart实例,否则会导致错误。 // 这里我们简单处理,在实际项目中需要更严谨的状态管理。 if(HAL_UART_GetState(&huart1) == HAL_UART_STATE_READY) { HAL_UART_Transmit_DMA(&huart1, uart_tx_buffer, offset); } // 3. 添加一个简单延时,避免发送过于频繁 HAL_Delay(1000); // 延时1秒 } // 其他任务... } /* USER CODE END 3 */

4.5 编译、下载与验证

  1. 编译工程:点击IDE中的编译按钮,确保0错误,0警告。
  2. 连接硬件:将STM32核心板的PA0(ADC输入)连接到一个可调电位器的中间引脚,电位器两端接3.3V和GND。将PA9(UART_TX)连接到USB转TTL模块的RX,共地。
  3. 下载程序:使用ST-LINK将程序下载到芯片。
  4. 观察结果:打开串口助手(如XCOM, Putty),设置波特率115200。旋转电位器,你应该能看到串口每秒打印一次10个ADC通道的电压值。

5. 常见问题与排查思路

DMA配置相对复杂,容易遇到问题。下表列出了常见现象、原因及解决方法:

问题现象可能原因排查思路与解决方案
ADC DMA不启动,数据不变1. DMA或ADC时钟未使能。
2. DMA通道映射错误。
3.HAL_ADC_Start_DMA调用失败未检查。
4. 缓冲区地址或长度参数错误。
1. 在CubeMX中检查RCC配置,确保DMA和对应外设时钟已开启。
2. 核对数据手册,确认ADC1是否对应DMA1 Channel1(F1系列)。
3. 检查函数返回值,并进入Error_Handler调试。
4. 确保缓冲区是全局变量或静态变量,地址有效。
UART DMA发送一次后卡死1. 在上一次DMA发送完成前,再次调用了HAL_UART_Transmit_DMA
2. UART或DMA发生错误未清除。
1. 在发送前检查UART状态:if(HAL_UART_GetState(&huart1) == HAL_UART_STATE_READY)
2. 实现HAL_UART_ErrorCallback回调函数,打印或处理错误。考虑使用发送完成标志位或队列管理发送请求。
ADC采样数据错乱或偏移1. 数据宽度配置不一致。
2. 内存地址增量模式错误。
3. 缓冲区大小太小,DMA覆盖了未处理的数据。
1. 在CubeMX中确认ADC侧和Memory侧的数据宽度(通常都是Word)。
2. 确认ADC的DMA配置:Memory Increment = Yes, Peripheral Increment = No。
3. 增大缓冲区,或使用“乒乓缓冲区”策略(半传输中断),确保处理速度大于采集速度。
DMA传输完成中断不触发1. DMA传输完成中断未使能。
2. 中断优先级配置过低,被其他中断阻塞。
3. 回调函数未重写(名字拼写错误)。
1. 在CubeMX的DMA配置中,确认Transfer Complete Interrupt已勾选。
2. 在NVIC配置中调整DMA通道中断的优先级。
3. 确保回调函数名完全正确,且放在USER CODE BEGIN 4区域。
存储器到存储器DMA失败1. 芯片不支持此模式(如某些F1型号)。
2. 源或目标地址不是内存地址(如外设寄存器)。
3. 数据缓存(Cache)一致性问题(在带Cache的M7等内核上)。
1. 查阅芯片参考手册的DMA章节。
2. 确保地址是SRAM地址。
3. 对于M7,使用SCB_CleanDCache_by_Addr等函数维护Cache一致性。

6. 最佳实践与工程建议

掌握了基础操作后,以下建议能帮助你在实际项目中更稳健、高效地使用DMA。

  1. 精心设计缓冲区

    • 大小:缓冲区大小需权衡。太小会导致溢出或频繁中断,消耗CPU;太大会增加内存占用和数据处理延迟。根据采样率、处理速度和系统内存综合决定。
    • 对齐:对于高性能MCU(如STM32H7),考虑将DMA缓冲区进行字节对齐(如使用__attribute__((aligned(32)))),这有助于发挥DMA或Cache的最佳性能。
    • 多缓冲区:对于连续高速数据流,强烈建议使用双缓冲区甚至多缓冲区(乒乓缓冲区),配合半传输和全传输中断,实现无锁、无丢失的数据生产和消费。
  2. 妥善管理DMA状态

    • 避免重入:对于Normal模式的DMA传输(如UART发送),一定要在本次传输完成(回调函数被调用)后,才能启动下一次传输。使用状态标志位或队列来管理发送请求。
    • 错误处理:务必实现DMA和对应外设的错误回调函数(如HAL_UART_ErrorCallback),并在其中进行错误标志清除、日志记录或系统复位,提高系统健壮性。
  3. 优化中断与优先级

    • DMA中断频率可能很高。如果数据处理逻辑复杂,不要在DMA传输完成中断回调中执行耗时操作,应仅设置标志位,在主循环或更低优先级的任务中处理数据。
    • 合理配置NVIC中断优先级。确保关键DMA通道(如用于高速ADC或DAC)的中断优先级高于其他非实时性任务的中断。
  4. 注意数据一致性

    • 在启用数据缓存(D-Cache)的Cortex-M7等内核上,如果CPU和DMA会访问同一块内存区域,必须手动维护缓存一致性。DMA写入后,CPU读取前需无效化(Invalidate)对应缓存行;CPU写入后,DMA读取前需清理(Clean)对应缓存行。
  5. 混合使用查询与中断

    • 不是所有场景都需要DMA中断。对于低速、非连续的数据,可以使用查询方式检查DMA传输是否完成(通过HAL_DMA_GetState__HAL_DMA_GET_FLAG),减少中断开销。
  6. 调试技巧

    • 在复杂DMA应用中,使用调试器的“实时变量”窗口观察DMA缓冲区的数据变化,非常直观。
    • 可以利用GPIO引脚在DMA中断开始和结束时拉高/拉低,用示波器测量中断处理时间,评估系统负载。

DMA是提升嵌入式系统性能的利器,但也引入了额外的复杂性。从简单的单外设DMA传输开始,逐步理解其机制,再应用到多外设、高并发的场景中。本文的ADC+UART案例是一个经典的起点,你可以在此基础上尝试更复杂的组合,如DAC波形生成、SPI全双工通信、SD卡读写等。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询