【说在前面的话】
其中,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
由此,我们不难发现,LCD Latency时间是非常影响我们实际的fps,所以,接下来我们就开始压榨这个时间,让他从263ms变成0ms
啥,变成0ms,不太可能吧......
哈哈哈,别着急,且看我们是怎么一步步把他优化到最小的。
【测试前的准备】
在优化之前,我们有必要把测试时需要的软硬件配置说明一下,如下表所示:
单片机MCU | stm32f103c8t6,主频72M |
屏幕LCD | 分辨率320*240,接口SPI,RGB565 |
PFB大小 | 160*24 |
Arm-2D编译优化等级 | -Oz(flash最小) |
屏幕驱动程序优化等级 | -O0(不优化) |
好,配置好以后我就开始编写今天的测试程序。
首先,我们找两张图片,用来填充整个屏幕,并且每秒钟切换一次,图片素材如下:
素材大小都为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();
}
测试程序我们就编写好了,实际运行效果如下:
视频中左上角的数字显示的最大值是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();
}
此驱动程序的运行效果如下:
看来效果还是很不错,直接少了115ms(* ̄︶ ̄)
对了,你的spi的速度是多少?
系统频率是72M,我设置成2分频,也就是36M。
那我们就用计算器计算一下理论上传输一帧数据所需要的时间,
spi时钟速率是36M,那传输一个bit就需要1s/36M*2=55ns,如下图所示
那传输一个字节就需要55*8ns,屏幕是RGB565的,一个像素需要55*8*2ns,屏幕大小为320*240像素,所以传输一帧数据所需要的时间为55*8*2*320*240ns,如下图所示
我们用计算器算出来的传输一帧数据所需要的时间为68 266 666ns,也就是68.27ms,看来硬件spi的优化空间还有80ms啊.
你计算的好有道理啊,可是这80ms又该如何优化呢?在少一个for循环好像不可以了...
【硬件SPI+DMA驱动】
此时,你好像想起来,有一种东西叫做DMA。
DMA基础知识
DMA(Direct Memory Access,直接存储器访问),可以简单理解为,大量的重复性工作经过CPU“牵线搭桥”后,剩下的工作就由他自己重复性的进行即可,不需要时刻关注。也就是DMA传输方式无须CPU直接控制传输,通过硬件为ram与IO设备开辟一条直接传输数据的通路,能使CPU的效率大大提高。如下图
这里,我们就用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);
}
此驱动程序的运行效果如下
哇哦,非常完美,已经接近我们的理论值(68ms)了(* ̄︶ ̄)
你等等,不是说好的要优化到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早就为我们准备好回调函数了,
那我们就去看看怎么在Arm-2D中设置DMA+ISR,如下
设置好之后,点击编译,此时会报一个错误,如下
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_for_DMA函数,如下
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件事就可以了:
清除DMA传输完成中断标志
将SPI的CS线置高
调用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的驱动程序我们就写好了,运行效果如下:
看吧,LCD Latency的时间已经变成0ms了,不过左上角的最大值还是9,居然和开DMA死等是一样的。
淡定、淡定,我们坐下来分析分析......
这个LCD Latency所测试的时间只是LCD_ShowPicture函数使用的时间,他去掉了while死循环,所以执行时间就小于1ms,就显示0了(当然,LCD Latency的时间具体是怎么测试的大家还需要自己看官方的代码,我们这里就不展开讨论了)。
那实际的fps也没增加是怎么回事呢?且看下图
原来是我们只用了一块PFB导致的CPU实际还是在死等,因为他没有其他事情可做(只有一个刷新显存的任务),聪明的你是不是很快就想到了,那我用2块PFB呢?此时,当DMA在搬运数据的同时CPU也可以继续写数据到另一块PFB显存了。好,那我们就试一试,设置2块PFB也很简单,如下
设置好后,我们再编译,运行效果如下
视频中,我们左上角的实际fps已经变成13了,增加了4fps(* ̄︶ ̄)。
哈哈,到这里,你以为我们的优化就完了吗?
no、no、no还没有,
我们还可以把渲染一帧画面所需的时间(也就是视频中的37ms)也优化一下,这个也很简单,只需要把优化等级从Oz变成Ofast就可以,如下
开启fast后,运行效果如下
是不是效果很明显,由原来的37ms变成了25ms,左上角的实际fps也增加了1,变成了14.
哇,好棒,请继续压榨。。。。。。
到这里,大家是不是也思路大开,还可以开Omax优化啊,或者是开3块PFB试试,是的,这些想法都很好,不过我们这里先不试了,我们再坐下来认认真真的算一下,看看还有多少优化空间(* ̄︶ ̄)
很显然,现在制约我们的已经不是渲染一帧画面所需的时间(25ms),因为按这个时间计算,我们的fps已经可以达到40了。现在的主要问题还是硬件SPI,我们在上面已经计算过,刷新一屏的时间需要68.27ms,也就是说DMA一直不停的搬运数据(不准偷懒的情况下),实际的fps理论最大值为(1000ms/68.27ms)=14.6
也就是说,我们现在的fps=14已经基本达到了理论值,这也解释了为什么有时候开3块PFB对实际的fps的提升也不起作用。
到这里,我们的压榨(优化)就基本结束了,不过今天的彩蛋环节也很精彩,大家不要错过哦。
接下来,是今天的彩蛋环节
上面我们的压榨已经和理论值非常接近了,想要在继续优化已经不太可能,不过,如果我们使用了脏矩阵(也就是局部刷新),他的刷新帧率还是可以提升的。
此时就需要讲一个概念UPS(update per second),由于我们使用了脏矩阵,只更新那些改变的区域而不是整个帧,此时使用fps(每秒帧数)可能会使人产生误解,因此Arm-2D引入了这个新的术语,称为UPS,以避免这种混淆。它反映了人们观看LCD时的感觉,但不一定意味着整个帧刷新到LCD中的速率。
也就是说UPS强调的是以视觉实际效果为准,而不像fps那样严格的说 Frame per second。
有了这个概念,那我们就在修改一下测试程序,看看这个UPS到底有多快,我们的刷新区域如下
就是两个红色框的小区域,背景也不需要更换。
设置脏矩阵也很简单,在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()
修改后的程序运行效果如下
左上角的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);
此时,就到了见证奇迹的时候了,运行效果如下图
ups居然达到了184,是不是很厉害。
当然,这个ups大是因为我们的脏矩阵太小了,这里我们只是举一个例子。目的是想告诉大家,如果你的fps优化已经达到了极限(理论值),此时记得开启脏矩阵哦。
到这里,我们的压榨之旅就真的结束了。
如果你觉得我的文章对你有所启发,请动动你发财的小手帮我点一个免费的赞,这对我很重要,谢谢啦!
欢迎订阅 嵌入式小书虫