拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SBC2332嵌入式LVGL移植实战:framebuffer显示与触摸驱动适配指南

1. 项目缘起与整体设计思路1.1 为什么要在 SBC2332 上跑 LVGL手头这块 SBC2332 单板计算机双核 Cortex-A7 架构主频 1.2GHz标配 512MB DDR3 和 8GB eMMC板载 7 寸 RGB 接口和 MIPI 接口出厂预装 Linux 系统。这个配置放在工控场景里属于典型的“够用但不富裕”的档次——跑 Qt 有点吃力跑纯命令行又太浪费那块屏幕。我最初拿到这块板子的需求很明确做一个本地人机界面用来显示设备运行状态、接收几个按钮操作、偶尔弹个报警提示。不需要联网不需要复杂动画但要稳定、开机快、界面别太丑。LVGL 正好卡在这个需求点上。它是一个开源嵌入式图形库C 语言编写核心库编译出来大概 300KB 左右运行时内存占用可以压到 2MB 以内。对比 Qt for EmbeddedLVGL 没有庞大的依赖链不需要 X11 或 Wayland直接往 framebuffer 上画启动速度是秒级的。我实测过在 SBC2332 上从系统启动到 LVGL 界面完全渲染出来大概 3 秒左右这个速度对于工控 HMI 来说完全够用。选 LVGL 还有一个很实际的原因它的 API 设计对嵌入式开发者友好。你不需要懂复杂的图形管线只要会创建对象、设置属性、绑定事件回调就能搭出一个能用的界面。而且 LVGL 的社区活跃度很高遇到问题搜一下基本都有答案这对项目推进速度影响很大。1.2 整体方案选型与架构设计整个项目的架构分三层底层是 Linux 系统提供的 framebuffer 设备/dev/fb0和输入设备/dev/input/eventX中间层是 LVGL 的移植适配层上层是具体的 HMI 应用逻辑。LVGL 本身不直接操作硬件它通过一个叫lv_disp_drv_t的显示驱动结构体来和底层通信通过lv_indev_drv_t来接收触摸或按键输入。为什么不用 DRM/KMS 而用 framebuffer因为 SBC2332 的 Linux 内核默认配置里 framebuffer 支持是打开的DRM 驱动虽然也在但配置起来麻烦而且对于单图层、无 GPU 加速的场景framebuffer 的性能完全够用。我测过 800x480 分辨率下全屏刷新framebuffer 的写入速度能到 60fps 以上LVGL 默认的 30fps 刷新率绰绰有余。触摸输入这块SBC2332 板载的电容触摸屏走的是 I2C 接口内核里已经带了驱动会在 /dev/input/ 下生成 event 设备。LVGL 的输入设备接口支持 evdev直接读事件就行不需要自己写 I2C 通信。这里有个细节LVGL 的 evdev 驱动需要知道屏幕的分辨率和触摸范围如果触摸坐标和显示坐标对不上需要在驱动里做坐标变换。编译环境我选的是在 Ubuntu 22.04 虚拟机上交叉编译工具链用板子厂商提供的 gcc-linaro 版本。为什么不直接在板子上编译因为 SBC2332 的 eMMC 读写速度一般编译 LVGL 这种规模的库在板子上跑一次要十几分钟交叉编译在虚拟机上只要几十秒。而且交叉编译环境可以复用后续改代码重新编译很快。2. 核心细节解析与移植实操要点2.1 LVGL 源码获取与编译配置LVGL 的源码从官方 GitHub 仓库获取我用的版本是 8.3这个版本稳定性和功能平衡得比较好。9.x 版本虽然新但 API 变动较大社区里很多教程还没跟上遇到问题不好搜。下载下来之后核心文件在src/目录下移植需要的主要是lv_conf.h配置文件和lv_port_disp.c、lv_port_indev.c这两个适配文件。lv_conf.h是整个移植过程中最关键的配置文件。它决定了 LVGL 启用哪些功能、分配多少内存、用什么颜色格式。我建议先把lv_conf_template.h复制成lv_conf.h然后把文件开头的#if 0改成#if 1否则整个配置不生效。这个坑我踩过当时编译一直报错说找不到配置查了半天才发现是模板文件默认不启用。颜色格式这块SBC2332 的 framebuffer 是 32 位 ARGB8888但 LVGL 内部渲染用 RGB565 更省内存。我试过直接用 ARGB8888内存占用大了将近一倍而且刷新率没明显提升。所以配置里LV_COLOR_DEPTH设成 16然后在显示驱动里做一次颜色转换。转换逻辑很简单把 RGB565 的两个字节拆开分别放到 framebuffer 的对应位置就行。内存分配是另一个关键点。LVGL 需要一块显存来做渲染缓冲大小至少是屏幕分辨率的十分之一。800x480 的屏幕十分之一就是 38400 像素按 RGB565 算就是 76800 字节。我实际分配了 800x100 的缓冲区也就是 160000 字节留了一倍余量。这块内存用malloc从堆里分配或者用静态数组都行我用的静态数组避免堆碎片问题。// lv_conf.h 关键配置 #define LV_COLOR_DEPTH 16 #define LV_MEM_SIZE (2 * 1024 * 1024) // 给 LVGL 内部对象分配留 2MB #define LV_DISP_DEF_REFR_PERIOD 30 // 30ms 刷新周期约 33fps #define LV_USE_LOG 1 #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN2.2 显示驱动适配与 framebuffer 操作显示驱动的核心任务是把 LVGL 渲染好的缓冲区内容刷到 framebuffer 上。framebuffer 的操作很直接open(/dev/fb0, O_RDWR)然后ioctl获取屏幕参数最后mmap把显存映射到用户空间。映射之后往那块内存里写数据就等于往屏幕上画。这里有个细节要注意SBC2332 的 framebuffer 虚拟分辨率可能比实际分辨率大比如实际 800x480虚拟可能是 800x960这是为了支持双缓冲。ioctl获取的vinfo.yres_virtual才是虚拟高度vinfo.yres是实际高度。写数据的时候要按虚拟宽度来算偏移否则画面会错位。我第一次移植的时候没注意这个画面显示出来是斜的查了好久才发现是虚拟分辨率的问题。LVGL 的显示驱动回调函数disp_flush是核心。它接收 LVGL 渲染好的区域坐标和颜色数据然后把这些数据写到 framebuffer 的对应位置。因为 LVGL 用 RGB565framebuffer 用 ARGB8888所以需要逐像素转换。转换代码不复杂但要注意效率用指针操作比用数组下标快很多。static void disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { int32_t x, y; uint32_t *fb_ptr (uint32_t *)fb_mem; for(y area-y1; y area-y2; y) { for(x area-x1; x area-x2; x) { uint32_t color color_p-full; // RGB565 转 ARGB8888 uint8_t r (color 11) 0x1F; uint8_t g (color 5) 0x3F; uint8_t b color 0x1F; fb_ptr[y * fb_width x] (0xFF 24) | (r 19) | (g 10) | (b 3); color_p; } } lv_disp_flush_ready(disp_drv); }注意lv_disp_flush_ready必须在数据写完以后调用否则 LVGL 会一直等界面就卡住了。这个函数是告诉 LVGL“这块区域我刷完了你可以准备下一块了”。2.3 触摸输入驱动与坐标校准触摸输入走 evdev 接口打开/dev/input/eventX之后用read读input_event结构体。每个事件包含类型、代码和值。触摸事件主要关注EV_ABS类型下的ABS_X和ABS_Y以及EV_KEY下的BTN_TOUCH。BTN_TOUCH的值为 1 表示按下0 表示松开。坐标转换是触摸驱动里最容易出问题的地方。evdev 报上来的坐标范围可能和屏幕分辨率不一致比如触摸屏报的是 0-4095屏幕是 0-799那就需要按比例缩放。更麻烦的是有些触摸屏的 X 轴或 Y 轴是反的需要做镜像。我建议先用evtest工具在板子上跑一下看看触摸事件报上来的原始坐标范围然后根据实际显示效果调整转换公式。static void indev_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) { struct input_event ev; static int16_t last_x 0, last_y 0; static uint8_t pressed 0; while(read(touch_fd, ev, sizeof(ev)) 0) { if(ev.type EV_ABS) { if(ev.code ABS_X) last_x ev.value * 800 / 4096; if(ev.code ABS_Y) last_y ev.value * 480 / 4096; } if(ev.type EV_KEY ev.code BTN_TOUCH) { pressed ev.value; } } >CC arm-linux-gnueabihf-gcc CFLAGS -I./lvgl -I./port -I./app -O2 -Wall LDFLAGS -lpthread -lm SRCS $(wildcard lvgl/src/*.c) $(wildcard lvgl/src/*/*.c) $(wildcard port/*.c) $(wildcard app/*.c) OBJS $(SRCS:.c.o) TARGET hmi_app $(TARGET): $(OBJS) $(CC) -o $ $^ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $编译的时候如果报错说找不到lv_conf.h检查一下CFLAGS里的-I路径是不是包含了lvgl目录。LVGL 源码里引用lv_conf.h用的是#include lv_conf.h所以lv_conf.h必须放在-I指定的某个目录下。我习惯把它放在lvgl目录的上一级然后-I.就能找到。3.2 第一个 LVGL 界面从空白到按钮响应移植完成之后先写一个最简单的测试程序创建一个屏幕、一个标签、一个按钮点击按钮改变标签文字。这个测试能跑通说明显示和触摸都正常了。#include lvgl.h #include lv_port_disp.h #include lv_port_indev.h static void btn_event_cb(lv_event_t *e) { lv_obj_t *label lv_event_get_user_data(e); static int count 0; count; lv_label_set_text_fmt(label, Clicked: %d, count); } void app_main(void) { lv_init(); lv_port_disp_init(); lv_port_indev_init(); lv_obj_t *scr lv_scr_act(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1E1E1E), 0); lv_obj_t *label lv_label_create(scr); lv_label_set_text(label, Clicked: 0); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 50); lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0); lv_obj_t *btn lv_btn_create(scr); lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0); lv_obj_set_size(btn, 120, 50); lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, label); lv_obj_t *btn_label lv_label_create(btn); lv_label_set_text(btn_label, Click Me); lv_obj_center(btn_label); while(1) { lv_timer_handler(); usleep(5000); } }这个程序里lv_timer_handler是 LVGL 的心跳必须周期性调用否则界面不会刷新、事件不会处理。调用间隔我设的 5ms也就是每秒 200 次这个频率对 LVGL 来说足够了。如果设太长界面会卡顿设太短CPU 占用会上去。实测 5ms 在 SBC2332 上 CPU 占用大概 3% 左右很轻。3.3 界面布局与中文字体支持工控 HMI 的界面布局讲究信息密度和可读性。我一般把屏幕分成三个区域顶部状态栏显示时间和报警图标中间主区域显示设备状态和数据底部操作栏放几个功能按钮。LVGL 的lv_obj支持 flex 和 grid 两种布局方式我习惯用 flex因为写起来简单调整也方便。中文字体是嵌入式 HMI 绕不开的问题。LVGL 内置的字体只支持 ASCII要显示中文必须自己转换字体。官方提供了一个在线转换工具可以把 TTF 字体文件转成 LVGL 的 C 数组。我用的思源黑体转的时候只选常用的 3000 多个汉字转出来的 C 文件大概 1MB 左右编译进程序里可以接受。// 声明外部字体 LV_FONT_DECLARE(font_source_han_sans_16); LV_FONT_DECLARE(font_source_han_sans_24); // 使用中文字体 lv_obj_set_style_text_font(label, font_source_han_sans_16, 0);注意字体转换的时候要选对字号和字符集。如果字符集选太大C 文件会非常庞大编译和链接都会变慢。我建议先统计界面上实际用到的汉字只转这些字能省不少空间。3.4 数据刷新与报警弹窗实现HMI 的核心功能是实时显示数据。我用一个定时器每 500ms 更新一次界面上的数值数据来源是模拟的传感器读数。LVGL 的lv_timer可以创建周期性任务比用线程简单而且和 LVGL 的刷新在同一线程里不需要加锁。static void data_update_timer(lv_timer_t *timer) { static int temp 25; temp 25 rand() % 10; lv_label_set_text_fmt(temp_label, %d C, temp); if(temp 30) { lv_obj_clear_flag(alarm_icon, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(alarm_icon, LV_OBJ_FLAG_HIDDEN); } } // 在 app_main 里创建定时器 lv_timer_create(data_update_timer, 500, NULL);报警弹窗用 LVGL 的lv_msgbox或者自己做一个模态窗口。我倾向于自己做因为lv_msgbox的样式定制起来不太灵活。做一个半透明的遮罩层上面放一个带边框的容器容器里放报警文字和确认按钮。遮罩层用lv_obj_create创建设置背景色为黑色、透明度 50%然后设置LV_OBJ_FLAG_CLICKABLE防止点击穿透。4. 常见问题与排查技巧实录4.1 显示异常问题速查现象可能原因排查方法屏幕全白或全黑framebuffer 未正确映射检查mmap返回值确认/dev/fb0存在画面颜色错乱颜色格式不匹配确认LV_COLOR_DEPTH和 framebuffer 的 bpp 一致画面斜切或错位虚拟分辨率与实际不一致用ioctl读vinfo按xres_virtual算偏移刷新闪烁缓冲区太小或刷新太快增大LV_MEM_SIZE降低刷新频率到 30ms界面卡顿lv_timer_handler调用间隔太长改成 5ms 调用一次显示问题里最常见的是颜色格式不匹配。SBC2332 的 framebuffer 默认是 32 位但有些板子出厂配置是 16 位。如果 LVGL 配的 16 位framebuffer 也是 16 位那直接拷贝就行不需要转换。但如果 framebuffer 是 32 位就必须做转换否则颜色会完全不对。我建议先用fbset命令看一下当前 framebuffer 的配置确认 bpp 和分辨率。4.2 触摸无响应或坐标偏移触摸问题分两类完全没反应和坐标不对。完全没反应先检查/dev/input/eventX是否存在用cat /proc/bus/input/devices看触摸屏对应的 event 编号。如果 event 编号变了程序里写死的路径就失效了。我一般会在程序里遍历/dev/input/下的 event 设备读设备名称找到包含 touch 或 ts 的那个。坐标偏移的话先用evtest看原始坐标范围。如果 X 轴范围是 0-4095屏幕宽 800那转换公式就是x * 800 / 4096。如果触摸方向反了比如按左边屏幕右边响应那就是 X 轴需要镜像公式改成800 - x * 800 / 4096。Y 轴同理。实操心得有些触摸屏的坐标原点在左上角有些在左下角。如果发现 Y 轴完全反了先检查触摸屏的安装方向有时候是屏幕装反了软件怎么调都不对。4.3 内存不足与性能优化SBC2332 只有 512MB 内存Linux 系统本身占掉一部分留给应用的其实不多。LVGL 的对象树如果太复杂内存占用会明显上升。我遇到过创建了几百个对象之后程序跑一段时间就崩溃的情况查下来是 LVGL 内部的内存池用完了。解决办法有两个一是增大LV_MEM_SIZE但要注意别把系统内存吃光二是及时删除不用的对象。LVGL 的对象如果不再显示应该用lv_obj_del删掉而不是只隐藏。隐藏的对象仍然占内存删掉才能释放。性能方面如果界面刷新率上不去先检查disp_flush里的转换代码是不是太慢。逐像素转换在 800x480 全屏刷新时确实有点吃力可以优化成按行转换或者用查表法预计算 RGB565 到 ARGB8888 的映射表。我实测查表法能把转换速度提升 3 倍左右。4.4 程序开机自启动配置HMI 程序最终要开机自动运行。SBC2332 的 Linux 系统用 systemd 管理服务写一个 service 文件放到/etc/systemd/system/下就行。service 文件里指定可执行文件路径、工作目录和重启策略。Restartalways保证程序崩溃后自动拉起这对工控设备很重要。[Unit] DescriptionLVGL HMI Application Aftermulti-user.target [Service] Typesimple ExecStart/opt/hmi/hmi_app WorkingDirectory/opt/hmi Restartalways RestartSec3 [Install] WantedBymulti-user.target配置好之后systemctl enable hmi.service开机就会自动启动。调试的时候可以用systemctl status hmi.service看运行状态和日志输出。如果程序启动失败日志里会显示具体错误比如找不到 framebuffer 设备或者权限不足。注意framebuffer 设备默认只有 root 能访问如果程序以普通用户运行需要把用户加到video组里或者改/dev/fb0的权限。我一般直接让程序以 root 运行省去权限配置的麻烦但生产环境建议用非 root 用户加组权限的方式。5. 界面美化与交互细节打磨5.1 配色方案与视觉层次工控 HMI 的配色不需要花哨但要有清晰的视觉层次。我的习惯是深色背景配高对比度文字重要数据用亮色突出报警用红色。LVGL 的样式系统支持继承和覆盖可以定义一个基础样式然后针对不同控件做微调。背景色我用0x1E1E1E这是深灰偏黑比纯黑柔和长时间看眼睛不累。文字主色用0xE0E0E0次要文字用0x9E9E9E。正常状态用绿色0x4CAF50警告用橙色0xFF9800报警用红色0xF44336。这套配色在 7 寸屏上实测对比度足够阳光下也能看清。按钮的圆角和阴影能明显提升质感。LVGL 的lv_obj_set_style_radius设圆角半径lv_obj_set_style_shadow_width设阴影宽度。圆角我一般设 6-8px阴影宽度设 10px 左右透明度 30%。这些参数不用太精确看着舒服就行。5.2 动画过渡与响应反馈LVGL 内置了动画系统按钮按下时的颜色变化、页面切换时的滑动效果都可以用动画实现。lv_anim_t结构体定义动画的起始值、结束值、持续时间和回调函数。我一般给按钮加一个 100ms 的颜色渐变动画按下时从正常色渐变到高亮色松开时渐变回来。页面切换动画用lv_scr_load_anim函数支持左滑、右滑、淡入淡出等效果。持续时间设 300ms 左右比较合适太快了看不清太慢了显得拖沓。工控场景下动画不是必须的但加了之后操作反馈更明确用户体验会好很多。实操心得动画会占用 CPU 资源如果系统负载本来就高建议减少动画或者把动画时间缩短。我遇到过加了页面切换动画之后数据刷新明显变慢的情况后来把动画时间从 500ms 降到 200ms 就正常了。5.3 多页面管理与状态保持一个完整的 HMI 通常有多个页面主界面、设置界面、报警历史、参数配置等。LVGL 的屏幕对象lv_scr_act()是当前活动屏幕切换页面就是加载不同的屏幕对象。我习惯在程序启动时把所有页面都创建好切换的时候直接lv_scr_load这样切换速度快不需要重新创建控件。页面多了之后状态保持是个问题。比如用户在设置页面改了参数切回主界面要能看到新参数。我的做法是用一个全局的结构体存所有状态数据页面切换时从结构体读数据刷新界面用户操作时写回结构体。这样页面之间不直接通信都通过全局状态来同步。typedef struct { int temperature; int humidity; int alarm_count; bool alarm_enabled; } hmi_state_t; static hmi_state_t g_state {0}; // 设置页面修改参数 g_state.alarm_enabled true; // 主界面读取参数 if(g_state.alarm_enabled) { lv_obj_clear_flag(alarm_switch, LV_OBJ_FLAG_HIDDEN); }这种单向数据流的方式在嵌入式 HMI 里很实用逻辑清晰不容易出 bug。唯一要注意的是多线程访问的问题如果 LVGL 跑在主线程数据更新也在主线程那就不需要加锁。如果数据来自其他线程就需要用互斥锁保护全局状态。6. 从原型到产品的几个关键考量6.1 稳定性与异常恢复原型跑通和产品稳定运行是两码事。工控设备要求 7x24 小时运行程序不能崩崩了要能自动恢复。除了前面说的 systemd 自动重启程序内部也要做异常处理。比如 framebuffer 写入失败、触摸设备断开这些都要有对应的错误处理逻辑不能直接让程序挂掉。我一般会在主循环里加一个看门狗机制每次lv_timer_handler调用成功后更新一个计数器如果计数器超过阈值没有更新说明主循环卡住了程序主动退出让 systemd 重启。这个机制能解决大部分偶发的死锁问题。static uint32_t watchdog_counter 0; while(1) { lv_timer_handler(); watchdog_counter; if(watchdog_counter 10000) { // 正常情况不会走到这里走到说明主循环卡住了 exit(1); } usleep(5000); }6.2 资源占用与长期运行测试长期运行测试是发现内存泄漏的最好方法。我让程序连续跑了 72 小时每 10 分钟记录一次内存占用。前几个小时内存会缓慢上升这是 LVGL 内部缓存和对象池的正常增长但到一定值之后应该稳定下来。如果内存持续上升不回落那就是有泄漏需要检查哪里创建了对象没有删除。CPU 占用也要关注。SBC2332 双核 1.2GHzLVGL 界面刷新加上业务逻辑正常应该在 10% 以内。如果超过 20%就要检查是不是有死循环或者刷新频率设太高了。我用top命令看 CPU 占用用free看内存这两个命令在板子上直接就能跑很方便。6.3 固件升级与远程维护产品部署到现场之后固件升级是个现实问题。SBC2332 有网络接口可以做一个简单的 HTTP 服务来接收固件包然后写一个升级脚本替换可执行文件并重启服务。LVGL 本身不涉及这部分但 HMI 程序里可以加一个升级按钮点击后触发升级流程。远程维护方面我一般会在 HMI 程序里加一个隐藏的调试页面通过特定的触摸手势进入显示系统信息、日志和重启按钮。这个页面不需要好看但要能快速定位问题。现场人员遇到问题拍个照发过来我一看调试页面的信息就能判断是软件问题还是硬件问题。注意固件升级功能要加校验比如 MD5 校验防止传输过程中文件损坏导致设备变砖。升级过程中要有明确的进度提示升级完成后自动重启不需要人工干预。7. 个人实操体会与后续扩展方向这套 SBC2332 LVGL 的方案我从原型到产品化大概花了三周时间其中移植和调试占了一半界面开发和优化占了另一半。最大的体会是LVGL 的移植本身不难难的是把显示和触摸调稳。framebuffer 的虚拟分辨率、触摸坐标的转换、颜色格式的匹配这三个点任何一个出问题都会导致界面异常而且现象很相似排查起来需要耐心。另一个体会是内存管理要趁早规划。LVGL 的对象树在界面复杂之后会占用不少内存如果一开始不控制后面改起来很麻烦。我的建议是界面尽量扁平化少用嵌套容器不用的对象及时删除这样内存占用能控制在比较低的水平。后续扩展的话有几个方向可以考虑一是加网络通信通过 MQTT 或 Modbus TCP 和上位机对接二是加数据存储用 SQLite 存历史数据支持报警记录查询三是加多语言支持LVGL 的字体系统可以很方便地切换不同语言的字体文件。这些扩展都不需要改动 LVGL 核心在应用层实现就行。最后分享一个小技巧LVGL 的 PC 模拟器在开发阶段非常有用。你可以在电脑上先把界面布局和逻辑调好然后再交叉编译到板子上跑。模拟器支持鼠标模拟触摸调试起来比在板子上插鼠标键盘方便多了。我一般是在模拟器上把界面做到 80% 的完成度再上板子做最后的适配和性能优化这样效率最高。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门