拓冰建站拓冰建站
首页 / 资讯中心 / 正文

12864多级菜单设计:表驱动与函数指针的优雅实现

简介对于51单片机开发者而言人机交互菜单往往是项目中最繁琐的一环。这份由作者zxianyong整理分享的12864多级菜单资源围绕LCD12864液晶与按键扫描演示了如何像手机界面一样构建可逐级进入、返回的菜单结构代码注释直白、逻辑清晰适合刚接触单片机菜单设计的初学者快速上手。压缩包共53个文件约382KB既包含液晶驱动与按键扫描的C源码、对应头文件也有电路原理图、Proteus仿真与Keil工程文件以及多张菜单界面效果图方便对照学习。资源还顺带集成了DS1302时钟、DS18B20温度、ADC0831采集等模块实用性很强。已有2765人学习下载说明其口碑经过了验证可作为51项目人机界面的直接参考或改造基础。 做嵌入式开发的人谁没被菜单UI折磨过。一个仪表、一个控制器、或者一套简单的人机交互面板硬件方案十有八九就是12864屏加几个按键。为什么带中文字库、四行汉字显示、成本低、驱动简单这些优点在同尺寸屏幕里几乎没有对手。但问题也出在这屏太小显示内容有限一旦菜单层级变多用传统switch-case嵌套写起来又臭又长改一个菜单项要动一堆代码排查跳转逻辑更是噩梦。这篇博文就围绕“12864实现多级菜单”展开我把自己的通用做法完整讲一遍。核心思路是表驱动 函数指针 软滚动配套完整的数据结构、按键逻辑和滚动算法。不管你用的是STC89C52、STM32还是GD32这套思路直接搬。代码也是以“看得懂、能抄走”为目标写的适合刚入门单片机菜单开发的朋友参考也适合做产品原型时快速搭一套菜单框架。1. 项目概述12864多级菜单的难点与整体思路1.1 12864屏为什么适合做菜单界面12864说的就是分辨率128×64像素的LCD液晶屏。市面上最常见的是带ST7920控制器的型号特点是内置GB2312中文字库可以显示4行、每行最多8个汉字。这个特性在菜单场景里太合适了标题栏占一行剩余三行正好用来列菜单项配一个光标指示当前选中项交互逻辑非常直观。带字库还有一个隐形好处就是省ROM和开发时间。传统无字库屏显示中文需要自己做字模、取模软件转数组、再通过画点函数一个个点出来光耗在这上面的时间就够喝一壶。12864直接用内码LCD_ShowString(0, 0, 系统设置)一行代码搞定底层初始化加显示函数二三十行就完事对中小项目来说性价比很高。当然它也有局限性只有4行显示窗口一屏最多展示3个有效菜单项去掉标题行菜单项多了必须做滚动另外8个汉字的宽度限制菜单文字太长就得缩略或横向跑马灯。这些恰恰是本文要解决的问题所以用到最后你会发现限制本身就是设计的一部分。1.2 传统菜单写法的痛点与破局点很多新手实现菜单第一反应是全局变量记当前状态然后用switch-case或者if-else判断“现在处于哪个界面”按键事件来了就逐层改变状态。比如switch(state) { case 0: // 主菜单 if(key KEY_UP) ... else if(key KEY_DOWN) ... else if(key KEY_OK) state 1; break; case 1: // 系统设置 ... break; }这种写法在菜单只有两三层、选项只有三五个的时候没问题。但一旦菜单结构变成“主菜单→设置→参数设置→具体参数”或者产品迭代要新增一个配置项你就得在各个case之间来回改状态多了之后代码阅读成本急剧上升也非常容易在某个分支里忘记处理“返回”逻辑。破局的办法就是把菜单的层级关系、显示文字、执行动作全部抽离到一张静态数据表里代码只负责“查表、渲染、响应按键”这三件事。想新增菜单项只需往表里加一行记录改表不改逻辑。这就是这篇博文方案的核心。2. 核心设计表驱动的菜单数据结构2.1 菜单项结构体的四个关键字段我先直接给出结构体定义这是整套菜单框架的地基typedef struct { const char *text; // 菜单显示的文本 unsigned char subIndex; // 子菜单在数组中的起始下标0xFF表示无子菜单 unsigned char subCount; // 子菜单数量0表示叶子节点 void (*action)(void); // 功能函数指针确认键触发 } MenuItem;逐个解释。text是菜单项在屏幕上显示的文字直接存汉字的GB2312内码字符串subIndex定位这个菜单项的下一级菜单在menu[]数组里的起始位置是整个跳转逻辑的“指针”subCount表示这个菜单项下面有几个子项如果是叶子节点比如“温度设置”这种最终执行项就配一个action函数功能。action是函数指针也是这套方案最灵活的地方。它让菜单项不仅仅是跳转容器还能挂载具体功能比如进入参数设置、读取传感器、显示版本信息等等。按确认键时如果检测到action ! NULL就直接调用它菜单框架本身完全不用关心这个函数内部干什么。2.2 一张菜单表是怎么构建起来的有了结构体接下来就是按深度优先顺序把整棵菜单树平铺到一张静态表里。这里有个约定必须注意同一父菜单下的子项在数组中必须是连续存放的这样才能用subIndex subCount精确圈定子菜单范围。const MenuItem menu[] { // 0: 根菜单 { 主菜单 , 1, 3, NULL}, // 子菜单从下标1开始共3项 // 1~3: 一级菜单 { 系统设置 , 4, 2, NULL}, // 子菜单从下标4开始共2项 { 数据查看 , 6, 1, NULL}, // 子菜单从下标6开始共1项 { 帮 助 , 7, 1, NULL}, // 4~5: 系统设置子菜单 { 温度设置 , 0xFF, 0, SetTemperature}, { 湿度设置 , 0xFF, 0, SetHumidity}, // 6: 数据查看子菜单 { 历史记录 , 0xFF, 0, ShowHistory}, // 7: 帮助子菜单 { 关于本机 , 0xFF, 0, ShowAbout}, };这张表读起来非常直白根菜单有3个一级子项一级菜单“系统设置”下面挂着2个叶子节点“温度设置”没有子菜单被确认时执行SetTemperature。整个菜单树的层级关系在表里一目了然后期新增一个菜单项只需要找个合适的位置插入一行并确认父项的subCount数字正确即可逻辑代码一行不用动。我看很多人第一次接触这种“预排序平铺表”会担心如果我在菜单中间插入一项后面所有子菜单索引不是全乱了确实会乱。所以更严谨的工程做法是用“枚举或宏定义”给每个菜单项定义唯一ID然后把subIndex写成对应ID而不是裸数字。这样插入新项时索引更清晰我建议你从第一个项目开始就养成用枚举的习惯。2.3 为什么函数指针比一堆if-else优雅如果用if-else实现“按下确认后执行什么”逻辑会写成这样根据当前菜单项编号判断是1号菜单就进时间设置是2号菜单就进温度查询……每加一个功能就要加一个分支。而函数指针把“行为和数据结构绑定”让执行逻辑变成一行通用代码void MenuEnter(void) { unsigned char target menu[current].subIndex selected; if(menu[target].subCount ! 0) { // 有子菜单把当前菜单压入返回栈切到目标菜单 stack[stackTop] current; current target; selected 0; } else if(menu[target].action ! NULL) { menu[target].action(); // 叶子节点直接执行功能 } }不管菜单树后面长多大MenuEnter这段代码永远不需要改。这就是“表驱动”比“状态机嵌套”更受老工程师喜欢的原因程序在逻辑复杂度和规模增长之间找到了一个让代码不随需求膨胀的平衡点。3. 实操从零手写一个12864多级菜单3.1 硬件接线与底层驱动准备我以STC89C52或者STM32F103为例都行硬件接线只说串行模式因为串行模式只占3根IO线CS片选、SID数据、SCLK时钟加上复位引脚非常省引脚。接在任意三个GPIO上即可。sbit LCD_CS P2^0; sbit LCD_SID P2^1; sbit LCD_SCL P2^2; sbit LCD_RST P2^3;ST7920串行模式的底层驱动核心就是“模拟三线SPI”。每次传输分三个字节第一个字节是同步头加方向位0xF8表示写命令0xFA表示写数据后两个字节分别传输高四位和低四位。void LCD_WriteByte(unsigned char dat) { unsigned char i; for(i 0; i 8; i) { LCD_SCL 0; if(dat 0x80) LCD_SID 1; else LCD_SID 0; dat 1; LCD_SCL 1; } } void LCD_WriteCmd(unsigned char cmd) { LCD_CS 1; LCD_WriteByte(0xF8); LCD_WriteByte(cmd 0xF0); LCD_WriteByte((cmd 4) 0xF0); LCD_CS 0; } void LCD_WriteData(unsigned char dat) { LCD_CS 1; LCD_WriteByte(0xFA); LCD_WriteByte(dat 0xF0); LCD_WriteByte((cmd 4) 0xF0); LCD_CS 0; }初始化序列是固定的我实测中必须严格按照时序延时尤其上电后要等至少40ms再发第一条指令否则容易白屏void LCD_Init(void) { delay_ms(50); LCD_WriteCmd(0x30); // 8位基本指令集 delay_ms(5); LCD_WriteCmd(0x30); delay_ms(5); LCD_WriteCmd(0x0C); // 开显示不显示光标 LCD_WriteCmd(0x01); // 清屏 delay_ms(5); LCD_WriteCmd(0x06); // 光标右移整屏不移动 }显示字符串函数需要特别说明一下DDRAM地址的分布。ST7920的DDRAM每行可以存16个字符位置但显示区域只有前8个汉字宽对中文字符来说是8个位置对ASCII是16个。四行的起始地址分别是0x80、0x90、0x88、0x98。所以我的显示函数用行号换算地址void LCD_ShowString(unsigned char x, unsigned char y, const char *str) { unsigned char addr; switch(y) { case 0: addr 0x80; break; case 1: addr 0x90; break; case 2: addr 0x88; break; case 3: addr 0x98; break; default: addr 0x80; break; } LCD_WriteCmd(addr x); while(*str) { if(*str 0x80) // ASCII字符占1个字节这里补个空格占位 { LCD_WriteData( ); LCD_WriteData(*str); } else // 中文字符占2个字节连续写两个数据 { LCD_WriteData(*str); LCD_WriteData(*str); } } }这里有个细节ASCII字符在ST7920中只占8个点宽的半角位置而中文字符占16点宽。为了对齐美观显示ASCII字符时前面补一个空格数据视觉上就会居中留白。这个处理细节在后续做光标和图标时非常关键。3.2 菜单渲染标题栏、光标与选中项菜单界面我建议统一用一套布局第一行显示当前菜单名称左对齐第二到第四行显示菜单项光标放在行首。看起来就是一个典型的列表菜单。// 当前菜单在数组中的下标 unsigned char current 0; // 当前选中项在子菜单中的序号0开始 unsigned char selected 0; // 滚动窗口起始项序号 unsigned char viewStart 0; #define PAGE_SIZE 3 // 扣除标题行后最多显示3个菜单项 void MenuDraw(void) { unsigned char i; unsigned char subIdx menu[current].subIndex; unsigned char cnt menu[current].subCount; // 标题栏 LCD_ShowString(0, 0, ▶); LCD_ShowString(1, 0, menu[current].text); // 滚动窗口调整保证选中项始终在可视范围内 if(selected viewStart) viewStart selected; else if(selected viewStart PAGE_SIZE) viewStart selected - PAGE_SIZE 1; if(cnt PAGE_SIZE) viewStart 0; // 逐行绘制 for(i 0; i PAGE_SIZE; i) { unsigned char idx subIdx viewStart i; if(viewStart i cnt) { LCD_ShowString(0, i 1, ); LCD_ShowString(0, i 1, ); continue; } if(viewStart i selected) { LCD_ShowString(0, i 1, ▶); LCD_ShowString(1, i 1, menu[idx].text); } else { LCD_ShowString(0, i 1, ); LCD_ShowString(1, i 1, menu[idx].text); } } }这里我用了▶这个符号当光标带字库的12864里内置了这个符号用GB2312内码可以直接显示。如果你的屏幕字库里没有这个符号可以用反白显示实现先正常显示整行字符再用0x04反白显示指令把整行刷成反色。反白的视觉辨识度高但需要额外写一个按行反白区域的函数代码稍长一些。3.3 按键扫描与消抖按键是菜单交互的入口质量直接影响手感。这里我用状态扫描 软件消抖的方式不用延时而是连续采样多次一致才判定有效避免阻塞主循环。unsigned char KeyScan(void) { static unsigned char keyState 0; static unsigned char lastKey 0x00; unsigned char key; key 0; if(KEY_UP 0) key | 0x01; if(KEY_DOWN 0) key | 0x02; if(KEY_OK 0) key | 0x04; if(KEY_BACK 0) key | 0x08; if(key lastKey) { if(keyState 0xFF) keyState; } else { lastKey key; keyState 0; } // 连续采样约10次假设每10ms调用一次认为稳定 if(keyState 10) { keyState 0; return key; } return 0x00; }这个函数的优势是“非阻塞、边沿触发”长按不会重复触发一次按下只返回一次有效键值。我在多个项目里实测了这种消抖策略手感比delay(20ms)后直接判断电平好很多不会出现按一次菜单跳两行的情况。3.4 主循环的调度逻辑主循环不需要复杂的状态机流程就是“扫描按键→按键分发→绘制菜单”。抖动已在按键扫描里处理掉所以主循环里代码很干净void main(void) { unsigned char key; LCD_Init(); MenuDraw(); while(1) { key KeyScan(); if(key 0x01) MenuUp(); if(key 0x02) MenuDown(); if(key 0x04) MenuEnter(); if(key 0x08) MenuBack(); if(key) MenuDraw(); } }MenuUp和MenuDown只改变selected不直接接触屏幕真正的重绘统一收口到MenuDraw。这个设计的好处是以后要改界面风格、切换动画、局部刷新都只动MenuDraw一个函数按键逻辑完全不受影响。4. 滚动与界面优化的几个实用技巧4.1 菜单项超过3条时的软滚动算法12864扣除标题行后只能显示3行菜单项如果某个菜单下超过3个子项不做滚动就只能显示前3个后面的永远看不到。虽然ST7920本身支持硬件垂直滚动指令但硬件滚动是针对整个DDRAM区域的用在菜单上很容易把标题栏也滚走控制起来费劲所以我从来不把硬件滚动用在菜单上——软件滚动虽然要多写几行代码但可控性是最好的。软滚动的核心就是前面MenuDraw里的viewStart变量。它代表“当前窗口显示的第一个菜单项序号”。算法就三句话如果选中项跑到窗口上方selected viewStart窗口就往上跟如果选中项跑到窗口下方selected viewStart PAGE_SIZE窗口就往下跟如果子项总数不足一页窗口始终从0开始。这种“滑动窗口”的效果是选中项永远保持在可见区域内上移、下移时画面整体滚动一格视觉上非常自然也符合绝大多数人使用菜单的习惯。4.2 光标形态与界面布局的小心思菜单光标的样式直接影响操作体验。我测试过三种方案字符光标▶、反白整行、闪烁反白。最终日常用的是▶符号因为它实现简单、不闪烁、且不会因为反白区域计算错误导致半个字花屏。▶符号在ST7920字库里的内码是0x10 0x10直接用LCD_ShowString就能输出。要注意它在显示上只是一个半高字符放在行首会和汉字基线有些视觉偏差所以我通常把菜单文字的X坐标统一从1开始列偏移1个字符位置并在反白或者光标行加一个空格占位保证对齐。如果你希望光标更醒目推荐“反白显示”方案ST7920有一条指令0x04配合0x30基本指令集可以开启整屏反白显示。但具体到某一行区域并没有直接指令可只反白一行需要先读那行的DDRAM内容、做位运算再回写实现成本偏高。所以教学演示和普通产品▶就足够了。4.3 让菜单结构支持“返回栈”而不迷路多级菜单最大的痛点之一是返回逻辑。如果不做记录从“主菜单→系统设置→温度设置”一路按进去返回到主菜单时光标应该落在“系统设置”还是“主菜单”大多数人的期望是“从哪里进来回到哪里再继续”。所以我用一个返回栈来记录进入路径#define STACK_DEPTH 8 unsigned char stack[STACK_DEPTH]; unsigned char stackTop 0; void MenuEnter(void) { unsigned char target menu[current].subIndex selected; if(menu[target].subCount ! 0) { if(stackTop STACK_DEPTH) stack[stackTop] current; current target; selected 0; } else if(menu[target].action ! NULL) { menu[target].action(); } } void MenuBack(void) { if(stackTop 0) { current stack[--stackTop]; selected 0; } }进入子菜单前把当前菜单索引压栈返回时弹栈。这样无论是三层还是五层嵌套返回路径都不会错。栈深度STACK_DEPTH设8对中小型产品菜单树来说足够如果项目菜单层级特别深记得同步调大上限并且注意数组边界溢出问题。4.4 12864自身滚动指令的正确打开方式虽然菜单滚动不推荐用硬件滚动但ST7920的滚动功能在“整屏滚动显示日志、广告语、介绍页面”时非常省事。它的核心是两条指令设置滚动起始行0x24加参数和垂直滚动方向0x18/0x1A再用0x2F开启垂直滚动。// 垂直滚动从第0行开始滚方向如下 LCD_WriteCmd(0x24); // 滚动起始行寄存器 LCD_WriteCmd(0x1A); // 滚动方向向上滚动 LCD_WriteCmd(0x2F); // 开启垂直滚动以我的经验硬件滚动适合开机欢迎页的“整屏文字从底部缓缓升起”效果或者传感器数据的长列表滚动展示。在多级菜单里用了它标题栏会被一起滚走反而不合适。把两种滚动手段分清用途项目才不会越做越乱。5. 常见问题与排查实录5.1 汉字乱码或显示空白这是新手上路遇到最多的问题。如果你用Keil C51或者MDK打开工程源文件默认是ANSI编码GB2312那么代码里直接写的汉字字符串会正确显示。但如果你用VS Code或某些现代编辑器把源文件另存为UTF-8编译出来的内码就变了12864带字库的屏自然显示成一堆乱码或空白。解决办法是在编辑器里把源文件编码强制改回“ANSI”或“GB2312”。对使用CLion、IAR的人要留意编译器字符集设置C51在Keil中直接写GB2312没问题MDK中也可以在编辑器设置里统一编码。另外确认屏的PSB引脚电平。串行模式PSB接地并行模式PSB接高。如果PSB接错屏要么不亮要么显示全随机字符。这个问题我踩过好几次排查优先级排在乱码之前。5.2 按键一次触发多次跳转原因95%是消抖不够彻底。按键从按下到稳定会产生约5~20ms的抖动期如果直接在电平变化时触发MenuUp()一次按下可能被判定成两三次菜单就多跳了几行。解决思路有两个层次第一层就是前面KeyScan里的连续采样消抖保证返回键值前已经稳定第二层是“边沿检测”也就是只有“从无键按下变成有键按下”的那一瞬间才返回键值保持按着不重复触发。我写的KeyScan同时做到了这两点如果你之前的实现是“实时读取电平并判断”赶紧换成这个方案。5.3 菜单跳转错乱或莫名返回顶层这类问题十有八九出在索引维护上。排查思路第一步检查菜单表里subIndex subCount是否超出数组长度。可以用一个断言或者在初始化时遍历一次表把所有子菜单范围算一遍越界直接打印错误。第二步检查返回栈是否溢出栈顶大于深度上限时入栈失败会导致路径丢失。第三步检查自己有没有不小心把menu[current].subIndex selected写成了current selected后者在非根菜单下一定会算错。还有一个隐蔽问题如果你在叶子节点的action函数里修改了全局状态而这个函数内部又调用了MenuDraw那重入时的selected可能已经变了导致绘制错乱。我的建议是所有action函数只负责“业务逻辑”不要在内部直接调用绘制函数重绘统一交回主循环派发。5.4 内存不足与编译报错12864菜单项目通常跑在RAM很小的51单片机上常用STC89C52只有128字节内部RAM。如果菜单表定义成非const数组会被拷贝到RAM100个菜单项可能要吃掉几百字节编译直接报内存溢出。解决方法是给菜单表加const限定符让编译器把表放入code区Flash。const MenuItem menu[] { ... };此外函数指针不要存太多局部变量能省则省菜单文本比较长时可以考虑只存缩写详细说明放在进入后的详情页。对STM32这类Flash大、RAM也大的MCU这些限制不敏感但对51平台的同学们这是实实在在的救命建议。最后再分享一个我自己的小习惯写菜单工程时先把菜单表用枚举定义出每个菜单项的ID再手动画一张“菜单树层次图”贴在代码注释里。别看这个动作简单后期排查跳转问题、加功能时能省一半时间。12864多级菜单这件事本身技术难度不大真正拉开差距的是数据结构和工程习惯。这套表驱动加函数指针的思路换到其他LCD屏、TFT屏甚至串口屏上也一样跑得通值得反复用几年。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门