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传输完成,每一步都标清楚了。
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。这三步走稳了,后面的事情就顺了。