STM32中DMA的使用——串口数据的DMA传输

DMA 的使用 —— 以STM32 串口DMA传输为例

本笔记总结修改自正点原子的F103开发指南。原文中的教程没有使用到CubeMX,并且说的很乱,因此我就总结简化了一下,然后把封装的函数拆解分析了一下。

一、DMA 简介

DMA(Direct Memory Access),直接存储器访问。它的作用是在不需要 CPU 参与的情况下,由硬件在 RAM 和 I/O 设备之间直接传输数据。和中断方式不同,DMA 不需要保存和恢复现场,CPU 的效率更高。

STM32F103 的 DMA 控制器

  • 内部有两个 DMA 控制器:DMA1 和 DMA2(DMA2 仅在大容量产品中存在)
  • DMA1 有 7 个通道,DMA2 有 5 个通道
  • 每个通道管理一个或多个外设对存储器的访问请求
  • 有一个仲裁器,负责协调各通道的优先级

DMA 的特性

  • 每个通道都有专用的硬件 DMA 请求,也支持软件触发
  • 优先级可软件编程设置(四级:很高、高、中等、低),同等优先级由硬件决定(通道号越小优先级越高)
  • 源和目标数据宽度独立设置(字节、半字、全字),支持打包和拆包
  • 支持循环缓冲器管理
  • 每个通道有 3 个事件标志:半传输完成、传输完成、传输出错,逻辑或成一个中断请求
  • 支持存储器到存储器、外设到存储器、存储器到外设的传输
  • 闪存、SRAM、APB1/APB2、AHB 外设均可作为源或目标
  • 可编程传输数据量,最大 65536

二、DMA 框图分析

img

DMA 控制器框图主要有三部分:

1. DMA 请求

外设要使用 DMA 传输数据时,必须先向 DMA 控制器发送请求。DMA 收到请求后会给外设一个应答信号,外设应答后启动传输,直到完成。

DMA1 和 DMA2 的各通道对应不同的外设请求,编程时需要查表确认通道分配。例如串口 1 的发送对应 DMA1 的通道 4。

2. 通道

DMA 有 12 个独立可编程的通道(DMA1 的 7 个 + DMA2 的 5 个)。每个通道对应不同外设的 DMA 请求。一个通道可以接收多个外设的请求,但同一时间只能处理一个。

3. 仲裁器

当多个通道同时有请求时,仲裁器决定响应顺序。分两个阶段:

  • 软件阶段:在 DMA_CCRx 寄存器中设置优先级(非常高/高/中/低)
  • 硬件阶段:优先级相同时,通道编号越低优先级越高

DMA1 的优先级高于 DMA2。

每个通道对应着不同的外设请求,这个是固定的不可改变的,如果有冲突就通过仲裁器来判断哪个优先级更高。

img

img

三、DMA 寄存器

DMA_ISR(中断状态寄存器)

img

只读寄存器,查询当前 DMA 传输状态。常用的位是 TCIFx(通道 x 传输完成标志)。

DMA_IFCR(中断标志清除寄存器)

img

写 1 清除对应的标志位。

DMA_CNDTRx(传输数量寄存器)

img

控制通道 x 每次传输的数据量,范围 0~65535。传输过程中该寄存器的值会递减,到 0 表示传输完成。可通过读取这个寄存器获取当前传输进度。

DMA_CCRx(配置寄存器)

img

核心控制寄存器,配置内容包括:

  • 数据宽度
  • 外设及存储器宽度
  • 通道优先级
  • 增量模式
  • 传输方向
  • 中断使能
  • DMA 使能

DMA_CPARx(外设地址寄存器)

img

存放外设地址。例如串口 1 要填入 0x40013804&USART1_DR)。

DMA_CMARx(存储器地址寄存器)

存放存储器地址。例如用 g_sendbuf[5200] 做缓冲区,就填入 &g_sendbuf

四、CubeMX 配置与程序设计

实验:按下 KEY0 按键后,CPU 把内存里准备好的一串数据,通过 DMA 自动送到串口 1 的发送寄存器,CPU 在传输过程中可以去做别的事。串口调试助手能看到接收到的数据。

实现这个流程,需要配置以下几个环节(从 HAL 库和寄存器的角度看):

  1. DMA 时钟使能 —— DMA 挂在 AHB 总线上,工作前必须先打开 DMA 控制器的时钟。对应操作 RCC_AHBENR 寄存器的 DMA1EN 位。如果这一步没做,DMA 寄存器写不进数据。

  2. 选 DMA 通道 —— 串口 1 的发送固定对应 DMA1 的通道 4(查 DMA1 请求映像表可知)。这一步要告诉 DMA 控制器用哪个通道来处理请求,实际上是选定了 DMA1_Channel4 这一组寄存器(DMA_CCR4DMA_CNDTR4DMA_CPAR4DMA_CMAR4)。

  3. 配置传输参数(DMA_CCRx) —— 核心配置寄存器,决定 DMA 怎么传。需要设置的内容包括:

    • 传输方向:存储器到外设(数据从 g_sendbuf 到串口的数据寄存器)
    • 增量模式:存储器地址递增(每发一个字节,源地址往后移一个位置),外设地址固定(串口数据寄存器地址就一个)
    • 数据宽度:8 位,因为串口一次发一个字节
    • 传输模式:普通模式(Normal),传完指定数量就停。如果用循环(Circular)模式,传完会自动从头再传,适合连续的数据流
    • 优先级:多个 DMA 通道同时请求时的仲裁依据,这里只有一路,设 Medium 即可
  4. 填入源地址和目标地址 —— DMA_CPAR4 写入 &USART1_DR(0x40013804),告诉 DMA 把数据送到串口的哪个寄存器;DMA_CMAR4 写入 &g_sendbuf,告诉 DMA 从哪里取数据。

  5. 设置传输数据量 —— DMA_CNDTR4 写入要发送的总字节数。传输过程中这个值递减,减到 0 表示传完了。可以实时读它来知道还剩多少没发。

  6. 关联外设和 DMA —— 前面几步把 DMA 配置好了,但 DMA 还不知道它服务的是哪个外设。在 HAL 库中通过 __HAL_LINKDMA() 把串口句柄的 hdmatx 成员指向 DMA 句柄,这样串口的 HAL 函数(如 HAL_UART_Transmit_DMA)才能正确地操作这个 DMA 通道。这只是软件层面的指针关联,没有寄存器操作。

  7. 开启串口的 DMA 发送请求 —— 串口端也有个开关:USART_CR3 寄存器的 DMAT 位。置 1 后,串口每发送完一个字节,会自动向 DMA 发出请求,DMA 收到请求就送下一个字节过来。这一步封装在 HAL_UART_Transmit_DMA() 里,调用时一并完成。

  8. DMA 中断(可选) —— 使能 NVIC 中 DMA1_Channel4_IRQn 的中断。DMA 传完后会产生中断,HAL 库的 HAL_DMA_IRQHandler 会收这个中断,然后调用对应的回调函数(比如 HAL_UART_TxCpltCallback)。如果不想用中断,也可以轮询 DMA_ISR 的 TCIF4 位来判断传输是否完成。

以上 8 点就是完成一次串口 DMA 发送所需的全部配置。下面看 CubeMX 如何把这些配置自动生成出来。

4.1 CubeMX 中的配置操作

在 CubeMX 的 Pinout & Configuration 页面需要配置以下几项:

USART1

  • Mode:选择 Asynchronous(异步模式)

    • 串口通信就是异步的,不需要时钟线,所以选这个。如果选 Single Wire(半双工)就只能发或只能收,这里收发都要用。
  • Parameter Settings:Baud Rate 设为 115200,其他默认

    • 波特率是每秒传多少位,115200 是常用值,和串口调试助手那边保持一致就行。
  • NVIC Settings:勾选 Enabled(使能 USART1 全局中断)

    • 如果不勾,串口收发的中断不会触发。虽然这个实验没用中断方式做 DMA,但 CubeMX 生成代码时如果发现 NVIC 没开,HAL 库函数会有断言报错,所以先勾上。
  • DMA Settings:点 Add,选择 USART1_TX

    • Direction:Memory To Peripheral
      • 数据从内存(g_sendbuf)送到外设(串口 1 的发送数据寄存器),所以方向是存储器到外设。
    • Mode:Normal
      • Normal 模式:传完指定数量的数据就停。还有一个 Circular(循环)模式,传完会自动从头再传,适合周期性的数据流(比如音频输出),这里只需要按一下传一次,所以用 Normal。
    • Periph Data Width:Byte / Mem Data Width:Byte
      • 数据宽度。发送缓冲区是 uint8_t 数组,串口一次传一个字节,所以两边都设 Byte。如果设 Half Word(16位)或 Word(32位),DMA 会一次拿 2 或 4 个字节往外送,串口用不了。
    • Priority:Medium
      • DMA 通道的优先级。如果还有其他 DMA 通道在跑,仲裁器会根据这个优先级决定谁先用总线。这里没有别的 DMA 通道,所以 Medium 就够了。

    添加后 CubeMX 会自动分配到 DMA1 的通道 4。因为这个通道是串口 1 发送的固定映射(见前面框图里的 DMA1 请求映像表),不能自己随便改。
    img

    img

    img

PB5:Pinout 视图里点击 PB5,选 GPIO_Output(LED0)

  • PB5 接了一个 LED(正点原子板子上是 LED0),低电平点亮。设成推挽输出、不用上下拉。

PE4:Pinout 视图里点击 PE4,选 GPIO_Input(KEY0)

  • PE4 接了按键 KEY0,按下为低电平。设成上拉输入(平时高电平,按下拉低)。

时钟配置:RCC 里 HSE 选 Crystal/Ceramic Resonator,Clock Configuration 页里设好 PLL,让 SYSCLK 跑到 72MHz

  • HSE 是外部高速晶振,板子上接了 8MHz 晶振,所以选这个。用它经过 PLL 倍频到 72MHz 作为系统时钟。AHB 分频默认 /1,这样 HCLK = 72MHz,DMA 也跑在 72MHz。不把 AHB 分频设得太低,否则 DMA 速度会受影响。

img

4.2 CubeMX 生成的代码

生成工程后,CubeMX 在 main.c 和相关文件里生成了以下初始化函数:

SystemClock_Config()

根据 Clock Configuration 页的设定生成时钟初始化代码,包括 HSE 起振、PLL 配置、SYSCLK 选择、AHB/APB1/APB2 分频系数。实质操作的是 RCC 寄存器组的时钟配置相关寄存器(RCC_CRRCC_CFGRRCC_PLLCFGR 等)。

MX_DMA_Init()

void MX_DMA_Init(void)
{
    __HAL_RCC_DMA1_CLK_ENABLE();        // 操作 RCC_AHBENR 寄存器
    HAL_NVIC_SetPriority(DMA1_Channel4_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(DMA1_Channel4_IRQn);   // 操作 NVIC 相关寄存器
}

这个函数只做了两件事:开启 DMA1 时钟、使能 DMA 中断。DMA 的具体参数配置不在这里。

MX_USART1_UART_Init()

static void MX_USART1_UART_Init(void)
{
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart1);
}

调用 HAL_UART_Init 时,HAL 库内部会调用 HAL_UART_MspInit,这个函数由 CubeMX 自动生成,里面包含了 DMA 的完整初始化:

void HAL_UART_MspInit(UART_HandleTypeDef* uartHandle)
{
    if (uartHandle->Instance == USART1)
    {
        __HAL_RCC_USART1_CLK_ENABLE();   // 使能 USART1 时钟
        __HAL_RCC_GPIOA_CLK_ENABLE();    // 使能 GPIOA 时钟
        // PA9(TX), PA10(RX) 的 GPIO 初始化...

        // ====== DMA 配置(对应原来手写的 dma_init 函数)======
        hdma_usart1_tx.Instance = DMA1_Channel4;     // IRQ 和 DMA 请求映像见框图
        hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
        hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
        hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE;
        hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
        hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
        hdma_usart1_tx.Init.Mode = DMA_NORMAL;
        hdma_usart1_tx.Init.Priority = DMA_PRIORITY_MEDIUM;
        HAL_DMA_Init(&hdma_usart1_tx);   // 写入 DMA_CCRx 等寄存器

        __HAL_LINKDMA(uartHandle, hdmatx, hdma_usart1_tx);  // 关联外设和 DMA

        HAL_NVIC_SetPriority(DMA1_Channel4_IRQn, 0, 0);
        HAL_NVIC_EnableIRQ(DMA1_Channel4_IRQn);
    }
}

手动方式里自己写的 dma_init() 做的事,全部被 CubeMX 生成到了 HAL_UART_MspInit 里了。

MX_GPIO_Init()

初始化 PB5(LED0)、PE4(KEY0),包括时钟使能、模式选择、上下拉、输出速度等。操作的是 GPIOx_CRL/CRHGPIOx_ODR 等寄存器。

4.3 步骤封装说明

原来的寄存器操作步骤被封装到了 CubeMX 生成的各个函数里,对应关系如下:

原来手写的操作 对应寄存器 被封装在哪个函数
使能 DMA1 时钟 RCC_AHBENR 的 DMA1EN 位 MX_DMA_Init() 中的 __HAL_RCC_DMA1_CLK_ENABLE()
设 DMA 通道 DMA_CPARxDMA_CMARx HAL_UART_MspInit 中的 hdma_usart1_tx.Instance = DMA1_Channel4
设传输方向、数据宽度、增量模式、优先级等 DMA_CCRx HAL_UART_MspInit 中的结构体赋值 + HAL_DMA_Init()
关联外设句柄和 DMA 句柄 HAL_UART_MspInit 中的 __HAL_LINKDMA()
使能串口 DMA 发送 USART_CR3 的 DMAT 位 用户代码调 HAL_UART_Transmit_DMA()
查询传输完成标志 DMA_ISR 的 TCIFx 位 用户代码调 __HAL_DMA_GET_FLAG()
获取剩余数据量 DMA_CNDTRx 用户代码调 __HAL_DMA_GET_COUNTER()
清除传输完成标志 DMA_IFCR 的 CTCIFx 位 用户代码调 __HAL_DMA_CLEAR_FLAG()
DMA 中断处理 NVIC + DMA 中断标志 DMA1_Channel4_IRQHandler -> HAL_DMA_IRQHandler -> 用户回调

从表格可以看出来,CubeMX 自动生成的部分是时钟、通道选择、参数配置、关联和中断使能,而启动传输、查进度、清标志这几步仍由用户代码完成。

4.4 需要自己写的代码

(1)发送缓冲区

/* USER CODE BEGIN PV */
const uint8_t TEXT_TO_SEND[] = {"正点原子 STM32 DMA 串口实验"};
#define SEND_BUF_SIZE (sizeof(TEXT_TO_SEND) + 2) * 200
uint8_t g_sendbuf[SEND_BUF_SIZE];
uint8_t transmit_done = 0;   // 中断方式用的标志
/* USER CODE END PV */

(2)缓冲区填充 写在 初始化函数全部调用之后

/* USER CODE BEGIN 2 */
uint16_t len = sizeof(TEXT_TO_SEND);
uint16_t k = 0;
uint8_t mask = 0;
for (uint16_t i = 0; i < SEND_BUF_SIZE; i++)
{
    if (k >= len)
    {
        if (mask) { g_sendbuf[i] = 0x0a; k = 0; }
        else      { g_sendbuf[i] = 0x0d; mask++; }
    }
    else
    {
        mask = 0;
        g_sendbuf[i] = TEXT_TO_SEND[k];
        k++;
    }
}
/* USER CODE END 2 */

(3)主循环

/* USER CODE BEGIN WHILE */
uint8_t key;
while (1)
{
    key = key_scan(0);
    if (key == KEY0_PRES)
    {
        HAL_UART_Transmit_DMA(&huart1, g_sendbuf, SEND_BUF_SIZE);

        while (1)
        {
            if (__HAL_DMA_GET_FLAG(&hdma_usart1_tx, DMA_FLAG_TC4))
            {
                __HAL_DMA_CLEAR_FLAG(&hdma_usart1_tx, DMA_FLAG_TC4);
                HAL_UART_DMAStop(&huart1);
                break;
            }
            /* 等待传输完成,CPU 可以在这里做其他事 */
        }
    }
    HAL_Delay(10);
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */

(4)中断回调(可选)

上面是轮询方式,CubeMX 已经开了 DMA 中断,也可以改成中断方式。把轮询的 while 去掉,在 /* USER CODE BEGIN 4 */ 里写回调:

/* USER CODE BEGIN 4 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        HAL_UART_DMAStop(&huart1);
        transmit_done = 1;
    }
}
/* USER CODE END 4 */

主循环里调完 HAL_UART_Transmit_DMA 就不用管了,传完会自动进回调。

posted @ 2026-07-29 16:35  Javenwww  阅读(0)  评论(0)    收藏  举报