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

带着疑惑在网上寻找资料的时候,发现很多屏幕也有类似的问题:
上图左是Arduino Giga Display官方的demo演示视频截图,可以明显看到在菜单转场动画中,画面出现了斜向撕裂条纹;右是stm32H747-Disco在运行lvgl官方demo时产生了画面撕裂。
这种情况通常是竖屏横用,或者横屏竖用导致。

屏幕像素的刷新方向由屏幕面板上的cof芯片决定,即上图红色圈内所示,它是贴装在显示面板上的控制芯片。它连接每一行和每一列像素的电极,作为驱动器件控制每一个像素的开启或关闭。屏幕的横竖方向在制造时已经确定,无法通过软件进行更改。虽然很多cof芯片有对应的寄存器,可以通过软件将其配置为横向显示或者竖向显示,但它实际上只是配置了cof读取GRAM的顺序,并未改变行场信号的刷新方向。如果将竖屏配置为横屏,像素刷新的顺序如同“Z”字型一样,每刷完一行,就会回到下一行的行首,如此往复;而GRAM却完全相反,它是同“N”字型一般,先刷完一列,然后回到下一列的起始再刷新下一列,以此类推……这样一来,在每一行都会产生冲突,即一半为旧帧,一半为新帧,自然会产生斜向撕裂。
解决斜向撕裂的方法,就是保证GRAM的刷新方向与面板的刷新方向保持一致。如果需要改变显示方向,可以开辟一个缓冲区,将图像数据渲染到这个缓冲区,软件旋转90°后再写入GRAM。很多微控制器没有GPU,增加旋转操作可能十分耗时,会显著造成帧率下降。显示效果和执行效率之间,往往需要进行权衡。
斜向撕裂解决之后,比较容易遇到是横向撕裂,如下图:
这种情况是因为屏幕面板和GRAM刷新不同步造成的。面板在刷新像素时,需要读取GRAM,此过程又被称为R(Read);微控制器刷新图像的动作,是向GRAM写数据的过程,因此被称作W(Write)。在屏幕像素刷新时,GRAM还未完成刷新,此时面板上显示的一半是旧帧数据,另一半新帧数据,且会持续1帧的时长,被人眼所看见,即呈现出来撕裂的效果。
下述是从网络上获取的动图,更加清晰的描述横向撕裂的细节:

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

因为W < R,所以R指针跑在前面,读出的是旧数据,第1帧显示的还是上一帧的图像。第2帧开始刷新时,W指针已经刷完了一半的图像,等到R刷新到最后时,W也已刷完整帧图像,因此GRAM中的图像得以完整的显示出来。如果R再稍微快那么一点,那么在第2帧R指针就又会赶上W指针,这样就会再次出现撕裂现象由以上可以得出避免横向撕裂结论:
上述的第2个条件很容易满足,R的速度由屏幕的帧率决定,W的速度由总线时钟决定,可以通过软件进行配置。以st7789v为例,在PDF中可帧率控制章节可以找到帧率控制模式:

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

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;
}