简介:这份资源是LVGL v8.2的移植源码包,面向嵌入式GUI开发者和ARM平台学习者,尤其适合使用正点原子I.MX6ULL阿尔法板等硬件进行界面开发的工程师。包内共1236个文件,以439个C源文件与218个头文件为核心,构成LVGL图形库主体;另有149个Python脚本、118个Markdown文档、52个reStructuredText说明及若干示例与配置文件,涵盖显示驱动、帧缓冲端口、字体与图片素材等模块,压缩包约348MB。资源围绕lv_drivers与lv_port_linux_frame_buffer展开,包含显示驱动适配、Linux帧缓冲配置、交叉编译与链接等关键环节的完整代码,可帮助读者理解LVGL内部机制、驱动工作原理及嵌入式GUI移植流程。目前已有1283人学习下载,适合希望掌握ARM平台图形界面开发、快速搭建可运行GUI工程的开发者参考。
1. lvgl v8.2移植源码:从零跑通一块屏,到底要改哪几个文件
如果你手头有一块 SPI 屏或者 RGB 屏,主控是 STM32、ESP32 或者某款国产 MCU,想把 lvgl v8.2 跑起来,大概率会经历这样一个过程:从仓库把源码拉下来,对着 examples 里的 porting 模板看半天,改完 lv_conf.h 和 lv_port_disp.c,编译通过,烧进去,屏幕要么全白,要么花屏,要么干脆没反应。然后开始怀疑是接线问题、时序问题、还是源码本身有问题。
lvgl v8.2 移植源码这件事,核心不是「把库编译进去」,而是把 lvgl 的显示刷新、输入读取、时基心跳这三条链路,和你板子上的实际硬件对齐。v8.2 这个版本在移植接口上比 v7 清晰很多,lv_port_disp_init和lv_port_indev_init两个文件基本把该改的地方都圈出来了,但真正翻车的地方往往不在这些文件里,而在 lv_conf.h 的宏开关、颜色格式匹配、以及刷新缓冲区的大小和位置。
这篇文章面向的是已经能点灯、能跑通 SPI 或 RGB 驱动的嵌入式开发者。如果你连屏幕的初始化序列都还没调通,建议先把裸屏点亮再来看移植。下面按「先理解 v8.2 的移植结构 → 再动手改配置和驱动 → 然后排坑 → 最后讲进阶技巧」的顺序展开,每一步都给到能直接抄的代码和参数说明。
2. lvgl v8.2 移植源码的目录结构与三条核心链路
2.1 v8.2 源码包里哪些文件必须进工程,哪些可以裁掉
从官方仓库拿到的 lvgl v8.2 源码,目录大致是这样的:src/下按功能分core、draw、font、hal、misc、widgets等子目录,examples/里放的是各平台的 porting 模板,lv_conf_template.h是配置模板,lvgl.h是总入口头文件。
实际进工程的时候,不需要把整个examples/都加进去。我一般只保留examples/porting/下的lv_port_disp_template.c/.h和lv_port_indev_template.c/.h,重命名后放到自己的bsp或者port目录里。src/整个目录都要加,但可以在 IDE 里按需排除掉不用的 widget,比如你只做仪表盘,widgets/lv_table、widgets/lv_calendar这些可以暂时不编译,能省不少 Flash。
lv_conf.h的位置有讲究。v8.2 默认从lvgl.h同级目录找lv_conf.h,如果你放在工程其他路径,需要在编译器预定义宏里加LV_CONF_INCLUDE_SIMPLE,或者直接改lvgl.h里的 include 路径。我习惯把lv_conf.h放在工程根目录的config/下,然后在 IDE 的 include path 里把这个目录加进去,同时在预定义宏里加上LV_CONF_INCLUDE_SIMPLE,这样最干净。
一个常见的裁剪参考:
| 目录/文件 | 是否必须 | 说明 |
|---|---|---|
| src/core | 必须 | 对象系统、事件、刷新调度 |
| src/draw | 必须 | 渲染、颜色混合、字体绘制 |
| src/font | 必须 | 至少保留一种字体 |
| src/hal | 必须 | 显示和输入 HAL 接口 |
| src/widgets | 按需 | 不用到的 widget 可排除 |
| src/extra | 按需 | 布局、主题、第三方库 |
| examples/porting | 必须 | 移植模板来源 |
| demos | 可选 | 调试阶段可加,量产去掉 |
2.2 显示刷新链路:从 lv_timer_handler 到你的 flush_cb
lvgl v8.2 的显示刷新是这样一个流程:lv_timer_handler()被周期性调用,内部判断有没有脏区域需要重绘,如果有,就调用你注册的flush_cb,把渲染好的像素数据推给屏幕。flush_cb是移植时唯一必须自己实现的显示接口。
在lv_port_disp_init里,关键动作有三个:初始化显示驱动、注册flush_cb、创建至少一个 draw buffer。draw buffer 可以是单缓冲、双缓冲,也可以直接用屏幕的 GRAM 做全屏缓冲。缓冲区越大,刷新越流畅,但 RAM 占用也越高。
/* lv_port_disp.c 中的核心注册逻辑 */ void lv_port_disp_init(void) { /* 1. 初始化你的屏幕硬件,比如 SPI 初始化、面板初始化序列 */ disp_init(); /* 2. 创建 draw buffer,这里用双缓冲,每个缓冲区 1/10 屏幕大小 */ static lv_disp_draw_buf_t draw_buf_dsc; static lv_color_t buf_1[MY_DISP_HOR_RES * 20]; static lv_color_t buf_2[MY_DISP_HOR_RES * 20]; lv_disp_draw_buf_init(&draw_buf_dsc, buf_1, buf_2, MY_DISP_HOR_RES * 20); /* 3. 注册显示驱动 */ static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = MY_DISP_HOR_RES; disp_drv.ver_res = MY_DISP_VER_RES; disp_drv.flush_cb = disp_flush; disp_drv.draw_buf = &draw_buf_dsc; lv_disp_drv_register(&disp_drv); }这段代码里,MY_DISP_HOR_RES和MY_DISP_VER_RES要改成你屏幕的实际分辨率。缓冲区大小MY_DISP_HOR_RES * 20表示每个缓冲区能存 20 行像素,双缓冲就是 40 行。如果你的 RAM 紧张,可以降到 10 行;如果 RAM 充裕,可以升到 1/4 屏甚至全屏。
disp_flush的实现要注意:lvgl 传给你的area是屏幕坐标区域,color_p是像素数据指针。你需要根据屏幕的接口(SPI/RGB/8080)把这块数据写到对应的 GRAM 地址。写完后必须调用lv_disp_flush_ready(drv),否则 lvgl 会一直等,界面就卡住了。
/* flush_cb 的典型实现,以 SPI 屏为例 */ static void disp_flush(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); /* 把 color_p 指向的数据通过 SPI 发出去 */ uint32_t px_count = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); spi_send_pixels((uint8_t *)color_p, px_count * sizeof(lv_color_t)); /* 必须调用,通知 lvgl 这一块已经刷完 */ lv_disp_flush_ready(drv); }注意:如果你用的是 DMA 传输,
lv_disp_flush_ready要放在 DMA 传输完成中断里调用,不能放在disp_flush函数末尾,否则会出现撕裂或者数据错位。
2.3 输入读取链路:触摸和按键怎么接到 lvgl 的 indev
输入设备和显示是两条独立的链路。v8.2 的输入接口在lv_port_indev.c里,支持触摸屏、鼠标、键盘、编码器、按键这几种类型。大多数嵌入式场景用的是触摸屏或者物理按键。
触摸屏的接入方式是注册一个read_cb,lvgl 会周期性调用它来获取触摸坐标和按下状态。read_cb里要做的是:读取触摸芯片的坐标,判断是否按下,然后填充lv_indev_data_t结构体。
/* 触摸屏 read_cb 的实现 */ static void touchpad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) { static lv_coord_t last_x = 0; static lv_coord_t last_y = 0; /* 从触摸芯片读取坐标和按下状态 */ if (touch_is_pressed()) { touch_get_xy(&last_x, &last_y); >/* 主循环中的调度逻辑 */ while (1) { uint32_t time_till_next = lv_timer_handler(); /* 如果距离下次处理还有时间,可以进低功耗或者处理其他任务 */ if (time_till_next > 0 && time_till_next < 10) { delay_ms(time_till_next); } /* 其他业务逻辑 */ app_task_handler(); }lv_tick_inc的调用频率必须是精确的 1ms,如果时基不准,动画会忽快忽慢,触摸响应也会变得迟钝。用 SysTick 的时候要注意,如果你用了 RTOS,SysTick 可能被 RTOS 占用,这时候要换一个硬件定时器来产生 1ms 中断。
三条链路都接通之后,lvgl 就能跑起来了。但能跑起来和跑得好是两回事,下面讲配置和参数怎么调。
3. lv_conf.h 与 lv_port_disp.c 的关键参数怎么设
3.1 lv_conf.h 里必须改的 6 个宏
lv_conf.h是 lvgl 的行为总控,里面有几百个宏,但移植阶段真正必须改的只有几个。下面这张表是我每次移植都会检查的:
| 宏名 | 默认值 | 建议值 | 作用 |
|---|---|---|---|
| LV_COLOR_DEPTH | 16 | 按屏幕设 16/32 | 颜色深度,必须和屏幕一致 |
| LV_COLOR_16_SWAP | 0 | SPI 屏常设 1 | 16 位色字节序交换 |
| LV_MEM_SIZE | 48KB | 按 RAM 调 | lvgl 内部堆大小 |
| LV_DISP_DEF_REFR_PERIOD | 30 | 16~30 | 刷新周期,单位 ms |
| LV_INDEV_DEF_READ_PERIOD | 30 | 16~30 | 输入读取周期 |
| LV_USE_LOG | 0 | 调试时 1 | 日志开关 |
LV_COLOR_DEPTH是最容易翻车的一个。如果你的屏幕是 RGB565,这里必须设 16;如果是 RGB888,设 32。设错了不会编译报错,但显示颜色会完全错乱,比如红色变蓝色、绿色变紫色。
LV_COLOR_16_SWAP是 SPI 屏的经典坑。很多 SPI 屏控制器(比如某款常见的 2.4 寸 ILI9341 类屏)接收像素数据时是高字节在前,而 lvgl 默认是低字节在前,不设这个宏就会看到颜色整体偏移,比如原本的红色变成暗红色或者偏色。设成 1 之后,lvgl 会在发送前自动交换字节序。
LV_MEM_SIZE决定 lvgl 能创建多少对象、能缓存多少字体数据。如果设得太小,创建几个控件就会失败,表现为界面元素显示不全或者直接死机。我一般从 48KB 起步,如果用了中文字体或者多页面,加到 64KB 或 128KB。
3.2 颜色格式匹配:RGB565、RGB888 和字节序的坑
颜色格式这件事,值得单独拿出来说。lvgl 的LV_COLOR_DEPTH和屏幕控制器的像素格式必须严格对应,中间任何一层做了转换而你没意识到,就会出问题。
以 RGB565 为例,一个像素占 2 字节,lvgl 内部用lv_color_t表示。在 16 位深度下,lv_color_t就是一个 16 位整数,内存布局取决于LV_COLOR_16_SWAP。如果屏幕控制器期望的是高字节先收,而 lvgl 发的是低字节先,颜色就会错。
判断是否需要开LV_COLOR_16_SWAP的方法很简单:画一个纯红色矩形,如果显示出来是蓝色或者暗红色,基本就是字节序问题。另一个方法是直接看屏幕控制器的数据手册,确认它的像素数据接收顺序。
对于 RGB888 屏幕,LV_COLOR_DEPTH设 32,每个像素 4 字节(其中 1 字节填充),内存占用会翻倍,刷新带宽也翻倍。如果你的 MCU 主频不高或者 SPI 时钟有限,RGB888 可能会让刷新率掉到不可接受的程度。这种时候要么换 RGB565,要么用 RGB 接口的屏幕而不是 SPI 屏。
3.3 draw buffer 大小与位置:单缓冲、双缓冲还是全屏
draw buffer 的配置直接影响刷新流畅度和 RAM 占用。v8.2 支持三种模式:
单缓冲:只创建一个 buffer,lvgl 渲染完一帧后调用 flush,flush 期间 lvgl 不能继续渲染,会等 flush 完成。RAM 占用最小,但刷新率最低。
双缓冲:创建两个 buffer,lvgl 在一个 buffer 里渲染的同时,另一个 buffer 可以被 flush。刷新率明显提升,RAM 占用翻倍。这是最常用的模式。
全屏缓冲:buffer 大小等于整个屏幕,lvgl 直接渲染到这块内存,flush 时一次性推给屏幕。刷新率最高,但 RAM 占用也最大。对于 320x240 的 RGB565 屏幕,全屏缓冲需要 3202402 = 150KB,很多 MCU 拿不出这么多 RAM。
我一般这样选:RAM 小于 64KB 的用单缓冲,每个 buffer 10 行;RAM 在 64KB 到 256KB 之间的用双缓冲,每个 buffer 20 到 40 行;RAM 大于 256KB 的可以考虑全屏缓冲或者 1/4 屏缓冲。
buffer 的位置也有讲究。如果 MCU 有 CCM 或者 TCM 内存,把 buffer 放在那里可以避免和 DMA 争抢总线。如果没有,就放在普通 SRAM 里,但要注意 DMA 能不能访问这块内存。有些 MCU 的 DMA 只能访问特定区域,放错了会传输失败。
/* 双缓冲配置示例,每个 buffer 20 行 */ static lv_color_t buf_1[MY_DISP_HOR_RES * 20]; static lv_color_t buf_2[MY_DISP_HOR_RES * 20]; lv_disp_draw_buf_init(&draw_buf_dsc, buf_1, buf_2, MY_DISP_HOR_RES * 20);如果编译时报 RAM 溢出,先把行数降到 10,再不够就改单缓冲。不要为了追求流畅度把 RAM 撑爆,系统跑不起来什么都白搭。
3.4 刷新率与 CPU 占用的平衡:LV_DISP_DEF_REFR_PERIOD 怎么调
LV_DISP_DEF_REFR_PERIOD控制 lvgl 多久检查一次是否需要刷新,默认 30ms,对应约 33fps。这个值越小,刷新越及时,但 CPU 占用也越高。
在 SPI 屏上,刷新率往往受限于 SPI 时钟和屏幕控制器的写入速度,而不是 lvgl 的渲染速度。这种情况下,把LV_DISP_DEF_REFR_PERIOD设得太小没有意义,因为 flush 本身就要花很长时间。我一般先设 30ms,跑起来之后用示波器或者 GPIO 翻转来测实际刷新率,如果 CPU 占用过高再往上调。
如果用了 DMA 传输,flush 期间 CPU 是空闲的,这时候可以把LV_DISP_DEF_REFR_PERIOD设小一点,比如 16ms,让 lvgl 更频繁地检查脏区域。但要注意 DMA 传输完成中断和 lvgl 的lv_disp_flush_ready之间的配合,中断优先级设错了会导致数据错位。
4. 移植过程中最容易翻车的 5 个地方
4.1 屏幕全白或全黑:flush_cb 没被调用或没调 ready
现象:烧录后屏幕背光亮,但没有任何内容,全白或者全黑。
原因:flush_cb没有被 lvgl 调用,或者调用了但没有执行lv_disp_flush_ready。lvgl 在等 ready 信号,等不到就不会继续渲染下一帧。
解决:在flush_cb入口加一个 GPIO 翻转或者打印,确认它有没有被调用。如果没有被调用,检查lv_disp_drv_register是否成功,以及lv_timer_handler是否在主循环里被周期性调用。如果被调用了但屏幕没反应,检查lv_disp_flush_ready是否在正确的位置被调用,特别是用 DMA 的时候。
4.2 颜色错乱:LV_COLOR_16_SWAP 设反了
现象:界面能显示,但颜色完全不对,红色变蓝色,或者整体偏色。
原因:LV_COLOR_16_SWAP的设置和屏幕控制器的实际字节序不匹配。
解决:把这个宏取反再试。如果原来是 0,改成 1;原来是 1,改成 0。改完要全量重新编译,因为lv_conf.h的改动会影响很多文件。如果取反后还是不对,检查屏幕控制器的初始化序列里有没有设置像素格式的寄存器,有些屏幕默认是 RGB888,需要手动切到 RGB565。
4.3 触摸坐标偏移:read_cb 里没做坐标映射
现象:触摸能触发,但点 A 位置响应的是 B 位置,或者边缘区域点不到。
原因:触摸芯片输出的坐标范围和屏幕分辨率不一致,read_cb里直接把原始坐标赋给了>/* DMA 方式的 flush_cb */ static void disp_flush(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); uint32_t px_count = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); /* 启动 DMA 传输,传输完成后在中断里调 ready */ spi_dma_start((uint8_t *)color_p, px_count * sizeof(lv_color_t)); } /* DMA 传输完成中断 */ void SPI_DMA_IRQHandler(void) { if (dma_transfer_complete()) { dma_clear_flag(); lv_disp_flush_ready(&disp_drv); } }
这里的关键是lv_disp_flush_ready必须在 DMA 中断里调用,而且disp_drv要能被中断访问到,通常声明为全局变量或者静态变量。另外,DMA 传输的数据源地址必须是 DMA 能访问的内存区域,如果 buffer 放在 CCM 里而 DMA 访问不了,传输会失败。
5.2 用 lv_conf.h 的日志和断言快速定位问题
lvgl v8.2 内置了日志和断言机制,移植阶段打开它们能省很多调试时间。在lv_conf.h里把LV_USE_LOG设为 1,LV_USE_ASSERT相关的宏也打开,然后实现一个lv_log_print函数把日志输出到串口。
/* 日志输出实现 */ void my_log_print(const char *buf) { uart_send_string(buf); } /* 在 lv_conf.h 中配置 */ #define LV_USE_LOG 1 #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN #define LV_LOG_PRINTF 0打开日志之后,如果 lvgl 内部检测到参数错误、内存不足、对象操作异常,会直接打印出来。比如创建对象时LV_MEM_SIZE不够,日志里会提示内存分配失败;flush_cb没有调用 ready,日志里会有超时警告。这些信息比盲猜快得多。
断言方面,LV_USE_ASSERT_NULL、LV_USE_ASSERT_MALLOC、LV_USE_ASSERT_OBJ这几个建议在调试阶段都打开,量产时再关掉。断言触发时会打印文件名和行号,直接定位到出问题的代码位置。
我自己的习惯是:每次移植新屏幕,先把日志和断言全开,跑通之后再逐个关掉,观察有没有异常。这样能在早期发现很多隐藏问题,比如某个 buffer 越界、某个对象被重复删除、某个定时器周期设成了 0。这些坑在量产阶段暴露出来,排查成本会高很多。
希望帮到你。
本文还有配套的精品资源,点击获取