ZYNQ 驱动 LVGL 9.5.0 并输出到 1024x600 RGB 屏,是一套非常典型的嵌入式 GUI 工程组合。LVGL 负责绘制界面,ZYNQ 的 PL 端生成 LCD 时序,PS 端通过帧缓冲(fbdev)传递像素,三个环节只要有一个细节没对齐,最终都会表现为黑屏、花屏、触摸漂移或帧率异常。这篇文章会以一条完整链路把关键环节讲清楚:从 ZYNQ PL 端显示链路配置,到 Linux 下的 framebuffer 设备树,再到 LVGL 9.5.0 在新版本的移植适配,最后给出验证、排错和性能优化建议。
适合的读者是已经有一定 ZYNQ 或嵌入式 Linux 基础的开发者。如果你只是刚把 Vivado 跑通,建议先补齐 AXInterconnect、VDMA、PetaLinux 设备树的资源再动手。如果你对 LVGL 的旧版本(7.x、8.x)比较熟,这篇文章也会重点强调 9.x 系列的 API 变化,避免把旧写法直接搬进来导致编译失败。
1. 先理解 ZYNQ 下 LVGL 9.5.0 的显示链路
1.1 ZYNQ 显示方案的基本组成
ZYNQ 与传统 MCU 平台带屏幕的方式有很大区别。MCU 平台上,LVGL 通常直接调用 SPI、并行总线或者 DMA 接口去写屏。但在 ZYNQ 上,典型方案是:
- PL 端生成 LCD 需要的时钟、同步信号、数据信号。常见做法是用 Xilinx 的 Video Timing Controller(VTC)和 AXI SmartConnect 构建像素流。
- PS 端运行 Linux,驱动提供
/dev/fb0这个帧缓冲设备。应用层可以使用mmap或write把图像数据写入帧缓冲。 - LVGL 作为一个用户态库,不直接操作寄存器,而是通过 framebuffer 的
write回调把绘制结果送出去。 - 如果使用 VDMA,帧缓冲在 DDR 中,VDMA 负责定时把 DDR 中的像素读到 PL 端并输出到 LCD。
这套链路可以抽象为:
LVGL 绘制 → 帧缓冲内存 → VDMA 读取 → PL 时序输出 → LCD 面板理解这条链路很重要。排查问题时你要先确定是哪一段出了问题:是 LVGL 没绘制,还是绘制了没写进帧缓冲,还是帧缓冲地址不对,还是 PL 时序参数和屏幕规格不匹配。
1.2 LVGL 9.5.0 版本变化带来的移植差异
LVGL 9.x 相比 8.x 做了大量重构,很多老教程里的写法不能直接用。
几个影响比较大的变化:
lv_disp_drv_t被替换为lv_display_t,注册显示设备的函数名和参数都变了。- 以前用
lv_disp_buf_t和lv_disp_drv_register,9.x 中统一改用lv_display_create、lv_display_set_flush_cb、lv_display_set_buffers。 - 驱动层不再通过
lv_disp_drv_t里的driver_data字段传自定义结构,而是通过lv_display_set_user_data和lv_display_get_user_data。 - 输入设备从
lv_indev_drv_t切换为lv_indev_create、lv_indev_set_type、lv_indev_set_read_cb。 - 默认内存管理方式也改了。9.x 可以通过
LV_USE_STDLIB_MALLOC、LV_USE_STDLIB_FREE等宏替换内存分配函数,很多桌面模拟器直接把标准库的 malloc 配置为默认分配器。
实际移植时,需要先确认自己拿到的是 9.5.0 的哪个分支,再对照源码里的lvgl.h、lv_display.h、lv_indev.h查看具体声明。不要凭记忆写 API,9.5.0 的接口处于持续演进中,可能在你下载时又有微调。
1.3 为什么是 1024x600 与 RGB 屏
1024x600 是很常见的 7 寸或者 10.1 寸 RGB 屏分辨率。RGB 屏意味着不需要 MCU 并口协议,而是按行同步、场同步、像素时钟的方式持续送数据。对 ZYNQ 来说,这种方式最适合发挥 PL 端并行数据输出的优势。
1024x600 的像素数据量不大,但实时性要求高。以 60Hz 刷新率计算:
1024 × 600 × 4 字节(ARGB8888) ≈ 2.46 MB 每帧VDMA 从 DDR 中读取 2.46 MB 数据只需要几毫秒,瓶颈不在带宽,而在时序参数和缓冲区分配是否合理。
2. 用 Vivado 构建 PL 端显示链路
2.1 Vivado 工程里的关键 IP
在 PL 端搭建 RGB LCD 输出的最小工程,一般需要这些 IP:
| IP 名称 | 作用 | 关键配置 |
|---|---|---|
| ZYNQ7 Processing System | PS 配置 | 使能 DDR、UART、SD,设置 AXI HP 接口 |
| AXI SmartConnect | 连接 PS 与 VDMA | 地址宽度、数据宽度与 PS 端对齐 |
| AXI VDMA | 从 DDR 搬像素 | 帧缓冲数量、行数、列数、颜色格式 |
| AXI Video Timing Controller | 生成行场同步信号 | 根据屏幕时序配置 H/V 参数 |
| AXI4-Stream to Video Out | 把像素流转换为 AXI4-Stream 与 VTC 信号 | 配置接口格式 |
其中 VDMA 的配置要和 LVGL 的颜色格式保持一致。如果 LVGL 使用 ARGB8888,VDMA 的 framebuffer 格式也要选择对应的 32bit 格式,否则会出现颜色错乱或透明通道异常。
2.2 地址映射与帧缓冲基址
在地址映射里,通常给 VDMA 分配一段 DDR 地址。比如把 VDMA 的 MM2S 基址设置为0x40000000,长度 16MB。这段地址会通过设备树传入 Linux,驱动把0x40000000映射为/dev/fb0的物理首地址。
需要注意,这个地址必须是 DDR 的范围。PS 端 DDR 默认从0x00100000或0x00000000开始,具体取决于你的内存控制器配置。VDMA 访问 DDR 必须走 HP 口,直接在 Address Editor 里把 VDMA 映射到 PS DDR 范围之外的地址是错的。
2.3 检查 PL 端输出时序参数
LCD 屏幕的时序一般由面板数据手册决定。下面是一个典型 1024x600、像素时钟约 51.2 MHz 的例子,实际数据请以你的屏幕手册为准:
| 参数 | 值 | 说明 |
|---|---|---|
| H Active | 1024 | 有效行像素 |
| H Front Porch | 160 | 行前肩 |
| H Sync Pulse | 20 | 行同步脉冲 |
| H Back Porch | 140 | 行后肩 |
| V Active | 600 | 有效场行数 |
| V Front Porch | 20 | 场前肩 |
| V Sync Pulse | 10 | 场同步脉冲 |
| V Back Porch | 20 | 场后肩 |
VTC 的配置就是把这几个值直接填进去。这里的数字只是示例,不同屏幕差异很大。如果时序不对,画面可能偏斜、闪烁,甚至直接无信号。
3. Linux 侧设备树与 framebuffer 驱动
3.1 设备树中 framebuffer 节点的作用
当 PL 端通过 AXI VDMA 驱动显示时,Linux 侧的 framebuffer 驱动通常由 Xilinx 提供的 DRM 驱动或者传统的 frame buffer 驱动实现。使用 PetaLinux 时,一般的做法是在设备树中加入 VDMA、VTC 和 framebuffer 节点。
一个简化的设备树节点示例:
amba_pl: amba_pl { #address-cells = <1>; #size-cells = <1>; compatible = "simple-bus"; ranges; vdma0: vdma@40000000 { compatible = "xlnx,axi-vdma-1.00.a"; reg = <0x40000000 0x10000>; #dma-cells = <1>; dma-channel = <&vdma0 0>; }; vtc0: vtc@40010000 { compatible = "xlnx,video-timing-controller-1.0"; reg = <0x40010000 0x1000>; xlnx,generator; xlnx,has-axi4s-out; }; fb0: framebuffer@40000000 { compatible = "xlnx,fb-vdma"; reg = <0x40000000 0x10000>; dma-coherent; dmas = <&vdma0 0>; dma-names = "vdma0"; xlnx,vtc = <&vtc0>; xlnx,bpp = <32>; xlnx,width = <1024>; xlnx,height = <600>; }; };这里把 VDMA 的基址设置为0x40000000,framebuffer 节点指向同一个 DMA 通道。xlnx,bpp表示每个像素的字节数,这里设为 32,对应 ARGB8888。xlnx,width和xlnx,height要与 LVGL 初始化时的宽高一致,也要与 VDMA 的行列配置一致。
实际使用中,设备树的具体 compatible 值要和你生成的内核驱动匹配。不同 BSP 或 PetaLinux 版本采用的 compatible 可能不同,这里只是说明思路。
3.2 确认/dev/fb0是否存在
硬件工程和内核启动之后,终端里执行:
ls -l /dev/fb* fbset -ifbset -i会显示当前 framebuffer 的信息,包括分辨率、颜色深度、虚拟分辨率等。如果你的屏幕分辨率是 1024x600、bpp 是 32,那么你应该看到:
mode "1024x600" geometry 1024 600 1024 600 32 timings 0 0 0 0 0 0 0 endmode如果这一步失败,后面 LVGL 的移植没有必要进行,因为 LVGL 只是用户态绘制,最终还是要靠 framebuffer 输出。
4. 编译 LVGL 9.5.0 基础工程
4.1 获取源码与目录结构
LVGL 9.5.0 的源码可以从 GitHub 仓库直接下载。工程目录建议按下面的结构组织:
lvgl9_demo/ ├── lvgl/ │ ├── src/ │ ├── lvgl.h │ └── lv_conf.h ├── porting/ │ ├── lv_port_disp.c │ ├── lv_port_disp.h │ ├── lv_port_indev.c │ └── lv_port_indev.h ├── main.c ├── CMakeLists.txt └── Makefile这里有两点要注意:
lv_conf.h放在源码根目录,通过LV_CONF_INCLUDE_SIMPLE或LV_CONF_PATH让模块能找到它。- 9.x 默认不再自动包含
lv_compatibility.h,如果你有旧代码想用 8.x 的兼容写法,需要在lv_conf.h里打开LV_USE_COMPAT相关宏。但迁移到 9.x 之后建议直接使用新 API,而不是长期依赖兼容层。
4.2 配置lv_conf.h
核心配置项集中在lv_conf.h,下面列出直接相关的选项:
#define LV_COLOR_DEPTH 32 #define LV_USE_DRAW_SW 1 #define LV_USE_STDLIB_MALLOC lv_malloc #define LV_USE_STDLIB_FREE lv_free #define LV_TIMER_TICK_PERIOD_MS 1 #define LV_DEF_REFR_PERIOD 33LV_COLOR_DEPTH必须和 framebuffer、VDMA 的颜色格式一致。使用 32 位色深时,LVGL 内部使用lv_color32_t,每个像素占 4 字节。LV_DEF_REFR_PERIOD是默认刷新周期,单位是毫秒。值越小刷新越频繁,CPU 占用越高。对于 1024x600 的屏幕,建议先保持 33ms 即约 30fps,跑起来后再根据实际帧率调整。
4.3 显示驱动适配:lv_port_disp.c
在 LVGL 9.x 中,显示驱动注册的流程大约是:
static lv_display_t *disp; void lv_port_disp_init(void) { disp = lv_display_create(1024, 600); if (!disp) { printf("lv_display_create failed\n"); return; } lv_display_set_flush_cb(disp, disp_flush_cb); static lv_color_t buf1[1024 * 100]; static lv_color_t buf2[1024 * 100]; lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL); }这里使用 1024x100 两个部分缓冲。对于 1024x600 全屏,单帧缓冲要 2.46 MB,双缓冲更大。如果 DDR 内存充裕,可以直接分配一整个全屏缓冲并设置全屏渲染模式,这样绘制效率更高,但内存占用会明显增加。
disp_flush_cb的作用是把 LVGL 绘制好的区域复制到 framebuffer,并告诉 LVGL“绘制结果已经提交”。在 framebuffer 方案下,最简单的 flush 回调如下:
static void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { int32_t w = lv_area_get_width(area); int32_t h = lv_area_get_height(area); uint32_t offset = ((uint32_t)area->y1 * FB_WIDTH + area->x1) * 4; memcpy((void *)(fb_ptr + offset), px_map, w * h * 4); lv_display_flush_ready(disp); }这里假设fb_ptr是已经通过mmap映射好的/dev/fb0地址,FB_WIDTH是屏幕宽度。offset计算时要注意fb_ptr是字节地址,px_map是uint8_t *类型,每像素 4 字节。
实际开发中,更高效的做法是把/dev/fb0当作一个连续内存区域,在 flush 回调里只做一次memcpy。如果屏幕支持局部刷新能力,还可以使用FBIO_WAITFORVSYNC等待垂直同步后再刷,但大部分 RGB 屏不需要做这个等待,因为 LVGL 自身已经按刷新周期调度。
4.4 输入设备适配:触摸屏与 LVGL 9.x
如果只是先用屏幕显示,可以暂时不接输入设备,直接在屏幕上运行一个循环动画。但要形成完整 UI,触摸输入通常是必须的。
触摸屏在嵌入式 Linux 下常见的接入方式:
- 通过 tslib 读取
/dev/input/eventX,在用户态做滤波和坐标变换。 - 直接读取
/dev/input/eventX,自己解析input_event结构。
在 LVGL 9.x 中,注册输入设备的方式如下:
static lv_indev_t *indev; void lv_port_indev_init(void) { indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, touch_read_cb); }touch_read_cb里需要把触摸屏上报的原始坐标转换为 LVGL 的坐标,并填入lv_indev_data_t。
static void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data) { if (ts_available) { >data->point.x = FB_WIDTH - 1 - raw_x;>void *lvgl_thread(void *arg) { while (1) { lv_timer_handler(); usleep(5000); } return NULL; }注意lv_timer_handler()的调用周期不能快于 LVGL 内部的最小定时器周期,否则空转浪费 CPU。lv_tick_inc()用于向 LVGL 提供时间基准,在 Linux 上可以通过gettimeofday实现,也可以使用LV_USE_TICK_CUSTOM配置系统 tick。
5. 编译、运行与验证
5.1 交叉编译环境准备
ZYNQ 的 Linux 用户态程序通常使用 ARM Cortex-A9 的交叉编译工具链。常见组合:
| 工具 | 选择 |
|---|---|
| 交叉编译器 | arm-linux-gnueabihf-gcc 或 PetaLinux 提供的 SDK 工具链 |
| CMake 版本 | 3.10 以上 |
| LVGL 配置 | 在lv_conf.h中关闭不需要的组件,裁剪内存 |
一个简单的 Makefile 示例:
CC = arm-linux-gnueabihf-gcc CFLAGS = -O2 -Wall -I./lvgl -I./porting -DLV_CONF_PATH=\"./lv_conf.h\" LDFLAGS = -lm -lpthread SRCS = main.c \ lvgl/src/core/lv_obj.c \ lvgl/src/core/lv_display.c \ ... porting/lv_port_disp.c \ porting/lv_port_indev.c TARGET = zynq_lvgl_demo all: $(TARGET) $(TARGET): $(SRCS) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)实际编译时直接编译 LVGL 的所有源码文件更方便。LVGL 9.x 的源码组织比较清晰,可以这样收集:
find lvgl/src -name "*.c" -o -name "*.h"然后用循环把.c文件加入编译列表。
5.2 在 ZYNQ 板卡上运行
把生成的zynq_lvgl_demo可执行文件拷到板卡上,用 root 权限运行:
chmod +x zynq_lvgl_demo ./zynq_lvgl_demo屏幕正常时会出现 LVGL 默认的 demo 画面,比如显示一个仪表盘或按钮。
如果画面只有一半、花屏、或者整体偏移,优先检查 framebuffer 和 LVGL 的分辨率、颜色深度是否一致。可以用/dev/fb0的纯色测试先做验证:
dd if=/dev/zero of=/dev/fb0 bs=1024 count=600这条命令会把 framebuffer 全部清零,如果屏幕显示纯黑,说明 PL 链路和 framebuffer 驱动正常。再写入 0xFFFFFFFF,屏幕应该变为纯白。这个手段能直接区分是 LVGL 的问题还是显示链路的问题。
5.3 验证帧率和内存占用
在板卡上统计帧率最简单的方法是在lv_timer_handler()调用处做计数:
uint32_t frame_cnt = 0; uint32_t last_sec = 0; while (1) { lv_timer_handler(); frame_cnt++; now = time(NULL); if (now != last_sec) { printf("fps: %u\n", frame_cnt); frame_cnt = 0; last_sec = now; } usleep(5000); }内存占用可以通过top或/proc/<pid>/status查看。如果 LVGL 使用了部分缓冲方案,内存占用会明显小于全屏缓冲,但绘制效率可能下降。尤其注意不要使用超出 DDR 大小的大数组,否则会出现运行一段时间后系统被 OOM 打断,或绘图内容异常跳动。
6. 常见问题排查
6.1 黑屏或白屏
可能原因按优先级排列:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 黑屏 | VDMA 没有启动或地址错误 | dmesg | grep dma,查看 VDMA 寄存器状态 | 检查设备树地址,确认帧缓冲地址在 DDR 范围内 |
| 黑屏 | framebuffer 无法打开 | ls -l /dev/fb0 | 检查驱动加载状态,确认 VDMA、DRM 模块是否加载 |
| 白屏 | LVGL 刷新回调没有调用 | 在flush_cb中加打印 | 检查 LVGL 定时器是否运行,lv_timer_handler是否被周期调用 |
| 白屏 | framebuffer 被写纯白,但 LVGL 没有初始化 | 检查初始化顺序 | 确保lv_init()在创建显示设备之前调用 |
6.2 花屏、偏移或闪烁
花屏的根因通常是像素格式不一致。比如 LVGL 使用 16bit 色深,而 framebuffer 设置为 32bit 显示,或者反过来。出现花屏时不要修改算法,先统一颜色深度。
偏移通常和行时序中的 H Front Porch / H Sync Pulse / H Back Porch 有关。把这些参数对照屏幕手册重新确认。特别要注意,部分屏幕的时序值要求单位是像素时钟,有些要求以“扫描行时间”为单位,填错会让画面整体右移或左移。
闪烁现象常见于 VDMA 只有一个帧缓冲且没有乒乓机制。VDMA 在读上一帧时,LVGL 在写下一帧,如果写地址覆盖到 VDMA 尚未读完的区域,就会出现撕裂闪烁。通常的做法是让 VDMA 开启两个或者三个帧缓冲。
6.3 触摸坐标不准或反轴
先进入调试模式打印原始坐标:
printf("raw x=%d y=%d\n", raw_x, raw_y);然后在屏幕上显示一个点或一个按钮,点击屏幕的不同角落,记录坐标值。如果实际点按下时打印的y方向和屏幕显示相反,说明触摸和显示的原点相反。此时在touch_read_cb中做坐标映射即可。
另一个容易被忽略的是,多个输入设备时事件串台。比如触摸屏被识别为event0,键盘或鼠标也被识别为event1,LVGL 的输入驱动读取了错误的事件节点。运行cat /proc/bus/input/devices可以查看每个输入设备的名称和对应的 event 编号。
6.4 帧率低或 CPU 占用高
1024x600 全屏刷新对 CPU 有一定压力。如果屏幕变化很频繁,建议优先优化渲染区域:
- 使用
LV_DISPLAY_RENDER_MODE_PARTIAL让 LVGL 只刷新变化区域。 - 不要在全屏背景上频繁播放 gif 或大动画,LVGL 的软件渲染性能有限。
- 开启
LV_USE_DRAW_SW中的多线程渲染,但要确认在同一套线程模型下没有竞态。 - 如果板卡带 GPU,检查 LVGL 9.x 是否已经提供硬件加速驱动,否则仍走软件渲染。
LV_DEF_REFR_PERIOD也可以适当调大。从 33ms 改成 40ms 能降低约 20% 的刷新压力,但视觉上可能不够流畅。
7. 生产环境下的工程建议
7.1 多缓冲与垂直同步
在资源允许的情况下,最好使用多个帧缓冲。LVGL 侧可以配置两块部分缓冲,VDMA 侧配置两个或三个帧缓冲。这样 LVGL 写一块,VDMA 读另一块,互不干扰,能明显减少撕裂。
垂直同步适合使用/dev/fb0的FBIO_WAITFORVSYNCioctl。在 flush 回调中,可以先执行:
ioctl(fb_fd, FBIO_WAITFORVSYNC, 0);再执行memcpy。这样可以让绘制的数据在一个完整的垂直同步间隙内提交,减少屏幕中部切换导致的撕裂。
7.2 日志与监控
从项目工程化角度,至少记录以下日志:
- LVGL 初始化是否成功。
- framebuffer 打开和 mmap 是否成功。
- 每个 flush 回调的执行耗时,统计最大值。
- 触摸事件点是否异常偏离。
可以写一个环形缓冲区保存最近 1000 次 flush 的执行时间,出现卡顿时导出分析。实际项目中,画面卡死往往不是 LVGL 卡死,而是 flush 回调里memcpy卡在了 DMA 带宽或内存总线竞争上。
7.3 内存管理建议
LVGL 9.x 默认支持标准库 malloc,但嵌入式环境建议在lv_conf.h中自定义分配器:
#define LV_USE_STDLIB_MALLOC my_custom_malloc #define LV_USE_STDLIB_FREE my_custom_free自定义分配器可以统计当前内存峰值,也可以把 LVGL 的内存限制在一个固定池内。对于 1024x600 的界面,UI 对象和样式使用内存一般在几十 KB 到几百 KB,具体的峰值可以通过lv_mem_monitor()查看。
7.4 升级和版本兼容
LVGL 版本更新很快,9.5.0 和未来版本之间仍然可能有 API 变化。建议锁定一个具体版本,并把lv_conf.h放到自己的工程版本控制中。升级 LVGL 时,优先查看 release note 中的 Breaking Changes,再逐项检查自己用到的 API 是否被替换。
如果项目会在多个屏幕分辨率之间切换,建议把分辨率定义放在独立的lcd_hw.h中,而不是散落在lv_port_disp.c、flush 回调、设备树三处。设备树、VDMA、LVGL、flush 回调四处都必须保持一致,任何一处单独改动都会造成问题。
8. 扩展:从 framebuffer 到 DRM/KMS
如果你的目标系统支持 DRM/KMS,可以考虑脱离 framebuffer 方案,让 LVGL 通过 DRM 渲染。DRM 相比 framebuffer 的优势是更贴近现代 Linux 图形栈,支持多屏、硬件图层、ModeSetting 和垂直同步回调。
在 LVGL 9.x 中,lv_display_set_flush_cb的 flush 回调可以对接 DRM 的 dumb buffer 或 GEM buffer。要把 LVGL 绘制结果提交到 DRM,需要处理drmModeAddFB、drmModeSetCrtc、drmModePageFlip这些底层调用,代码量比 framebuffer 方案大不少,但为后续接入 GPU 提前打了基础。
对于大多数以显示仪表、控制面板、菜单页面为主的 ZYNQ 项目,framebuffer 方案足够稳定,优先把时序、帧缓冲和 LVGL 三个环节跑稳,再去追求 DRM 的现代特性。如果一个页面切换时偶发花屏,最先排查的永远是帧缓冲数量、像素格式和时序参数,而不是 LVGL 版本。