3、显示驱动框架:framebuffer原理、显示驱动接口、初始化显示控制器、实现打点函数
好,到了第三章。这一章是真正开始跟硬件打交道的地方。很多初学者一上来就急着写打点函数,结果屏幕死活不亮,然后就开始怀疑人生。我当年也干过这种事——焊好线、烧好程序,屏幕一片漆黑,折腾了两天才发现是时钟配置错了。
所以这一章,咱们把显示驱动框架拆开揉碎了讲。你理解了 framebuffer 的本质,搞清了显示控制器的初始化流程,再写打点函数就是水到渠成的事。
3.1 什么是 framebuffer?
Framebuffer,直译过来就是「帧缓冲区」。说白了,它就是一块内存区域,里面存着屏幕上每个像素的颜色数据。CPU 往这块内存里写数据,显示控制器就自动把数据刷到屏幕上。
你可以把它想象成一个二维数组:
// 假设屏幕分辨率是 800x480,每个像素 2 字节(RGB565)
uint16_t framebuffer[480][800]; // 行 × 列
你修改 framebuffer[100][200] = 0xF800;,屏幕上 (200, 100) 那个点就变成红色。就是这么简单粗暴。
核心概念:Framebuffer 是 CPU 和显示控制器之间的「共享内存」。CPU 只管写,显示控制器只管读。两者通过硬件同步机制(比如 VSync 中断)来避免撕裂。
我在项目里见过有人用单缓冲,也有人用双缓冲。单缓冲简单省内存,但会有撕裂现象——屏幕上半部分显示旧帧,下半部分显示新帧。双缓冲虽然多占一倍内存,但画面流畅得多。LVGL 默认支持双缓冲,后面我们会讲到怎么配置。
3.2 显示驱动接口长什么样?
不管底层硬件是 SPI、RGB 还是 MIPI,LVGL 只认三个接口:
| 接口函数 | 作用 | 参数说明 |
|---|---|---|
disp_init() |
初始化显示控制器和 GPIO | 无参数,通常在 lv_port_disp_init() 中调用 |
disp_flush() |
将一块矩形区域的数据刷到屏幕 | x1, y1, x2, y2, color_p |
disp_rounder()(可选) |
对齐刷新区域边界 | 某些硬件要求坐标是 2 或 4 的倍数 |
你想想看,LVGL 其实不关心你的屏幕是怎么接的。它只调用 disp_flush(),把一块矩形区域的颜色数据扔给你。你怎么把数据送到屏幕上,那是你自己的事。
我的习惯:在写 disp_flush() 之前,先写一个裸机的打点函数测试一下。确认屏幕能亮、能画点,再封装成 flush 接口。这样出了问题,你能快速定位是硬件问题还是 LVGL 配置问题。
3.3 初始化显示控制器
初始化这一步,不同芯片差异很大。但核心流程是通用的:
- 配置 GPIO:把数据线、时钟线、控制线(CS、DC、RST、BL)设为正确的功能模式。
- 配置时钟:给 LCD 控制器提供正确的时钟源和分频系数。
- 配置时序参数:HBP、HFP、VBP、VFP、Hsync、Vsync 这些参数必须跟屏幕数据手册一致。
- 发送初始化命令:通过 SPI 或并行接口向 LCD 驱动 IC 写入寄存器配置。
- 点亮背光:拉高 BL 引脚,或者通过 PWM 调节亮度。
我拿一个常见的 ILI9341 屏幕举个例子:
void lcd_init(void) {
// 1. 复位屏幕
gpio_set_level(PIN_RST, 0);
delay_ms(10);
gpio_set_level(PIN_RST, 1);
delay_ms(120);
// 2. 发送初始化命令序列
lcd_write_cmd(0x01); // 软件复位
delay_ms(120);
lcd_write_cmd(0x11); // 退出睡眠模式
delay_ms(120);
lcd_write_cmd(0x36); // 内存访问控制
lcd_write_data(0x48); // 根据你的屏幕方向调整
lcd_write_cmd(0x3A); // 像素格式
lcd_write_data(0x55); // RGB565
lcd_write_cmd(0x29); // 开启显示
delay_ms(10);
// 3. 点亮背光
gpio_set_level(PIN_BL, 1);
}
我曾经踩过的坑:初始化命令序列里的延时不能省。有些屏幕对时序很敏感,延时不够会导致初始化失败。我遇到过一块屏幕,复位后必须等 120ms 才能发命令,否则它根本不响应。查数据手册查了两天才发现。
3.4 实现打点函数
打点函数是所有显示操作的基础。它的逻辑很简单:
- 设置光标位置(告诉屏幕你要往哪个坐标写数据)。
- 写入像素颜色值。
对于 ILI9341 这类 SPI 接口的屏幕,打点函数大概长这样:
void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color) {
// 设置列地址
lcd_write_cmd(0x2A);
lcd_write_data(x >> 8);
lcd_write_data(x & 0xFF);
// 设置行地址
lcd_write_cmd(0x2B);
lcd_write_data(y >> 8);
lcd_write_data(y & 0xFF);
// 写像素数据
lcd_write_cmd(0x2C);
lcd_write_data(color >> 8);
lcd_write_data(color & 0xFF);
}
注意这里的数据格式。RGB565 下,一个像素占 2 个字节。高位在前还是低位在前,取决于你的屏幕和 MCU 的字节序。我习惯先发高字节再发低字节,大部分屏幕都认这个顺序。
3.5 从打点到 flush 接口
有了打点函数,封装 disp_flush() 就很简单了。LVGL 每次刷新会传一个矩形区域,你只需要遍历这个区域内的所有像素:
void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
int32_t x, y;
for (y = area->y1; y <= area->y2; y++) {
for (x = area->x1; x <= area->x2; x++) {
lcd_draw_pixel(x, y, color_p->full);
color_p++;
}
}
lv_disp_flush_ready(disp); // 通知 LVGL 刷新完成
}
嗯,这里要注意。上面的代码虽然能工作,但效率很低。每个像素都要发三条命令,SPI 通信开销巨大。实际项目中,我们会用「写矩形」命令来优化:
void lcd_fill_rect(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t *colors) {
uint32_t size = (x2 - x1 + 1) * (y2 - y1 + 1);
// 设置矩形区域
lcd_write_cmd(0x2A);
lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF);
lcd_write_data(x2 >> 8); lcd_write_data(x2 & 0xFF);
lcd_write_cmd(0x2B);
lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF);
lcd_write_data(y2 >> 8); lcd_write_data(y2 & 0xFF);
// 连续写入所有像素数据
lcd_write_cmd(0x2C);
lcd_write_data_bulk(colors, size * 2); // 批量写,不拆包
}
这样一次命令就能刷完整个矩形区域,速度能提升几十倍。
性能建议:如果你的 MCU 支持 DMA,一定要用 DMA 来传输像素数据。CPU 只需要启动传输,然后就可以去干别的事。LVGL 的刷新帧率能从 15fps 直接跳到 60fps。
3.6 本章知识体系
下面这张图总结了显示驱动框架的核心流程:
从这张图可以看得很清楚:数据从 framebuffer 出发,经过 LVGL 渲染引擎处理,通过 disp_flush() 回调进入你的驱动层,最终由显示控制器送到屏幕上。每一层都有优化的空间,但最底层的打点函数是基础中的基础。
我个人习惯先把打点函数调通,再往上封装。这样出了问题,你能确定是底层驱动的问题,还是上层配置的问题。别问我怎么知道的——当年我同时改了驱动和 LVGL 配置,结果屏幕不亮,排查了整整一个下午。