4、显示驱动移植(Display Driver):实现lv_disp_drv_t结构体、编写flush_cb回调函数、处理DMA加速

显示驱动移植,说白了就是让LVGL能跟你的屏幕“对上话”。很多新手卡在这一步,屏幕不亮、花屏、刷新慢,十有八九是这里出了问题。我刚开始做LVGL移植时,也在这上面折腾了两三天,后来才发现是flush_cb里少了一句通知。

这一章,咱们就把显示驱动拆开揉碎,从结构体配置到DMA加速,一步步说清楚。

4.1 lv_disp_drv_t结构体:显示驱动的“身份证”

LVGL里每个显示设备都对应一个 lv_disp_drv_t 结构体。它告诉LVGL:你的屏幕多大、用什么颜色格式、怎么刷新。配置不对,后面全白搭。

4.1.1 核心字段一览

字段名 类型 说明 我的建议值
hor_res lv_coord_t 水平分辨率(像素) 你的屏幕实际宽度
ver_res lv_coord_t 垂直分辨率(像素) 你的屏幕实际高度
flush_cb lv_disp_flush_cb_t 刷新回调函数指针 必须实现
draw_buf lv_disp_draw_buf_t * 绘制缓冲区 至少1个,建议2个
user_data void * 用户自定义数据 可传屏幕句柄等
antialiasing uint8_t 抗锯齿开关 1(开启)
rotated uint8_t 旋转方向 0/1/2/3
screen_transp uint8_t 屏幕是否支持透明 0(一般不用)
dpi lv_coord_t 屏幕DPI 根据实际设置

小技巧: user_data 字段非常实用。我习惯把屏幕的SPI句柄或DMA通道号塞进去,这样在flush_cb里直接取出来用,不用搞全局变量。

4.1.2 初始化代码模板

/* 1. 定义显示驱动结构体 */
static lv_disp_drv_t disp_drv;

/* 2. 定义绘制缓冲区 */
#define DISP_BUF_SIZE (LV_HOR_RES_MAX * 40)  /* 10行像素 */
static lv_color_t buf1[DISP_BUF_SIZE];
static lv_color_t buf2[DISP_BUF_SIZE];
static lv_disp_draw_buf_t draw_buf;

/* 3. 初始化绘制缓冲区 */
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, DISP_BUF_SIZE);

/* 4. 初始化显示驱动 */
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
disp_drv.flush_cb = my_flush_cb;
disp_drv.draw_buf = &draw_buf;
disp_drv.antialiasing = 1;

/* 5. 注册显示设备 */
lv_disp_t * disp = lv_disp_drv_register(&disp_drv);

注意: 缓冲区大小不是越大越好。我见过有人直接分配全屏缓冲区(320×240×2=150KB),结果内存不够直接死机。一般用10~20行像素的缓冲区就够了,LVGL会自己分块绘制。

4.2 flush_cb回调函数:屏幕刷新的“心脏”

flush_cb是LVGL和屏幕驱动之间的桥梁。LVGL每画好一块区域,就调用这个函数把数据送过去。你想想看,如果这个函数写得不对,画面肯定出问题。

4.2.1 flush_cb的标准结构

static void my_flush_cb(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p)
{
    /* 1. 获取区域坐标 */
    int32_t x1 = area->x1;
    int32_t y1 = area->y1;
    int32_t x2 = area->x2;
    int32_t y2 = area->y2;
    int32_t w = x2 - x1 + 1;
    int32_t h = y2 - y1 + 1;

    /* 2. 设置屏幕窗口(只刷新脏区域) */
    set_window(x1, y1, x2, y2);

    /* 3. 发送像素数据 */
    /* 这里根据你的屏幕接口实现,比如SPI、8080并口等 */
    send_pixels((uint8_t *)color_p, w * h * sizeof(lv_color_t));

    /* 4. 【关键】通知LVGL刷新完成 */
    lv_disp_flush_ready(drv);
}

核心要点: lv_disp_flush_ready() 必须调用!我曾经漏掉这一句,结果屏幕只刷新一次就卡死了。LVGL靠这个函数知道缓冲区可以复用了。

4.2.2 常见坑点

  • 颜色格式不匹配: LVGL默认是 LV_COLOR_16(RGB565),如果你的屏幕是RGB888,需要做转换。我建议统一用RGB565,省内存又快。
  • 坐标顺序搞反: 有些屏幕的窗口设置是 (x1, y1, x2, y2),有些是 (x1, y1, w, h)。查数据手册,别想当然。
  • 字节序问题: 有些屏幕要高位在前,有些要低位在前。用逻辑分析仪抓一下数据,一目了然。

4.3 DMA加速:让刷新飞起来

没有DMA的flush_cb,CPU得傻等着数据发完。尤其是SPI屏幕,一个像素一个像素地发,CPU占用率能到80%以上。用DMA,CPU只管把缓冲区地址告诉DMA控制器,然后就可以去干别的事了。

4.3.1 DMA传输流程

static void my_flush_cb(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p)
{
    /* 设置窗口 */
    set_window(area->x1, area->y1, area->x2, area->y2);

    /* 启动DMA传输(非阻塞) */
    dma_start((uint32_t)color_p, 
              (uint32_t)spi_data_register, 
              w * h * sizeof(lv_color_t));

    /* 注意:这里不调用 lv_disp_flush_ready() */
    /* 等DMA传输完成中断里再调用 */
}

/* DMA传输完成中断回调 */
void dma_complete_callback(void)
{
    lv_disp_flush_ready(&disp_drv);
}

我的经验: DMA传输完成后,记得清一下Cache(如果MCU有Cache的话)。我踩过这个坑——DMA把数据写到内存了,但Cache里还是旧数据,结果屏幕显示乱码。加一句 SCB_CleanInvalidateDCache() 搞定。

4.3.2 双缓冲+DMA的黄金组合

LVGL支持双缓冲:一个缓冲区在DMA传输,另一个缓冲区在绘制。这样CPU和DMA可以并行工作,帧率直接翻倍。

模式 CPU占用率 帧率(320×240) 内存占用
单缓冲+轮询 ~85% 15 FPS 12.8KB
单缓冲+DMA ~30% 25 FPS 12.8KB
双缓冲+DMA ~15% 45 FPS 25.6KB

你看,双缓冲+DMA的效果立竿见影。代价就是多占一份缓冲区内存,但对于现在的MCU来说,十几KB不算什么。

4.4 显示驱动移植完整流程图

下面这张图把整个流程串起来了,从初始化到DMA传输完成,每一步都标清楚了。

显示驱动移植流程图 显示驱动移植完整流程 1. 初始化 lv_disp_drv_t 2. 配置绘制缓冲区 3. 注册显示设备 4. LVGL调用 flush_cb 5. 设置窗口 + 启动DMA 6. DMA传输完成中断 7. lv_disp_flush_ready() 关键提醒 • 颜色格式统一用RGB565 • DMA完成后清Cache • 别忘了flush_ready() 性能对比 单缓冲轮询: 15FPS 双缓冲+DMA: 45FPS

4.5 避坑指南

做显示驱动移植,有几个坑我反复踩过,今天一次性说出来:

  • 坑1:忘记调用 lv_disp_flush_ready() —— 屏幕只刷新一次就卡死。解决方案:在DMA完成中断或发送完成后立即调用。
  • 坑2:缓冲区地址没对齐 —— DMA要求缓冲区地址按4字节或8字节对齐。用 __attribute__((aligned(4))) 或 __ALIGN_BEGIN 声明。
  • 坑3:中断优先级太低 —— DMA完成中断被其他中断打断,导致刷新延迟。把DMA中断优先级设高一点。
  • 坑4:SPI时钟极性/相位不对 —— 屏幕数据错位,显示花屏。用逻辑分析仪对比数据手册的时序图。

特别提醒: 如果你用的是SPI屏幕,注意SPI时钟频率不要超过屏幕的规格上限。我见过有人把SPI时钟设到80MHz,结果屏幕直接不工作。降回40MHz就正常了。

显示驱动移植说难不难,说简单也不简单。核心就是三件事:配好结构体、写对flush_cb、用上DMA。这三步走稳了,后面的事情就顺了。