[ARM裸机开发] LCD 显示
1. 基本原理
开发板的 LCD 屏幕使用的是 i.MX 自带的液晶屏幕接口 —— eLCDIF。所以最先需要了解一下这个接口时序。再者,时序都需要一个基本时钟,所以接着还需要了解一下 LCD 接口相关的时钟配置。
1.1 eLCDIF 时序
eLCDIF 时序分为行显示时序和帧显示时序。行显示时序过程中显示一行像素,帧显示时序过程中显示一帧图片。
首先看行显示的时序图,如图 1 所示。图中展示了 4 根信号线,分别为 HSYNC、CLK、DE 和 DATA。其中:
CLK 是时钟信号,波形周期固定。
HSYNC 是行同步信号,有效时(图中为低电平有效)表示开始准备显示一行数据。HSYNC 信号需要持续的时间由 HSPW 指定。还有 HBP 和 HFP 参数,这两个时间的参数和图 2 中 Word 设置的左右边距参数类似。HBP 和 HFP 的作用我猜想是和显示同步有关,有时间“调整”以更准确的显示图片:就像打印时,如果没有边距,那么第一个字就会有一点没打印到纸上。中间的 HOZVAL 对应传输真正有效像素数据的时间。还需要注意的是,HSPW、HBP、HFP 和 HOZVAL 的单位都是单个时钟周期。

DE 是数据使能信号,其使能过程中,DATA 数据线上的数据才被视为有效。可以看到使能的过程就对应的 HOZVAL。
接下来帧显示时序,如图 3 所示,多了一条 VSYNC 信号线。VSYNC 信号指示一帧图像准备开始传输,需要持续 VSPW 时间才算有效。同样的,VBP 和 VFP 两个时间参数和图 2 中的上下边距类似。中间的 LINE 包含了多行的图 1 时序。还需要说明的是,VSPW、VBP 和 VFP 这些参数的单位为 行,即显示一行所花费的时钟周期。
最后,我们回到 i.MX 文档中关于 eLCDIF 的官方定义。如图 4 所示,行显示时序和帧显示时序被画到了一块,并且可以看到其中涉及到的参数在图 1 和 图 3 中都已经讲过了。
按图 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 所示:
参数 | 值 | 单位 |
---|---|---|
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)。
2. 代码编写
因为 eLCDIF 协议是 i.MX 所特有的,所以其内部也内置了相关的模块,我们还是只需要操作相关寄存器即可。因此在了解了 LCD 的基本原理之后,就可以结合寄存器定义,开始相关代码的编写了。
2.1 初始化管脚
如图 6 所示,LCD 相关的管脚有 LCD_DATA、LCD_CLK、LCD_VSYNC、LCD_HSYNC、LCD_DE 和 LCD_BL。其中 LCD_BL 是控制背光的,其余的管脚都在图 4 中有所对应。不同的是,数据线有 23 位(RGB888)。
管脚初始化的步骤如片段 1 中所示,封装在 lcdgpio_init() 函数中。内容比较多的原因是 DATA 占 24 个管脚,其实逻辑很简单:第一步设置管脚复用,第二步设置管脚属性。背光管脚还需要输出高电平,这样显示屏才能够点亮。
- void lcdgpio_init()
- {
- /* DATA Mux */
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA00_LCDIF_DATA00, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA01_LCDIF_DATA01, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA02_LCDIF_DATA02, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA03_LCDIF_DATA03, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA04_LCDIF_DATA04, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA05_LCDIF_DATA05, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA06_LCDIF_DATA06, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA07_LCDIF_DATA07, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA08_LCDIF_DATA08, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA09_LCDIF_DATA09, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA10_LCDIF_DATA10, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA11_LCDIF_DATA11, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA12_LCDIF_DATA12, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA13_LCDIF_DATA13, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA14_LCDIF_DATA14, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA15_LCDIF_DATA15, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA16_LCDIF_DATA16, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA17_LCDIF_DATA17, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA18_LCDIF_DATA18, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA19_LCDIF_DATA19, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA20_LCDIF_DATA20, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA21_LCDIF_DATA21, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA22_LCDIF_DATA22, 0);
- IOMUXC_SetPinMux(IOMUXC_LCD_DATA23_LCDIF_DATA23, 0);
- /* CLK Mux */
- IOMUXC_SetPinMux(IOMUXC_LCD_CLK_LCDIF_CLK, 0);
- /* ENABLE Mux*/
- IOMUXC_SetPinMux(IOMUXC_LCD_ENABLE_LCDIF_ENABLE, 0);
- /* HSYNC Mux */
- IOMUXC_SetPinMux(IOMUXC_LCD_HSYNC_LCDIF_HSYNC, 0);
- /* VSYNC Mux*/
- IOMUXC_SetPinMux(IOMUXC_LCD_VSYNC_LCDIF_VSYNC, 0);
- /* BL Mux */
- IOMUXC_SetPinMux(IOMUXC_GPIO1_IO08_GPIO1_IO08, 0);
- /* DATA Config */
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA00_LCDIF_DATA00, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA01_LCDIF_DATA01, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA02_LCDIF_DATA02, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA03_LCDIF_DATA03, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA04_LCDIF_DATA04, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA05_LCDIF_DATA05, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA06_LCDIF_DATA06, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA07_LCDIF_DATA07, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA08_LCDIF_DATA08, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA09_LCDIF_DATA09, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA10_LCDIF_DATA10, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA11_LCDIF_DATA11, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA12_LCDIF_DATA12, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA13_LCDIF_DATA13, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA14_LCDIF_DATA14, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA15_LCDIF_DATA15, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA16_LCDIF_DATA16, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA17_LCDIF_DATA17, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA18_LCDIF_DATA18, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA19_LCDIF_DATA19, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA20_LCDIF_DATA20, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA21_LCDIF_DATA21, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA22_LCDIF_DATA22, 0xB9);
- IOMUXC_SetPinConfig(IOMUXC_LCD_DATA23_LCDIF_DATA23, 0xB9);
- /* CLK Config */
- IOMUXC_SetPinConfig(IOMUXC_LCD_CLK_LCDIF_CLK, 0xB9);
- /* ENABLE Config*/
- IOMUXC_SetPinConfig(IOMUXC_LCD_ENABLE_LCDIF_ENABLE, 0xB9);
- /* HSYNC Config */
- IOMUXC_SetPinConfig(IOMUXC_LCD_HSYNC_LCDIF_HSYNC, 0xB9);
- /* VSYNC Config */
- IOMUXC_SetPinConfig(IOMUXC_LCD_VSYNC_LCDIF_VSYNC, 0xB9);
- /* BL Config */
- IOMUXC_SetPinConfig(IOMUXC_GPIO1_IO08_GPIO1_IO08, 0xB9);
- /* BL output */
- gpio_pin_config_t gpio_config;
- gpio_config.direction = kGPIO_DigitalOutput;
- gpio_config.outputLogic = 1;
- gpio_init(GPIO1, 8, &gpio_config);
- gpio_pinwrite(GPIO1, 8, 1);
- }
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 分频。
NUM 和 DENOM 参数分别定义在 CCM_ANALOG_PLL_VIDEO_NUM 和 CCM_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 寄存器中定义。
- void lcdclk_init(unsigned char loopDiv, unsigned char preDiv, unsigned char div)
- {
- uint32_t val = 0, mask = 0;
- CCM_ANALOG->PLL_VIDEO_NUM = 22624;
- CCM_ANALOG->PLL_VIDEO_DENOM = 100000;
- /* PLL = OSC(24M) * (loopDiv + num / denom) / preDiv / div */
- CCM_ANALOG->PLL_VIDEO = (2 << 19) | (1 << 13) | (loopDiv);
- CCM_ANALOG->MISC2 = 0; /* bit31:30 VIDEO_DIV !default 0! */
- val = CCM->CSCDR2;
- mask = ~(0x07 << 15); /* bit17:15 LCDIF1_PRE_CLK_SEL*/
- mask &= ~(0x07 << 12); /* bit14:12 LCDIF1_PRED */
- mask &= ~(0x07 << 9); /* bit11:9 LCDIF1_CLK_SEL !default 0!*/
- val &= mask;
- val |= (2 << 15); /* PLL5 */
- val |= (preDiv - 1) << 12;
- CCM->CSCDR2 = val;
- val = CCM->CBCMR;
- val &= ~(0x07 << 23); /* bit23:25 LCDIF1_PODF */
- val |= (div - 1) << 23;
- CCM->CBCMR = val;
- }
可以看到这节内容不像以往一样贴出寄存器的内容了。
主要的原因是调试的过程中,结合配套样例中的代码,发现有些时候需要先把寄存器全部设置为 0(应该是有些位必须写 0),仅仅通过与或操作影响代码中关心的这几个参数(不影响其他参数)是有问题的!
与或操作是最“安全”的。先直接写 0 的话,其他的位含义心里又没底,还是需要寄存器各位“全盘了解”。所以目前也就根据样例代码依葫芦画瓢,先只关心结果(显示结果正确就行)。
还是需要寄存器的各位繁杂逻辑,因此干脆就不贴出寄存器定义了,只往逻辑上记录,不记录细节。像本节的逻辑就全部源于图 5。
2.3 初始化 eLCDIF
在 1.1 节中已经说明过,i.MX 自带 eLCDIF 模块,所以我们不需要自己实现或者模拟这个时序,只需要指定相应的参数即可,而相应参数就是通过寄存器来传递指定的。寄存器的定义先暂不深究,只需要关注片段 3 中第 22 至 33 行的 tftlcd_dev 结构体。可以看到这些参数就是 1.1 节中介绍的,并且后续会填充到相应的寄存器中。
还有需要特别关注的地方是 LCDIF_CUR_BUF 和 LCDIF_NEXT_BUF 寄存器,它们分别指定当前显示帧和下一帧的地址。在这边,我们简单的把它们设置为相同。同时寄存器也有相应的位来控制接口模块的复位和使能。
- void lcd_reset()
- {
- LCDIF->CTRL = 1<<31;
- }
- void lcd_noreset()
- {
- LCDIF->CTRL = 0<<31;
- }
- void lcd_enable()
- {
- LCDIF->CTRL |= 1;
- }
- void elcdif_init()
- {
- lcd_reset();
- delayms(20);
- lcd_noreset();
- tftlcd_dev.width = 800;
- tftlcd_dev.height = 480;
- tftlcd_dev.pixsize = 4; /* ARGB8888 */
- tftlcd_dev.vspw = 3;
- tftlcd_dev.vbpd = 32;
- tftlcd_dev.vfpd = 13;
- tftlcd_dev.hspw = 48;
- tftlcd_dev.hbpd = 88;
- tftlcd_dev.hfpd = 40;
- tftlcd_dev.framebuffer = LCD_FRAMEBUF_ADDR;
- tftlcd_dev.backcolor = LCD_WHITE;
- tftlcd_dev.forecolor = LCD_BLACK;
- LCDIF->CTRL |= (1 << 19) | (1 << 17) | (0 << 14) | (0 << 12) |
- (3 << 10) | (3 << 8) | (1 << 5) | (0 << 1);
- LCDIF->CTRL1 = (0x07 << 16);
- LCDIF->TRANSFER_COUNT = (tftlcd_dev.height << 16) | (tftlcd_dev.width);
- LCDIF->VDCTRL0 = (0 << 29) | (1 << 28) | (0 << 27) |
- (0 << 26) | (0 << 25) | (1 << 24) |
- (1 << 21) | (1 << 20) | tftlcd_dev.vspw;
- LCDIF->VDCTRL1 = tftlcd_dev.height + tftlcd_dev.vspw + tftlcd_dev.vfpd + tftlcd_dev.vbpd;
- LCDIF->VDCTRL2 = (tftlcd_dev.hspw << 18) |
- (tftlcd_dev.width + tftlcd_dev.hspw + tftlcd_dev.hfpd + tftlcd_dev.hbpd);
- LCDIF->VDCTRL3 = ( (tftlcd_dev.hbpd + tftlcd_dev.hspw) << 16) |
- ( tftlcd_dev.vbpd + tftlcd_dev.vspw );
- LCDIF->VDCTRL4 = (1 << 18) | (tftlcd_dev.width);
- LCDIF->CUR_BUF = tftlcd_dev.framebuffer;
- LCDIF->NEXT_BUF = tftlcd_dev.framebuffer;
- lcd_enable();
- delayms(20);
- //lcd_clear(LCD_WHITE);
- lcd_show_pika();
- }
2.4 实验
至此,LCD 显示的初始化工作就全部完成了,步骤包括:
1. 初始化管脚。初始化用到管脚的复用功能和相关属性,对应函数为 lcdgpio_init()。
2. 初始化时钟。将 PLL5 作为像素时钟的输入,需要根据时钟树指定一系列选择器和分频器,对应的函数为 lcdclk_init()。
3. 初始化 eLCDIF。初始化 LCD 接口对应的寄存器,对应的函数为 elcdif_init()。
以上步骤就整合为片段 4 中的 lcd_init() 函数。
- void lcd_init()
- {
- lcdgpio_init();
- lcdclk_init(41, 4, 8);
- elcdif_init();
- }
2.4.1 显示自定义图片
在片段 3 的第 62 行,我把原先的清屏操作换成显示“皮卡丘”图片的函数。在 2.3 节中了解到会通过寄存器指定图片帧的地址,所以我们只要在这块地址中填充我们想要的图片数据,就能在屏幕上显示出来。
我这边的做法是把想要的图片数据首先定义在一个数组中(所以可以看到生成的 bin 文件很大),接着再填充到帧地址上。图片数据是通过 ffmpeg 转化得到的,根据此开发板的配套代码了解到,需要转化为 bgra 格式:
- > ffmpeg.exe -i pika.jpg -s 800x480 -pix_fmt bgra pika.bgra
最后显示的效果为:

3. 总结
做 LCD 裸机实验的原因是,u-boot 的移植实验中也需要适配 LCD 屏,而当时对 LCD 这块一无所知。还有一个原因是,屏幕作为显示最重要的载体,了解一种接口后,应该对后续 FrameBuffer 等驱动框架的阅读也会有所帮助。
目前了解下来,因为接口模块的独立性,所以相关操作也还是一些寄存器的操作。并且在学习过程中没有对寄存器的定义细节做深入了解,只是了解了其逻辑方面的含义。