2. LVGL基础框架与显示驱动移植:framebuffer原理、初始化流程、打点函数实现、flush回调函数注册
好,我们直接进入正题。上一章我们把LVGL的源码工程搭起来了,但屏幕还是黑的,对吧?这一章我们要做的,就是让LVGL真正能在你的硬件上画出东西来。
说白了,LVGL本身不直接操作硬件。它只负责计算“哪个像素应该是什么颜色”,然后把结果丢给一个回调函数。至于这个回调函数怎么把数据送到屏幕上,那是你的事。这就是显示驱动移植的核心。
核心认知:LVGL是画家,你是送画工。画家画好了,你得负责把画挂到墙上。
2.1 Framebuffer原理——你总得有个画布
Framebuffer,帧缓冲,听起来高大上。其实就是一个内存区域,里面每个字节或每个像素对应屏幕上的一个点。你往这个区域写数据,屏幕控制器就会自动把数据显示出来。
我刚开始做嵌入式GUI时,以为framebuffer是什么神秘硬件。后来发现,它就是一块内存。在Linux下,它是/dev/fb0;在裸机或RTOS下,它就是你自己malloc出来的一块缓冲区。
| 概念 | 说明 |
|---|---|
| Framebuffer | 一块内存区域,每个像素点对应一个或多个字节 |
| 像素格式 | RGB565、RGB888、ARGB8888等,决定了每个像素占多少字节 |
| 分辨率 | 宽度×高度,决定了framebuffer的总大小 |
| 双缓冲 | 两个framebuffer交替使用,避免画面撕裂 |
举个例子,如果你的屏幕是480×272,像素格式是RGB565(每个像素2字节),那么一个framebuffer的大小就是:
480 × 272 × 2 = 261,120 字节 ≈ 255 KB
嗯,这个数字要记住。后面分配内存时你会用到。
我的习惯:在项目初期,我会先确认屏幕的规格书,把分辨率、像素格式、接口类型写在注释里。这样后面移植时不会搞混。
2.2 初始化流程——先让屏幕亮起来
显示驱动的初始化,通常分三步走。我把它总结成一个流程图,你照着做就行。
这个流程看起来简单,但每一步都有坑。我一个个说。
2.2.1 硬件初始化
这一步取决于你的屏幕接口。如果是SPI屏,你要初始化SPI外设、设置GPIO、发送初始化命令序列。如果是RGB接口屏,你要配置LTDC或DSI控制器。如果是Linux下的fbdev,你只需要open /dev/fb0。
我曾经在一个项目里,因为屏幕初始化序列少发了一条命令,结果屏幕一直花屏。查了两天才发现。所以我的建议是:先把屏幕厂商提供的初始化代码跑通,再谈LVGL移植。
2.2.2 分配Framebuffer
这一步很简单,就是malloc一块内存。但要注意对齐。LVGL要求framebuffer的地址至少是4字节对齐,有些硬件要求更高。
// 单缓冲
static lv_color_t buf[LV_HOR_RES_MAX * LV_VER_RES_MAX];
// 双缓冲
static lv_color_t buf1[LV_HOR_RES_MAX * LV_VER_RES_MAX];
static lv_color_t buf2[LV_HOR_RES_MAX * LV_VER_RES_MAX];
嗯,这里lv_color_t的大小取决于你在lv_conf.h中配置的像素格式。如果是RGB565,它就是2字节;如果是ARGB8888,它就是4字节。
我曾经踩过的坑:在STM32上,如果使用内部SRAM,注意不要超过芯片的SRAM大小。480×272的RGB565双缓冲需要510KB,很多芯片内部SRAM只有128KB或256KB。这时候你需要使用外部SDRAM。
2.3 打点函数实现——最原始的绘图方式
打点函数,就是往framebuffer的某个坐标写一个像素。这是最底层的操作。虽然LVGL不直接调用打点函数(它用flush回调批量刷新),但理解打点函数有助于你理解framebuffer的寻址方式。
假设你的屏幕是480×272,RGB565格式。那么坐标(x, y)对应的framebuffer偏移量是:
offset = (y × 屏幕宽度 + x) × 每个像素字节数
打点函数的实现:
void my_draw_pixel(int x, int y, lv_color_t color) {
// 边界检查
if (x < 0 || x >= LV_HOR_RES_MAX) return;
if (y < 0 || y >= LV_VER_RES_MAX) return;
// 计算偏移
uint32_t offset = (y * LV_HOR_RES_MAX + x);
// 写入framebuffer
((lv_color_t *)framebuffer)[offset] = color;
}
你看,就是这么简单。但LVGL不会用这个函数,因为它太慢了。LVGL要的是批量刷新——一次刷新一个矩形区域。
2.4 Flush回调函数注册——LVGL和硬件的桥梁
这是本章的重头戏。Flush回调是LVGL用来刷新显示区域的函数。LVGL会调用它,把一整个矩形区域的数据交给你,你要负责把这些数据送到屏幕上。
Flush回调的函数签名是这样的:
void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p);
三个参数:
disp_drv:显示驱动结构体,里面包含你注册的framebuffer地址等信息area:需要刷新的矩形区域,包含x1、y1、x2、y2四个坐标color_p:指向颜色数据的指针,数据格式与lv_color_t一致
实现这个回调,你需要做两件事:
- 把
color_p指向的数据,拷贝到framebuffer对应的位置 - 调用
lv_disp_flush_ready(disp_drv)通知LVGL刷新完成
一个典型的实现:
void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p) {
int32_t x, y;
uint32_t w = lv_area_get_width(area);
uint32_t h = lv_area_get_height(area);
// 逐行拷贝到framebuffer
for (y = area->y1; y <= area->y2; y++) {
for (x = area->x1; x <= area->x2; x++) {
uint32_t offset = y * LV_HOR_RES_MAX + x;
((lv_color_t *)framebuffer)[offset] = *color_p;
color_p++;
}
}
// 通知LVGL刷新完成
lv_disp_flush_ready(disp_drv);
}
嗯,这个实现能工作,但效率不高。为什么?因为双重循环太慢了。更好的做法是用memcpy逐行拷贝:
void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p) {
int32_t y;
uint32_t w = lv_area_get_width(area);
uint32_t h = lv_area_get_height(area);
uint32_t line_bytes = w * sizeof(lv_color_t);
for (y = area->y1; y <= area->y2; y++) {
uint32_t offset = y * LV_HOR_RES_MAX + area->x1;
memcpy(&((lv_color_t *)framebuffer)[offset], color_p, line_bytes);
color_p += w;
}
lv_disp_flush_ready(disp_drv);
}
这样快很多。如果你用的是带DMA的芯片,还可以用DMA传输,进一步解放CPU。
我的建议:在flush回调里,尽量少做计算。把数据拷贝到framebuffer就完事。如果你需要做像素格式转换(比如LVGL用RGB565,但屏幕硬件用RGB888),最好在初始化时统一格式,或者在DMA传输时做硬件转换。
2.5 注册显示驱动——把一切连起来
好,现在framebuffer有了,flush回调也有了。最后一步,就是把它们注册到LVGL里。
static lv_disp_drv_t disp_drv;
static lv_disp_t * disp;
void lvgl_display_init(void) {
// 1. 初始化硬件(根据你的屏幕接口实现)
lcd_hardware_init();
// 2. 分配framebuffer(这里用双缓冲)
static lv_color_t buf1[LV_HOR_RES_MAX * LV_VER_RES_MAX];
static lv_color_t buf2[LV_HOR_RES_MAX * LV_VER_RES_MAX];
// 3. 初始化显示驱动
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = LV_HOR_RES_MAX;
disp_drv.ver_res = LV_VER_RES_MAX;
disp_drv.flush_cb = my_flush_cb;
disp_drv.draw_buf = &draw_buf;
// 4. 初始化draw buffer(双缓冲)
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_HOR_RES_MAX * LV_VER_RES_MAX);
// 5. 注册显示驱动
disp = lv_disp_drv_register(&disp_drv);
}
这里有几个关键点:
hor_res和ver_res必须和你的屏幕分辨率一致flush_cb就是上面实现的回调函数draw_buf是LVGL的绘图缓冲区,可以是单缓冲或双缓冲lv_disp_drv_register返回一个显示句柄,后面可能会用到
我曾经犯过的错:一开始我用了单缓冲,结果画面一直闪烁。后来换成双缓冲,问题解决了。双缓冲的原理是:LVGL在一个缓冲区里绘图,同时把另一个缓冲区的内容刷新到屏幕。两者互不干扰。
2.6 验证——你的屏幕亮了吗?
注册完成后,你可以在main函数里创建一个简单的LVGL对象来测试:
int main(void) {
lv_init();
lvgl_display_init();
// 创建一个简单的标签
lv_obj_t * label = lv_label_create(lv_scr_act());
lv_label_set_text(label, "Hello LVGL!");
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
while(1) {
lv_task_handler();
delay_ms(5);
}
}
如果一切正常,你的屏幕上应该会显示"Hello LVGL!"。如果没显示,别急。先检查硬件初始化是否成功,再检查framebuffer地址是否正确,最后检查flush回调是否被调用。
我个人习惯在flush回调里加一个调试打印,每次被调用时输出刷新的区域坐标。这样能快速判断LVGL是否在正常工作。