STM32
直播中

王玉兰

7年用户 1289经验值
私信 关注
[问答]

如何去实现一种基于STM32的DMA驱动设计呢

串口与DMA是如何结合的

如何去实现一种基于STM32的DMA驱动设计呢?

回帖(1)

甘孟杰

2021-12-10 12:07:47
       stm32串口通信是开发中比较常用的功能,基本大家都会需要它来向别的设备或者PC端传递一些信息。然而大多数人却不能很好地设计一个好的串口驱动。对于新手或者比较懒的人一般直接赋值粘贴的网上的代码,一般这些代码包含三个功能:初始化、发送数据、中断入口函数。然后稍微变更一下端口号波特率之类的配置就可以用了。若只是调试这样用也无可厚非,但若要加入到一个系统中则有些不太严谨,对于一个系统来说追求的是占用最少的资源来实现一些外设功能。直接赋值粘贴的代码存在如下问题:
1、通用性比较弱,如初始化函数需要在不同的地方直接更改设置,规范的写法应该将基本配置放在头文件并使用宏定义集中配置。
2、对于STM32来说比较占用资源,如发送函数一般是阻塞发送,而串口通讯速率又相对较低,势必会消耗占用系统时间
3、频繁进入中断来接收数据,一般串口中断会开启接收缓存区非空中断,即每次接收一个字节都要进入一次中断,这样中断资源势必吃紧
总之如果仅仅测试可以随意,但当开发中如果感觉资源不够可以参考本文串口驱动。使用这个驱动的好处是配置简单、后台发送和接收不占用资源、调用简单。驱动适用于STM32F4系列,使用标准外设库。其他系列也可以做参考,因为本文注重介绍的是思路。
  串口通信简介

  首先还是先简单介绍下串口通信,一般串口通信包含两个接线口:TX接口和RX接口。通讯不分主从直接连接,如设备1和设备2连接时需要交叉连接,即设备1的TX接设备2的RX,设备1的RX接设备2的TX。然后双方使用同一个串口配置即可成功通讯,一般串口设置包括波特率、数据位数、停止位数、校验位数。如果这些不太懂可以百度之,这里就不详细介绍了,其实都是和硬件时序有关。这里向大家普及下波特率,原来一直不太懂波特率,如波特率9600代表什么,其实波特率单位是bps,翻译一下即为bits per second,即比特/每秒。我们一般接收数据都是以字节接收到单片机的,所以有人可能会误认为9600波特率代表每秒9600个字节数据,其实是错的。如下图是使用逻辑分析仪抓取的一个以9600波特率通讯的字节波形
  

  

这里使用了标准的配置,数据位是8 bit,停止位 1bit,再加上1 bit起始位一共是10 bit。所以若满负载发送时(即两个字节之间没有空隙)其发送速率为9600/10=960 bytes/s。图上A1和A2之间为这个字节的起始位,测试发现A1-A2=104.16us,这个数大小约为1000000us/9600,这也印证了9600指的是比特而不是字节。
  STM32 USART通讯

  既然是基于STM32的设计所以这里还需要介绍USART通讯。这里着重介绍下几个寄存器和程序。
stm32关于串口的寄存器列表如下:
  [tr]寄存器描述[/tr]
USART_SR状态寄存器
USART_DR数据寄存器
USART_BRR波特率寄存器
USART_CR1控制寄存器 1
USART_CR2控制寄存器 2
USART_CR3控制寄存器 3
对于一般我们复制的初始化程序一般如下

//GPIO端口设置
  GPIO_InitTypeDef GPIO_InitStructure;
        USART_InitTypeDef USART_InitStructure;
       
        RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOC,ENABLE); //使能GPIOC时钟
        RCC_APB1PeriphClockCmd(RCC_APB1Periph_UART4,ENABLE);//使能USART4时钟

        //串口4对应引脚复用映射
        GPIO_PinAFConfig(GPIOC,GPIO_PinSource10,GPIO_AF_UART4); //GPIOA10复用为USART4
        GPIO_PinAFConfig(GPIOC,GPIO_PinSource11,GPIO_AF_UART4); //GPIOA11复用为USART4
       
        //USART1端口配置
  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11; //GPIOA10与GPIOA11
        GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;//复用功能
        GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;        //速度50MHz
        GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //推挽复用输出
        GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //上拉
        GPIO_Init(GPIOC,&GPIO_InitStructure); //初始化
   //USART1 初始化设置
        USART_InitStructure.USART_BaudRate = bound;//波特率设置
        USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长为8位数据格式
        USART_InitStructure.USART_StopBits = USART_StopBits_1;//一个停止位
        USART_InitStructure.USART_Parity = USART_Parity_No;//无奇偶校验位
        USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//无硬件数据流控制
        USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;        //收发模式
          USART_Init(UART4, &USART_InitStructure); //初始化串口4
        USART_ITConfig(UART4, USART_IT_RXNE, ENABLE);        /* 使能接收中断 */
        USART_Cmd(UART4, ENABLE);  //使能串口6
        USART_ClearFlag(UART4, USART_FLAG_TC);


其实这里关于串口的配置主要调用的是:


USART_Init(UART4, &USART_InitStructure); //初始化串口4


如果大家感兴趣可以参考一下标准库这个函数具体内容,主要做的工作是配置了串口CR1、CR2、CR3、和BRR这几个寄存器,而这几个寄存器里面内容是关于以上提到的串口配置,如波特率、数据位数、停止位数、校验位数等配置。这里不建议大家再详细了解每个寄存器每个位对应什么,对于我们来说只需要会调用官方库配置即可。省的费一番苦心造一个官方早就造好的轮子,而且还不一定比官方的好。需要的时候再查阅对应的寄存器即可。完成初始化后就剩发送接收寄存器了。这里USART_DR寄存器承担了这部分功能,复制标准库的发送和接收函数如下:

/**
  * @brief  Transmits single data through the USARTx peripheral.
  * @param  USARTx: where x can be 1, 2, 3, 4, 5 or 6 to select the USART or
  *         UART peripheral.
  * @param  Data: the data to transmit.
  * @retval None
  */
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data)
{
  /* Check the parameters */
  assert_param(IS_USART_ALL_PERIPH(USARTx));
  assert_param(IS_USART_DATA(Data));
   
  /* Transmit Data */
  USARTx->DR = (Data & (uint16_t)0x01FF);
}


/**
  * @brief  Returns the most recent received data by the USARTx peripheral.
  * @param  USARTx: where x can be 1, 2, 3, 4, 5 or 6 to select the USART or
  *         UART peripheral.
  * @retval The received data.
  */
uint16_t USART_ReceiveData(USART_TypeDef* USARTx)
{
  /* Check the parameters */
  assert_param(IS_USART_ALL_PERIPH(USARTx));
  
  /* Receive Data */
  return (uint16_t)(USARTx->DR & (uint16_t)0x01FF);
}


不难看出发送时其实是将数据直接赋值给USARTx->DR,而接收数据时


return (uint16_t)(USARTx->DR & (uint16_t)0x01FF);


所以大多数应用中发送函数都是使用for循环不断调用USART_SendData发送,并且在发送前阻塞查询状态寄存器SR的值,形式如下:


void Usart_SendBytes(USART_TypeDef *USARTx, unsigned char *str, unsigned short len)
{
        unsigned short count = 0;
        for(; count < len; count++)
        {
                USART_SendData(USARTx, *str++);                                                //发送数据
                while(USART_GetFlagStatus(USARTx, USART_FLAG_TC) == RESET);                //等待发送完成
        }
}


这样的发送方式首先会阻塞运行,其次每次发送之间存在间隙,不能达到满负载发送,即发送两个字节之间会有空隙。
接收时一般会在中断中处理:


void USART1_IRQHandler(void)
{
        if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)        //接收中断
        {
                USART_ReceiveData(USART1)
                USART_ClearFlag(USART1, USART_FLAG_RXNE);
        }
}


对于需要小批量解析数据勉强可以接受,一旦通讯比较频繁这种方式将会占用大量中断资源。
  串口与DMA结合

  DMA的设计主要来解决外设通讯时比较频繁占用CPU的问题,所以建议在通讯时尽量使用DMA来完成。然而使用DMA最大的问题是大家不知道怎么用。因为根本不理解DMA是做什么用的。其实DMA可以理解为外设与内存之间的搬运工。众所周知我们最终的数据都是要放在内存里的,如串口通讯接收时将寄存器DR的数据读取出来然后存放在某个数组或者变量里。但实现这个功能需要我们写接收程序,大概如下代码:

unsigned char date;
date=USART_ReceiveData(USART1);


这样我们就将串口1的数据存放在变量date里了。因为是由程序完成,所以也就相当于CPU在处理接收这件事。而这些工作其实可以交给DMA来完成的,我们的CPU可以不去干预读取这件事。怎么实现?
这就需要我们通过程序配置,因为DMA是外设与内存之间的搬运工,所以需要CPU(程序)告诉DMA外设和内存的地址。这样它才能完成搬运。同时可以配置搬运数量,当搬运完成后DMA即可通知CPU(中断)已经搬完(该支付工钱了,嘿嘿),如上面串口接收的例子中我们提前配置告诉DMA外设地址为USART1->DR,内存地址位&date,接收数量为1(当然大多数应用DMA接收数量比较大),这样当接收完成后,date这个变量已经由DMA更新为串口接收的数据了,CPU收到DMA通知后直接操作这个变量就可以了。
读到这里可能已经明白DMA的作用了,但还是有些问题比较难以解决。对于串口通讯,需要发送数据时比较好实现,因为知道需要发送的数据量大小,只需要将要发送的数据放入一个数组内,然后配置DMA外设和内存地址及发送数据量。然后DMA发送完后通知CPU,至此确认发送完成。但是对于接收数据提前并不知道要接收数据的大小,而DMA一旦配置完数据量大小后直到接收指定长度后才会通知CPU。所以对于DMA接收驱动设计来说非常棘手。所以到了核心思想揭秘时刻,请看下一节
  DMA接收思路

  为什么这个问题单独开一节,主要这个思路很重要。
为什么这个问题单独开一节,主要这个思路很重要。
为什么这个问题单独开一节,主要这个思路很重要。
对于上文的疑问,在未知接收数据量大小的时候核心的思想是尽可能开辟比较大的接收内存。同时需要根据系统处理接收数据的周期来定制接收内存。举例如对于一个960 Byte/s的串口通讯其满负载情况下每秒960字节,假如我们处理周期为1s,则接收内存最好选择比960稍大的量,有一定的冗余。这样即使外设满负载通讯也不会出现数据丢失问题。
这里还是没有解决核心问题:DMA只有在接收完成设置的数据量后才会通知CPU,也就没有了触发信号,处理数据时CPU怎样判断现在到底接收到了多少个数据?
其实这个问题在学习完STM32 DMA后变得相当简单。因为STM32F4的DMA(注意这里是STM32F4,并不是所有STM32)可以记录当前剩余数据传输量。这个寄存器便是DMA_SxNDTR,寄存器描述如下:
  

   位 31:16 保留,必须保持复位值。
位 15:0 NDT[15:0]:要传输的数据项数目 (Number of data items to transfer)
要传输的数据项数目(0 到 65535)。只有在禁止数据流时,才能向此寄存器执行写操作。
使能数据流后,此寄存器为只读,用于指示要传输的剩余数据项数。每次 DMA 传输后,此
寄存器将递减。
传输完成后,此寄存器保持为零(数据流处于正常模式时),或者在以下情况下自动以先前
编程的值重载:
— 以循环模式配置数据流时。
— 通过将 EN 位置“1”来重新使能数据流时
如果该寄存器的值为零,则即使使能数据流,也无法完成任何事务。
  
  这个功能简直为串口接收量身打造,首先 “每次 DMA 传输后,此寄存器将递减。”这样当CPU配置完成接收数据量后寄存器的值便更新为数据量大小。即如果配置DMA传输数据量为256,则这个寄存器便更新为256,当接收到数据后这个寄存器值会递减,也就是我们在处理数据时只需要读取这个寄存器数值即可得出接收数据的长度。如读取到的值为255(配置长度为256)则代表串口一共接收到256-255=1个数据。是不是完美解决了不知道接收的数据量问题。还有另外一个功能便是
“传输完成后,此寄存器保持为零(数据流处于正常模式时),或者在以下情况下自动以先前
编程的值重载:
— 以循环模式配置数据流时。
— 通过将 EN 位置“1”来重新使能数据流时”
可以看到在循环模式下当接收完成后这个寄存器可以重载。如设置DMA传输数据长度为256,当传输完成后普通模式下这个寄存器会变为0,DMA可以配置此时产生中断来告知CPU传输完成。但这不是最完美的方法,因为这样每次传输一定量的数据后会进入中断,占用中断资源。同时CPU还需要重新配置DMA来开启下一次传输。还有可能在配置时丢失数据。
所以串口接收DMA一定要使用循环模式!
所以串口接收DMA一定要使用循环模式!
所以串口接收DMA一定要使用循环模式!
否则真对不起这个寄存器的功能。接收采用FIFO模型,(不懂FIFO的一定要百度,这里偷个懒不讲了)每次读取缓存区的数据时记录好已经读取的数据位置,作为下次读取的参考,举例如下表:
                                                                                                                                                                                                                                                                                                    
  驱动程序介绍

  驱动思路完全按照以上思路实现,其中发送部分逻辑比较简单就没有过多介绍,主要接收部分逻辑比较复杂故而编写了大量说明。测试使用这种方法接收完全不占用中断资源,只需要配置好接收缓存和读取周期即可。发送部分只需配置后发送即可,完全后台发送,无需阻塞等待。使用步骤如下:
1、按照如下格式配置端口信息,如串口号、引脚、DMA等,根据实际需要更改即可

#define SERIAL_UART2_PORT        GPIOD
#define SERIAL_UART2_RX_PIN        GPIO_Pin_6
#define SERIAL_UART2_TX_PIN        GPIO_Pin_5
#define SERIAL_UART2_RX_SOURCE        GPIO_PinSource6
#define SERIAL_UART2_TX_SOURCE        GPIO_PinSource5
#define SERIAL_UART2_RX_DMA_ST        DMA1_Stream5
#define SERIAL_UART2_TX_DMA_ST        DMA1_Stream6
#define SERIAL_UART2_RX_DMA_CH        DMA_Channel_4
#define SERIAL_UART2_TX_DMA_CH        DMA_Channel_4
#define SERIAL_UART2_TX_DMA_IT        DMA1_Stream6_IRQHandler
#define SERIAL_UART2_TX_IRQn        DMA1_Stream6_IRQn
#define SERIAL_UART2_RX_TC_FLAG        DMA_FLAG_TCIF5
#define SERIAL_UART2_RX_HT_FLAG        DMA_FLAG_HTIF5
#define SERIAL_UART2_RX_TE_FLAG        DMA_FLAG_TEIF5
#define SERIAL_UART2_RX_DM_FLAG        DMA_FLAG_DMEIF5
#define SERIAL_UART2_RX_FE_FLAG        DMA_FLAG_FEIF5
#define SERIAL_UART2_TX_TC_FLAG        DMA_FLAG_TCIF6
#define SERIAL_UART2_TX_HT_FLAG        DMA_FLAG_HTIF6
#define SERIAL_UART2_TX_TE_FLAG        DMA_FLAG_TEIF6
#define SERIAL_UART2_TX_DM_FLAG        DMA_FLAG_DMEIF6
#define SERIAL_UART2_TX_FE_FLAG        DMA_FLAG_FEIF6


2、调用函数serialOpen 函数打开对应串口,举例如下:


serialPort_t *serialHandle;
serialHandle=serialOpen(USART1, 9600, USART_HardwareFlowControl_None, 128, 128);


打开前请确认配置过对应的端口,参考第一步配置。
3、发送数据调用函数j举例如下:


_serialStartTxDMA(serialHandle, buf, 20, txDMACallback, txDMACallbackParam);


这里的txDMACallback为发送完成后的回调函数,txDMACallbackParam为回调参数。
4、接收数据需要调用函数:


serialRead(serialHandle)


返回值为读取的数值。需要用户以一定频率调用来保证接收缓存区不会溢出。同时提供串口是否有数据函数


serialAvailable(serialHandle);


推荐用法为:


unsigned char date;
while(serialAvailable(serialHandle))
{
        date=serialRead(serialHandle);
        process(date);
}
举报

更多回帖

发帖
×
20
完善资料,
赚取积分