如何解决液晶屏TE(Tearing Effect,显示撕裂)问题
分类: 硬件 发布于: 2023-11-29

最近在调试液晶显示屏的时候,发现屏幕有很明显的撕裂现象。为了验证,编写了一个程序:在死循环里使用红屏和蓝屏交替刷新,肉眼就可以观察到下述现象: 撕裂屏powerstyle={max-width: 60%;min-width=300px}

带着疑惑在网上寻找资料的时候,发现很多屏幕也有类似的问题: 撕裂屏powerstyle={max-width: 80%;min-width=300px} 上图左是Arduino Giga Display官方的demo演示视频截图,可以明显看到在菜单转场动画中,画面出现了斜向撕裂条纹;右是stm32H747-Disco在运行lvgl官方demo时产生了画面撕裂。

解决斜向撕裂

这种情况通常是竖屏横用,或者横屏竖用导致。 powerstyle={max-width: 60%;min-width=300px}

屏幕像素的刷新方向由屏幕面板上的cof芯片决定,即上图红色圈内所示,它是贴装在显示面板上的控制芯片。它连接每一行和每一列像素的电极,作为驱动器件控制每一个像素的开启或关闭。屏幕的横竖方向在制造时已经确定,无法通过软件进行更改。虽然很多cof芯片有对应的寄存器,可以通过软件将其配置为横向显示或者竖向显示,但它实际上只是配置了cof读取GRAM的顺序,并未改变行场信号的刷新方向。如果将竖屏配置为横屏,像素刷新的顺序如同“Z”字型一样,每刷完一行,就会回到下一行的行首,如此往复;而GRAM却完全相反,它是同“N”字型一般,先刷完一列,然后回到下一列的起始再刷新下一列,以此类推……这样一来,在每一行都会产生冲突,即一半为旧帧,一半为新帧,自然会产生斜向撕裂。

解决斜向撕裂的方法,就是保证GRAM的刷新方向与面板的刷新方向保持一致。如果需要改变显示方向,可以开辟一个缓冲区,将图像数据渲染到这个缓冲区,软件旋转90°后再写入GRAM。很多微控制器没有GPU,增加旋转操作可能十分耗时,会显著造成帧率下降。显示效果和执行效率之间,往往需要进行权衡。

解决横向撕裂

斜向撕裂解决之后,比较容易遇到是横向撕裂,如下图: powerstyle={max-width: 60%;min-width=300px} 这种情况是因为屏幕面板和GRAM刷新不同步造成的。面板在刷新像素时,需要读取GRAM,此过程又被称为R(Read);微控制器刷新图像的动作,是向GRAM写数据的过程,因此被称作W(Write)。在屏幕像素刷新时,GRAM还未完成刷新,此时面板上显示的一半是旧帧数据,另一半新帧数据,且会持续1帧的时长,被人眼所看见,即呈现出来撕裂的效果。

下述是从网络上获取的动图,更加清晰的描述横向撕裂的细节: powerstyle={max-width: 60%;min-width=300px}

解决横向撕裂,基本前提是W和R同步。如果不同步,那么W可能发生在R的任意阶段,则必然会造成撕裂。在同步的情况下,对W和R的速率也有一定的要求。

  • 当W > R时,R指针永远在W之后,因此不会造成撕裂,如下图所示: powerstyle={max-width: 60%;min-width=300px}
  • 当W < R 时,须满足 W ≥ 0.5R。下图是临界值的情况: powerstyle={max-width: 60%;min-width=300px} 因为W < R,所以R指针跑在前面,读出的是旧数据,第1帧显示的还是上一帧的图像。第2帧开始刷新时,W指针已经刷完了一半的图像,等到R刷新到最后时,W也已刷完整帧图像,因此GRAM中的图像得以完整的显示出来。如果R再稍微快那么一点,那么在第2帧R指针就又会赶上W指针,这样就会再次出现撕裂现象

由以上可以得出避免横向撕裂结论:

  1. W与R须同步开始
  2. W ≥ 0.5R

上述的第2个条件很容易满足,R的速度由屏幕的帧率决定,W的速度由总线时钟决定,可以通过软件进行配置。以st7789v为例,在PDF中可帧率控制章节可以找到帧率控制模式: powerstyle={max-width: 60%;min-width=300px}

而要满足第一个条件:使WR同步,则需要硬件配合。

TE引脚

常见的cof芯片都有一个TE(Tearing Effect)引脚,它是液晶屏向控制端输出同步信号的引脚。在软件上可以对其进行配置,使其在帧开始之前,输出一个脉冲或者跳变电平信号,控制端收到信号后开始写入GRAM,从而达到同步刷新的目的。

powerstyle={max-width: 80%;min-width=300px}

TE引脚及信号的配置,芯片手册中也有详细说明,通常配置为每帧输出一个脉冲,即下图中的Mode 1。另外,还可以配置TE信号发出的时机,用来微调WR的先后时差,这里主要用来补偿总线的传输时差。以stm32为例,将TE信号作为外部中断触发信号,在中断服务函数中,触发屏幕的刷新,关键代码如下:

#define LCD_WIDTH     240
#define LCD_HEIGHT    320

// 这里的 GRAM 不同于上文提到的 GRAM
// 此处是微控制器中开辟的内存空间,用于缓存图像数据;后者是cof芯片中的RAM
#define LCD_GRAM_LEN ((uint32_t)(LCD_WIDTH*LCD_HEIGHT))

// LCD 硬件设备定义
typedef struct {
	int8_t frontLayer; // 前景层,待刷新层
	int8_t lastFlushLayer; // 上次刷新的图层

    // 刷新的区域。0代表上半区,1代表下半区
    // 由于硬件限制,单次无法传输完1帧画面,因此分为两次,先传上半部分,再传输下半部分
	int8_t flushPart;

    // 初始化标志。会在设备初始化完成之后设为1。避免在设备未初始的情况下启动传输
	int8_t isInited;
} LCDTypeDef;

LCDTypeDef hlcd = {0};

// 采用双缓冲,一个图层用于绘制,另外一个图层用于传输(前景层),交替进行
static uint16_t LCD_GRAM[LCD_WIDTH*LCD_HEIGHT*2];
// 保存两个图层的首地址
uint8_t *LCD_LayerAddr[2] = {LCD_GRAM, &(LCD_GRAM[LCD_WIDTH*LCD_HEIGHT])};

// -------------------- MCU 中断入口 -------------------- //
// TE 信号中断,触发帧传输 LCD 传输完成回调
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == GPIO_PIN_6) { // TE 引脚连接到 GPIO_PIN_6
    	LCD_OnTE();
    }
}

// SPI 传输完成中断,执行 LCD 回调函数
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) {
	LCD_SPI_TxCallback(hspi);
}

// -------------------- LCD 驱动部分 -------------------- //
// TE 信号中断回调,用于触发帧传输
int8_t LCD_OnTE(){
	if (!hlcd.isInited) {
		return 1;
	}
    if (hlcd.lastFlushLayer == hlcd.frontLayer || hlcd.flushPart >= 0) {
        // 此图层已刷新过,或者刷新到一半
        return 1;
    }

    // 启动传输,将前景层数据写入屏幕
    hlcd.flushPart = 0; // 初始化刷新区域
    return HAL_SPI_Transmit_DMA(&LCD_SPI, LCD_LayerAddr[hlcd.frontLayer], LCD_WIDTH*LCD_HEIGHT/2);
}

// 单次传输完成回调
void LCD_SPI_TxCallback(SPI_HandleTypeDef *hspi) {
	if (!hlcd.isInited) {
		return;
	}
	if (hlcd.flushPart == 0){
		// 上次 SPI 传输的是上半部分,此时需要启动下半部分的传输
        hlcd.flushPart = 1;
		HAL_SPI_Transmit_DMA(&LCD_SPI, LCD_LayerAddr[hlcd.frontLayer] + LCD_WIDTH*LCD_HEIGHT, LCD_WIDTH*LCD_HEIGHT/2);

	} else if (hlcd.flushPart == 1) {
		// 下半部分的传输已经完成,重置标记
		hlcd.flushPart = -1;
        hlcd.lastFlushLayer = hlcd.frontLayer;
	}
}

// 获取绘制层 GRAM 首地址
uint16_t* LCD_GetBackGRAMAddr() {
    return (uint16_t *) LCD_LayerAddr[hlcd.frontLayer == 0 ? 1 : 0];
}

// 执行 LCD 刷新。在刷新完GRAM之后调用此函数
void LCD_Flush() {
    // 等待刷新完成
    while (hlcd.lastFlushLayer != hlcd.frontLayer);

    // 将绘制层设置为前景层
    hlcd.frontLayer = hlcd.frontLayer == 0 ? 1 : 0;

    // 清除旧帧标记,在下一次进入 LCD_OnTE 时,就会触发 SPI 传输从而刷新画面
    hlcd.lastFlushLayer = -1;
}