21

傻孩子(GorgonMeducer) · 2022年11月22日 · 北京市

群友看傻了!三个简单步骤我就把LCD刷新率逼到了理论极限

编者荐语:

我们常常戏谑的说“8帧可玩、9帧不卡、10帧电竞”,在小资源的MCU上跑GUI已然是奇迹,居然还可以压榨出远超“电竞”的帧率,这是如何做到的呢?这篇手把手教程你真不应该错过。

以下文章来源于嵌入式小书虫,作者FledgingSu 支离苏

【说在前面的话】

做过UI界面都知道,往LCD刷新一帧数据动辄就是几十毫秒,甚至一些低端的芯片(flash<64K,ram<32K)动不动就是几百毫秒,这个数量级真是让人头痛,为此也有很多大佬绞尽脑汁来优化这个时间,并取得了较好的效果。哈哈,所以我们今天就可以站在巨人的肩膀上也来优化一下这个让人头痛时间。

要优化这个时间,当然的有测量时间的工具。众所周知(玩过Arm-2D的都知道),Arm-2D有一个小工具就是会给我们计算FPS和LCD Latency的时间,并在屏幕下方用绿色的小字显示出来,如下图
image.png

  • 其中,FPS就是我们常说的刷新率(单位为Hz),即芯片每秒刷新多少帧画面。后面的37ms即渲染一帧画面所需的时间,FPS的计算公式为(26 = 1000ms/37ms),这个参数对我们做优化是非常方便的
  • LCD Latency时间为芯片把一帧(显存RAM)的数据搬到LCD显示的时间,这个“刷新显存”所消耗的时间是由芯片和LCD之间的连接方式决定的,而与芯片的2D图形处理能力无关,因此,上面的FPS也没有统计这个时间。

综上,如果我们想计算实际的fps(_注意这里我们用小写字母表示_),那就需要加上LCD Latency的时间,计算公式如下:实际fps= 1s/(渲染一帧画面所需的时间 + LCD Latency时间)

图中,我们实际的fps= 1000ms/(37ms + 263ms)=3.333Hz

image.png
由此,我们不难发现,LCD Latency时间是非常影响我们实际的fps,所以,接下来我们就开始压榨这个时间,让他从263ms变成0ms

image.png

啥,变成0ms,不太可能吧......

哈哈哈,别着急,且看我们是怎么一步步把他优化到最小的。

【测试前的准备】

在优化之前,我们有必要把测试时需要的软硬件配置说明一下,如下表所示:

image.png

好,配置好以后我就开始编写今天的测试程序。

首先,我们找两张图片,用来填充整个屏幕,并且每秒钟切换一次,图片素材如下:
image.png
素材大小都为50*50像素,然后填充整个屏幕就可以,如下:


if(ground_show_flag & 0x01){          
   arm_2dp_rgb16_tile_fill_only(NULL,
         &c_tileground_1RGB565,
         ptTile,
         NULL);             
}else{
   arm_2dp_rgb16_tile_fill_only(NULL,
         &c_tileground_2RGB565,
         ptTile,
         NULL);
}         
  • _ground_show_flag_ 每秒增加一次就实现了屏幕的切换

为了方便观察,我们再增加一个变量,每刷新一帧数据增加一次,并显示出来,一秒后清零,也就是他的最大值就是我们实际的fps,程序如下


if(bIsNewFrame){
    fps_show_num++;
}
arm_lcd_printf("%d",fps_show_num-1);

清零函数如下

static void __on_scene0_frame_complete(arm_2d_scene_t *ptScene)
{
    if (arm_2d_helper_is_time_out(1000, &this.lTimestamp)) {        
        ground_show_flag++;
        //1秒后清零
        fps_show_num = 0;
    }
}

接着,就是屏幕绘制的驱动程序,我们采用硬件spi来驱动(spi初始化的程序就先不贴了),如下


void LCD_Show_Picture_Spi(   int_fast16_t x, int_fast16_t y, 
                        int_fast16_t width, int_fast16_t height,
                        uint8_t *frame_ptr)
{  
  u16 i,j;
  u32 k=0;
  /* Setup paint area. */
  LCD_1IN3_SetWindows(x, y, x+width-1, y+height-1);
  SPI_ST7789_MODE_DAT() ;
  SPI_ST7789_CS_LOW()  ;
  /* write to screen. */
  for(i=0;i<uiWidth;i++)
  {
    for(j=0;j<uiHeight;j++)
    {
      ST7789_WriteByte(frame_ptr[k*2]);
      ST7789_WriteByte(frame_ptr[k*2+1]);
      k++;
    }
  }
  SPI_ST7789_CS_HIGH();
} 

测试程序我们就编写好了,实际运行效果如下:

image.png

视频中左上角的数字显示的最大值是3,和我们的计算一样,而LCD Latency=263ms我们也运行出来了,可是该怎么减少它呢?

首先,我们再看看屏幕驱动程序,不难发现他使用了两个for循环,如果我们改成一个效果会不会好一些呢?

那我们就试一试,修改后的程序如下:


void LCD_Show_Picture_Spi2(   int_fast16_t x, int_fast16_t y, 
                        int_fast16_t width, int_fast16_t height,
                        uint8_t *frame_ptr)
{
    uint16_t i;
    int len;
    LCD_1IN3_SetWindows(x, y, x+width-1, y+height-1);
    SPI_ST7789_MODE_DAT() ;
    SPI_ST7789_CS_LOW()  ;
    
   len = height*width*2;
   for ( i = 0; i < len; i+=2) {              
      SPI_I2S_SendData(SPI_ST7789_SPI, frame_ptr[i]); 
       while(SPI_I2S_GetFlagStatus(SPI_ST7789_SPI, SPI_I2S_FLAG_TXE) == RESET);       
       SPI_I2S_SendData(SPI_ST7789_SPI, frame_ptr[i+1]);   
      while(SPI_I2S_GetFlagStatus(SPI_ST7789_SPI, SPI_I2S_FLAG_TXE) == RESET);           
  }      
    SPI_ST7789_CS_HIGH(); 
}

此驱动程序的运行效果如下:
image.png
看来效果还是很不错,直接少了115ms(* ̄︶ ̄)

对了,你的spi的速度是多少?

系统频率是72M,我设置成2分频,也就是36M。

那我们就用计算器计算一下理论上传输一帧数据所需要的时间,

spi时钟速率是36M,那传输一个bit就需要1s/36M*2=55ns,如下图所示
image.png
那传输一个字节就需要55*8ns,屏幕是RGB565的,一个像素需要55*8*2ns,屏幕大小为320*240像素,所以传输一帧数据所需要的时间为55*8*2*320*240ns,如下图所示
image.png
我们用计算器算出来的传输一帧数据所需要的时间为68 266 666ns,也就是68.27ms,看来硬件spi的优化空间还有80ms啊.

你计算的好有道理啊,可是这80ms又该如何优化呢?在少一个for循环好像不可以了...

image.png

【硬件SPI+DMA驱动】

此时,你好像想起来,有一种东西叫做DMA。

image.png

DMA基础知识

DMA(Direct Memory Access,直接存储器访问),可以简单理解为,大量的重复性工作经过CPU“牵线搭桥”后,剩下的工作就由他自己重复性的进行即可,不需要时刻关注。也就是DMA传输方式无须CPU直接控制传输,通过硬件为ram与IO设备开辟一条直接传输数据的通路,能使CPU的效率大大提高。如下图
image.png
这里,我们就用DMA把显存ram中的数据通过硬件spi接口搬到LCD去显示,DMA的配置程序如下:


uint16_t DMA1_MEM_LEN;//保存DMA每次数据传送的长度       
/*DMA1的各通道配置
这里的传输形式是固定的,这点要根据不同的情况来修改
从存储器->外设模式/8位数据宽度/存储器增量模式
DMA_CHx:DMA通道CHx
cpar:外设地址
cmar:存储器地址
cndtr:数据传输量 */
void MYDMA_Config(DMA_Channel_TypeDef* DMA_CHx,u32 cpar,u32 cmar,u16 cndtr)
{
   RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);  //使能DMA传输
  DMA_DeInit(DMA_CHx);   //将DMA的通道1寄存器重设为缺省值
  DMA1_MEM_LEN=cndtr;
  DMA_InitStructure.DMA_PeripheralBaseAddr = cpar;  //DMA外设ADC基地址
  DMA_InitStructure.DMA_MemoryBaseAddr = cmar;  //DMA内存基地址
  DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST;  //数据传输方向,从内存读取发送到外设
  DMA_InitStructure.DMA_BufferSize = cndtr;  //DMA通道的DMA缓存的大小
  DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;  //外设地址寄存器不变
  DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;  //内存地址寄存器递增
  DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;  //数据宽度为8位
  DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; //数据宽度为8位
  DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;  //工作在正常缓存模式
  DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; //DMA通道 x拥有中优先级 
  DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;  //DMA通道x没有设置为内存到内存传输
  DMA_Init(DMA_CHx, &DMA_InitStructure);  //根据DMA_InitStruct中指定的参数初始化DMA的通道USART1_Tx_DMA_Channel所标识的寄存器
  DMA_ITConfig(DMA1_Channel5,DMA_IT_TC,ENABLE);      
}
//开启一次DMA传输
void MYDMA_Enable(DMA_Channel_TypeDef*DMA_CHx)
{ 
  DMA_Cmd(DMA_CHx, DISABLE );
  DMA_SetCurrDataCounter(DMA1_Channel5,DMA1_MEM_LEN);
  DMA_Cmd(DMA_CHx, ENABLE);
}      

屏幕驱动程序如下:


void LCD_Show_Picture_for_DMA(u16 x,u16 y,u16 length,u16 width,const u8 pic[])
{
  u16 num;
  num=length*width*2;
  
  LCD_1IN3_SetWindows(x,y,x+length-1,y+width-1);
  SPI_ST7789_MODE_DAT() ;
  SPI_ST7789_CS_LOW()  ;
  ||配置DMA传输数据
  MYDMA_Config(DMA1_Channel5,(u32)&SPI2->DR,(u32)pic,num);
  SPI_I2S_DMACmd(SPI2,SPI_I2S_DMAReq_Tx,ENABLE);
  MYDMA_Enable(DMA1_Channel5);
  ||等待数据传输完成
  while(1)
  {
    if(DMA_GetFlagStatus(DMA1_FLAG_TC5)!=RESET)//等待通道5传输完成
    {
      DMA_ClearFlag(DMA1_FLAG_TC5);//清除通道5传输完成标志
      break; 
    }
  }
    SPI_ST7789_CS_HIGH();//DEV_Digital_Write(EPD_CS_PIN, 1);  
}

此驱动程序的运行效果如下

image.png

  • 哇哦,非常完美,已经接近我们的理论值(68ms)了(* ̄︶ ̄)

image.png

你等等,不是说好的要优化到0ms吗?

【DMA+ISR驱动屏幕】

好,接下来我们就让他变成0ms。上面的DMA驱动有一个很大的问题,想必大家也发现了,就是我们让CPU在while循环那里死等数据发送完成,

DMA在搬运数据时,cpu什么也没干,只是等待它发送完成。所以,我们接下来就是把这个死等的过程优化掉,让cpu和DMA实现真正的并行运行。此时,我们就需要用到DMA+ISR(interrupt service routine中断服务程序)了,也就是当DMA发送完成后,通过中断服务程序来通知CPU发送完成,这样CPU在DMA发送数据的同时就可以干其他的事情,不用在那里死等发送完成了。

那问题又来了,这个ISR是怎么通知到Arm-2D的呢?

哈哈哈,其实这个Arm-2D早就为我们准备好回调函数了,
image.png
那我们就去看看怎么在Arm-2D中设置DMA+ISR,如下
image.png
设置好之后,点击编译,此时会报一个错误,如下


linking...
.\Objects\testArm2D.axf: Error: L6218E: Undefined symbol __disp_adapter0_request_async_flushing (referred from arm_2d_disp_adapter_0.o).

大家不要慌,这个是正常的,他提示我们有一个符号__disp_adapter0_request_async_flushing未定义,我们定义一下就可以了,如下

void __disp_adapter0_request_async_flushing( 
                void *pTarget,
                bool bIsNewFrame,
                int16_t iX, 
                int16_t iY,
                int16_t iWidth,
                int16_t iHeight,
                const uint16_t *pBuffer){
    LCD_ShowPicture(iX,iY,iWidth,iHeight,pBuffer);                                                                                                        
}

他还调用了LCD_ShowPicture函数(是需要自己加的),其实这个函数就是去掉while循环的LCD_Show_Picture_forDMA函数,如下

void LCD_ShowPicture(u16 x,u16 y,u16 length,u16 width,const u8 pic[])
{
  u16 num;
  num=length*width*2;
  LCD_1IN3_SetWindows(x,y,x+length-1,y+width-1);
  SPI_ST7789_MODE_DAT() ;
  SPI_ST7789_CS_LOW()  ;//LCD_CS_Clr();
  MYDMA_Config(DMA1_Channel5,(u32)&SPI2->DR,(u32)pic,num);
  SPI_I2S_DMACmd(SPI2,SPI_I2S_DMAReq_Tx,ENABLE);
  MYDMA_Enable(DMA1_Channel5);
  /*while(1)
  {
    if(DMA_GetFlagStatus(DMA1_FLAG_TC5)!=RESET)//等待通道5传输完成
    {
      DMA_ClearFlag(DMA1_FLAG_TC5);//清除通道5传输完成标志
      break; 
    }
  }*/
    //SPI_ST7789_CS_HIGH();  
}
  • 避坑指南:这里需要注意的是最后一行也要注释掉,因为SPI的CS线置高要等数据发送完成后。

DMA发送完成的中断服务函数如下:


void DMA1_Channel5_IRQHandler(void){
    if(DMA_GetFlagStatus(DMA1_FLAG_TC5)!=RESET)//等待通道5传输完成
    {
      DMA_ClearFlag(DMA1_FLAG_TC5);//清除通道5传输完成标志
      SPI_cs_high();//SPI_ST7789_CS_HIGH();                           
      disp_adapter0_insert_async_flushing_complete_event_handler();            
    }
}

中断服务函数中只需要做3件事就可以了:

  1. 清除DMA传输完成中断标志
  2. 将SPI的CS线置高
  3. 调用disp_adapter0_insert_async_flushing_complete_event_handle

函数通知Arm-2D数据传输完成(这个函数也不需要我们自己实现

当然,在开启DMA中断时,要记得配置NVIC中断控制器,否则是进不了中断的,配置NVIC如下:


void spi_ir_init(){
    NVIC_InitTypeDef NVIC_Init_Struct;
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);
    NVIC_Init_Struct.NVIC_IRQChannel = DMA1_Channel5_IRQn;
    NVIC_Init_Struct.NVIC_IRQChannelPreemptionPriority = 0;
    NVIC_Init_Struct.NVIC_IRQChannelSubPriority = 0;
    NVIC_Init_Struct.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_Init_Struct);
    
}

好了,DMA+ISR的驱动程序我们就写好了,运行效果如下:
image.png
看吧,LCD Latency的时间已经变成0ms了,不过左上角的最大值还是9,居然和开DMA死等是一样的。
image.png
淡定、淡定,我们坐下来分析分析......

这个LCD Latency所测试的时间只是LCD_ShowPicture函数使用的时间,他去掉了while死循环,所以执行时间就小于1ms,就显示0了(当然,LCD Latency的时间具体是怎么测试的大家还需要自己看官方的代码,我们这里就不展开讨论了)。

那实际的fps也没增加是怎么回事呢?且看下图

image.png

原来是我们只用了一块PFB导致的CPU实际还是在死等,因为他没有其他事情可做(只有一个刷新显存的任务),聪明的你是不是很快就想到了,那我用2块PFB呢?此时,当DMA在搬运数据的同时CPU也可以继续写数据到另一块PFB显存了。好,那我们就试一试,设置2块PFB也很简单,如下

image.png

设置好后,我们再编译,运行效果如下

image.png

视频中,我们左上角的实际fps已经变成13了,增加了4fps(* ̄︶ ̄)。

哈哈,到这里,你以为我们的优化就完了吗?

no、no、no还没有,

image.png

我们还可以把渲染一帧画面所需的时间(也就是视频中的37ms也优化一下,这个也很简单,只需要把优化等级从Oz变成Ofast就可以,如下

image.png

开启fast后,运行效果如下

image.png

是不是效果很明显,由原来的37ms变成了25ms,左上角的实际fps也增加了1,变成了14.

哇,好棒,请继续压榨。。。。。。

image.png

到这里,大家是不是也思路大开,还可以开Omax优化啊,或者是开3块PFB试试,是的,这些想法都很好,不过我们这里先不试了,我们再坐下来认认真真的算一下,看看还有多少优化空间(* ̄︶ ̄)

很显然,现在制约我们的已经不是渲染一帧画面所需的时间(25ms),因为按这个时间计算,我们的fps已经可以达到40了。现在的主要问题还是硬件SPI,我们在上面已经计算过,刷新一屏的时间需要68.27ms,也就是说DMA一直不停的搬运数据(不准偷懒的情况下),实际的fps理论最大值为(1000ms/68.27ms)=14.6

image.png

也就是说,我们现在的fps=14已经基本达到了理论值,这也解释了为什么有时候开3块PFB对实际的fps的提升也不起作用

image.png

到这里,我们的压榨(优化)就基本结束了,不过今天的彩蛋环节也很精彩,大家不要错过哦。

接下来,是今天的彩蛋环节

image.png

上面我们的压榨已经和理论值非常接近了,想要在继续优化已经不太可能,不过,如果我们使用了脏矩阵(也就是局部刷新),他的刷新帧率还是可以提升的。

此时就需要讲一个概念UPS(update per second),由于我们使用了脏矩阵,只更新那些改变的区域而不是整个帧,此时使用fps(每秒帧数)可能会使人产生误解,因此Arm-2D引入了这个新的术语,称为UPS,以避免这种混淆。它反映了人们观看LCD时的感觉,但不一定意味着整个帧刷新到LCD中的速率。

也就是说UPS强调的是以视觉实际效果为准,而不像fps那样严格的说 Frame per second。

有了这个概念,那我们就在修改一下测试程序,看看这个UPS到底有多快,我们的刷新区域如下
image.png

  • 就是两个红色框的小区域,背景也不需要更换。

设置脏矩阵也很简单,在scene初始化函数__arm_2d_scene0_init中修改就可以,如下

 /*! define dirty regions */
    IMPL_ARM_2D_REGION_LIST(s_tDirtyRegions, static)
    ADD_REGION_TO_LIST(s_tDirtyRegions,
        .tLocation = {
            .iX = 0,
            .iY = 0,
        },
        .tSize = {
            .iWidth = 60,
            .iHeight = 8,
        },
    ),
    ADD_LAST_REGION_TO_LIST(s_tDirtyRegions,
        .tLocation = {
            .iX = 0,
            .iY = 240-17,
        },
        .tSize = {
            .iWidth = 320,
            .iHeight = 9,
        },
    ),
END_IMPL_ARM_2D_REGION_LIST()

修改后的程序运行效果如下

image.png

左上角的ups值已经达到了3位数(由于变化太快,我们已经看不清它的值了),所以我们在修改一下程序,只显示ups的最大值,如下

if(bIsNewFrame){
    fps_show_num++;
    if( fps_show_num > ups_max){
        ups_max = fps_show_num;
    }
}
arm_lcd_printf("ups=%d",ups_max-1);

此时,就到了见证奇迹的时候了,运行效果如下图

image.png

ups居然达到了184,是不是很厉害。

当然,这个ups大是因为我们的脏矩阵太小了,这里我们只是举一个例子。目的是想告诉大家,如果你的fps优化已经达到了极限(理论值),此时记得开启脏矩阵哦。

到这里,我们的压榨之旅就真的结束了。

原文:嵌入式小书虫
作者:FledgingSu 支离苏

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息