简介:这是一份面向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; }如果你的缓存方案实现得足够彻底,甚至可以做一个“脏矩形”机制:只把变化涉及的行标记为 dirty,lcd_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,状态机内部完全不用动。这样一套代码,既能用按键操作,也能用编码器操作,实用性大大提升。
本文还有配套的精品资源,点击获取