记录一些 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,并重新初始化才能继续工作。
DMA0 | Channel0 | Channel1 | Channel2 | Channel3 | Channel4 | Channel5 | Channel6 |
TIMER0 | – | TIMER0_CH0 | TIMER0_CH1 | TIMER0_CH3 TIMER0_TG TIMER0_CMT | TIMER0_UP | TIMER0_CH2 | – |
TIMER1 | TIMER1_CH2 | TIMER1_UP | – | – | TIMER1_CH0 | – | TIMER1_CH1 TIMER1_CH3 |
TIMER2 | – | TIMER2_CH2 | TIMER2_CH3 TIMER2_UP | – | – | TIMER2_CH0 TIMER2_TG | – |
TIMER3 | TIMER3_CH0 | – | – | TIMER3_CH1 | TIMER3_CH2 | – | TIMER3_UP |
ADC0 | ADC0 | – | – | – | – | – | – |
SPI/I2S | – | SPI0_RX | SPI0_TX | SPI1/I2S1_TX | SPI1/I2S1_TX | – | – |
USART | – | USART2_TX | USART2_RX | USART0_TX | USART0_RX | USART1_RX | USART1_TX |
I2C | – | – | – | I2C1_TX | I2C1_RX | I2C0_TX | I2C0_RX |
使用定时器触发 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)
{
}
}
大佬!关注了!
对DMA请求和地址讲得很细节,支持支持