DMA 学习笔记
DMA 学习笔记

DMA 学习笔记

记录一些 DMA 学习过程中的心得体会,适用于 GD32 或 STM32 平台。从新的视角去理解 DMA 中的…

DMA 的传输模式

DMA 的数据传输有大致可以分为两类,一类是需要请求信号,另一类是不需要请求信号。DMA 的内存到内存模式(M2M)不需要请求信号,外设到内存(P2M)内存到外设(M2P) 模式需要请求信号。

拿内存拷贝为例,如果只是简单的内存拷贝,那么可以用 DMA 的 M2M 模式,此时数据的拷贝速度仅受限于内部总线的传输速度,这就属于不需要请求信号的 DMA 传输;如果对拷贝的速度有要求,例如需要每隔 10us 拷贝一个数据,那么就需要引入定时器发起请求信号控制拷贝速度,此时 DMA 的传输就与定时器,准确的说是定时器发起的请求信号扯上了关系,这时就不能用 M2M 模式了,应该用 P2M 或 M2P 模式。使用定时器控制 DMA 做内存拷贝情况下,DMA 的外设地址可以指向系统中的任一地址,也就是说,可以指向处理器某一外设的寄存器地址,也可以指向 SRAM/Flash 里的任一地址;同理,此时 DMA 的内存地址也可以指向系统中的任一地址。

DMA 与外设间的数据传输

DMA 与外设间的数据传输可以分成两类,一类是外设支持 DMA,另一类是外设不支持 DMA。外设支持 DMA 是指外设自己能产生 DMA 请求信号,以 GD32F303ZET6 的串口为例,该处理器总共有 5 个串口,其中 UART0 ~ UART3 支持 DMA,UART4 不支持 DMA。也就是说 UART0 ~ UART3 能够自己产生 DMA 请求信号,UART4 并不能产生 DMA 请求信号。

对于支持 DMA 的外设,与 DMA 之间的交互可以有两种选择,一是外设自己产生 DMA 请求信号,请求 DMA 发起一次数据传输;二是外设不发起 DMA 请求信号,而是由其它外设,例如定时器发起 DMA 请求信号,触发数据传输。

对于不支持 DMA 的外设,只能通过其它外设,例如定时器产生 DMA 请求,触发数据传输。

这里我们可以看到,DMA 的请求与外设地址、内存地址并无关系。微处理器中的外设,例如定时器、ADC、DAC 等等,只要是能产生 DMA 请求的,都能用于控制其它外设与 DMA 的交互,使用定时器控制 DMA 传输速度的比较多。

DMA 的数据传输方向

使用 DMA 时,最重要的一点,不要被外设地址和内存地址误导,外设地址和内存地址可以指向系统中任意地址,外设地址并不一定要指向某一寄存器地址,内存地址也不一定非得是指向内存 SRAM 或 Flash。DMA 的数据传输方向并不代表外设的数据传输方向。DMA 数据传输方向为外设到内存,只是说要将数据从外设地址(可以指向任意地址)搬运到内存地址(也可以指向任意地址),并不是说数据一定要从 SRAM 搬运到某一外设的寄存器。以下面这段代码为例。

这是一段串口 DMA 发送数据程序,用于将内存中的一段数据通过串口发送出去。首先声明一点,这段代码是可以正常运行的。这里我故意将外设地址指向了 SRAM/Flash 某一地址,内存地址反而指向了串口数据寄存器地址,数据传输方向设定为外设到内存。同时禁用了内存地址增涨和使能了外设地址增涨。为了能产生 DMA 请求,需要将串口的 DMA 发送开关打开

这段代码可以正常运行就说明了前边的假设是正确的,外设地址和内存地址实际指向哪里,开发人员可以自由配置。实际应用的时候,外设地址还是要指向外设寄存器,内存地址还是要指向 SRAM/Flash ,方便后续的维护和开发,否则容易出现误解。

//串口发送函数
unsigned int WriteUART0(unsigned char *pBuf, unsigned int len)
{
  //DMA初始化结构体
  dma_parameter_struct dma_init_struct;
  
  //配置DMA
  rcu_periph_clock_enable(RCU_DMA0);
  dma_deinit(DMA0, DMA_CH3);                                      //复位DMA通道
  dma_init_struct.direction    = DMA_PERIPHERAL_TO_MEMORY;        //设置DMA数据传输方向(故意设成外设到地址)
  dma_init_struct.memory_addr  = (uint32_t)&USART_DATA(USART0);   //内存地址设置(故意设成 UART 的数据寄存器地址)
  dma_init_struct.memory_inc   = DMA_MEMORY_INCREASE_DISABLE;     //禁止内存地址增涨
  dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT;           //内存数据位数设置
  dma_init_struct.number       = len;                             //内存数据量设置
  dma_init_struct.periph_addr  = (uint32_t)pBuf;                  //外设地址设置(故意设成 SRAM 内地址)
  dma_init_struct.periph_inc   = DMA_PERIPH_INCREASE_ENABLE;      //使能外设地址增涨
  dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT;       //外设数据位数设置
  dma_init_struct.priority     = DMA_PRIORITY_MEDIUM;             //优先级设置
  dma_init(DMA0, DMA_CH3, &dma_init_struct);                      //根据参数初始化
  dma_circulation_disable(DMA0, DMA_CH3);                         //单次传输
  dma_memory_to_memory_disable(DMA0, DMA_CH3);                    //禁用内存到内存

  //开启DMA传输
  dma_channel_enable(DMA0, DMA_CH3);
  
  //等待传输完成
  while(RESET == dma_flag_get(DMA0, DMA_CH3, DMA_FLAG_FTF)){}

  //返回实际写入数据的个数
  return len;
}

在 TI 公司发布的 C28x 架构中,以TMS320F2833x、TMS320F2823x 为例,这类微处理器的 DMA 就没有外设地址和内存地址一说,只设置了源地址和目的地址,数据固定从源地址传输到目的地址。

DMA 请求

现在回过头来看 DMA 与外设间的数据传输,还是以串口为例。我们都知道,串口的传输速度是固定的,以波特率 115200为例,假定一个起始位 + 8 个数据位 + 一个停止位。在 115200 波特率下,每秒钟可以传输 11520 个字节,也就是一个字节需要 86.8us 才能被发送出去。这时候我们不能用 DMA 的 M2M 模式去发送串口数据,因为 M2M 模式下数据传输速度特别快,上一个字节还未传输完成,写一个字节又来了,这时就会造成数据传输错误。所以通过 DMA 发送串口数据时,只能使用 M2P 或 P2M模式。

在 M2P 或 P2M模式下,DMA 的传输依赖于 DMA 请求,外设每发起一次 DMA 请求,DMA 便传输一个数据,可以是 1 个 8 位数据,可以是 1 个 16 位数据,也可以是 1 个 32 位数据。这个 DMA 请求可以是外设自己产生,也可以是其它外设产生。

串口发送数据时,如果打开了 DMA 发送使能开关,那么每发完一个字节就会发出一个 DMA 请求,这个 DMA 请求只会作用于特定的 DMA 通道,对于 GD32F303ZET6,就是 DMA0 的通道 3。注意:这个 DMA 发送使能开关位于 UART 外设,而不是 DMA外设。DMA0 的通道 3 接收到请求后便传输下一个数据,如此循环反复,直至数据发送完成。DMA0 的通道 3 发送完最后一数据后,随即将通道的使能开关关掉,即 DMA 通道的使能开关在数据传输完毕后会自动关闭。因为是写入串口的数据寄存器,所以 UART 会开启一次数据传输,将写入的数据发送出去。数据发送完毕后,因为打开了串口 DMA 发送使能开关,所以 UART 会发起一次 DMA 请求,但此时 DMA 通道处于禁用状态,所以该请求会被忽略(抛弃)。UART 只管发起 DMA 请求,并不关心该请求是否有作用。

这也就是为什么我们是能 DMA 发送串口数据后,printf 函数还能正常使用的原因,printf 函数的移植如下所示。printf 函数每输出一个字节,UART 都会发起一次 DMA 请求,但此时 DMA 通道时关闭的,该请求被忽略(抛弃)。所以只要 DMA 发送期间不调用 printf 函数,单片机就能正常运行。

//重定向 fputc 函数
int fputc(int ch, FILE *f)
{
  //发送字符函数,专由fputc函数调用
  usart_data_transmit(USART0, (uint8_t) ch);

  //等待上一次发送完成
  while(RESET == usart_flag_get(USART0, USART_FLAG_TBE));

  return ch;
}

综上,如果启用了 M2P 或 P2M模式,那么 DMA 的传输将依赖于 DMA 请求,这个请求可以是外设自己发起,也可以是其它外设产生。DMA 请求只会作用于特定的 DMA 通道,具体是哪个得看芯片的数据手册或参考手册。通常一个 DMA 通道可以对应多个外设,如下所示,DMA0 的通道0 对应着 TIMER1、TIMER3 和 ADC0。一旦 DMA 通道使能,这些外设都可以发起 DMA 请求,触发 DMA 传输。这些外设都会设置有 DMA 传输使能开关,位于外设内部,复位后处于关闭状态。

注意:启用 DMA 传输后,DMA 通道下只能有一个外设发起 DMA 请求,否则会引起传输错误。现在一些高端处理器的 DMA 控制器内部有外设选择器,例如 GD32F470 系列、STM32F4 系列,可以指定只接收某一外设的 DMA 请求,屏蔽掉其它外设的 DMA 请求。使用 GD32F470 系列或 STM32F4 系列时,需要特别注意 ,如果 DMA 开启了单次模式,一旦 DMA 停止工作,与之关联的外设也随之停止工作,此时必须要复位外设和 DMA,并重新初始化才能继续工作。

DMA0Channel0Channel1Channel2Channel3Channel4Channel5Channel6
TIMER0TIMER0_CH0TIMER0_CH1TIMER0_CH3
TIMER0_TG
TIMER0_CMT
TIMER0_UPTIMER0_CH2
TIMER1TIMER1_CH2TIMER1_UPTIMER1_CH0TIMER1_CH1
TIMER1_CH3
TIMER2TIMER2_CH2TIMER2_CH3
TIMER2_UP
TIMER2_CH0
TIMER2_TG
TIMER3TIMER3_CH0TIMER3_CH1TIMER3_CH2TIMER3_UP
ADC0ADC0
SPI/I2SSPI0_RXSPI0_TXSPI1/I2S1_TXSPI1/I2S1_TX
USARTUSART2_TXUSART2_RXUSART0_TXUSART0_RXUSART1_RXUSART1_TX
I2CI2C1_TXI2C1_RXI2C0_TXI2C0_RX
DMA0 各通道请求表

使用定时器触发 DMA 发送串口数据

通过观察 DMA0 各通道请求表,我们可以看到所有通道都可以通过定时器产生 DMA 请求。定时器产生中断请求有几种方式,可以使用更新事件产生(TIMERx_UP),可以使用触发事件产生(TIMERx_TG),定时器的触发事件不仅可以触发 ADC、DAC 转换,也能用于产生 DMA 请求。定时器的比较输出事件(TIMERx_CHx)同样可以用于 DMA 请求。TIMERx_CMT 暂时不知道是什么事件。通常我们会选择使用定时的更新事件或触发事件产生 DMA 请求,因为比较简单。

前边说过,DMA 请求可以是外设自己发起,也可以是其它外设产生,所以我们也可以用 TIMER0 的更新事件触发 DMA 传输,发送串口数据。但为了证明其它通道也可以将数据发送出去,此处选择了 TIMER1 的更新事件。实际使用的时候,只要控制好发送间隙,也可以不用等待 DMA 传输完成,以节省一些 CPU 资源。

//串口配置函数
static void ConfigUART(unsigned int bound)
{
  //局部变量
  dma_parameter_struct dma_init_struct;  //DMA 初始化结构体
  timer_parameter_struct timer_initpara; //定时器初始化结构体
  
  //RCU 使能
  rcu_periph_clock_enable(RCU_GPIOA);  //使能 GPIOA 时钟
  rcu_periph_clock_enable(RCU_TIMER1); //使能 TIMER1 的时钟
  rcu_periph_clock_enable(RCU_DMA0);   //使能 DMA0 时钟

  //配置TX的GPIO 
  gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9);

  //配置USART的参数
  rcu_periph_clock_enable(RCU_USART0);                            //使能串口时钟
  usart_deinit(USART0);                                           //恢复默认值
  usart_baudrate_set(USART0, bound);                              //设置波特率
  usart_stop_bit_set(USART0, USART_STB_1BIT);                     //设置停止位
  usart_word_length_set(USART0, USART_WL_8BIT);                   //设置数据字长度
  usart_parity_config(USART0, USART_PM_NONE);                     //设置奇偶校验位
  usart_transmit_config(USART0, USART_TRANSMIT_ENABLE);           //使能发送
  usart_enable(USART0);                                           //使能串口

  //配置TIMER1
  timer_deinit(TIMER1);                                           //设置 TIMER1 参数恢复默认值
  timer_struct_para_init(&timer_initpara);                        //初始化 timer_initpara
  timer_initpara.prescaler         = 119;                         //设置预分频器值,1MHz
  timer_initpara.counterdirection  = TIMER_COUNTER_UP;            //设置向上计数模式
  timer_initpara.period            = 1 + (10000000 / bound);      //设置自动重装载值
  timer_initpara.clockdivision     = TIMER_CKDIV_DIV1;            //设置时钟分割
  timer_init(TIMER1, &timer_initpara);                            //根据参数初始化定时器
  timer_enable(TIMER1);                                           //使能定时器
  timer_dma_enable(TIMER1, TIMER_DMA_UPD);                        //定时器更新事件作为 DMA 触发源
  
  //配置DMA
  dma_deinit(DMA0, DMA_CH1);                                      //复位 DMA 通道
  dma_init_struct.direction    = DMA_MEMORY_TO_PERIPHERAL;        //设置 DMA 数据传输方向
  dma_init_struct.memory_addr  = NULL;                            //内存地址设置,先为空
  dma_init_struct.memory_inc   = DMA_MEMORY_INCREASE_ENABLE;      //内存增长使能
  dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT;           //内存数据位数设置
  dma_init_struct.number       = 0;                               //内存数据量设置,先为 0
  dma_init_struct.periph_addr  = (uint32_t)(&USART_DATA(USART0)); //外设地址设置
  dma_init_struct.periph_inc   = DMA_PERIPH_INCREASE_DISABLE;     //外设地址增长失能
  dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT;       //外设数据位数设置
  dma_init_struct.priority     = DMA_PRIORITY_MEDIUM;             //优先级设置
  dma_init(DMA0, DMA_CH1, &dma_init_struct);                      //根据参数初始化
  dma_circulation_disable(DMA0, DMA_CH1);                         //单次传输
  dma_memory_to_memory_disable(DMA0, DMA_CH1);                    //禁用内存到内存
  
  //先禁用 DMA
  dma_channel_disable(DMA0, DMA_CH1);
}

//串口发送函数
unsigned int WriteUART0(unsigned char *pBuf, unsigned int len)
{
  timer_disable(TIMER1);                                    //禁用触发定时器
  timer_counter_value_config(TIMER1, 0);                    //定时器计数清零
  dma_channel_disable(DMA0, DMA_CH1);                       //禁用DMA
  dma_memory_address_config(DMA0, DMA_CH1, (uint32_t)pBuf); //设置内存地址
  dma_transfer_number_config(DMA0, DMA_CH1, (uint32_t)len); //设置数据量
  dma_channel_enable(DMA0, DMA_CH1);                        //开启DMA传输
  timer_enable(TIMER1);                                     //使能定时器
  
  //等待传输完成
  while(RESET == dma_flag_get(DMA0, DMA_CH1, DMA_FLAG_FTF)){}

  //返回实际写入数据的个数
  return len;
}

这个驱动的精髓在于使用定时器以固定的频率产生 DMA 请求,将需要发送的数据写到串口数据寄存器,以达到 DMA 发送串口数据的效果,既避免了 M2M 模式下传输速度过快的尴尬,也不用 CPU 过多的干预。同样的,GD32F303ZET6 的 UART4 不支持 DMA,我们可以用一个定时器产生 DMA 请求,达到 DMA 发送串口数据的目的。

类似的,使用呼吸灯驱动时,我们需要以一定的周期去修改定时器 PWM 的占空比,这部分工作也可以让 DMA 自动完成。我们用 DMA 以同样的频率更新定时器通道 x 捕获/比较寄存器 (TIMERx_CHxCV),即可实现全自动的呼吸灯。如果 GPIO 支持端口位操作寄存器(GPIOx_BOP),使用 DMA 以一定的频率更新该寄存器,直接操作引脚输出电平,也可以实现呼吸灯的效果。

DMA 做内存拷贝

前边介绍了,DMA 还有另一种工作模式,即内存到内存模式,常常用于内存拷贝,如下列代码表示。如果源缓冲区、目的缓冲区的首地址是4字节对齐,且传输数据量也为4字节对齐的话,可以将外设数据位宽、内存数据位宽均设成 32 位,充分利用 32 位总线的优势,可以将速度提升 4 倍;因为是按字传输的,所以 DMA 传输的数据量要除以 4。

注意:DMA 每个通道都具有 M2M 功能,只要这个通道没有被占用,就可以用于内存拷贝。

//使用 DMA 做内存拷贝
void CopyWithDMA(unsigned char* source, unsigned char* target, unsigned int len)
{
  //DMA 初始化结构体
  dma_parameter_struct dma_init_struct;
  
  //配置 DMA
  rcu_periph_clock_enable(RCU_DMA0);                          //使能 DMA 时钟
  dma_deinit(DMA0, DMA_CH0);                                  //复位 DMA 通道
  dma_init_struct.direction    = DMA_PERIPHERAL_TO_MEMORY;    //设置 DMA 数据传输方向,外设到内存
  dma_init_struct.memory_addr  = (uint32_t)target;            //内存地址设置
  dma_init_struct.memory_inc   = DMA_MEMORY_INCREASE_ENABLE;  //使能内存地址增涨
  dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT;       //内存数据位数设置
  dma_init_struct.number       = len;                         //内存数据量设置
  dma_init_struct.periph_addr  = (uint32_t)source;            //外设地址设置
  dma_init_struct.periph_inc   = DMA_PERIPH_INCREASE_ENABLE;  //使能外设地址增涨
  dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT;   //外设数据位数设置
  dma_init_struct.priority     = DMA_PRIORITY_MEDIUM;         //优先级设置
  dma_init(DMA0, DMA_CH0, &dma_init_struct);                  //根据参数初始化
  dma_circulation_disable(DMA0, DMA_CH0);                     //单次传输
  dma_memory_to_memory_enable(DMA0, DMA_CH0);                 //使能内存到内存模式

  //开启 DMA 传输
  dma_channel_enable(DMA0, DMA_CH0);
  
  //等待传输完成
  while(RESET == dma_flag_get(DMA0, DMA_CH0, DMA_FLAG_FTF)){}
}

附上测试代码

int main(void)
{
  static u8 s_arrSourceBuf[1024];
  static u8 s_arrTargetBuf[1024];
  unsigned int i, error;
  
  //初始化串口
  InitUART0(115200);
  
  //初始化源缓冲区和目的缓冲区
  for(i = 0; i < 1024; i++)
  {
    s_arrSourceBuf[i] = (u8)i;
    s_arrTargetBuf[i] = 0;
  }
  
  //内存拷贝
  CopyWithDMA(s_arrSourceBuf, s_arrTargetBuf, 1024);
  
  //验证
  error = 0;
  for(i = 0; i < 1024; i++)
  {
    if(s_arrTargetBuf[i] != s_arrSourceBuf[i])
    {
      error = 1;
      break;
    }
  }
  
  //验证成功
  if(0 == error)
  {
    printf("Copy ok\r\n");
  }
  else
  {
    printf("Fail to copy\r\n");
  }
  
  //主循环
  while(1)
  {
    
  }
}

2条评论

评论已关闭。