STM32串口DMA实现自定义容量FIFO
1 概述
1.1 含义
通常STM32串口应用主要为查询方式和中断方式,但是这两种方式难以应对高并发的应用场景,实际设计软件的时候需考虑复杂的时序。如果使用查询方式,当软件运行其他功能的时候,外部设备向stm32发送串口数据则可能导致数据获取失败;如果使用中断方式,每收到一个字节都触发中断,则需解决一个软件频繁进入串口接收中断导致其他业务运行效率不高的问题。
而DMA可极大程度提高效率,充分榨干stm32的资源,物尽其用。DMA即内存直接访问,无需CPU参与,映射好串口外设到片内RAM的关系之后,进行托管,需要处理数据时再从RAM中读出数据即可。
FIFO,即先进先出,每次只能出一个字节,也只能进一个字节。
1.2 传统FIFO实例
传统的一种解决如下:在电路板中使用串口FIFO芯片,FIFO芯片与主控芯片之间通过总线连接,通常总线读取FIFO的速率比较快,至少比串口传输快。主控芯片可通过总线读取FIFO里的数据,以及读取FIFO状态(是否空?是否满?FIFO中有多少个字节?)。假设串口数据以5ms周期发送10字节到我们的电路板,主控芯片运行周期为2ms,即每2ms触发一次定时器中断,在中断中机型数据处理,在某中断周期起始的时候判断FIFO中是否收到了10个字节,并且第一个字节为AA,第二个字节为55,并且校验通过,则认为是有效数据,再进行后续的业务处理。
这种传统的FIFO+主控芯片的方案虽然可以节省CPU的时间,在收发数据的同时可以让CPU执行其他业务,但是成本较高,需要额外采用一个FIFO芯片。而且市面上的串口FIFO芯片容量也不会太大,常见的64字节、128字节这个级别的,如果数据再多一些,显然不能再让数据都存在FIFO之后再处理。通常都是只要FIFO中有数据就读出来,在软件中拼接成完整的1帧数据,这种拼接数据的方法是经济、高效的做法,也是很流行的用法,需要在软件中定义一个缓冲区,如果FIFO中的数据不是完整的1帧,先将FIFO的数据放到软件缓冲区中,FIFO收到新的数据之后再继续填到缓冲区中,这样必定可以获取到完整1帧数据,这种方法可以降低对FIFO芯片的要求,即使是容量较小的FIFO,也能保证长数据帧的缓存不受影响。
最近再调试项目的时候,发现一种使用STM32串口DMA+环形缓冲区的方式,实现任意容量的FIFO,相当于把上述FIFO芯片的功能交给DMA来实现,再配合环形缓冲区,可毫无压力地处理源源不断地串口数据,在硬件上节约了FIFO芯片的价钱。
2 环形缓冲区
在此之前,先介绍环形缓冲区。
2.1 环形缓冲区定义
缓冲区的功能就是开辟一段内存空间,缓存数据,在嵌入式设备中一般使用数组,以数组为例如下所示,串口数据依次填入位置0、位置1、位置2,直到最后1个位置:
当收到N个数据后,数组填满:
若此时再来一个数据,则填到位置0,覆盖数据1。这样就好像一个环形,首尾相连:
该环形有两个重要的属性,就是“头”和“尾”,头和尾的作用是计算FIFO中还剩多少个数据待处理。当有数据写入缓冲区时,“尾”向后移动1个单位;当要取出数据进行处理时,“头”向后移动1个单位。头和尾都只能向后移动,当移动到最后1个位置时,下一次移动右从位置0开始,最终形成一个”头“追”尾“的局面。当头的位置与尾的位置相同时,说明缓冲区数据为空;当头落后尾X个位置时,说明缓冲区中有X个数据,注意X最大只能为数组最大容量,若X超过数组最大容量N,就类似于运动员长跑比赛,”头“已经落后”尾“一圈了,这种情况是不允许的,说明运动场不够大,即数组定义得不够大,数据接收的速度大于数据处理的速度。
2.2 环形缓冲区举例
使用STM32串口DMA时,指定了本次DMA的触发次数,若数据出现”头“已经落后”尾“一圈的情况,则需定义下一次比赛时,尾的起始位置有一个偏移。比如数据定义DMA每次触发10个字节,而每次有6字节传输到我们的板子,第一次的时候还挺正常,接收6字节并处理6字节,第二次的时候只剩4次触发DMA的机会。当第二次传输4字节时,就停止了DMA,此时数组里面10个位置都填满了,但是还有2个字节需接收,此时再次使能DMA传输,但是尾指针不能从位置0开始,因为位置0和位置1已经预留给上一次剩下的2个字节了,由于第一次的6字节已经处理了,此时是可以覆盖位置0和位置1的。
STM32的DMA控制器中,没有数据计数的寄存器,但是有DMA触发次数的寄存器CNDTR。如开启一次10个字节的传输,CNDTR就被设置为10,每收到1个字节,CNDTR寄存器的值减小1,当减到0时停止DMA接收。根据这个特性,软件定义一个剩余值,设计计算FIFO长度的值为剩余值减去CNDTR。例如,定义一个10个字节的数组 a[10],那么数组容量就为10,指定数组a[]的首地址为DMA接收的首地址。
当没有收到数据时,剩余值=10,CNDTR=10,缓冲区中的数据为 len = 剩余值 - CNDTR = 0;
假设收到1个字节,CNDTR自动变为9,剩余值仍是10,缓冲区中的数据为 len = 剩余值 - CNDTR = 1,计算完之后,再将CNDTR的值赋值给剩余值,即剩余值=9;
继续接收2字节,CNDTR自动变为7,缓冲区中的数据为 len = 剩余值 - CNDTR = 9 - 7 =2。以此类推。
另外还需考虑上述提到的情况,每帧数据为6字节,不能被数组容量整除的情况。当一次DMA停止时,将剩余值放大到超过数组容量。
由此可确定环形FIFO缓冲区的属性:
typedef struct
{
uint8_t data[RING_BUFF_SIZE];//缓存数据
uint32_t out;//头
uint32_t in;//尾
uint32_t len;//FIFO中的字节数
uint32_t reserve;//灵活变量
}ring_buff;
void Init_ring_buff(ring_buff *buff);/*初始化ring*/
uint8_t Get_ring_fullstate(ring_buff *buff);/*判断ring buff 是否满*/
uint8_t Get_ring_emptystate(ring_buff *buff);/*判断ring buff 是否空*/
int8_t Data_into_ring(ring_buff *buff,uint8_t data);/*写入ring,本项目使用DMA接收,用不到写入操作,硬件自动写入*/
uint8_t Read_ring(ring_buff *buff);/*从fifo中读取1个字节*/
3 实例
项目中按照如下进行配置
DMA开启接收通道,由外设到内存,DMA模式设置为Normal,正常模式,内存递增勾上。也就是在初始化地时候规定好接收N个字节,接收数据时,依次将数据由DMA控制器自动填在内存中,每填1个字节内存地址递增1,当N个字节接收完成后自动关闭DMA,并触发DMA中断服务程序,在中断回调函数中重新开启DMA,为下一次接收数据做准备。
使用CubeMX自动生成代码后,main函数初始的代码如下:
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
while(1)
{
......
}
}
进入循环工作之前,需要我们指定DMA的目标内存地址,可在while(1)之前插入如下代码,改行代码执行就开启了本次DMA传输,当接收到N个字节后,DMA自动停止。
HAL_UART_Receive_DMA(&huart1,目标首地址,一次DMA的数据长度N);
DMA停止之后会触发中断函数,在中断函数中会调用回调函数,在HAL库的注释中也能看到相关说明。
在HAL库中使用__weak定义了一个弱函数,若发现重名的函数,则会取消弱函数的编译.HAL库中该弱函数定义为
/**
* @brief Tx Half Transfer completed callbacks.
* @param huart Pointer to a UART_HandleTypeDef structure that contains
* the configuration information for the specified UART module.
* @retval None
*/
__weak void HAL_UART_TxHalfCpltCallback(UART_HandleTypeDef *huart)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(huart);
/* NOTE: This function should not be modified, when the callback is needed,
the HAL_UART_TxHalfCpltCallback could be implemented in the user file
*/
}
我们需要修改的就是自定义一个同名函数,在该函数中实现重新启动DMA的操作。
初始化时,将灵活变量reserve赋值为数组容量RING_BUFF_SIZE,在轮询函数poll_uart1_program()中,设置“尾”移动,移动的大小为上一次调用轮询函数到这一次调用轮询函数期间收到的字节数。
以2.3节发送2帧数据,每帧6字节举例:
1)定义数组容量为10;
2)当第一次收到6字节后,在第一次调用轮询函数poll_uart1_program()中,dma_remain 为4,len为6,in也为6,reserve最后为4;
3)当继续收到第二帧前4字节时,触发回调函数运行,reserve变为14;
3)收到第二帧后2字节时,轮询函数poll_uart1_program()调用,此时CNDTR为2,len为14-2=12,表示一共收到了12个字节。
给使用者的感觉就像轮询读取FIFO芯片一样。
ring_buff g_uart1_ring;
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
Init_ring_buff(&g_uart1_ring);/*ring初始化*/
g_uart1_ring.reserve = RING_BUFF_SIZE;
HAL_UART_Receive_DMA(&huart1,g_uart1_ring.data,sizeof(g_uart1_ring.data));/*开启DMA接收*/
while(1)
{
poll_uart1_program();//轮询处理串口数据
...//其他业务
}
}
uint32_t poll_uart1_program(void)
{
static uint32_t dma_remain = 0;
dma_remain = hdma_usart1_rx.Instance->CNDTR;
if(dma_remain == g_uart1_ring.reserve) return 0;
g_uart1_ring.len += g_uart1_ring.reserve - dma_remain;
g_uart1_ring.in = (g_uart1_ring.in + g_uart1_ring.reserve - dma_remain) % RING_BUFF_SIZE;
g_uart1_ring.reserve = dma_remain;
return g_uart1_ring.len;
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart == &huart1){
g_uart1_ring.reserve += RING_BUFF_SIZE;
HAL_UART_DMAStop(huart);
huart1.RxState = HAL_UART_STATE_READY;
hdma_usart1_rx.State = HAL_DMA_STATE_READY;
HAL_UART_Receive_DMA(&huart1,g_uart1_ring.data,sizeof(g_uart1_ring.data));
}
}
4 应用:IAP串口软件上传
实例中将RING_BUFF_SIZE定义为4096字节,串口波特率使用115200。这个波特率下,1ms能传输十几个字节。在代码中轮询DMA构建的FIFO,当收到2048个字节时,就将2048个字节写入FLASH,直到bin文件传输完成,实现串口升级。
需使用两个keil工程,1个工程用于boot,实现上述功能;另一个工程为APP,实现需求业务。
boot工程从0x08000000启动,APP从0x08010000启动。
上电后自动运行boot,在500ms内判断,如果收到连续13个‘f’,则进入bin文件上传模式,再收到1个’e’开启文件接收写入处理。如果没有收到任何操作,则跳转到APP运行。
boot工程配置:
APP工程配置:
在APP代码的开始,需要设置向量:
APP生成bin的操作:
D:\Keil_v5\ARM\ARMCLANG\bin\fromelf.exe --bin -o CUBEMX\CUBEMX.bin CUBEMX\CUBEMX.axf
此行代码的作用是调用Keil安装目录下面的fromelf.exe将编译生成的.axf文件转换为bin文件,以我的APP工程为例,文件夹名为CUBEMX。
主要业务逻辑如下:
int main(void)
{
...
while (1)
{
if(poll_uart1_program() >= 2048){//轮询FIFO中的数据是否超过2048字节
cnt = 0;
data_start = 1;
for (i = 0; i < 2048; i ++){
*((uint8_t*)g_iapbuf + i) = Read_ring(&g_uart1_ring);//读取FIFO中2048个字节到g_iapbuf
}
STMFLASH_Write(curent_addr,g_iapbuf,1024);//将g_iapbuf中的2048个字节写入FLASH,每次写16bit
curent_addr += 2048; //FLASH操作地址自加2048
}
HAL_Delay(1);
cnt ++;
if((cnt > 2000) && (data_start == 1) ){//如果2秒未收到数据,说明文件已发送完成
memset(g_iapbuf,0xff,2048);
cnt = g_uart1_ring.len;
for (i = 0; i < cnt; i ++){
*((uint8_t*)g_iapbuf + i) = Read_ring(&g_uart1_ring);//将FIFO中剩余的不足2048个字节全部读出
}
cnt = ((cnt % 2) == 0)? (cnt << 1) : (cnt << 1) + 1;
STMFLASH_Write(curent_addr,g_iapbuf,cnt); //最后一部分数据写入FLASH
printf("program bin file complete.\r\n\r\n");
HAL_Delay(2000);
JumpTo(0x08010000); //跳转到APP运行
}
}
}
5 过程中遇到的问题
调试过程中,刚开始串口中断和DMA中断都打开了,结果只能收到一两个字节,然后再也不触发DMA请求了,后面在CubeMX中将串口中断关闭后就可以了。