
简介这是一份基于51单片机的12864液晶多级菜单完整工程源码适合单片机初学者和有菜单交互开发需求的工程师参考。压缩包共382KB、包含53个文件以C源码.c/.h、Keil工程.uv2/.opt/.lnp、编译中间文件.lst/.obj/.hex/.m51以及Proteus仿真原理图.DSN为主并附带开机、二级菜单、三级子菜单等效果图和取模工具链接方便对照实际显示效果进行修改。作者在描述中强调通过菜单化设计可以像操作手机一样简化设备人机交互资源本身也覆盖1602、18B20、DS1302、ADC0831等多个模块示例完整展示了按键扫描、菜单层级切换、LCD12864驱动等关键逻辑适合直接移植或学习。目前已有2765人学习下载对于想在开发板上快速搭建菜单系统的读者具有直接参考价值。 当初第一次拿到12864液晶屏时我满脑子想的不是“点亮背光显示个你好世界”而是拿它做一个像样的交互界面——多级菜单。这块屏幕虽然只有128×64像素但如果菜单逻辑设计得当完全能撑起一台小仪表的完整操作流程。最近把以前写的12864菜单框架翻出来重构了一遍顺手整理成一篇可以直接落地复现的实战文章。文章不堆概念只讲一个能跑通的多级菜单怎么设计、怎么写、怎么调重点突出“容易学习”这四个字。适合刚学会驱动12864、想给项目加上菜单交互的同学也适合把手头项目从“单屏显示”升级成“可操作界面”的人。下面进入正题。1. 先理清楚12864的多级菜单本质是什么1.1 12864需要了解的族谱网上搜“12864”会看到两类长相完全不同的屏幕驱动方案也不同这个必须先分清楚否则后面代码全乱套。带字库的LCD12864驱动芯片以ST7920为主常见为20引脚5V供电支持并行和串行两种接口。优点是内置中文字库直接写GB2312编码的汉字就能显示文本模式非常方便。缺点是绘图和刷新速度一般内置字库稍微占MCU便宜资源。OLED 12864或者不带字库的LCD驱动芯片常见SSD1306/ST7565等I2C或SPI接口显示细腻但必须自己准备全字库16x16的中文字模一个就要32字节几百个汉字下来占Flash比较多。多级菜单对字符显示的要求比较高中文菜单名最好用内置字库解决。所以本文章例程以ST7920方案为主串行接口接线最少只有RS、RW、E三根控制线加电源适合入门。1.2 多级菜单的本质一棵树加一套导航规则菜单再多级本质上就是一棵树。根菜单在最上层每个菜单项可以有若干个子菜单最底层的菜单项对应一个具体功能比如“修改背光亮度”“查看温度”“清除数据”。用户操作时通过上、下、确认、返回四个动作在这棵树里游走。理解了树就很容易知道代码里需要哪些东西每个菜单节点要有名称、自己的子节点范围、父节点信息以及一个可选的执行函数。这也是后面数据结构的核心。很多同学一开始上来直接写显示函数结果做完一层就觉得别扭再多一层就乱了就是因为在动手前没有把树的结构定下来。我给12864菜单定了一个简单的导航规则向上光标在子菜单项中上移到顶则回到底部。向下光标在子菜单项中下移到底则回到顶部。确认如果当前项有子菜单进入下一级如果是功能项执行对应函数。返回回到上一级菜单。这套规则非常简单符合大多数手持设备的操作直觉也容易用状态机实现。2. 我的设计方案索引表加状态机最稳妥的组装方式2.1 为什么不选链表网上很多教程喜欢把菜单做成双向链表每个菜单项动态分配内存、用指针连接。我得说在PC上这么搞没问题但在单片机里我强烈不建议。原因有三动态内存分配malloc容易产生内存碎片小内存MCU跑久了容易莫名死机。链表遍历效率低节点多了以后定位一个菜单项要走很多次指针。初学者看着指针套指针一个-next一个-child直接劝退。所以我推荐用“静态结构体数组”。所有菜单节点在编译期就固定好放在Flash里程序运行时只维护当前层级和光标位置。这种方案内存开销极小、运行稳定而且每个菜单节点的关系一眼就能看清。2.2 用索引区间把菜单组织成树我用的做法是把所有菜单节点放进一个大数组每个节点只记录自己子菜单在数组里的起始下标和结束下标再加一个父节点标识。这样不需要任何指针用下标就能表达父子关系。举个例子如果数组里有10个节点节点0是根菜单“系统设置”它的子菜单从下标1开始到下标4结束那节点0只需要记录child_begin 1和child_end 4就明确了“我有三个子项”。这三项分别是什么名字显示时通过遍历下标1到3去取即可。这种“索引区间”方案的优点是很直观查询子菜单个数时直接用end - begin判断某个节点是不是叶子节点时只需要看begin end或者约定为0xFF跳转下一级时直接把当前光标项的下标作为下一层的起始节点。逻辑简单也就不容易写错。2.3 按键状态流转整个菜单的状态流转其实只有一个核心变量当前所在菜单项的数组下标。菜单层级不需要单独维护一个栈因为每个节点的索引区间天然包含层级关系。用局部变量记录当前菜单的child_begin和child_end配合光标值就可以完成所有操作。状态流转流程可以概括为按下“确认”时先判断当前项是否有子菜单。有则把child_begin和child_end更新为新一层的区间光标置为该区间第一个子项没有子菜单则执行功能回调函数执行完自动返回当前菜单。按下“返回”时根据当前节点的父节点信息回到父级菜单区间。父节点为根时直接显示根菜单。这套逻辑用不到复杂的任务调度一个switch-case加上两三个if就能搞定。想继续扩展比如增加“长按确认返回首页”也只需要在这个基础上加一个定时器判断。3. 核心代码实操C语言实现三级菜单3.1 菜单表格如何定义下面是我重构后的核心结构体放在头文件里typedef struct { const char *name; // 菜单显示的标题 uint8_t child_begin; // 子菜单起始下标 uint8_t child_end; // 子菜单结束下标不包含 uint8_t parent; // 父菜单下标0xFF表示根 void (*action)(void); // 功能回调函数没有则为NULL } MenuNode_t;菜单节点全部用const修饰并放到Flash里因为这部分内容运行时不会变烧录后直接存在Flash段不占RAMstatic const MenuNode_t menu_table[] { {系统设置, 1, 4, 0xFF, NULL}, // 0: 根 {时间参数, 4, 5, 0, NULL}, // 1 {显示设置, 5, 7, 0, NULL}, // 2 {数据清除, 0, 0, 0, func_clear}, // 3: 叶子 {设置小时, 0, 0, 0, func_set_hour}, // 4 {设置分钟, 0, 0, 0, func_set_min}, // 5 {背光亮度, 0, 0, 0, func_bright}, // 6 {对比度, 0, 0, 0, func_contrast}, // 7 };这个表里节点0“系统设置”的子节点是下标1到3节点1“时间参数”的子节点是下标4节点4、5、6、7都是叶子节点直接执行函数。整个表一眼就能看懂层级关系要增加菜单项时只需要在对应区间改下标并追加数据不需要动任何逻辑代码。3.2 按键扫描与菜单处理逻辑先定义一个很小的菜单运行上下文结构体typedef struct { uint8_t cur; // 当前选中的菜单项下标 uint8_t begin; // 当前层级的子菜单起始下标 uint8_t end; // 当前层级的子菜单结束下标 } MenuCtx_t; static MenuCtx_t menu {0, 1, 4};然后处理按键时遵循我之前说的导航规则。下面是核心处理函数我只贴最关键的逻辑void Menu_HandleKey(uint8_t key) { uint8_t count menu.end - menu.begin; switch (key) { case KEY_UP: if (menu.cur menu.begin) menu.cur--; else menu.cur menu.end - 1; // 回到最后一个 break; case KEY_DOWN: if (menu.cur menu.end - 1) menu.cur; else menu.cur menu.begin; // 回到第一个 break; case KEY_ENTER: if (count 0 menu_table[menu.cur].action) { menu_table[menu.cur].action(); // 执行功能 } else if (menu_table[menu.cur].child_begin menu_table[menu.cur].child_end) { menu.begin menu_table[menu.cur].child_begin; menu.end menu_table[menu.cur].child_end; menu.cur menu.begin; // 进入子菜单 } break; case KEY_BACK: if (menu_table[menu.cur].parent ! 0xFF) { menu.begin menu_table[menu_table[menu.cur].parent].child_begin; menu.end menu_table[menu_table[menu.cur].parent].child_end; menu.cur menu_table[menu.cur].parent; } break; } Menu_Draw(); }注意几点按下“确认”时先判断有没有功能回调函数有就直接执行这时候当前项可能是叶子节点也有项目既是叶子又有子菜单的极端情况这时可以约定功能优先还是进入子菜单优先我这里是功能优先。按下“返回”时通过当前节点的parent字段回到上一层的区间并把光标移到父节点本身这样用户返回后看到的是刚才进入的那一项操作体验很自然。3.3 屏幕刷新标题、列表和光标12864在文本模式下一行最多显示4个汉字16×16点阵整个屏幕最多4行。我的布局方案是第一行显示当前菜单的路径或名称下面三行显示菜单项光标用“”箭头标记。刷新函数的核心逻辑是根据menu.begin、menu.end和menu.cur计算屏幕上要显示哪三项。如果子菜单项总数不超过3直接显示超过3项则从顶部偏移开始截取。代码片段void Menu_Draw(void) { uint8_t i, idx; uint8_t total menu.end - menu.begin; uint8_t top 0; LCD_ClearScreen(); // 第一行显示当前层级的名称 LCD_ShowString(0, 0, menu_table[menu_table[menu.cur].parent 0xFF ? 0 : menu.cur].name); if (total 3) { top 0; } else { // 滚动窗口保证光标始终在屏幕范围内 if (menu.cur - menu.begin 1) top 0; else if (menu.cur - menu.begin total - 2) top total - 3; else top menu.cur - menu.begin - 1; } for (i 0; i 3 i total; i) { idx menu.begin top i; if (idx menu.cur) { LCD_ShowString(0, i 1, ); LCD_ShowString(2, i 1, menu_table[idx].name); } else { LCD_ShowString(0, i 1, ); LCD_ShowString(2, i 1, menu_table[idx].name); } } }这段代码的滚动逻辑是按“光标尽量保持在中间”的思路设计的当前项高于显示窗口中间时窗口整体上移低于中间时窗口整体下移。如果根本不足3项窗口固定从第一项开始。实际跑起来很跟手不会有光标滚丢的情况。4. 界面优化超长菜单滚动与显示细节4.1 滚动窗口再深入一点上面代码把滚动窗口写在了刷新函数里这个思路对初学者很友好。但有的场景要求菜单项本身不止文字还要带箭头提示“后面还有更多”这时需要在窗口的边界判断上动手脚。比如子菜单超过3项时可以在屏幕最下行右侧显示一个向下的“▼”符号在显示最前面一项时右侧显示向上的“▲”。这个可以通过12864的自定义字库或直接修改文本缓冲区实现虽然ST7920的字库里没有现成的箭头符号但是可以用ASCII中的^、v、等字符凑合或者切换到图形模式后画几个像素进去都不难。带上下箭头提示后用户立刻就明白“这层菜单还有更多东西可滚”这是很多产品设计里特别容易忽视的小细节但对使用体验的提升是立竿见影的。4.2 箭头指示和反白光条怎么选作为菜单光标最朴素的做法就是用“”和空格来切换显示这是我在3.3节采用的办法。它实现最简单不依赖任何图形操作文本模式直接搞定。缺点是不够炫不能像手机App那样背景反白。如果想让选中的菜单项整行反白ST7920方案就比较麻烦了。因为文本模式下的反白需要把整行显示内容改成“黑底白字”而内置字库是按前景色显示的没法简单实现颗粒度到像素的反白。真要做的方案有两条路简单点将选中项的文字改为其他颜色——LCD12864的背光和底色一般不能改单个字符颜色这个方案通常走不通。通用做法切到图形模式自己维护字库绘制菜单时把选中项的整个16×16像素行区域先清成黑色再用取反后的字模绘制白色文字。效果好看但工作量和代码复杂度都上去了而且需要自备字库一般LCD ST7920的例程中不太常见。综合考虑我还是推荐为了“容易学习”用箭头指示方案。如果以后你改用OLED 12864SSD1306方案反白光条就容易多了因为SSD1306有硬件反转矩形区域的功能命令级支持。4.3 刷新效率小技巧实际运行中如果每按一次键就整屏清空重画会看到比较明显的闪烁尤其是ST7920的并行模式还好串行模式下刷新一屏需要一点时间闪起来更明显。我的处理方法是只刷新变化区域。光标移动时只需要把原来的光标项恢复成普通样式再把新光标项标上箭头不必整屏重刷。标题行不每帧重画。标题只有在层级切换时才变化平时保持不动。如果确实需要整屏刷新先关显示刷新完再开显示利用人眼视觉暂留降低闪烁感知。具体代码可以加一个last_cur变量记录上次光标位置在Menu_Draw里只对这两个位置对应的行做重绘。这样串行通信量会明显减小屏幕显示也稳定很多。5. 实测记录与常见问题5.1 硬件接线和初始化的坑我测试用的是ST7920驱动、20针的LCD12864串行模式接线如下芯片引脚功能接MCU1GNDGND2VCC5V3VO接电位器中点调对比度4RS(CS)任意IO示例P1.05R/W(SID)任意IO示例P1.16E(SCLK)任意IO示例P1.215PSB接GND选择串行模式19LED5V经限流电阻20LED-GND串行模式下PSB必须拉低很多朋友第一次接出来只有上半屏显示就是PSB悬空导致模块默认并行模式。还有VO不接电位器时屏幕要么全黑要么全白这个不是代码bug调一下对比度电压就行。初始化代码里注意ST7920串口的时序它同步时钟的最高频率不能太高起步先用SPI分频或软件延时。我的软件模拟时序比较稳初始化后延时一段时间再做显示判断。5.2 按键误触和菜单乱跳菜单乱跳十有八九是按键扫描没做消抖或者把一次短按当成了多长事件。我的处理经验是确认按键按下后加10~20ms延时再读一次电平两次都稳定才判断有效。如果用GPIO外部中断触发按键建议在中断里只置一个标志位在主循环里做完整扫描和菜单处理避免在中断里直接调用显示刷新函数不然串行刷屏会把中断响应时间拖得很长按键很容易连跳。有些板子按键接法是高电平有效我调试时发现按下“确认”会连跳两级菜单排查半天发现不是程序逻辑问题而是GPIO内部上拉电阻没有配置引脚悬空时电平抖动。最终把所有按键GPIO配置成内部上拉外部按键另一端接地问题就解决了。5.3 乱码、闪烁等显示问题速查我把实际调试中遇到过的显示类问题整理成表格方便直接检索现象可能原因排查方法屏幕全黑或全白对比度电压不对调节VO电位器观察变化只有上半屏显示PSB未拉低确认PSB引脚接地汉字显示乱码编码不是GB2312把编译器源码编码改成GB2312刷新闪烁严重整屏清空重画改为局部刷新显示无反应初始化未完成上电后延时100ms再初始化菜单乱跳按键消抖不足加10~20ms消抖逻辑滚动光标消失窗口滚动越界检查top变量的边界其中源码编码这一个坑最隐蔽。很多人用VS Code的默认UTF-8编码写menu_table里的中文字符串而ST7920内置字库只认GB2312编码烧录到单片机后显示的就是乱码。解决方法是把源文件全部转为GB2312编码或者把中文菜单项统一用\x转义为GB2312字节就没有编码问题。5.4 扩展建议执行函数返回后如何保持界面不闪叶子菜单项执行完功能函数后通常会回到菜单界面。这里有个小讲究如果功能函数内部需要修改全局状态返回后最好立即刷新一次菜单但不要做成整屏闪烁。我的做法是在action()执行完后只刷新光标行区域标题和未变化的行都不动。实测下来视觉上非常自然用户会觉得菜单一直“活着”而不是每次执行功能都被重启一遍。最后再分享一个小经验我最早写12864菜单时总觉得菜单层级越多越高级一上来就做五层结果代码越写越绕最后自己都看不清哪个节点属于哪层。后来重构换成索引区间方案菜单表摊开一列一列看下去树的层级清清楚楚修改菜单项也不需要动逻辑代码。还有一点菜单框架搭好后先别急着堆满功能项先用两层菜单把上下、确认、返回四个键全部测通再加入第三层和滚动显示。基础流程稳了后面加菜单项就是填表格的事。另外我把滚动窗口和光标逻辑分离后维护起来特别省心。显示部分只负责画按键部分只负责改状态两者通过MenuCtx_t这个上下文通信。以后你想加旋转编码器、加触摸屏或者换OLED屏只需要改按键采集和显示驱动菜单核心逻辑可以原封不动搬过去。这块内容按现在的方案去做哪天你手头项目从LCD换OLED就能体会到这个设计的价值。本文还有配套的精品资源点击获取