TGUI+TMENU:嵌入式菜单模块化开发架构设计与实践指南

发布时间:2026/9/2 3:30:38
TGUI+TMENU:嵌入式菜单模块化开发架构设计与实践指南 简介面向单色 LCD 点阵屏场景TGUI/TMENU 提供了基于文本的超小型 GUI 内核与配套菜单调整系统可独立或组合使用适合 ARM、AVR、C51 等资源受限的嵌入式平台尤其适合显存与 Flash 受限的裸机或轻量 OS 环境。该源码已开源并应用于多家公司超过五个产品包含框架、水平与垂直滚动条、普通及扩展列表框、单选框、复选框、文本框等常用控件覆盖菜单与界面开发的主要需求。资源包共 230 个文件、297KB其中 85 个 C/H 源文件为核心实现另有 svn-base、entries 等版本控制目录信息和少量 txt、bak 文件通过 SVN 元数据还可追溯版本变更辅助文件可按需删减。已有 436 人浏览学习适合希望快速上手单色屏 GUI 的嵌入式开发者参考模块划分直观。研读源码能理解轻量级控件的接口设计与内存控制思路为后续移植或扩展提供基础。 做嵌入式界面开发的朋友应该都对“界面代码越写越乱”有切身体会。我前段时间在做一个带屏幕的小设备功能不算复杂但菜单有三层每个页面还有不同的设置项和实时数值显示。刚开始图省事把所有界面逻辑和业务逻辑都塞在同一个文件里结果加一个菜单项要改三处地方调一个按键要翻几十行 switch代码越改越怕动。后来实在忍不了干脆把界面部分重写抽出了 TGUI 图形抽象层和 TMENU 菜单内核两块。今天就把这套方案的完整思路、核心数据结构、移植过程以及我踩过的坑一次性讲清楚适合正在做单片机/嵌入式 GUI或者想把产品菜单模块化的朋友参考。1. TGUI TMENU 内核是什么为什么需要独立出来1.1 内核与图形库的边界划分很多人在嵌入式项目里第一次接触“菜单”都是在主循环里放一个全局高亮序号然后按上下键加减数字按确认键跳 switch 分支。这么做在小项目里没问题但一旦菜单层级变深、页面带状态、按键需要长按连续调节代码就会迅速失控。我在这套方案里把界面部分拆成了两层TGUI 是底层图形抽象层负责把不同屏幕驱动统一成绘制接口TMENU 是跑在 TGUI 之上的菜单内核负责菜单树组织、焦点管理、事件分发和回调调度。TGUI 不管业务逻辑只管“画矩形、画文字、画图标、填充区域”TMENU 不管像素怎么落只管“当前屏幕应该显示哪一个菜单、按键按下后焦点应该移到哪里、什么时候该调用业务回调”。这个边界一旦划清楚换屏不碰菜单逻辑加菜单项不碰绘制代码整个项目结构会清爽很多。1.2 独立内核解决的三个核心痛点第一业务代码不再和菜单导航耦合。以前加一个子菜单要在按键处理函数里加 case要改当前屏幕绘制函数要处理返回逻辑现在只需要在菜单表里加一行节点配置导航逻辑全部由 TMENU 内核处理。第二事件处理统一。短按、长按、重复触发、返回栈这些交互细节如果散落在业务代码里每个人写的风格都不一样。抽成内核后按键事件先经过内核过滤再决定是直接处理还是转发给回调交互一致性明显提升。第三资源可控、可预测。菜单表可以用 const 数组放到 Flash不占用 RAM运行时的状态只有当前菜单 ID、高亮索引、无效标记和返回栈内存占用可以精确估算。这对没有 MMU 的 MCU 来说非常关键。2. TMENU 内核的核心数据结构与运行机制2.1 用菜单树组织界面用静态数组拍平TMENU 内核的数据模型是菜单树。每个节点代表一个菜单项、子菜单或叶子动作。理论上看树结构很适合描述“系统设置 - 亮度调节 - 滑块”这种层级关系但我在实现时没有用指针动态建树而是用静态数组把树拍平。节点结构体定义大致是这样的typedef struct tmenu_node { uint16_t id; /* 节点唯一ID用于回调识别 */ uint16_t parent_id; /* 父节点ID0表示根 */ const char *label; /* 菜单文字指向Flash字符串 */ uint8_t type; /* NODE / ACTION / TOGGLE / BACK */ uint8_t child_count; /* 直接子节点数量 */ tmenu_callback enter_cb; /* 进入节点时回调 */ tmenu_callback event_cb; /* 节点在焦点区时处理按键 */ } tmenu_node_t;每个字段都有明确用途。parent_id用于返回时定位父级比直接存指针更安全不会因为 Flash 里的结构体地址变化而出错。type字段让内核在收到确认键时自己判断该进入子菜单还是执行动作。child_count用来在子节点遍历时避免越界也方便画菜单列表时知道该显示多少项。菜单表就是一组常量static const tmenu_node_t menu_table[] { {1, 0, 系统设置, NODE, 3, NULL, NULL}, {2, 1, 亮度调节, NODE, 0, on_enter_brightness, on_event_brightness}, {3, 1, 声音设置, NODE, 2, NULL, NULL}, {4, 3, 音量, ACTION, 0, NULL, on_event_volume}, {5, 3, 铃声, ACTION, 0, NULL, on_event_ringtone}, };新增菜单时只需要在数组里加一行主逻辑完全不动。这就是数据驱动的好处。2.2 焦点、事件分发和无效重绘机制内核运行时的状态很少当前所在菜单 ID、当前高亮项索引、重绘无效标记、返回栈。用户按下“下”键时内核只把高亮索引向下移动并标记界面需要重绘不直接画屏幕。主循环检测到无效标记后再调用 TGUI 渲染这样能避免每次按键都立刻刷屏减少闪烁和 CPU 开销。事件分发采用两段式策略。导航类按键上下左右、返回由内核直接处理因为它们的逻辑对所有菜单页面都通用确认键则根据当前节点类型决定动作是进入子菜单还是触发业务回调。这个设计把高频且通用的逻辑收进内核把跟业务相关的动作留在回调里业务代码量会少很多。重绘接口可以是整屏重绘也可以是局部矩形重绘。我在 SPI 屏上用的是局部重绘只刷新高亮项变化前后的两个矩形区域效果很明显。内核不需要知道具体重绘了哪里只需要在状态变化后置一个invalidated标记然后由 TGUI 层根据状态自己决定怎么画。2.3 内核如何调度绘制tmenu_render是内核暴露给主循环的渲染入口但它不直接画界面。它的职责是遍历当前菜单的所有子节点告诉 TGUI 哪些文字要画、什么位置、是否高亮。画文字需要字体和坐标这些信息由 TGUI 根据节点 label 计算。简单来说TMENU 是“导演”TGUI 是“执行者”。一个典型的渲染调度流程是void tmenu_render(void) { int i; tmenu_node_t *cur find_node_by_id(current_menu_id); for (i 0; i cur-child_count; i) { tmenu_node_t *child find_child(cur-child_id_base, i); tgui_draw_text(child-label, menu_origin_x, menu_origin_y i * item_height); if (i current_index) { tgui_draw_highlight(menu_origin_x, menu_origin_y i * item_height, menu_width, item_height); } } }这里find_child可以用一个字段child_id_base加速查找或者提前建索引。实际数据量只有几十个节点时线性查找也完全够用关键是要保证代码逻辑清晰。3. 在真实嵌入式平台上的移植与实操步骤3.1 平台选择和接入准备我的目标平台是 STM32F407 一块 320x240 的 SPI 屏裸机大循环不带 RTOS。屏幕驱动已经初始化好可以画纯色矩形和中文字符。按键是 5 个 GPIO对应上、下、确定、返回、功能键采用定时扫描方式没有用外部中断。移植前要确认三件事屏幕驱动能稳定画矩形、按键扫描能稳定识别单次按下、系统有一个毫秒级 tick 用于长按计时。这三件事都满足后TGUI 层只需要提供几个基础接口tgui_init、tgui_fill、tgui_draw_rect、tgui_draw_text。3.2 初始化内核并注册菜单表在 main 函数里先初始化 TGUI再调用tmenu_inittmenu_config_t cfg; cfg.table menu_table; cfg.table_size sizeof(menu_table) / sizeof(menu_table[0]); cfg.default_menu_id 1; cfg.on_redraw tgui_redraw_all; tmenu_init(cfg);tmenu_init内部会遍历一次菜单表把每个节点的父子关系建立成索引方便后续快速查找。同时把当前高亮项初始化为default_menu_id对应菜单的第一个子节点。这里有个细节不要在tmenu_init里面做动态内存分配所有资源都在编译期确定运行时不申请堆内存避免碎片化和分配失败的问题。3.3 裸机主循环的事件接入主循环的任务就是扫描按键、喂事件、查无效标记、触发渲染while (1) { tmenu_event_t ev; if (key_scan(ev)) { ev.tick get_tick_ms(); tmenu_handle_event(ev); if (tmenu_is_invalidated()) { tmenu_render(); tmenu_clear_invalidated(); } } update_status_icons(); }按键事件里带上时间戳很重要。TMENU 内核内部用 tick 判断长按和重复触发比如音量调节按住“加”不松手时第一下立即执行之后每 300ms 再执行一次连续增加。这个逻辑如果放在业务回调里每个页面都要重复写放内核后只需要配一个重复周期参数。3.4 TGUI 层需要补充的接口TMENU 只关心状态真正画界面的是 TGUI 层。它需要提供几个基础操作填充整个屏幕背景色在指定坐标绘制高亮矩形在指定坐标绘制文本可选绘制图标和边界框我实现 TGUI 时用了单 buffer没有双缓冲。因为屏幕本身不支持局部读回双缓冲需要额外一块 320x240x2 的 SRAM成本太高。局部重绘配合裁剪可以缓解闪烁已经满足当前需求。3.5 内存占用估算与优化建议我实际测过 50 个菜单节点的工程节点结构体在 32 位 MCU 上占 16 字节左右50 个节点占 800 字节 FlashRAM 方面只有当前菜单 ID、高亮索引、无效标记、返回栈等总计不超过 64 字节。如果菜单文字很多可以把label指针换成 Flash 字符串表索引进一步压缩结构体大小。需要注意返回栈的深度要按最大菜单层级设置。我设的是 8 层实际只用到 3 层但多留一点容量可以避免极端操作时越界。这个空间用静态数组预分配不涉及动态内存。4. 常见问题与排查技巧实录4.1 返回子菜单时“回不到上一级”这是我遇到的第一个典型坑。现象是从根菜单进入“系统设置”再进入“亮度调节”按返回键后界面直接回到根菜单而且高亮项变成第一项而不是之前进入时的位置。原因很简单进入子菜单时没有记录父级菜单的高亮索引。解决方法是在内核里增加一个返回栈每次进入子菜单时把“当前菜单 ID 当前高亮索引”压栈返回时再弹栈恢复。只保存当前菜单 ID 是不够的因为同一级菜单的高亮位置也必须恢复。typedef struct { uint16_t menu_id; uint8_t index; } back_stack_item_t; static back_stack_item_t back_stack[8]; static uint8_t back_stack_depth;4.2 菜单文字闪烁和重影SPI 屏刷新一整屏要几十毫秒如果按键一触发就全屏重绘视觉上会明显闪烁。我从全屏重绘改成局部重绘后屏幕刷新量只覆盖高亮项变化前后的两个矩形区域效果立竿见影。重影问题通常出在绘制文字前没有先填充背景色。画矩形高亮时如果只画前景文字而不重绘背景残留的旧文字边缘会在快速切换时叠在一起。所以 TGUI 绘制文本前一定要先画背景矩形再做裁剪和叠字。4.3 长按触发过于灵敏或重复无效早期实现长按是“按下后开始计时超过阈值触发”。但实测发现不同用户按键手感差异很大稍微抖动就可能反复触发。后来改成按键按下先触发一次短按动作然后进入长按检测只有连续超过 500ms 没有松开才进入重复触发周期。这样既保证短按响应快又不会因为抖动误触。内核里还要设置一个“事件冷却时间”比如连续触发两次按住事件之间的最小间隔防止按键扫描和去抖逻辑不完善时出现的重复事件。4.4 按键无反应和菜单空白按键无反应首先确认key_scan是否返回了事件GPIO 上下拉配置是否正确。我遇到过按键 GPIO 内部上拉没打开导致电平一直在 0扫描函数永远认为按键被按住。子菜单进入后空白通常是enter_cb没有初始化页面状态。TMENU 只负责把当前菜单切换过去但具体页面的控件、状态、绘制内容需要业务回调去配置。如果enter_cb为空页面就什么都画不出来。这是把 UI 逻辑和菜单导航分离后最常见的“副作用”实现回调时别漏。4.5 常见问题速查表问题现象可能原因排查方向按键后无反应事件未分发到内核检查 key_scan 和 GPIO 配置子菜单进入后空白enter_cb 未初始化页面检查回调是否注册、页面状态是否设置返回时高亮位置丢失返回栈未实现在进入子菜单时压栈返回时弹栈长按重复触发去抖和事件冷却不足增加单次按下去抖设置重复触发间隔刷新严重闪烁全屏重绘改为局部矩形重绘文字重影绘制前未填充背景在 TGUI 文本绘制前画背景矩形5. 从裸机到 RTOSTMENU 内核的扩展方向5.1 移到 RTOS 任务中的注意事项我后来把 TMENU 内核接到了带 RTOS 的项目里发现内核代码完全不用改。因为 TMENU 不持有任何独占资源也没有阻塞等待只是几段纯逻辑处理。真正需要调整的是 TGUI 层把绘制放到独立 GUI 任务配合双 buffer 和同步信号量避免多个任务同时访问显示设备。这种情况下主循环里的tmenu_handle_event变成了消息队列的处理函数。按键中断或输入任务把事件写到队列GUI 任务从队列读出后调用tmenu_handle_event再根据无效标记触发渲染。内核的接口不变扩展性就体现出来了。5.2 动画过渡和动态菜单表如果后续想加菜单切换动画可以在 TMENU 的状态切换事件里增加一个transition_type字段比如“左滑进入”“淡入淡出”。TGUI 层根据这个字段对当前页和下一页做插值渲染菜单导航逻辑和动画逻辑仍然分离。动态菜单表也不是没法做。固定场景用 const 数组需要动态增删时可以额外维护一个可变数组运行时通过注册接口更新。只是要注意内存分配策略提前规划最大容量不要用malloc频繁申请释放。我个人在实际操作中的体会是把菜单逻辑独立成内核前期会多花一天时间做设计但后续每个功能迭代都会还回来。刚开始从小项目试起先把菜单树和事件分发跑通再慢慢加复杂交互这套 TGUI TMENU 的组合会越用越顺手。希望这篇拆解能给你一些参考少走几个我踩过的弯路。本文还有配套的精品资源点击获取

相关新闻