
简介面向单片机与嵌入式开发者的单色LCD点阵屏GUI组件包内核代号TGUI配套TMENU菜单调整系统。TGUI基于文本实现体积小巧可独立运行TMENU用于菜单界面组织与调整。该套件已在ARM、AVR、C51等芯片的多个量产项目中验证适用于需要轻量化人机交互的仪器仪表、小家电及工业控制界面。压缩包共230个文件约297KB以C源码43个.c、42个.h为主另含SVN版本管理记录与少量说明文档便于查看工程结构和代码演化。已有436人学习浏览。内容上TGUI已实现框架内嵌的滚动条、独立列表框、扩展列表框、单选框、复选框及文本框等控件可直接裁剪移植对于需要快速搭建点阵屏菜单系统的开发者这套经过实际项目检验的开源代码能提供完整的内核参考。 去年做一个环境监测仪的项目屏幕菜单部分差点让我把头发揪光。市面上现成的嵌入式GUI库要么太臃肿要么和硬件耦合得死死的怎么也拆不开。最后我干脆基于一个轻量GUI协议栈自己写了一套菜单系统内核代号TMENU。这篇文章就是把TMENU内核从设计到落地的完整过程复盘一遍包括菜单树怎么组织、事件怎么分发、绘制怎么优化以及我在实际项目里踩过的几个大坑。想自己做嵌入式屏幕交互的朋友可以直接参考这套方案少走不少弯路。1. 为什么需要一套菜单内核1.1 现成方案的痛点一开始我用的是某个开源GUI库控件相当丰富按钮、滑块、图表齐活儿。但问题很快暴露项目里真正需要的交互只有上下翻页、确认返回、参数微调那些华丽控件根本用不上反倒吃掉大半个Flash。更难受的是刷新策略它默认全局重绘320x240的屏每翻一页就全屏刷一遍肉眼可见的闪烁客户看第一眼就皱眉头。我也试过直接在应用层写if-else管理页面切换页面少的时候还能凑合。但菜单一旦超过两层每个页面都要维护“上一个页面”“下一个页面”“子页面”的关系代码很快变成一团乱麻。后来我意识到需要的不是一个控件库而是一个能管理菜单结构、焦点位置、事件路由的“内核”——控件的长相反而不重要。TGUI正好提供了基础的数据结构和绘制原语我在它上面做了TMENU这一层专门解决菜单导航和状态管理。1.2 我理解的“内核”到底是什么很多人听到“内核”就想到操作系统。但在这里内核指的是菜单系统的核心框架层它不关心具体控件长什么样只管三件事——菜单树怎么组织、焦点怎么移动、事件怎么分发。显示只是它最后调用一个绘制回调而已。这样设计的好处非常直接屏幕驱动是SPI的还是并口的屏幕是320x240还是800x480内核完全不在乎。替换驱动层不影响菜单逻辑反过来菜单结构重构也不用动显示代码。分层之后每个模块都能单独测试出问题排查起来也快很多。1.3 整体分层方式我最终把整个软件栈分成四层驱动层直接对接屏幕硬件提供画点、画线、画矩形的底层接口。内核层TGUI提供基础数据类型TMENU在上面实现菜单树、焦点管理、事件循环。控件层基于内核接口实现的列表项、数值项、开关项、提示框等具体控件。应用层每个页面的业务逻辑比如读取传感器、修改参数、保存配置。TGUI在中间扮演“胶水”的角色它定义了屏幕坐标、区域、颜色这些基础对象也提供文字绘制、位图块操作这类实用工具。TMENU不重复发明这些东西而是把TGUI当成基础设施专心把菜单逻辑做好。这套分层跑下来替换屏幕驱动只动第一层业务逻辑只动第四层各干各的活。2. 内核核心机制拆解2.1 菜单树的数据结构菜单系统不是简单的列表它天然是一棵树。主菜单下面有子页面子页面下面还有参数项。我把每个节点定义成一个结构体包含类型、文本、回调、以及父子兄弟指针typedef enum { MENU_ITEM_NORMAL 0, // 普通显示项 MENU_ITEM_ACTION, // 触发动作项 MENU_ITEM_NAV, // 导航项进入子菜单 MENU_ITEM_VALUE, // 数值调整项 MENU_ITEM_TOGGLE // 开关项 } menu_item_type_t; typedef struct tmenu_item tmenu_item_t; struct tmenu_item { menu_item_type_t type; const char *label; int32_t value; int32_t min; int32_t max; void (*on_enter)(tmenu_t *menu, tmenu_item_t *item); void (*on_change)(tmenu_t *menu, tmenu_item_t *item, int delta); tmenu_item_t *parent; tmenu_item_t *child; tmenu_item_t *next; tmenu_item_t *prev; };为什么用双向链表而不是数组因为菜单结构在编译期就能确定用静态内存池预分配节点再用指针串成链表插入和删除只需改指针不需要搬移数据。上下移动焦点时next和prev指针让跳转复杂度降到O(1)。嵌入式环境里malloc不能乱用用这种预先分配的方式系统启动时的内存峰值就是确定的跑多久都不会产生碎片。2.2 焦点移动与事件分发菜单交互的输入源五花八门可能是编码器、按键、触摸屏甚至串口命令。内核做了一个统一的事件入口把硬件差异挡在外面void tmenu_feed_wheel(tmenu_t *menu, int step); // 旋钮/上下键 void tmenu_feed_enter(tmenu_t *menu); // 确认键 void tmenu_feed_back(tmenu_t *menu); // 返回键内部逻辑不算复杂旋钮事件修改当前焦点指针current确认事件分两种——如果当前项是导航项就进入它的子菜单如果是动作项就调用on_enter回调如果是数值项进入编辑模式。返回事件则把焦点移回parent。我特意没有把输入处理耦合进内核内部。内核只提供一个tmenu_poll()函数应用层在主循环里调用它传入结构化的输入事件。这样一来编码器抖动、按键消抖、触摸去重这些脏活累活全在应用层解决内核保持干净。2.3 绘制回调与脏区域标记TMENU内核本身不画任何像素。它通过一个注册的回调函数把“当前需要显示哪些项、焦点在哪一行”告诉应用层应用层拿到数据后自己去调用驱动接口绘制。内核负责记录脏区域焦点移动时只有旧焦点行和新焦点行所在区域是脏的触发重绘时只重绘这两行。这个设计直接解决了全屏重绘的闪烁问题。实测下来从全屏重绘降到局部两行重绘之后单帧绘制时间从原来的130毫秒降到22毫秒肉眼完全看不到闪烁CPU占用也大幅下降。代价是代码里需要仔细管理脏区域的坐标计算但和流畅度相比这点工作量太值了。3. 实操从零搭建TGUI TMENU内核3.1 内核初始化流程初始化要干的事情很少把节点池清零、设置绘制回调、设置每行高度和可见行数。我习惯用静态数组做节点池这样编译期就能算出RAM占用。tmenu_t menu; tmenu_item_t pool[64]; uint8_t pool_count 0; tmenu_init(menu, pool, 64); tmenu_set_draw_cb(menu, my_draw_callback); tmenu_set_row_height(menu, 24); tmenu_set_visible_rows(menu, 6); tmenu_set_highlight_color(menu, RGB565(0x20, 0x80, 0x20));这里有个很容易忽视的细节tmenu_set_visible_rows()设置的是菜单一屏能显示多少行。如果菜单项超过这个数上下移动时指针越过边界就要滚动视口。滚动逻辑也在内核里实现应用层不需要管。实际调参时行高24像素在240像素高的屏幕上刚好显示10行配上6行可见区域滚动效果很自然。3.2 创建菜单树初始化完成之后就是往树里挂节点。我封装了几个辅助函数把链表操作细节藏起来tmenu_item_t *main_menu tmenu_add_root(menu, 主菜单); tmenu_item_t *temp_page tmenu_add_item(menu, main_menu, 温度设置, MENU_ITEM_NAV); tmenu_add_value_item(menu, temp_page, 目标温度, 25, 10, 40); tmenu_add_value_item(menu, temp_page, 回差, 2, 1, 10); tmenu_item_t *sys_page tmenu_add_item(menu, main_menu, 系统设置, MENU_ITEM_NAV); tmenu_add_toggle_item(menu, sys_page, 蜂鸣器, true); tmenu_add_action_item(menu, sys_page, 恢复出厂设置, factory_reset_cb);创建节点时tmenu_add_item内部从池里取一个空闲节点填写类型、标签、回调然后把它挂到父节点的孩子链表尾部。整个过程的复杂度是O(1)不会因为菜单项增多而变慢。我推荐把菜单树构建放在main()刚开始执行的时候所有节点一次性创建完。这样后面运行期间内核完全不需要再分配内存稳定性有保障。3.3 主循环集成主循环的结构非常固定就是“读输入 → 喂事件 → 绘制脏区域”这个循环while (1) { input_event_t ev read_encoder_and_keys(); tmenu_feed_wheel(menu, ev.steps); if (ev.enter_pressed) tmenu_feed_enter(menu); if (ev.back_pressed) tmenu_feed_back(menu); tmenu_poll(menu); tmenu_draw_if_dirty(menu); system_sleep_until_next_tick(); }tmenu_poll()是内核的驱动核心它检查当前是否有待处理的内部状态切换比如进入子菜单后需要重新计算显示范围。tmenu_draw_if_dirty()则根据脏标记决定是否调用用户注册的绘制回调。这套主循环跑在Cortex-M4上主频168MHz平均负载不到15%。3.4 一个实际页面的显示效果跑起来之后屏幕左边显示菜单标签右侧显示当前值。比如温度设置页目标温度25°C回差2°C温度单位摄氏度焦点行高亮绿色按确认键进入编辑模式此时高亮变成橙色边框旋钮调整数值再按确认锁定退出。整个交互链条非常顺滑帧率稳定在30fps以上。客户在这个界面上操作了几分钟评价是“跟家电面板一样顺手”。4. 常见问题与排查技巧实录4.1 重绘闪烁问题这是嵌入式GUI最经典的问题。我最早实现时每帧都全屏清空再重画屏幕闪得像老式CRT显示器。改成局部重绘后问题立刻消失。但局部重绘有个隐藏坑如果字符和背景色没有完全覆盖原区域会留下残影。解决办法是在重绘脏区域前调用TGUI的tgui_fill_rect()把该区域先填成背景色再绘制新内容。注意填充面积必须和脏区域完全一致差一个像素都会出横线。4.2 旋钮转动过快导致漏步编码器硬件上有个致命特性转太快时如果主循环没有及时读取脉冲就丢了。我一开始在主循环里轮询GPIO结果快速拨动时经常一次跳多个菜单项。后来改成在定时器中断里读取编码器状态把步进值累加到一个环形缓冲区主循环再从缓冲区消费。这样即使主循环被某个长时间操作卡住中断也不会丢事件。缓冲长度我设为8实际测试旋钮以最高速旋转也不会溢出。4.3 回调里删除当前节点导致野指针项目后期我加了一个“动态菜单”功能需要运行时添加和删除菜单项。结果发现如果某个动作回调里删除了当前正在聚焦的节点整个菜单树直接崩掉因为焦点指针变成野指针了。解决办法是延迟删除回调里只标记这个节点为“待删除”内核在tmenu_poll()完成后统一回收。这样保证处理事件时所有节点都还活着。4.4 文字渲染拖慢刷新速度菜单项多了以后每帧要渲染十几个字符串。中文字库点阵取模一个16x16字要读32字节如果每个字符都现场取模、现场渲染速度感人。我做了个简单的字体缓存第一次使用时把字符位图缓存到内存块里后面直接memcpy到帧缓冲字符串渲染速度提升了一个数量级。这个优化做完之后整体刷新帧率从18fps提升到了33fps。4.5 常见问题速查表现象可能原因解决方案菜单翻页闪烁全屏重绘改为脏区域局部重绘旋钮快转丢步主循环轮询太慢中断读取环形缓冲切换页面时崩溃回调中删除了活跃节点延迟删除标记后统一回收文字边缘有残影重绘前未清背景先用背景色填充脏区域数值调整跳变输入抖动未消抖在上层做软件消抖5. 最后的经验心得TGUI TMENU这套内核做下来我最深的体会是嵌入式菜单不是功能堆叠而是状态机。焦点位置、编辑模式、页面层级每一个都是状态。把状态管理做清楚了界面再多也不乱。调试的时候我习惯在关键状态转换处打日志比如“进入子菜单:温度设置”“焦点移动:25→30”。别看这是笨办法实际定位滚动越界、焦点错乱这些问题时比看一百行代码都管用。项目后期这些日志我还留着客户现场出问题串口一抓日志就能快速判断是驱动问题、内核问题还是业务问题。另外一个小技巧给菜单内核写一个PC端仿真环境用SDL模拟屏幕同一套C代码在电脑上编译运行调试UI逻辑比在开发板上烧录快十倍。我后面做另一个项目时直接复用了这套仿真框架新菜单开发只用了一天就调完省下来的时间都够我多喝好几杯咖啡了。这套方案推荐给所有被嵌入式菜单折磨过的人。本文还有配套的精品资源点击获取