STM32的直接存储器访问

STM32的DMA

DMA的概念

DMA(Direct Memory Access, 直接存储器访问)是在内存的两个区域,或内存与外设之间以流水线方式传输数据的方式。

在需要转移大量数据的情况下,如果使用 CPU 处理,那么每传输一个字节,都需要将数据读入 CPU 中,CPU 计算出目标地址后再移到目标区域,这一过程需要占用 CPU 大量资源,从而影响 CPU 正常的工作流程。

STM32 内置了 DMA 控制器,它是内核外一个类似外设的结构,可以在没有 CPU 介入的情况下,自行处理数据转移的任务,使 CPU 的资源能用于其它的工作,大大提高了系统的运行效率。SRAM、Flash 闪存、APB1、APB2 和 AHB 外设的数据寄存器均可作为访问的源和目标。

STM32F1 至少有一个 DMA1 ,大容量的型号还有 DMA2 。

STM32的DMA控制器

DMA 的用途非常纯粹,就是转移数据。下图展示了 STM32 的 DMA 结构:

DMA 控制器作为一个类似外设结构,它挂载在 AHB 系统总线上。因此,如果要配置 DMA 的寄存器,同样要先通过 RCC 打开 DMA 的时钟。

DMA 控制器通过系统数据总线读取存储器和外设寄存器的数据。当内核和 DMA 同时访问相同的目标时,总线上的控制器也会通过循环调度的方式让 DMA 控制器拥有一部分的总线带宽。

虽然 DMA 的用途很简单,就是数据传输,但是 DMA 初始化结构体的成员却非常多,有多达 11 个成员:

typedef struct {
    uint32_t DMA_PeripheralBaseAddr;
    uint32_t DMA_MemoryBaseAddr;
    uint32_t DMA_DIR;
    uint32_t DMA_BufferSize;
    uint32_t DMA_PeripheralInc;
    uint32_t DMA_MemoryInc;
    uint32_t DMA_PeripheralDataSize;
    uint32_t DMA_MemoryDataSize;
    uint32_t DMA_Mode;
    uint32_t DMA_Priority;
    uint32_t DMA_M2M;
} DMA_InitTypeDef;

为了更好地了解这 11 个成员如何取值,需要更详细地研究 DMA 的处理过程。

DMA请求

如果想让 DMA 开始转运数据,就需要给 DMA 发生一个信号。这个信号可以是通过软件向配置寄存器的使能位写入值触发,也可以是其它外设通过硬件向 DMA 发出。通过硬件发生 DMA 信号的优点是外设可以自动处理收发的时机。例如 ADC 采集完毕以后直接发送信号给 DMA ,DMA 自动执行转换结果的搬运,这一过程完全不需要内核处理,实现硬件流程的自动化。

DMA 的配置寄存器中有一个设置位可以选择是硬件触发还是软件触发。在标准库编程中,可以通过 .DMA_M2M 位切换触发方式。

在 STM32 中,许多外设(如 USART、TIM、ADC 等)都可以向 DMA 发送传输数据的请求。每个请求都标识了数据传输的需求,当 DMA 控制器接收到请求后,会根据配置开始数据传输。

DMA通道

STM32 的 DMA 允许提前准备多个数据传输的配置,每个这样的配置都包括数据的源地址、目标地址、数据个数等属性,每组这样的配置称为通道,可以将不同的通道用作不同的数据传输任务。其中 DMA1 有 7 个通道, DMA2 有 5 个通道。

所以,DMA 的初始化、使能等相关操作时,操作的对象是 DMA 的通道而不是外设本身。例如,DMA 在初始化时,函数应该这样调用:

DMA_Init(DMA1_Channel6, &DMA_InitStructure);

此外,每个外设可能发起的请求与通道的对应关系是固定的,因此如果想让 DMA 处理某个特定的请求,那么就必须提前配置对应的通道。DMA1 包含的 7 个通道可以响应的请求如下表所示:

外设通道1通道2通道3通道4通道5通道6通道7
ADC1ADC1
SPI/I2SSPI1_RXSPI1_TXSPI/I2S2_RXSPI/I2S2_TX
USARTUSART3_TXUSART3_RXUSART1_TXUSART1_RXUSART2_RXUSART2_TX
I2CI2C2_TXI2C2_RXI2C1_TXI2C1_RX
TIM1TIM1_CH1TIM1_CH2TIM1_TX4
TIM1_TRIG
TIM1_COM
TIM1_UPTIM1_CH3
TIM2TIM2_CH3TIM2_UPTIM2_CH1TIM2_CH2
TIM2_CH4
TIM3TIM3_CH3TIM3_CH4
TIM3_UP
TIM3_CH1
TIM3_TRIG
TIM4TIM4_CH1TIM4_CH2TIM4_CH3TIM4_UP

同一个通道可以接收多个外设的请求,但是同一时间只能接收一个,其余请求会被忽略。因此一般情况下尽量只启用其中一个外设的请求。

优先级与仲裁

DMA 同一时间只能执行一项数据传输的工作,但是当 DMA 的多个通道同时接收请求时,就出现了和中断一样的优先级问题。DMA 每个通道都有一个可编程的软件优先级,这个软件优先级有 4 个等级:低、中、高和非常高,可以通过初始化结构体的成员 .DMA_Priority 设置这个优先级。

DMA 内有一个结构仲裁器负责处理响应的顺序问题。仲裁器首先判断软件优先级,如果两个或以上通道的软件优先级一样,则它们的优先级取决于通道编号,编号越低优先权越高。

在大容量产品中,DMA1 的优先级高于 DMA2 。

综合这些结构,就可以得到 DMA 处理的基本流程:首先,外设向对应的 DMA 通道发出请求。如果想要处理该请求,就需要配置并使能对应的通道。同时,每个通道也都支持软件触发,在软件触发的情况下,通道之间没有任何区别。然后,请求进入仲裁器,经由优先级判断后进入 DMA 内部的执行器,执行器读取通道配置寄存器的配置并执行,如下图所示:

DMA中断

STM32 中的 DMA 可以产生中断,并且有 3 种不同类型的中断类型:

  1. 传输完成(TC)
  2. 传输进度过半(HT)
  3. 传输错误(TE)

DMA1 的每个通道都有独立的中断向量,可以在中断服务函数内判断以上中断的标志位以确定当前通道的工作状态。

在使用 DMA 转运数据时,如果传输的目标地址是只读寄存器或 Flash 存储器,它们都不支持总线直接写入,就会出现错误。

通道的数据配置

DMA 的作用是数据传输,最核心就是配置要传输的数据,包括数据源地址和目标地址、传输数据的数量、是单次传输还是循环传输等。

数据的源和目标

每个通道都有一个外设地址寄存器和存储器地址寄存器,分别保存传输数据的源地址和目标地址,反映在初始化结构体上就是 .DMA_PeripheralBaseAddr.DMA_MemoryBaseAddr 这两个成员的配置。

但其实外设地址寄存器不必非得保存外设地址,存储器地址寄存器也不必非得保存存储器地址,甚至反过来填写也没有问题;这样命名的主要是用于区分传输方向:成员 .DMA_DIR 用于设置传输方向,它有两个可用的值:DMA_DIR_PeripheralSRCDMA_DIR_PeripheralDST ,如果使用前者,那就是将 .DMA_PeripheralBaseAddr 地址的数据搬到 .DMA_MemoryBaseAddr 地址上,使用后者就是反向搬运。在后续编写存储器之间数据复制的程序时,就需要注意这种方向的表示。

数据的量

当配置了数据的源和目标之后,还需要配置数据传输的量。DMA 内部有一个传输数量计数器,每搬运一次数据,传输数量计数器的值就向下减一,可以通过 .DMA_BufferSize 成员配置传输数量计数器的值,也就是数据传输的个数。通过 DMA 发送的数据理论上没有长度限制,但由于传输数量计数器只有 16 位有效,因此一次最多只能传输 65535 个数据。

在 DMA 中,传输数量计数器还有一个自动重装载器,它可以在传输数量计数器减到零后将其重装为初始值,它允许将 DMA 配置为循环传输模式,可以通过 .DMA_Mode 成员设置。

要传输的数据可能未必都是 32 位的,例如 ADC 的数据寄存器只有 16 位有效,而 USART 的数据寄存器更是只有 8 位有效。可以使用 .DMA_PeripheralDataSize.DMA_MemoryDataSize 配置数据传输的单位 ,可以是字节、半字(16 位)和字(32 位)。不过,为了能接收到正确的数据,应该要使源地址和目标地址存储的数据位数保持一致。

在源地址和目标地址数据位数不一致的情况下,会有一个数据对齐的问题。简单来说,在源数据位数小于目标数据位数的情况下,会在结果的高位补 0 ;大于的情况下会发生高位的数据丢失。

在处理存储器的数据时,会涉及地址的递增问题:将一块数据转移到存储器之后,下一次转移可能要将数据移到到存储器的下一个位置,但处理外设时,寄存器的地址就没必要递增。初始化结构体的成员 .DMA_PeripheralInc.DMA_MemoryInc 用于处理源地址和目标地址的递增问题,它们应该根据实际填写地址指向的位置选择合适的处理方式。


最后,通道可能要选择软件触发或硬件触发的方式,具体的触发方式可以通过成员 .DMA_M2M 切换。这个成员的本意是 memory to memory ,如果要使用 DMA 将存储器的数据转移到存储器中,就需要将其配置为 DMA_M2M_Enable ,并允许软件触发。此外,软件触发和循环模式不能同时使用。

至此,DMA 的初始化部分已经介绍完毕,可以被触发并开始工作。下面以 DMA1 为例,介绍 DMA 使用的一般步骤。

DMA程序示例

使用DMA向串口发送数据

在本节中完成一个很简单的任务:使用 DMA 将缓冲区中的数据发给 USART 。在发送的同时,CPU 也可以执行其它任务。

首先,DMA 作为 AHB 总线上的外设,需要使能其时钟:

RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

然后需要初始化 DMA 通道。首先是数据的源地址为缓冲区数组,目标地址为 USART 的数据寄存器 DR ,那么就可以通过如下形式配置:

DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t) &(USART1->DR);
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t) SendBuffer;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST;

然后是数据的量,由于 USART 的串口发送寄存器只有 8 位有效,那么数据的传输单位为字节,数量的数量就是缓冲区的字节数,可以使用 sizeof 操作符得到。发送时,缓冲区的地址需要递增,但外设寄存器的地址不应递增。最后,程序中只要发生一次即可。那么这些配置对应的代码就是:

DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStructure.DMA_BufferSize = sizeof(SendBuffer);
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;

最后,通道使用硬件触发的方式,并设置通道的优先级设置为中。

DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;

查阅上表可知,USART1 发送对应的通道是通道 4 ,那么这个结构就需要用于初始化通道 4 :

DMA_Init(DMA1_Channel4, &DMA_InitStructure);

在初始化完成后,自然还要使能该通道:

DMA_Cmd(DMA1_Channel4, ENABLE);

现在,DMA 已经准备就绪,就等 USART1 发送端的 DMA 请求了。为了使能该发送请求,对应的外设需要启用 DMA 请求。STM32 的标准外设库为各个与 DMA 有关联的外设都提供了一个 *_DMACmd 函数,对于 USART 来说,对应的函数是 USART_DMACmd() ,通过这个函数即可启用 USART 的 DMA 请求。

对于 USART 来说,每个外设都可以产生两个 DMA 请求(收/发),因此还需要通过第二个参数指定具体使能哪一个请求,具体的调用方式为:

USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);

一旦调用了该函数后,USART 就会触发 DMA 请求。根据配置,DMA 会将缓存区中的数据搬到 USART 的数据寄存器中,并通过 USART 发送出去。

使用DMA读取ADC采集数据

接下来再看一个类似的应用,使用 DMA 将 ADC 采集的数据搬运到缓存中。在介绍 ADC 时曾提到一个关键的问题:在使用 ADC 采集多通道的数据时,由于 ADC 只会在采集完所有通道的数据时才发出信号,因此如果使用内核处理,不管是通过轮询还是中断的方式都无法及时转运数据。

这个问题的标准解决方案就是使用 DMA 转运数据:如果 ADC 使能了 DMA 请求,那么它会在每采集一个通道的数据后就会立即触发一次 DMA 请求,DMA 就可以及时将数据转运走。

ADC 对应的 DMA 通道是通道 1 ,相关的初始化结构的定义为:

DMA_InitTypeDef DMA_InitStructure = {
    .DMA_PeripheralBaseAddr = (uint32_t) &(ADC1->DR),
    .DMA_MemoryBaseAddr = (uint32_t) ADC_Values,
    .DMA_DIR = DMA_DIR_PeripheralSRC,
    .DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord,
    .DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord,
    .DMA_BufferSize = 4,
    .DMA_PeripheralInc = DMA_PeripheralInc_Disable,
    .DMA_MemoryInc = DMA_MemoryInc_Enable,
    .DMA_Mode = DMA_Mode_Circular,
    .DMA_Priority = DMA_Priority_High,
    .DMA_M2M = DMA_M2M_Disable
};
DMA_Init(DMA1_Channel1, &DMA_InitStructure);

该初始化结构将通道配置为:

数据的源和目标

  • 外设地址为 ADC 的数据寄存器 DR 地址
  • 内存地址为数据缓冲区的地址
  • 传输方向为外设到存储器

数据的量

  • ADC 的数据寄存器是 16 位起效,因此配置外设和内存数据单位均为半字
  • 数据的个数为 ADC 采集的通道数,当然缓存也需要预留相同的长度
  • 外设地址不变,内存地址递增
  • 因为 ADC 是连续采集的,因此设置 DMA 也循环传输。传输一轮后,DMA 重新开始搬运 ADC 采集的第一个通道的数据,两者正好同步

通道的其它参数

  • 设置优先级为高
  • 不需要内存到内存的传输,设置触发方式为硬件触发

然后,在 ADC 配置完毕后,通过 ADC_DMACmd(ADC1, ENABLE) 使能 ADC 的 DMA 请求,DMA 便可将采集得到的数据搬运到缓冲区的位置,留待进一步处理了。


在转移 ADC 采集的数据时,数量是固定的,DMA 的配置比较清晰。但如果用 DMA 转移 USART 读取的数据时,由于接受数据的量并不是固定的,就需要换一种方式得到实际接收的数据:DMA 内部有一个传输计数器,每接收一个数据,传输计数器的值就递减。那么可以在串口数据传输完成后,通过读取传输计数器并计算递减值来得到实际接收的数据长度。

首先,在初始化 DMA 时,可以通过初始化成员配置传输计数器的值:

#define ReceiveBufferSize 512
uint8_t ReceiveBuffer[ReceiveBufferSize];
DMA_InitStructure.DMA_BufferSize = ReceiveBufferSize;

传输完成的标志可以通过串口的空闲中断标志判断。标准库提供了一个函数,可以用于读取传输计数器的值:

  • uint16_t DMA_GetCurrDataCounter(DMA_Channel_TypeDef *DMAy_Channelx);

但是注意在读写传输计数器时,需要先关闭 DMA 。这样实际接收的数据长度可以通过类似如下代码获取:

void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_IDLE) == SET) {
        DMA_Cmd(DMA1_Channel5, DISABLE);
        uint16_t CurrCount = DMA_GetCurrDataCounter(DMA1_Channel5);
        uint16_t ReceivedDataSize = ReceiveBufferSize - CurrCount;
    // ...
    }
}

在读取完成后,应该重新向传输计数器写入初始值,用于下一次的串口接收:

DMA_SetCurrDataCounter(DMA1_Channel5, ReceiveBufferSize);
DMA_Cmd(DMA1_Channel5, ENABLE);

使用DMA复制存储器数据

DMA 可以在存储器和存储器之间传输数据,由此可以用于实现类似 memcpy() 的功能。

使用 DMA 在存储器之间传输数据时,关键的配置在于将 .DMA_M2M 设置为使能模式:

DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;

当使用存储器到存储器模式时候,软件触发情况下选择哪一个通道都是一样的,因此可以随便选一个当前没有用到的通道:

DMA_Init(DMA1_Channel6, &DMA_InitStructure);
DMA_Cmd(DMA1_Channel6, ENABLE);

在软件触发模式下,一旦 DMA 使能后,软件触发就会立即发生,DMA 也就会立即开始转移数据,并一次性将所有的数据转运完成。因此 DMA 不能应用循环传输模式。

最后,可以通过判断传输完成标志位检查 DMA 是否完成。在数据量较大的情况下,也可以通过中断的形式等待 DMA 完成。

while (DMA_GetFlagStatus(DMA1_FLAG_TC6) == RESET);
京ICP备2021034974号
contact me by hello@frozencandles.fun