嵌入式设备上的 UI 一直是个挺折磨人的话题。你说功能吧单片机跑得了你说交互吧以前在 12864 上做菜单全靠人肉维护一堆状态机加一个菜单项就要改半天。后来我转到 LVGL才发现原来嵌入式 UI 也可以像开发手机 App 一样控件建一建、事件绑一绑界面就出来了。LVGL 的菜单控件lv_menu尤其适合做多级设置页、设备状态页这类树形导航场景再配合 FreeRTOS 跑在 STM32 上整个界面层从零到能点击跳转我用它做过几个项目之后最快的一次确实做到了 5 分钟把菜单骨架跑起来。这篇文章就把这套可复用的菜单代码完整拆开先讲方案怎么选再讲移植和配置接着给你可以直接抄的完整代码最后是中文适配和一堆踩坑记录。不管你是刚学嵌入式的小白还是已经在做产品的工程师照着这套思路走一遍基本就能拿到一个能跑、能改、能上产品的菜单界面。1. 项目定位为什么用 LVGL 菜单控件做嵌入式 UI1.1 嵌入式界面开发的痛点和 LVGL 的价值在 LVGL 出现之前做嵌入式 UI 的常规方案是自己维护一个界面状态机用一个 switch-case 记录当前页面然后手写绘制函数。界面里只有三五项还好一旦产品经理说“加一个二级菜单”“这里弹个确认框”代码就开始失控。绘制逻辑、按键逻辑、页面切换逻辑全搅在一起每加一个功能就得重新捋一遍状态流转。LVGL 解决的就是这个问题。它把界面抽象成控件树控件的事件、样式、布局、动画都由库内部统一管理。你要做的只是创建控件、设置属性和绑定事件剩下的刷新和渲染它自己处理。而且它整个库就是纯 C 写的不依赖任何平台库RAM 占用几十 KB 就能跑起来这对嵌入式设备非常友好。我自己的体会是LVGL 最值钱的地方不是那套控件长得好看而是它改变了嵌入式 UI 的开发方式。以前是“画什么、什么时候画”都由应用代码决定现在是“界面长什么样、点击之后干什么”由应用代码决定底层绘制细节全部交给库。这种思维转变带来的开发效率提升远比省几个人月更有意义。1.2 菜单实现的几种方案对比不是所有菜单都得用 lv_menuLVGL 里做菜单其实有好几条路每个方案都有自己的适用场景。我当时在选择时也纠结了一下这里把对比情况整理出来方便你根据项目需求直接选。方案适用场景优点缺点lv_menu多级树形菜单、设置页、状态页自带页面层级、返回按钮、可加侧边栏自定义样式相对复杂API 比 list 多lv_list同级列表、简单功能入口API 少、结构简单、上手快多级跳转要自己管理页面切换lv_tabview同级标签切换、仪表盘分区标签栏点击切换方便不适合深层级导航lv_tileview全屏滑动翻页、引导页横向滑动动画流畅不适合长列表和复杂层级为什么我最终选了 lv_menu因为它本身就是为“树形导航”设计的。手机里那种“设置 → 显示 → 亮度调节”的层级关系用 lv_menu 实现就是在代码里创建对应页面然后绑定父子关系就行。菜单自带 header 栏和返回按钮用户从子页面跳回上一级不需要你写任何页面管理逻辑这一点在项目迭代时特别值钱。当然如果你的产品界面只有一层列表比如一个功能选择页点击之后直接进入操作界面那用 lv_list 反而更清爽。这个判断标准很简单页面之间有没有深度层级关系。有层级关系就上 lv_menu没有的话用 lv_list 更省事。1.3 这套代码的交付范围这篇文章的“5 分钟搞定”不是标题党是我真实跑通过的记录。前提是 LVGL 已经移植好、屏幕可以正常点亮剩下的菜单界面构建、页面切换、事件处理就是下面这段代码的工作量。我会先给完整工程结构再逐步拆解每个 API 的用途和背后逻辑保证你复制到自己的工程里改一改就能用。2. 跑通前的准备环境、移植和资源配置2.1 先用 PC 模拟器跑通省一半开发时间很多初学者一上来就把 LVGL 往 STM32 板子上怼结果屏幕不亮、触摸乱飘、内存溢出问题全挤在一起根本分不清是驱动问题还是 UI 代码问题。我现在的习惯是任何界面功能都先在 PC 模拟器上验证跑通了再搬到板子。模拟器的方案有很多我常用的是 VSCode 加 SDL2 加 CMake 的模板工程LVGL 官方仓库有一些 PC 模拟器示例也可以自己拉 lvgl 源码建一个简单的 SDL 工程。鼠标可以模拟触摸事件开发体验接近于写上位机程序编译、运行、调试都很快。每次改完界面代码直接刷新窗口就能看到效果省去了烧录的等待时间。但要注意模拟器跑通不代表板子直接没问题。有几个参数必须跟实际屏幕保持一致最常见的就是LV_COLOR_DEPTH如果你的屏是 RGB565而模拟器里配成了 32 位色那显示效果会和板子差很多。另外字体和图片资源在 PC 上不存在内存压力搬到小内存 MCU 上就要特别关注占用这个在后面章节单独讲。2.2 把 LVGL 移植到 STM32 FreeRTOS只需要搞清楚三个回调第一次接触 LVGL 移植的人容易被一堆文件和配置吓到实际上裸机移植只需要解决三件事屏幕刷新、输入设备、时间基准。这三个东西在 LVGL 里的对应物就是几个回调函数。第一个是disp_flush这是输出通道。LVGL 内部绘制完成后会把这个回调传过来的 buffer 数据发给屏幕驱动。比如你用 SPI 接口的 ILI9341就在这个回调里通过 SPI DMA 把lv_disp_drv_t里指定的颜色数据发出去发送完成之后调用lv_disp_flush_ready(disp_drv)告诉 LVGL 可以继续绘制下一帧了。这里特别容易漏的就是flush_ready漏了之后屏幕会一直卡在第一帧。第二个是indev_read这是输入通道。触摸屏就读取触摸控制器得到坐标编码器就读取旋转方向和按键状态然后把结果填到lv_indev_data_t结构体里。LVGL 内部会周期性调用这个回调用于检测用户交互。第三个是时间基准 tick。LVGL 的动画、长按检测、定时刷新都要依赖一个单调递增的毫秒计数。有 RTOS 的话可以拿系统的 tick 转换一下裸机的话用 SysTick 中断里调用lv_tick_inc(1)也行。在 FreeRTOS 工程里我的做法是单独开一个 UI 任务优先级给 2 到 3数值小优先级低任务栈给 2048 到 4096 字节任务里写一个while(1)每 5ms 调用一次lv_timer_handler()再加上vTaskDelay(pdMS_TO_TICKS(5))。LVGL 的所有操作都放在这个任务里不要在中断里直接操作 UI否则很容易死锁或崩溃。2.3 首次配置就设置对的几个关键宏LVGL 的性能和稳定性很大程度上取决于lv_conf.h里的配置。这里列几个我每次新建工程都会反复确认的参数以及它们背后的逻辑。LV_MEM_SIZE是 LVGL 内置动态内存池的大小。这是最容易出问题的配置设小了控件创建到一半内存不足界面画不全甚至直接不显示。我的经验是跑个完整菜单和几个页面8KB 能勉强跑起来32KB 比较舒服如果还要放图片、动画和较多中文字体建议直接 48KB 以上。LV_COLOR_DEPTH必须和屏幕硬件一致。市面上大多数 4 寸以下 TFT 屏是 RGB565也就是 16 位色。如果你配成 32 位色显示虽然也能出来但颜色会被压缩而且 buffer 内存翻倍白白浪费。LV_DISP_DEF_REFR_PERIOD是屏幕刷新周期默认是 30ms。这个值不宜调得太小太小了 CPU 一直忙着刷屏菜单反而容易卡。我一般保留默认或者在小屏上调到 20ms。还有日志开关LV_USE_LOG。调试阶段务必打开它能把 LVGL 内部的报错和警告打出来很多诡异问题其实就是内存不足或者参数不合法日志里一眼就能看出来。3. 5 分钟搞定菜单界面核心代码与拆解3.1 先看整体页面结构在写代码之前先把页面层级想清楚。我这次做一个典型的设备菜单根页面是主菜单上面有三个入口分别是“系统状态”“参数设置”和“关于设备”。点击“系统状态”进入对应的子页面这个子页面里有 CPU 占用率、温度等文本信息点击“参数设置”进入另一个子页面里面可以放滑条、开关等控件。对应的页面树是这样的根页面 main_page 下面有一个 section 区域区域内三个可点击的菜单项子页面 sys_page 和 cfg_page 各自也有自己的 section 和内容控件。lv_menu 的内部机制就是维护这样一棵页面树用户从根页面进入子页面时它自动显示 header 和返回按钮返回时自动回到上一级不需要用布尔变量记状态。3.2 lv_menu 创建菜单页面的完整代码下面的代码基于 LVGL 8.xAPI 基本可以稳定使用。完整代码可以直接复制到你的 UI 初始化函数里跑前提是 LVGL 已经初始化完成。/* 全局菜单指针方便事件回调里切换页面 */ static lv_obj_t * menu; /* 菜单项点击事件跳转到 user_data 指向的子页面 */ static void menu_item_click_cb(lv_event_t * e) { lv_obj_t * page lv_event_get_user_data(e); lv_menu_set_page(menu, page); } /* 初始化完整菜单界面 */ static void ui_menu_init(void) { menu lv_menu_create(lv_scr_act()); lv_obj_set_size(menu, lv_disp_get_hor_res(NULL), lv_disp_get_ver_res(NULL)); lv_obj_center(menu); /* 子页面 1系统状态 */ lv_obj_t * sys_page lv_menu_page_create(menu, System Status); lv_obj_t * sys_sec lv_menu_section_create(sys_page); lv_obj_t * sys_cont lv_menu_cont_create(sys_sec); lv_obj_t * sys_label lv_label_create(sys_cont); lv_label_set_text(sys_label, CPU: 25%\nTemperature: 42C); /* 子页面 2参数设置 */ lv_obj_t * cfg_page lv_menu_page_create(menu, Settings); lv_obj_t * cfg_sec lv_menu_section_create(cfg_page); lv_obj_t * cfg_cont lv_menu_cont_create(cfg_sec); lv_obj_t * cfg_label lv_label_create(cfg_cont); lv_label_set_text(cfg_label, Brightness: 80); /* 主页面根菜单 */ lv_obj_t * main_page lv_menu_page_create(menu, NULL); lv_obj_t * main_sec lv_menu_section_create(main_page); lv_obj_t * item1 lv_menu_cont_create(main_sec); lv_obj_t * item1_label lv_label_create(item1); lv_label_set_text(item1_label, System Status); lv_obj_add_flag(item1, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(item1, menu_item_click_cb, LV_EVENT_CLICKED, sys_page); lv_obj_t * item2 lv_menu_cont_create(main_sec); lv_obj_t * item2_label lv_label_create(item2); lv_label_set_text(item2_label, Settings); lv_obj_add_flag(item2, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(item2, menu_item_click_cb, LV_EVENT_CLICKED, cfg_page); /* 设置主页面并允许根页面显示返回按钮 */ lv_menu_set_page(menu, main_page); lv_menu_set_mode_root_back_btn(menu, LV_MENU_ROOT_BACK_BTN_ENABLED); }这段代码看着不长但每一句背后都有含义。lv_menu_page_create的第一个参数是菜单对象第二个参数是页面标题。如果传 NULL这个页面的顶部就不会显示标题通常根页面用 NULL子页面给一个标题这样从根页面进入子页面时header 区域会显示“System Status”和返回箭头用户一眼就知道自己在哪一层。lv_menu_section_create是在页面里创建一个区块相当于分组。比如系统状态页面里可以分成“运行信息”和“告警信息”两个 section视觉上更清晰。区块本身没有交互真正的菜单项是lv_menu_cont_create创建的容器。lv_menu_cont_create这个函数在不同 LVGL 版本里参数略有差异8.x 里直接传父对象就行父对象可以是 page也可以是 section。我在主页面里把菜单项建在 section 下面这样根页面看起来就是一组一组的功能列表。子页面里的内容控件也建在 section 下方便以后扩展多个分组。每个菜单项默认不可点击必须手动加LV_OBJ_FLAG_CLICKABLE标志否则事件回调不会触发。这个坑我踩过刚用 LVGL 时菜单项怎么点都没反应后来检查才发现忘了加 flag。事件回调里用lv_event_get_user_data拿到的是lv_obj_add_event_cb最后一个参数传进去的指针这里传的是子页面的指针所以点击菜单后可以非常自然地切换页面。3.3 页面切换与返回的事件处理上面的代码里页面切换通过lv_menu_set_page(menu, page)完成。注意这个函数不是把页面盖在当前页上面而是把菜单的当前显示页切换到指定的 pagelv_menu 内部会维护一个页面栈自动处理 header 变化和返回按钮的显示。返回按钮的行为是 lv_menu 自己处理的不需要你额外写“点击返回就 lv_menu_set_page 到上一页”这种逻辑。但是有一个场景需要自己接管用户从根页面点击返回按钮时你要决定是直接退出界面还是给个确认提示。这时候可以给菜单对象监听LV_EVENT_READY事件这个事件就是返回按钮被点击时发出的。static void menu_back_cb(lv_event_t * e) { lv_obj_t * mbox lv_msgbox_create(NULL, 提示, 确定退出菜单, NULL, NULL, true); lv_obj_center(mbox); }这里把LV_EVENT_READY绑定到 menu 上当根页面的返回按钮被点击时就会弹出确认框。这个设计在设备参数没有保存时特别有用可以防止用户误触返回导致设置丢失。此外lv_menu 还支持侧边栏模式适合宽屏设备。用lv_menu_set_sidebar_page(menu, sidebar_page)可以把一级菜单放到左侧右侧显示对应页面内容。这个模式在手持设备上不常用但在带触摸屏的工控设备上体验不错有兴趣可以自己试一下。3.4 不用 lv_menu 的保底方案lv_list 极简菜单如果你的项目界面层级很浅或者硬件资源特别紧张可以考虑用lv_list做一个简化版菜单。lv_list 的思路更直接创建列表往里面加按钮按钮点击后处理跳转。lv_obj_t * list lv_list_create(lv_scr_act()); lv_obj_set_size(list, lv_pct(100), lv_pct(100)); lv_obj_center(list); lv_obj_t * btn1 lv_list_add_button(list, LV_SYMBOL_SETTINGS, Settings); lv_obj_add_event_cb(btn1, open_settings_cb, LV_EVENT_CLICKED, NULL); lv_obj_t * btn2 lv_list_add_button(list, LV_SYMBOL_INFO, About); lv_obj_add_event_cb(btn2, open_about_cb, LV_EVENT_CLICKED, NULL);这个方案的优点是代码短、内存占用低缺点是没有 lv_menu 的页面栈你需要自己管理“从哪里来、到哪里去”的逻辑。比如打开设置页之后要创建一个新的页面对象并释放旧的返回时再恢复根页面。如果只有一层跳转这个方案完全够用如果有多级嵌套还是乖乖用 lv_menu 省心。4. 中文字体、样式美化和交互细节4.1 中文字体生成在线工具三步走用 LVGL 做过界面的朋友都知道英文和数字显示没有任何问题因为官方自带了蒙瑟拉特字体文件但中文一个字符都不会显示。原因很简单中文字符集太大全量字库放进来一个小 MCU 的 Flash 根本装不下。所以中文字体的正确做法是“按需生成”。我一般用 LVGL 官方的字体转换工具可以生成只有指定字符的字体数组。操作分三步设置字体和字号、填入需要用到的汉字、导出 C 文件。第一步选择字体文件。电脑上有安装的思源黑体或文泉驿字体都可以我常用 Noto Sans SC显示效果比较干净。字号先定好比如 16 像素用于正文、20 像素用于标题每一个字号都要单独生成一份字体文件。第二步把界面里所有中文都列出来包括“系统状态”“参数设置”“返回”“确定”“取消”等。宁可多收集几个项目里的共用字也不要缺字因为缺了之后显示的就是方框或空白。第三步配置导出的位图位数和像素格式生成一个 .c 文件和一个 .h 文件。把这两个文件放进工程#include进来然后用样式把字体设置到对应控件上。#include my_font_16.c static lv_style_t style_font; lv_style_init(style_font); lv_style_set_text_font(style_font, my_font_16); lv_obj_add_style(label, style_font, LV_PART_MAIN);如果项目很多也可以把字体挂到全局主题上这样所有控件默认就用中文字体逐个控件设置样式的成本就省了。有个细节要注意使用 LVGL 内置字体时字体文件占的是 Flash但 LVGL 运行时会有一部分缓存放在 RAM 里中文字体如果包含太多字形RAM 占用也会上来所以字符集能精简就精简。4.2 样式定制给菜单加上产品级观感默认的 LVGL 控件外观比较朴素做什么产品都得改一改。样式定制是 LVGL 里最有价值的部分因为它完全改变了界面气质。拿菜单项来说通常要改背景色、圆角、边框、内边距和文字颜色还要针对按下和聚焦状态做不同样式。static void apply_menu_style(lv_obj_t * obj) { lv_obj_set_style_bg_color(obj, lv_color_hex(0x2D2D2D), LV_PART_MAIN); lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(obj, 8, LV_PART_MAIN); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(obj, 12, LV_PART_MAIN); lv_obj_set_style_text_color(obj, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_bg_color(obj, lv_color_hex(0x1E88E5), LV_STATE_PRESSED); lv_obj_set_style_text_color(obj, lv_color_hex(0xFFFFFF), LV_STATE_PRESSED); }理解这段代码的关键是LV_PART_MAIN和LV_STATE_PRESSED。LVGL 控件由多个部分组成比如背景是主部分滚动条、指示器是其他部分。状态则是控件在交互过程中的表现按压、聚焦、选中都会触发不同状态。通过给同一个属性设置不同状态下的值LVGL 会在状态切换时自动应用对应样式不需要你手写任何状态判断逻辑。主题颜色也可以整体修改。LVGL 的默认主题支持调色板如果你想要蓝色系就选LV_PALETTE_BLUE想要橙色系就换LV_PALETTE_ORANGE。调主题色是为了全局统一特别是进度条、开关等控件跟随主题色变化比逐个改样式省事得多。4.3 触摸和编码器两组输入下的菜单导航体验产品形态不同输入设备也不同。带触摸屏的设备lv_menu 天然支持手指点到哪个菜单项就进入哪个页面。但很多低成本设备用的是编码器加按键这时候就要借助 LVGL 的 group 机制来管理焦点。lv_group_t * g lv_group_create(); lv_group_add_obj(g, menu); lv_indev_set_group(encoder_indev, g);把 menu 对象加入 group 后编码器的焦点就会在菜单项之间移动。但光加 group 还不够用户旋转编码器时需要能看到当前聚焦的是哪一项否则操作起来完全是盲人摸象。解决办法是给菜单项增加聚焦状态样式lv_obj_set_style_bg_color(obj, lv_color_hex(0x1E88E5), LV_STATE_FOCUSED); lv_obj_set_style_outline_width(obj, 2, LV_STATE_FOCUSED); lv_obj_set_style_outline_color(obj, lv_color_hex(0xFFC107), LV_STATE_FOCUSED);这样用户旋转编码器时焦点项会有一个明显的背景色或描边变化按下按键进入页面逻辑就跟手机上的方向键导航一样清晰。5. 常见问题与排查技巧实录5.1 屏幕白屏、控件画到一半消失这个现象通常是三个原因之一disp_flush回调里没有调用lv_disp_flush_ready、LVGL 内存池太小、初始化顺序不对。先看disp_flush。LVGL 渲染完一个 buffer 后会调用这个回调你需要把 buffer 的内容发给屏幕然后立刻调用lv_disp_flush_ready。如果忘了这一步LVGL 会认为屏幕还没准备好接收下一块数据整个绘制流程就卡住了。其次是LV_MEM_SIZE如果太小创建控件时会分配内存失败日志里会打印错误信息界面上表现为某些控件没显示或者显示一半。排查顺序我建议这样先开日志看有没有内存报错再确认disp_flush里的flush_ready是否每次都被执行了最后检查lv_init和显示驱动的初始化顺序lv_init必须在任何控件创建之前完成。5.2 界面闪烁、撕裂感严重闪烁的本质是“边绘制边显示”。如果只用单缓冲LVGL 每绘制一部分就直接刷到屏幕用户就能看到绘制过程自然觉得闪。解决方向有两个双缓冲和 DMA。双缓冲是说准备两个 buffer一个给 LVGL 绘制另一个给屏幕刷送交替使用这样用户看到的是一个完整的帧。代价是内存翻倍如果 RAM 不够可以采用“部分刷新”的策略即 buffer 只覆盖屏幕的一部分LVGL 分块刷新也能减轻闪烁。DMA 的作用是加速数据传输SPI 屏尤其明显用阻塞式 SPI 发送一帧数据的时间太长CPU 全被占满。还有一个容易忽略的点刷新频率别设太高。把LV_DISP_DEF_REFR_PERIOD设成 10ms 并不代表界面更流畅反而会导致 CPU 频繁切换绘制任务卡顿感更明显。我实测下来中小屏用 30ms 起步比较稳妥。5.3 FreeRTOS 下界面卡死、假死在 RTOS 环境下跑 LVGL最常见的错误是在多个任务里同时调用 LVGL 函数。LVGL 内部不是线程安全的两个任务同时创建控件或者读写控件状态轻则数据错乱重则死锁。我的规范是所有 UI 相关操作都放在同一个 UI 任务中执行。外部任务需要更新界面时不要直接调 LVGL API而是通过消息队列给 UI 任务发消息由 UI 任务统一处理。这样既避免了锁竞争也让逻辑更清晰因为界面的更新顺序完全可控。如果必须跨任务调用可以用 LVGL 新版本提供的锁机制在进入临界区时加锁操作完成后再解锁。但锁的粒度不好控制锁太久会影响实时性锁太短又防不住竞态所以我个人还是推荐“消息队列 单任务刷新”的模式。另外UI 任务的栈大小一定要给够。LVGL 的控件创建和样式操作都比较吃栈我在某个项目里把 UI 任务栈从 1024 字节改成 4096 字节之后原来偶发的死机问题就消失了。如果遇到任务栈溢出可以试试 FreeRTOS 的栈统计功能把每个任务的最大栈用量打出来看看。5.4 触摸位置偏移、点击错位触摸和屏幕显示不匹配一般不是 LVGL 的问题而是驱动层坐标变换没做好。各种 LCD 屏的扫描方向不同触摸控制器的 xy 轴方向也可能不同有的原点在左上角有的在右下角组合起来就很容易错位。处理方法是先写一个调试函数把原始触摸坐标打印出来然后和屏幕分辨率做映射。比如屏幕是 320x240触摸控制器返回的原始范围是 0 到 4095那就要做等比缩放再根据实际调整 x 和 y 的方向以及是否需要交换 xy。void my_touch_read(lv_indev_drv_t * drv, lv_indev_data_t * data) { int16_t x, y; get_touch_raw(x, y); >