公司动态
12864多级菜单设计:基于表驱动与状态机的嵌入式方案
简介这是一份面向51单片机初学者的12864多级菜单设计示例围绕LCD12864人机交互界面完整展示按键扫描、菜单层级切换与界面刷新的实现思路适合项目里需要加入菜单交互功能的中初级开发者。压缩包共53个文件约382KB以C语言源码、Keil工程文件uv2/lnp/obj、Proteus仿真文件DSN/PWI和HEX烧录文件为主并包含开机LOGO、菜单效果图等位图素材。内容覆盖12864驱动、按键菜单框架、开机及二三级子菜单示例同时附带DS1302、DS18B20、ADC0831等常用外设的C代码方便在实验板上组合验证其中还包含1602液晶与18B20、1302、按键联动的综合工程便于扩展学习。DSN仿真电路可在Proteus中直接打开适合边看效果边理解代码文件结构清楚可对照仿真图、效果图和源码逐步理解菜单的状态切换与画面刷新过程。已有2765人学习浏览适合需要动手实现单片机菜单交互的开发者参考下载。 做单片机小项目时12864屏幕几乎是万能标配。但很多人一碰“菜单”就头疼给温控器加个设置界面按一个键进一层改个参数再退两层代码越写越臭。我见过最夸张的版本一个双层菜单用 if-else 写了三百行想加个新菜单项得顺着逻辑翻半天。如果你也在折腾 12864 多级菜单这篇文章就是把“菜单系统”这件事拆开揉碎给出一套结构清晰、容易学习、也容易改的裸机方案适合 STM32、51、Arduino 这类主流平台。下面聊的很多细节是常规教程里不会写的坑和解法。1. 多级菜单难在哪先看清三个真正的问题先别急着写代码。做多级菜单之前我建议你想清楚三个问题不然很容易陷入“加一个功能就重构一次”的循环。1.1 菜单结构要用代码“描述”层级关系菜单不是一屏一屏的图片而是一棵“树”根节点是一级菜单下面挂着二级菜单项每个二级菜单项可能是最终动作也可能继续往下挂三级菜单。用代码描述这棵树是第一个关键点。很多新手习惯用 switch-case 模拟层级switch (level) { case 1: if (key OK) { level 2; } break; case 2: if (key OK) { level 3; } break; }这种写法短期没什么问题可一旦菜单项变多或者需要“返回上一级”时level 变量就会被到处改代码很快变成一团乱麻。正确思路是把菜单的父子关系、同层顺序、执行动作都变成一张可以查表的数据结构。这样结构变了只需要改表逻辑变了只需要改驱动函数。后文第 3 节我会给出具体结构体定义。1.2 屏幕资源12864每屏只能放4行汉字12864 这个名字已经把限制写得很清楚了分辨率 128×64 像素。如果使用 16×16 点阵的汉字横着刚好 8 个竖着刚好 4 行。也就是说一个简单的菜单页一屏最多显示 4 个菜单项选中的光标准确说只能放 4 行。平时我们做产品一级菜单 5~6 个项目很常见所以“滚动”是 12864 菜单绕不开的话题。不是简单把光标往下移而是整个可视窗口要在菜单项列表里滑动。这个细节我会在第 2.3 节展开。先记住12864 的窄屏幕会倒逼你简化菜单层级和文案这未必是坏事好的嵌入式 UI 本来就是精炼优先。1.3 交互流程按键事件和界面状态要解耦第三个问题最容易被忽略按键扫描、菜单界面刷新、参数修改这三件事如果全混在一个 while 循环里就会出现“按一下没反应”“屏幕闪一下才切换”这类现象。真正好维护的写法是按键模块只负责告诉菜单模块“发生了什么事件”比如按下、长按、旋钮正转。菜单模块根据当前所处的状态决定这个事件要怎么处理。这个思路就是状态机。它不需要什么高级抽象一个枚举变量加几个 switch 分支就够了但能让整个逻辑清爽很多。后面第 5 节我会给完整框架。想清楚这三件事再回头看各种 12864 菜单例程你会发现万变不离其宗。2. 12864显示基础这些参数直接影响菜单效果写菜单前先把硬件底子打熟。不同型号的 12864驱动方式差距很大直接影响你会不会做图形缓存、能不能局部刷新。2.1 并口、SPI和显存缓冲市面上常见的 12864 有两类带字库的 ST7920常见接口是并行、SPI 或串行支持文本模式也可以写图形。不带字库的 KS0108纯图形模式需要自己取模显示汉子和图标。还有不少 OLED 屏虽然物理分辨率也是 128×64但内部有 1KB GDDRAM可以直接按 page 读写驱动方式和 12864 类似菜单逻辑完全可以通用。我做菜单时最推荐的做法是在 RAM 里开一块uint8_t frame[8][128]作为显存缓冲所有菜单绘制操作画文字、画光标、画图标都先往 frame 里写需要刷新到屏幕时再把这个数组整段或按脏区域同步到 LCD 控制器。这样做的好处有两个一是避免屏幕闪烁二是让菜单逻辑与具体屏幕型号解耦。如果你的 12864 是 KS0108 这类需要外部控制地址的屏缓存刷新的优势更明显。2.2 汉字取模与菜单排版汉字显示看起来简单实际坑不少。我的建议是菜单标题统一用 16×16 点阵不要混用 8×16 和 16×16否则排版会很乱。取模方向也要统一推荐“横向取模、低位在前”这样在代码里按字节顺序填充 frame 数组最顺畅。菜单项左侧的光标我喜欢用“反白”而不是箭头。因为 12864 每个汉字是 16×16你要在左侧画三角往往需要跨两个字节处理起来更容易出残影。反白就是把选中项所在的那一行整块数据取反代码简单视觉效果也清楚。菜单排版的基本约定第一行显示当前菜单标题第二行到第四行显示 3 个子菜单项如果子项超过 3 个启用滚动右侧预留 1~2 像素间距避免文字顶到屏幕边缘。2.3 滚动显示解决菜单项超过4个的问题一屏最多 4 行子菜单第 5 个以后就被截断。滚动方案通常有两种光标固定在屏幕底部内容整体往上滚光标在 3 个可视行里移动超过可视区才滚动一屏。第二种手感更好推荐做成“当选中项超出可视窗口时可视窗口向下滑动”。实现的核心是维护一个offset变量表示当前可视窗口的起始索引。绘制的时候只画offset到offset 3这几项。光标位置用sel - offset来计算行号。这个思路对超过一屏的列表都适用不只是菜单。滚动之后返回功能也得跟着校对每次进入新菜单offset要清零向上滚到顶部后offset也需要归零不能让窗口停在空区域。3. 多级菜单的数据结构用数组表驱动代替if-else第 1 节说要用“表”描述菜单树这一节直接上可抄作业的结构体。3.1 节点结构体怎么设计我用过多种写法最推荐初学者理解的是这种“节点索引”结构#define MAX_CHILDREN 6 typedef struct { const char *name; // 菜单显示名称 int parent; // 父节点索引-1表示根 int children[MAX_CHILDREN]; // 子节点索引表-1表示空 uint8_t child_count; // 实际子节点数量 void (*action)(int step); // 动作回调step表示参数增减 } MenuNode;children数组里存的是“索引”不是指针。这样好处很明显菜单节点定义时不需要担心前向声明只需要在数组里用下标互相指向。就算菜单有几百个节点也完全可控。3.2 用一张静态表把菜单树铺开以温控器为例主菜单下面挂“温度设置”“湿度设置”“系统信息”三个子项温度设置又进入“目标温度”“回差温度”两个参数项。用静态表初始化void temp_action(int step) { /* 修改目标温度 */ } void diff_action(int step) { /* 修改回差温度 */ } void version_action(int step) { /* 显示版本 */ } MenuNode menu_nodes[] { // 0: 主菜单 [0] { .name 系统菜单, .parent -1, .children {1, 2, 3}, .child_count 3, .action NULL, }, // 1: 温度设置二级菜单 [1] { .name 温度设置, .parent 0, .children {4, 5}, .child_count 2, .action NULL, }, // 2: 湿度设置可执行项 [2] { .name 湿度设置, .parent 0, .children {-1}, .child_count 0, .action humi_action, }, // 3: 系统信息 [3] { .name 系统信息, .parent 0, .children {-1}, .child_count 0, .action version_action, }, // 4: 目标温度 [4] { .name 目标温度, .parent 1, .children {-1}, .child_count 0, .action temp_action, }, // 5: 回差温度 [5] { .name 回差温度, .parent 1, .children {-1}, .child_count 0, .action diff_action, }, };从这张表能直观看到1号节点和4、5号节点的父子关系通过parent字段表现出来只要顺着children数组访问就能从主菜单一路走到最深层。这已经是一棵完整的多级菜单树不是只能支持两级的玩具。3.3 进入、返回、动作回调的实现逻辑运行时只需要维护两个变量static int cur_node 0; // 当前所在节点的索引 static int cur_sel 0; // 当前选中的子项序号按“确定”键时取出menu_nodes[cur_node].children[cur_sel]得到目标子节点child如果child_count 0说明它还有下级就执行cur_node child; cur_sel 0;然后刷新屏幕如果child_count 0说明它是一个参数项或者动作项就调用menu_nodes[child].action(step)进入参数编辑状态。按“返回”键时更简单cur_node menu_nodes[cur_node].parent; cur_sel 0;如果parent是 -1说明已经在主菜单此时再按返回不响应即可。整个菜单逻辑里没有任何 if-else 嵌套层级只有一个统一的事件处理函数。以后要加一个新的二级菜单只需要在menu_nodes表里增加节点然后把它挂到某个父节点的children数组中其它代码一行都不用改。这就是“表驱动”带来的维护效率提升。4. 菜单绘制与刷新让12864不闪烁不残影有了数据结构下一步是把它画到屏幕上。这一节的内容直接决定你的菜单是“顺滑”还是“辣眼睛”。4.1 先画到缓存再整体提交12864 这类屏的写入速度并不快每次调清屏函数都要等待较长时间。如果每按一次键都“清屏→重绘全部文字”你会明显看到闪烁和残影时间长了也容易视觉疲劳。我的做法是强制自己开一个全局缓存uint8_t frame[8][128]; // 12864点阵8页 * 128列 void lcd_flush(void) { // 把 frame 写入液晶控制器 }所有菜单绘制只写frame不直接操作液晶。写完后调用一次lcd_flush()把整帧提交过去。对于 SSD1306 这类带内部显存的屏甚至可以直接把 frame 映射到 GDDRAM对于 ST7920、KS0108也可以按页连续写。缓存带来的另一个好处是你可以在缓存里做“反白”“擦除”“画图”而不用担心破坏屏幕上已有内容。菜单绘制变成了纯内存操作调试也容易。4.2 当前菜单页面绘制代码假设可视区最多显示 3 个子菜单项标题固定在第一行第二到第四行显示菜单项。绘制函数可以这样写#define MENU_VISIBLE_ITEMS 3 static void draw_menu_page(void) { MenuNode *node menu_nodes[cur_node]; int count node-child_count; int offset 0; // 如果选中项超出可视区就滚动窗口 if (cur_sel MENU_VISIBLE_ITEMS) { offset cur_sel - MENU_VISIBLE_ITEMS 1; } // 标题 draw_string(0, 0, node-name, FONT_16X16); // 清空数据区只清第二到第四行 clear_region(0, 1, 7, 3); // 绘制可视菜单项 for (int i 0; i MENU_VISIBLE_ITEMS; i) { int item offset i; if (item count) break; int child node-children[item]; int y 1 i; if (item cur_sel) { reverse_region(0, y, 7, 1); // 反白选中行 } draw_string(0, y, menu_nodes[child].name, FONT_16X16); } }这段代码里有几个细节值得注意clear_region只清第二到第四行不清标题减少闪烁如果选中项在可视区内offset不变内容不会重绘只有当选中项超过可视区才滚动窗口反白要在画字之前还是之后我推荐先反白再画字或者画完字再对整行取反效果一样看你缓存实现是否方便。4.3 局部刷新与光标更新很多菜单操作只涉及光标的上下移动没必要重绘整页。此时可以只更新旧光标行和新光标行static void update_cursor(int old_sel, int new_sel) { // 清除旧光标行反白 unhighlight_menu_row(old_sel); // 如果 old_sel 和 new_sel 在同一行就不需要重新绘制文字 if (old_sel ! new_sel) { highlight_menu_row(new_sel); } // 更新底层变量 cur_sel new_sel; }如果你的缓存方案实现得足够彻底甚至可以做一个“脏矩形”机制只把变化涉及的行标记为 dirtylcd_flush只提交 dirty 行。这样在 12864 这种慢速屏上菜单能明显感觉到“跟手”。当然裸机小项目不必把脏矩形做得太复杂先保证整帧刷新不闪就已经比很多例程好了。5. 按键交互与状态机按一下该干什么数据结构和绘制函数都有了接下来要把按键和菜单状态串起来。5.1 按键去抖的底层逻辑按键没去抖菜单会“一跳跳两行”。我建议不要在状态机里做延时去抖而是用定时扫描的方式#define KEY_SCAN_PERIOD_MS 10 static void key_scan(void) { static uint8_t last_state 0; uint8_t now_state read_key_pins(); uint8_t changed now_state ^ last_state; last_state now_state; // 只处理“刚刚按下”的边缘 if (changed now_state) { KeyEvent evt key_to_event(now_state); menu_handle_key(evt); } }在主循环里每 10ms 调用一次key_scan。如果只检测单边沿短按和长按可以继续扩展按下后计时超过 500ms 再产生一次“长按”事件。这套逻辑独立于菜单菜单模块只需要接收事件。5.2 导航状态机的三个状态菜单交互至少需要两个状态浏览状态和编辑状态。我给自己的项目增加一个“执行确认”状态但一般用两个就够。定义状态枚举typedef enum { MENU_STATE_NAV, // 浏览菜单 MENU_STATE_EDIT, // 编辑参数 } MenuState;浏览状态下的按键处理static MenuState state MENU_STATE_NAV; void menu_handle_key(KeyEvent evt) { if (state MENU_STATE_NAV) { switch (evt) { case KEY_UP: if (cur_sel 0) { update_cursor(cur_sel, cur_sel - 1); } break; case KEY_DOWN: if (cur_sel menu_nodes[cur_node].child_count - 1) { update_cursor(cur_sel, cur_sel 1); } break; case KEY_ENTER: enter_current_item(); break; case KEY_BACK: if (menu_nodes[cur_node].parent 0) { cur_node menu_nodes[cur_node].parent; cur_sel 0; state MENU_STATE_NAV; draw_menu_page(); } break; } } else { // 编辑状态 } }enter_current_item的逻辑其实就是 3.3 节说的如果选中项有子节点就切换节点如果没有子节点就切换到编辑状态并调用对应 action 做一次初始化显示。5.3 参数编辑态的额外考量编辑态下按键的含义变了上键参数加一下键参数减一确定键保存参数退出编辑态返回键放弃修改退出编辑态。为了让结构不糊成一团参数项可以多提供一个回调void temp_action(int step) { // step 为 1 表示增加-1 表示减少 target_temp step; draw_value(target_temp); }编辑状态下的按键处理if (state MENU_STATE_EDIT) { MenuNode *node menu_nodes[menu_nodes[cur_node].children[cur_sel]]; switch (evt) { case KEY_UP: if (node-action) node-action(1); break; case KEY_DOWN: if (node-action) node-action(-1); break; case KEY_ENTER: case KEY_BACK: state MENU_STATE_NAV; draw_menu_page(); break; } }这里的node是当前正在编辑的参数项不是容器节点。用同一个action(step)回调处理增减比写一堆针对不同变量的temperature_up()、humidity_down()要通用得多。6. 实测踩坑与优化建议从能跑到好用前面几节讲的是主干思路但真正把这些代码移植到不同板子上时还会遇到很多细节问题。以下是我实测总结的一些坑和优化方向。6.1 整屏刷新闪烁缓存之外还要注意“恢复画面”只开缓存不一定能根除闪烁。问题出在“画字”和“清行”的顺序如果先清空数据区再逐行画字这个过程在 12864 这类写入较慢的屏上会因为多次写数据而出现肉眼可见的撕裂感。我在一个 ST7920 项目中做过对比整帧刷新时第一次先清屏再画字闪烁明显第二次把所有绘制都先写到缓存最后一次性把缓存整体提交闪烁几乎消失。原因是 ST7920 写 DDRAM 时需要先设置地址地址跳来跳去会产生延迟而整页连续写可以明显减少地址切换开销。SSD1306 这类 GDDRAM 屏也是一样尽量使用“连续写模式”。如果还是觉得残影严重可以适当降低刷新率不要每次按键后都立刻调用lcd_flush可以等当前按键事件处理完后再统一提交一次避免中间过程被显示出来。6.2 文本截断与中文编码12864 一行最多 8 个 16×16 汉字。菜单标题一长就容易在中间被截断。我见过有人用“目标温度设定值”这种七字标题到了第四行菜单项就只剩 1 个字宽度按键还容易误触。建议标题控制在 4~6 个字以内“子菜单 当参数值”同时显示的场景参数值右侧对齐中间用空格填充。如果你用 ST7920 带字库版本要注意内置字库的汉字编码通常是 GB2312别在代码里直接写 UTF-8 字符串否则显示出来全是乱码。最保险的方式是统一用取模软件生成字库数据不依赖内置字库。6.3 从裸机菜单到LVGL的迁移参考有人会问既然流程这么麻烦为什么不用 LVGL如果你的目标平台内存足够、跑的是嵌入式 RTOS用 LVGL 确实事半功倍。但 LVGL 对内存和算力的要求对普通 STM32F103 12864 这类组合来说并不轻松尤其是多级菜单刷新时单色屏跑 GUI 库经常得不偿失。裸机表驱动菜单最大的价值是让你理解菜单的本质是“状态 数据 绘制”。迁移到 LVGL 或 TouchGFX 时你依然会用到“列表项”“选中项”“进入回调”这些概念。我建议初学者先用裸机方案把菜单状态机跑通再决定要不要上 GUI 框架。两种方式不冲突反而互相印证。最后再说一个实际优化如果项目里有旋转编码器只需要把编码器正转对应KEY_UP反转对应KEY_DOWN按下对应KEY_ENTER状态机内部完全不用动。这样一套代码既能用按键操作也能用编码器操作实用性大大提升。本文还有配套的精品资源点击获取