第二讲:显示驱动移植——framebuffer机制与缓冲区策略

大家好,欢迎来到第二讲。

上一讲我们把LVGL的工程骨架搭好了,但屏幕还是黑的,对吧?这一讲,我们就来点亮它。

显示驱动移植,说白了就是让LVGL知道:你的像素该往哪里画。在Linux嵌入式系统中,最常见的显示接口就是framebuffer。我最早接触framebuffer时也觉得挺神秘,后来发现它其实就是一块内存区域,你把颜色数据填进去,屏幕就亮了。

2.1 理解framebuffer机制

Framebuffer,帧缓冲。名字听着高大上,其实就是一个内存数组。

你想想看,屏幕上一共有多少个像素点?假设是800×480的分辨率,那就是384000个点。每个点用2个字节(RGB565)表示颜色,那么一帧画面就需要768000字节。这块内存,就是framebuffer。

在Linux中,framebuffer被抽象为一个设备文件:/dev/fb0。你只需要:

  1. open("/dev/fb0", O_RDWR) 打开设备
  2. mmap() 把显存映射到用户空间
  3. 往映射的内存里写像素数据
  4. 屏幕自动刷新(硬件完成)

嗯,就是这么简单。我在项目中遇到过有人问:要不要手动调用刷新函数?其实不用。你写完了显存,LCD控制器会自动把数据推送到屏幕上。当然,有些硬件需要你手动触发刷新,但那是少数情况。

核心要点:framebuffer就是一块内存,你写什么,屏幕显示什么。没有中间商赚差价。

2.2 编写lv_port_disp.c

LVGL的显示驱动接口,核心文件就是lv_port_disp.c。这个文件在LVGL源码的porting目录下,你复制出来改就行。

我们需要实现三个回调函数:

回调函数 作用 说明
disp_init() 初始化显示硬件 打开fb0、mmap显存
disp_flush() 刷新指定区域 把LVGL的绘制缓冲区拷贝到显存
rounder_callback() 对齐刷新区域 有些硬件要求坐标对齐到2或4的倍数

下面是我常用的代码骨架,你可以直接参考:

// lv_port_disp.c 核心片段
static int fb_fd = -1;
static struct fb_var_screeninfo vinfo;
static struct fb_fix_screeninfo finfo;
static uint8_t *fb_mem = NULL;

void disp_init(void)
{
    // 1. 打开framebuffer设备
    fb_fd = open("/dev/fb0", O_RDWR);
    if (fb_fd < 0) {
        perror("open fb0 failed");
        return;
    }

    // 2. 获取屏幕参数
    ioctl(fb_fd, FBIOGET_VSCREENINFO, &vinfo);
    ioctl(fb_fd, FBIOGET_FSCREENINFO, &finfo);

    // 3. mmap映射显存
    fb_mem = mmap(NULL, finfo.smem_len,
                  PROT_READ | PROT_WRITE, MAP_SHARED,
                  fb_fd, 0);
    if (fb_mem == MAP_FAILED) {
        perror("mmap failed");
        close(fb_fd);
        return;
    }

    printf("Framebuffer: %dx%d, %dbpp\n",
           vinfo.xres, vinfo.yres, vinfo.bits_per_pixel);
}

void disp_flush(lv_disp_drv_t *drv,
                const lv_area_t *area,
                lv_color_t *color_p)
{
    int32_t x, y;
    uint32_t fb_offset;
    uint32_t line_bytes;

    // 计算每行字节数
    line_bytes = (area->x2 - area->x1 + 1) * (vinfo.bits_per_pixel / 8);

    for (y = area->y1; y <= area->y2; y++) {
        fb_offset = (y * finfo.line_length) +
                    (area->x1 * (vinfo.bits_per_pixel / 8));
        memcpy(fb_mem + fb_offset, color_p, line_bytes);
        color_p += (area->x2 - area->x1 + 1);
    }

    // 通知LVGL刷新完成
    lv_disp_flush_ready(drv);
}

个人经验:我在调试时经常遇到屏幕花屏,后来发现是line_lengthxres * bpp/8不一致导致的。有些硬件为了对齐,每行会多几个字节的padding。一定要用finfo.line_length,别自己算!

2.3 配置颜色深度与缓冲区

颜色深度,就是每个像素用多少位来表示。常见的有:

  • RGB565(16位):R占5位,G占6位,B占5位。最常用,性价比高。
  • RGB888(32位):每个颜色8位,色彩更丰富,但显存翻倍。
  • RGB332(8位):颜色少,基本淘汰了。

在LVGL中,通过lv_conf.h配置:

/* lv_conf.h */
#define LV_COLOR_DEPTH     16    /* 16位RGB565 */
#define LV_COLOR_16_SWAP   0     /* 字节序,有些硬件需要交换高低字节 */

为什么我推荐RGB565?

说白了,就是性能和画质的平衡。800×480的屏幕,RGB565一帧只需要768KB。RGB888要1.5MB,翻了一倍。对于嵌入式设备,内存就是钱啊。

我曾经在一个项目里用了RGB888,结果内存不够,不得不加外挂SDRAM。后来改成RGB565,省了硬件成本,用户也看不出区别。嗯,从那以后我就学乖了。

2.4 双缓冲与全缓冲策略选择

这是显示驱动里最关键的决策点。LVGL支持三种缓冲模式:

模式 缓冲区大小 优点 缺点
单缓冲 1/10屏幕 内存占用极小 刷新次数多,可能有撕裂
双缓冲 1个屏幕 无撕裂,性能好 内存占用大
全缓冲 2个屏幕 极致流畅 内存占用巨大

怎么选?我给出我的建议:

  • 内存紧张(比如只有8MB RAM):用单缓冲,缓冲区大小设为屏幕高度的1/10。比如480行,就设48行。牺牲一点性能,但能跑起来。
  • 内存够用(32MB以上):用双缓冲。这是最推荐的方式。LVGL在一个缓冲区里绘制,同时另一个缓冲区的内容被送到屏幕。互不干扰。
  • 土豪配置(64MB以上):全缓冲。两个完整的屏幕缓冲区,LVGL画完一个,直接切换显示。性能最好,但内存翻倍。

配置代码示例:

/* 在lv_port_disp.c中 */
static lv_disp_drv_t disp_drv;
static lv_disp_buf_t disp_buf;
static lv_color_t buf1[LV_HOR_RES_MAX * 100];  /* 100行缓冲区 */

void lv_port_disp_init(void)
{
    disp_init();  /* 初始化硬件 */

    /* 初始化缓冲区 */
    lv_disp_buf_init(&disp_buf, buf1, NULL,
                     LV_HOR_RES_MAX * 100);

    /* 注册驱动 */
    lv_disp_drv_init(&disp_drv);
    disp_drv.buffer = &disp_buf;
    disp_drv.flush_cb = disp_flush;
    lv_disp_drv_register(&disp_drv);
}

避坑指南:我曾经在双缓冲模式下踩过一个坑——缓冲区大小设成了整个屏幕,但LVGL的绘制区域是动态的。结果每次刷新都要拷贝全屏数据,性能反而下降了。正确的做法是:缓冲区大小设为屏幕的1/10到1/4,让LVGL分块绘制,这样内存和性能都兼顾。

2.5 性能优化小技巧

最后分享几个我在实际项目中用过的优化手段:

  1. 使用DMA传输:如果硬件支持,用DMA把缓冲区数据拷贝到显存,CPU可以干别的事。
  2. 只刷新脏区域:LVGL默认只刷新变化的部分,别全屏刷新。我的disp_flush函数里只拷贝area指定的区域。
  3. 调整缓冲区大小:缓冲区越大,刷新次数越少,但内存占用越大。找到平衡点。
  4. 关闭颜色转换:如果LVGL的颜色格式和framebuffer一致,关闭颜色转换可以省不少CPU。

好了,这一讲的内容就到这里。下一讲我们会深入触摸屏的移植,让仪表盘不仅能看,还能点。到时候见!