[ARM裸机开发] LCD 显示

1. 基本原理

开发板的 LCD 屏幕使用的是 i.MX 自带的液晶屏幕接口 —— eLCDIF。所以最先需要了解一下这个接口时序。再者,时序都需要一个基本时钟,所以接着还需要了解一下 LCD 接口相关的时钟配置。

1.1 eLCDIF 时序

eLCDIF 时序分为行显示时序和帧显示时序。行显示时序过程中显示一行像素,帧显示时序过程中显示一帧图片。

首先看行显示的时序图,如图 1 所示。图中展示了 4 根信号线,分别为 HSYNCCLKDEDATA。其中:

图1 行显示时序

CLK 是时钟信号,波形周期固定。

HSYNC 是行同步信号,有效时(图中为低电平有效)表示开始准备显示一行数据。HSYNC 信号需要持续的时间由 HSPW 指定。还有 HBPHFP 参数,这两个时间的参数和图 2 中 Word 设置的左右边距参数类似。HBPHFP 的作用我猜想是和显示同步有关,有时间“调整”以更准确的显示图片:就像打印时,如果没有边距,那么第一个字就会有一点没打印到纸上。中间的 HOZVAL 对应传输真正有效像素数据的时间。还需要注意的是,HSPWHBPHFPHOZVAL 的单位都是单个时钟周期。

图2 纸张的边距信息

DE 是数据使能信号,其使能过程中,DATA 数据线上的数据才被视为有效。可以看到使能的过程就对应的 HOZVAL

接下来帧显示时序,如图 3 所示,多了一条 VSYNC 信号线。VSYNC 信号指示一帧图像准备开始传输,需要持续 VSPW 时间才算有效。同样的,VBPVFP 两个时间参数和图 2 中的上下边距类似。中间的 LINE 包含了多行的图 1 时序。还需要说明的是,VSPWVBPVFP 这些参数的单位为 ,即显示一行所花费的时钟周期。

图1 行显示时序

最后,我们回到 i.MX 文档中关于 eLCDIF 的官方定义。如图 4 所示,行显示时序和帧显示时序被画到了一块,并且可以看到其中涉及到的参数在图 1 和 图 3 中都已经讲过了。

图4 eLCDIF 时序

按图 4 中的内容,梳理一下一帧完整的时序:

1. VSYNC 信号有效持续 VSYNC Pulse Width 时间,指示后续要开始传输一帧图片了。

2. 继续持续 Vertical Back Porch 时间。

3. HSYNC 信号有效持续 HSYNC Pulse Width,指示后续要开始传输一行像素了。

4. HYSNC Wait Count 时间之后,ENABLE 使能,DATA 上传输一行像素数据。

5. 数据传输完毕,ENABLE 复位,并持续一段时间(图中这段时间没有特别标出)。

6. 数行数据传输完毕,即多次步骤 3 至步骤 5 之后,VSYNC 继续持续 Vertical Frint Porch

1.2 像素时钟

像素时钟就是节 1.1 中的时钟信号,即传输一个像素所需要的时间。配置时钟的单位为 Hz,所以还需要进一步计算:开发板文档里说明以 1 秒显示 60 帧数据进行计算。

目前自己使用的配套 LCD 屏的各个相关参数如表 1 所示:

表1:4.3寸 800x480 分辨率 LCD 显示屏参数
参数 单位
HSPW(thp) 48 tCLK
HBP(thb) 88 tCLK
HFP(thf) 40 tCLK
VSPW(tvp) 3 th
VBP(tvb) 32 th
VFP(tvf) 13 th
像素时钟 31 MHz

我们可以用以上参数进行计算,对表末尾的像素时钟值进行核对验证。显示一帧所需要的 tCLK 数量为:

\(T = (VSPW + VBP + height + VFP) * \)

\( (HSPW + HBP + width + HFP) \)

\( = (3 + 32 + 480 + 13) * (48 + 88 + 800 + 40) \)

\( = 515328 \)

所以按 1 秒 60 帧来算,像素时钟为:

\( 515328 * 60 = 30919680 \approx 31MHz \)

计算好了像素时钟的频率,就可以依据时钟树进行配置。像素时钟相关的时钟树如图 5 所示,所用的系统时钟为 LCDIF1,时钟源选择专为视频提供的 PLL_VIDEO(PLL5)

图5 像素时钟相关时钟树

2. 代码编写

因为 eLCDIF 协议是 i.MX 所特有的,所以其内部也内置了相关的模块,我们还是只需要操作相关寄存器即可。因此在了解了 LCD 的基本原理之后,就可以结合寄存器定义,开始相关代码的编写了。

2.1 初始化管脚

如图 6 所示,LCD 相关的管脚有 LCD_DATALCD_CLKLCD_VSYNCLCD_HSYNCLCD_DELCD_BL。其中 LCD_BL 是控制背光的,其余的管脚都在图 4 中有所对应。不同的是,数据线有 23 位(RGB888)。

图6 LCD 相关管脚

管脚初始化的步骤如片段 1 中所示,封装在 lcdgpio_init() 函数中。内容比较多的原因是 DATA 占 24 个管脚,其实逻辑很简单:第一步设置管脚复用,第二步设置管脚属性。背光管脚还需要输出高电平,这样显示屏才能够点亮。

片段1 初始化管脚
  • void lcdgpio_init()
  • {
  •     /* DATA Mux */
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA00_LCDIF_DATA000);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA01_LCDIF_DATA010);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA02_LCDIF_DATA020);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA03_LCDIF_DATA030);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA04_LCDIF_DATA040);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA05_LCDIF_DATA050);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA06_LCDIF_DATA060);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA07_LCDIF_DATA070);
  •  
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA08_LCDIF_DATA080);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA09_LCDIF_DATA090);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA10_LCDIF_DATA100);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA11_LCDIF_DATA110);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA12_LCDIF_DATA120);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA13_LCDIF_DATA130);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA14_LCDIF_DATA140);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA15_LCDIF_DATA150);
  •  
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA16_LCDIF_DATA160);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA17_LCDIF_DATA170);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA18_LCDIF_DATA180);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA19_LCDIF_DATA190);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA20_LCDIF_DATA200);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA21_LCDIF_DATA210);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA22_LCDIF_DATA220);
  •     IOMUXC_SetPinMux(IOMUXC_LCD_DATA23_LCDIF_DATA230);
  •  
  •     /* CLK Mux */
  •     IOMUXC_SetPinMux(IOMUXC_LCD_CLK_LCDIF_CLK0);
  •     /* ENABLE Mux*/
  •     IOMUXC_SetPinMux(IOMUXC_LCD_ENABLE_LCDIF_ENABLE0);
  •     /* HSYNC Mux */
  •     IOMUXC_SetPinMux(IOMUXC_LCD_HSYNC_LCDIF_HSYNC0);
  •     /* VSYNC Mux*/
  •     IOMUXC_SetPinMux(IOMUXC_LCD_VSYNC_LCDIF_VSYNC0);
  •     /* BL Mux */
  •     IOMUXC_SetPinMux(IOMUXC_GPIO1_IO08_GPIO1_IO080);
  •  
  •     /* DATA Config */
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA00_LCDIF_DATA000xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA01_LCDIF_DATA010xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA02_LCDIF_DATA020xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA03_LCDIF_DATA030xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA04_LCDIF_DATA040xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA05_LCDIF_DATA050xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA06_LCDIF_DATA060xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA07_LCDIF_DATA070xB9);
  •  
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA08_LCDIF_DATA080xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA09_LCDIF_DATA090xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA10_LCDIF_DATA100xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA11_LCDIF_DATA110xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA12_LCDIF_DATA120xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA13_LCDIF_DATA130xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA14_LCDIF_DATA140xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA15_LCDIF_DATA150xB9);
  •  
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA16_LCDIF_DATA160xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA17_LCDIF_DATA170xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA18_LCDIF_DATA180xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA19_LCDIF_DATA190xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA20_LCDIF_DATA200xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA21_LCDIF_DATA210xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA22_LCDIF_DATA220xB9);
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_DATA23_LCDIF_DATA230xB9);
  •  
  •     /* CLK Config */
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_CLK_LCDIF_CLK0xB9);
  •     /* ENABLE Config*/
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_ENABLE_LCDIF_ENABLE0xB9);
  •     /* HSYNC Config */
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_HSYNC_LCDIF_HSYNC0xB9);
  •     /* VSYNC Config */
  •     IOMUXC_SetPinConfig(IOMUXC_LCD_VSYNC_LCDIF_VSYNC0xB9);
  •     /* BL Config */
  •     IOMUXC_SetPinConfig(IOMUXC_GPIO1_IO08_GPIO1_IO080xB9);
  •  
  •     /* BL output */
  •     gpio_pin_config_t gpio_config;
  •     gpio_config.direction = kGPIO_DigitalOutput;
  •     gpio_config.outputLogic = 1;
  •     gpio_init(GPIO18, &gpio_config);
  •     gpio_pinwrite(GPIO181);
  • }

2.2 初始化时钟

时钟相关的内容已经在 1.2 节中进行了说明,现在只需要根据图 5 的时钟树依次进行初始化即可。

我们最后选到 PLL5 这一路,PLL5 的输出为 Fref * (DIV_SELECT + NUM/DENOM)

公式在参考手册的 18.5.1.3.4 节:Audio / Video PLL。

DIV_SELECT 参数定义在 CCM_ANALOG_PLL_VIDEO 寄存器中。同时第一个分频器 POST_DIV_SELECT 也定义在这个寄存器中,代码中默认设置其为 1 分频。

NUMDENOM 参数分别定义在 CCM_ANALOG_PLL_VIDEO_NUMCCM_ANALOG_PLL_VIDEO_DENOM 寄存器中。原来实验里没有用到小数分频,但是实验了一下,观察到屏幕上有些明显的小条纹,设置小数分频后效果就好多了。值依据 1.2 节算出。

片段 2 中的第 5 至 9 行就设置了 PLL5 的输出和第一个分频器(1 分频)。

片段 2 中的第 11 行设置第二个分频器,也是固定设置为 1 分频。其在 CCM_ANALOG_MISC2 寄存器中定义。

片段 2 中的第 13 至 20 行设置 CCM_CSCDR2 寄存器的相关内容。其中设置两个选择器,以及第三个分频器。

片段 2 中的第 22 至 25 行设置第四个分频器,在 CCM_CBCMR 寄存器中定义。

片段2 初始化时钟
  1. void lcdclk_init(unsigned char loopDiv, unsigned char preDiv, unsigned char div)
  2. {
  3.     uint32_t val = 0, mask = 0;
  4.  
  5.     CCM_ANALOG->PLL_VIDEO_NUM = 22624;
  6.     CCM_ANALOG->PLL_VIDEO_DENOM = 100000;
  7.     /* PLL = OSC(24M) * (loopDiv + num / denom) / preDiv / div */
  8.  
  9.     CCM_ANALOG->PLL_VIDEO = (2 << 19) | (1 << 13) | (loopDiv);
  10.     
  11.     CCM_ANALOG->MISC2 = 0/* bit31:30 VIDEO_DIV !default 0! */
  12.  
  13.     val = CCM->CSCDR2;
  14.     mask = ~(0x07 << 15); /* bit17:15 LCDIF1_PRE_CLK_SEL*/
  15.     mask &= ~(0x07 << 12); /* bit14:12 LCDIF1_PRED */
  16.     mask &= ~(0x07 << 9); /* bit11:9 LCDIF1_CLK_SEL !default 0!*/
  17.     val &= mask;
  18.     val |= (2 << 15); /* PLL5 */
  19.     val |= (preDiv - 1) << 12;
  20.     CCM->CSCDR2 = val;
  21.  
  22.     val = CCM->CBCMR;
  23.     val &= ~(0x07 << 23); /* bit23:25 LCDIF1_PODF */
  24.     val |= (div - 1) << 23;
  25.     CCM->CBCMR = val;
  26. }

可以看到这节内容不像以往一样贴出寄存器的内容了。

主要的原因是调试的过程中,结合配套样例中的代码,发现有些时候需要先把寄存器全部设置为 0(应该是有些位必须写 0),仅仅通过与或操作影响代码中关心的这几个参数(不影响其他参数)是有问题的!

与或操作是最“安全”的。先直接写 0 的话,其他的位含义心里又没底,还是需要寄存器各位“全盘了解”。所以目前也就根据样例代码依葫芦画瓢,先只关心结果(显示结果正确就行)。

还是需要寄存器的各位繁杂逻辑,因此干脆就不贴出寄存器定义了,只往逻辑上记录,不记录细节。像本节的逻辑就全部源于图 5。

2.3 初始化 eLCDIF

在 1.1 节中已经说明过,i.MX 自带 eLCDIF 模块,所以我们不需要自己实现或者模拟这个时序,只需要指定相应的参数即可,而相应参数就是通过寄存器来传递指定的。寄存器的定义先暂不深究,只需要关注片段 3 中第 22 至 33 行的 tftlcd_dev 结构体。可以看到这些参数就是 1.1 节中介绍的,并且后续会填充到相应的寄存器中。

还有需要特别关注的地方是 LCDIF_CUR_BUFLCDIF_NEXT_BUF 寄存器,它们分别指定当前显示帧和下一帧的地址。在这边,我们简单的把它们设置为相同。同时寄存器也有相应的位来控制接口模块的复位和使能。

片段3 eLCDIF
  1. void lcd_reset()
  2. {
  3.     LCDIF->CTRL  = 1<<31;
  4. }
  5.  
  6. void lcd_noreset()
  7. {
  8.     LCDIF->CTRL  = 0<<31;
  9. }
  10.  
  11. void lcd_enable()
  12. {
  13.     LCDIF->CTRL |= 1;
  14. }
  15.  
  16. void elcdif_init()
  17. {
  18.     lcd_reset();
  19.     delayms(20);
  20.     lcd_noreset();
  21.  
  22.     tftlcd_dev.width = 800;
  23.     tftlcd_dev.height = 480;
  24.     tftlcd_dev.pixsize = 4/* ARGB8888 */
  25.     tftlcd_dev.vspw = 3;
  26.     tftlcd_dev.vbpd = 32;
  27.     tftlcd_dev.vfpd = 13;
  28.     tftlcd_dev.hspw = 48;
  29.     tftlcd_dev.hbpd = 88;
  30.     tftlcd_dev.hfpd = 40;
  31.     tftlcd_dev.framebuffer = LCD_FRAMEBUF_ADDR;
  32.     tftlcd_dev.backcolor = LCD_WHITE;
  33.     tftlcd_dev.forecolor = LCD_BLACK;
  34.  
  35.     LCDIF->CTRL |= (1 << 19) | (1 << 17) | (0 << 14) | (0 << 12) |
  36.            (3 << 10) | (3 << 8) | (1 << 5) | (0 << 1);
  37.  
  38.     LCDIF->CTRL1 = (0x07 << 16);
  39.  
  40.     LCDIF->TRANSFER_COUNT = (tftlcd_dev.height << 16) | (tftlcd_dev.width);
  41.  
  42.     LCDIF->VDCTRL0 = (0 << 29) | (1 << 28) | (0 << 27) |
  43.                      (0 << 26) | (0 << 25) | (1 << 24) |
  44.                      (1 << 21) | (1 << 20) | tftlcd_dev.vspw;
  45.     
  46.     LCDIF->VDCTRL1 = tftlcd_dev.height + tftlcd_dev.vspw + tftlcd_dev.vfpd + tftlcd_dev.vbpd;
  47.  
  48.     LCDIF->VDCTRL2 = (tftlcd_dev.hspw << 18) | 
  49.                      (tftlcd_dev.width + tftlcd_dev.hspw + tftlcd_dev.hfpd + tftlcd_dev.hbpd);
  50.  
  51.     LCDIF->VDCTRL3 = ( (tftlcd_dev.hbpd + tftlcd_dev.hspw) << 16) |
  52.                      ( tftlcd_dev.vbpd + tftlcd_dev.vspw );
  53.     
  54.     LCDIF->VDCTRL4 = (1 << 18) | (tftlcd_dev.width);
  55.  
  56.     LCDIF->CUR_BUF = tftlcd_dev.framebuffer;
  57.     LCDIF->NEXT_BUF = tftlcd_dev.framebuffer;
  58.  
  59.     lcd_enable();
  60.     delayms(20);
  61.     //lcd_clear(LCD_WHITE);
  62.     lcd_show_pika();
  63. }

2.4 实验

至此,LCD 显示的初始化工作就全部完成了,步骤包括:

1. 初始化管脚。初始化用到管脚的复用功能和相关属性,对应函数为 lcdgpio_init()

2. 初始化时钟。将 PLL5 作为像素时钟的输入,需要根据时钟树指定一系列选择器和分频器,对应的函数为 lcdclk_init()

3. 初始化 eLCDIF。初始化 LCD 接口对应的寄存器,对应的函数为 elcdif_init()

以上步骤就整合为片段 4 中的 lcd_init() 函数。

片段4 初始化 LCD
  • void lcd_init()
  • {
  •     lcdgpio_init();
  •     lcdclk_init(4148);
  •     elcdif_init();
  • }

2.4.1 显示自定义图片

在片段 3 的第 62 行,我把原先的清屏操作换成显示“皮卡丘”图片的函数。在 2.3 节中了解到会通过寄存器指定图片帧的地址,所以我们只要在这块地址中填充我们想要的图片数据,就能在屏幕上显示出来。

我这边的做法是把想要的图片数据首先定义在一个数组中(所以可以看到生成的 bin 文件很大),接着再填充到帧地址上。图片数据是通过 ffmpeg 转化得到的,根据此开发板的配套代码了解到,需要转化为 bgra 格式:

  • > ffmpeg.exe -i pika.jpg -s 800x480 -pix_fmt bgra pika.bgra

最后显示的效果为:

图7 显示效果

3. 总结

做 LCD 裸机实验的原因是,u-boot 的移植实验中也需要适配 LCD 屏,而当时对 LCD 这块一无所知。还有一个原因是,屏幕作为显示最重要的载体,了解一种接口后,应该对后续 FrameBuffer 等驱动框架的阅读也会有所帮助。

目前了解下来,因为接口模块的独立性,所以相关操作也还是一些寄存器的操作。并且在学习过程中没有对寄存器的定义细节做深入了解,只是了解了其逻辑方面的含义。