☰
12864多级菜单设计:用节点数组与状态机实现嵌入式交互
2026/10/16 12:28:04 网站建设 项目流程

简介:面向51单片机开发者的12864多级菜单工程,旨在解决液晶屏人机交互中菜单结构复杂、逻辑难理清的问题,适合正在学习嵌入式界面设计或需要快速搭建按键菜单的读者。工程共53个文件,压缩包仅382KB,涵盖C源程序、头文件、Keil工程、Proteus仿真、Hex固件及菜单效果图等,类型覆盖编程、编译、仿真到烧录验证的完整闭环。作者基于实际实验板编写,菜单层级包含首页、二级与三级子菜单,按键扫描和界面刷新分离,代码便于移植;同时融合DS1302时钟、DS18B20温度采集、ADC0831转换等驱动,方便扩展多功能设备。目前已有2770人学习,配套仿真与截图能直观看到多级菜单跳转、开机Logo和游戏菜单等实际效果,尤其适合单片机爱好者动手改造和二次开发。

1. 12864 多级菜单:把交互逻辑写成一张“菜单树”,新手的第一个状态机

某开发者在做一个温控板,原本用 12864 只显示一行温度,后来想加菜单:主界面按确认进设置,设置里分“温度校准”“蜂鸣器开关”“恢复出厂”,每一级还能返回。他第一版在每个页面里写死跳转,if-else 嵌套到第三层就乱成一团,改一个页面要连带改三处。换成“节点数组”之后,整个菜单框架只有三百多行,再往深加两级也就多写几个数组元素——这就是 12864 做多级菜单最顺手的理由:内建汉字库免去取模,节点数组让跳转变成查表,学习成本集中在“怎么组织数据”,而不是“怎么处理几十个页面”。这篇笔记适合正在学单片机、想给设备加真实交互界面的朋友,按下面的步骤走,下午就能在自己板子上跑出一个三级菜单。

2. 从 ST7920 到菜单框架:硬件驱动与“节点树”设计

2.1 ST7920 的串口时序:三条线的“半个汉字”内码

12864 里最通用的控制器是 ST7920,它自带 GB2312 字库,写“温度”只需要发两个内码字节,不用一张张抠字模。工作模式上我一般用串口:PSB 引脚接地,SCLK、SID、CS 三条线接单片机,加上电源和背光一共七根线,省下来的 IO 正好给按键。

串口写数据要先发一个同步头:连续 5 个逻辑 1,接着是 RS 位(0 写指令、1 写数据),最后才是 8 位数据。ST7920 的数据格式是低位在前,这点和很多 SPI 外设相反,新手第一次翻车基本都翻在这里。下面是一个最常用的底层写函数:

void LCD_WriteByte(uint8_t dat) { uint8_t i; CS_1; SCLK_0; CS_0; // 拉低片选,开始同步传输 for (i = 0; i < 8; i++) { if (dat & 0x01) // 低位在前,逐个送出 SID_1; else SID_0; SCLK_1; // 上升沿,控制器锁存数据 SCLK_0; } CS_1; // 结束一字节传输 } void LCD_WriteCmd(uint8_t cmd) { LCD_WriteByte(0xF8); // 11111 + RS=0 + 0,写指令 LCD_WriteByte(cmd & 0xF0); // 先高四位 LCD_WriteByte(cmd << 4); // 再低四位 } void LCD_WriteData(uint8_t dat) { LCD_WriteByte(0xFA); // 11111 + RS=1 + 0,写数据 LCD_WriteByte(dat & 0xF0); LCD_WriteByte(dat << 4); }

这个函数把所有操作拆成“起止同步帧 + 高低四位”,写指令和写数据只差一个 RS 位。初学时不必纠结为什么分高低四位,只要记住 ST7920 内部是 4 位总线,外部不管是并口还是串口,最后都要拆成两个四位喂进去。参数说明:SID 和 SCLK 用普通 IO 驱动即可,速度不用快,几百 kHz 就足够;如果初始化后屏幕无反应,先检查 PSB 是否确实接地,再检查同步头是不是 5 个 1,这是最常出现的两个点。

有了这个基础,写汉字就顺理成章。汉字内码是两字节,要连续调用两次 LCD_WriteData,中间不能插指令:

void LCD_ShowChinese(uint8_t x, uint8_t y, const char *str) { // x: 0-7,y: 0-3,一个汉字占 16x16 while (*str) { LCD_SetPos(x, y); // 设置当前显示地址 LCD_WriteData(*str++); // 内码高字节 LCD_WriteData(*str++); // 内码低字节,必须紧跟 x++; if (x > 7) { // 一行最多 8 个汉字,超出换行 x = 0; y++; } } }

注意 LCD_WriteData 是连写两次内码,不是写完高字节再设置一次坐标。有人图省事在每字节后面都调用 LCD_SetPos,结果第二个字节被 ST7920 当成新数据存到同一地址,字形直接错乱。LCD_SetPos 的实现也很固定:第二行起始地址是 0x90,第三行是 0x88(从第一列 0x88 往后数),第四行是 0x98,这个地址映射和物理行序对不上,写代码时最好用一个函数封装。

2.2 为什么用“节点数组”组织菜单,而不是一堆 if-else

菜单的本质是树:根节点是主界面,子节点是各级菜单,叶节点是具体功能。新手直觉是用 switch-case 写页面跳转,页面少还好,一旦有“设置 → 高级 → 通信 → 波特率”这种三级以上路径,每加一个页面要改至少三处跳转,维护成本指数上升。

我常用的方式是定义一张静态的菜单节点数组,每个节点描述自己的“身份”和“邻居”:

typedef struct { uint8_t id; // 节点唯一编号 uint8_t parent; // 父节点 ID,根节点为 0xFF uint8_t child; // 第一个子节点 ID,无子节点为 0xFF uint8_t next; // 同一个父级下的下一个兄弟节点 uint8_t prev; // 上一个兄弟节点 const char *text; // 菜单显示文本,GB2312 内码 void (*enter)(void); // 进入该节点时的回调,按确认键触发 } MenuNode; // 定义全局菜单树,数组下标就是节点的“地址” static const MenuNode menu_tree[] = { { 0, 0xFF, 1, 2, 2, "主界面", NULL }, { 1, 0, 3, 0xFF, 0xFF, "温度设置", NULL }, { 2, 0, 4, 0xFF, 1, "系统设置", NULL }, { 3, 1, 0xFF, 0xFF, 0xFF, "温度校准", Enter_TempCal}, { 4, 2, 0xFF, 0xFF, 0xFF, "恢复出厂", Enter_Reset}, };

每个节点用数字索引而不是指针来互相引用。原因有二:一是 const 数组会存放在只读区,如果节点里直接存指针,初始化时各结构体互相引用容易产生“声明顺序”问题;二是数字索引在调试时能直接打印出来,顺着数组查节点内容比解引用指针直观得多。这个结构里没有“层级”字段,层级是靠 parent-child 关系推出来的,所以无论树多深,跳转逻辑都是一样的。

enter回调函数指针是“容易学习”的关键。按下确认键时,如果当前节点有子节点,就跳转到 child;如果是叶节点,就调用 enter(),把菜单框架和实际功能解耦。这种“查表 + 回调”的写法,就是嵌入式菜单的常见做法,不用引入复杂的状态机库。

2.3 绘制一屏菜单:反白光标与坐标定位

菜单显示最朴素但最稳的方案:固定位置画文字,当前选中项反白。12864 在汉字模式下每行 8 个汉字,最多 4 行,所以主菜单最多一次显示 4 项(后面讲滚动,这里先做静态版)。

绘制函数先清屏再逐项画,画当前项时用反白:

void LCD_DrawMenu(const MenuNode *menu, uint8_t currentIndex) { uint8_t i; LCD_Clear(); // 清屏(发 0x01 指令) for (i = 0; i < MENU_MAX_ITEMS; i++) { // 最多 4 项 uint8_t id = menu[currentIndex].id; if (i == currentIndex) { LCD_ShowChinese_Reverse(0, i, menu[id].text); } else { LCD_ShowChinese(0, i, menu[id].text); } } }

实际项目里不会只画当前选中项的兄弟节点,而是需要先拿到“当前节点所在的这一层”。我一般用父节点的 child 指针找到层级起点,再用 next 遍历出整层。反白函数本质是先把该区域逐个像素取反,或者先读回当前显存,再写回反色数据:

void LCD_ShowChinese_Reverse(uint8_t x, uint8_t y, const char *str) { // 先正常显示 LCD_ShowChinese(x, y, str); // 再把 16 * 16 区域逐行取反(读回 + 异或 0xFF) uint8_t row, col; for (row = 0; row < 16; row++) { for (col = 0; col < 2; col++) { uint8_t addr = 0x80 + (y * 0x10) + row; // GDRAM 地址计算 // 读出的数据异或 0xFF 后写回 } } }

这里说一个调试技巧:反白实现前,先只画一个“▶”符号在当前项前面,比如用 ASCII 码 0x10 附近的自定义符号。如果反白逻辑有问题,至少菜单文字还是正常的,不至于整屏花掉。等反白稳定了再换回真正的反白显示,视觉更干净——这是我在某图像处理 Demo 里调出来的血泪经验。

3. 让菜单动起来:按键、光标与多级跳转的代码骨架

3.1 按键扫描与消抖:下降沿才算“一次按下”

按键处理看似简单,但“按一下跳两格”的问题几乎每个初学者都遇到过。根因是把电平状态当成了按键事件:按住期间程序一直检测到低电平,就会连续执行 next。

正确的做法是边沿检测。每次扫描记住上一次状态,只有从“松开”变成“按下”时才算一次有效按键。同时加一个 20ms 的消抖窗口:

#define KEY_UP_PIN P1_0 #define KEY_DOWN_PIN P1_1 #define KEY_ENTER_PIN P1_2 #define KEY_BACK_PIN P1_3 uint8_t Key_Scan(void) { static uint8_t last_state = 0xFF; // 上一次所有按键状态,按位保存 uint8_t cur_state = 0; uint8_t press = 0; uint8_t i; // 读取 4 个按键,按下为 0,松开为 1 cur_state |= (KEY_UP_PIN ? 0x01 : 0); cur_state |= (KEY_DOWN_PIN ? 0x02 : 0); cur_state |= (KEY_ENTER_PIN ? 0x04 : 0); cur_state |= (KEY_BACK_PIN ? 0x08 : 0); delay_ms(20); // 简单消抖 // 再次读取并求“下降沿”:上一次是 1,现在是 0 press = (last_state & ~cur_state); // 过滤掉同时按下的组合键情况,只返回单个按键 for (i = 0; i < 4; i++) { if (press & (1 << i)) { last_state = cur_state; return (1 << i); // 返回按键掩码 } } last_state = cur_state; return 0; // 无按键 }

这段代码里关键的参数是delay_ms(20)。消抖时间太短,机械按键的抖动毛刺会直接触发事件;太长则按键手感发“肉”。20ms 是机械轻触开关的常见平衡点,如果按键很劣质,可以加到 30ms,但不要超过 50ms,否则快速连按会丢按键。last_state 变量必须声明为 static,因为在函数返回后还要保存状态,供下一次扫描比较。

3.2 焦点移动与循环边界:用取模代替 if 判断

当前菜单项在兄弟节点之间移动,最常见的边界问题是“在最上面按上键没反应”。好的交互是循环移动:第一项按上滚到最后一项,最后一项按下滚到第一项。用取模运算写最简洁:

uint8_t menu_next(uint8_t current, uint8_t count) { // current 是当前项在兄弟列表中的序号,count 是兄弟总数 return (current + 1) % count; } uint8_t menu_prev(uint8_t current, uint8_t count) { // C 语言取模对负数不友好,先加上 count 再取模 return (current + count - 1) % count; }

这里有个容易被忽略的细节:menu_prev 里必须写(current + count - 1),不能写(current - 1) % count。因为某些编译器的%对负数返回负值,直接导致数组越界。这个写法不仅简洁,还避免了 if 判断边界,在菜单项多的时候不会出现“卡在底部”的 bug。

有了这两个函数,按键处理就能写成一张统一的事件表:

void Menu_ProcessKey(uint8_t key) { switch (key) { case 0x01: // 上键 currentSibling = menu_prev(currentSibling, siblingCount); LCD_DrawMenu(currentParent, currentSibling); break; case 0x02: // 下键,同上方向相反 currentSibling = menu_next(currentSibling, siblingCount); LCD_DrawMenu(currentParent, currentSibling); break; case 0x04: // 确认键 Menu_Enter(); break; case 0x08: // 返回键 Menu_Back(); break; } }

注意这里把“当前节点”拆成了两个变量:currentParent表示当前层级父节点,currentSibling表示当前在这个层级里选中的是第几个兄弟。这样设计的好处是跳转时只需修改两个变量,绘制函数总是根据这两个值重新计算可见菜单,逻辑单一,不容易出现“页面画对了但状态变量没同步”的问题。

3.3 进入子菜单与返回父级:用 parent 指针回溯

多级菜单的跳转本质上就是改 currentParent 和 currentSibling 两个全局变量。进入子菜单时,把当前选中的节点变成新的父节点,它的 child 作为第一个兄弟;返回时直接用当前节点的 parent 回到上一层。不需要额外的栈。

void Menu_Enter(void) { MenuNode cur = menu_tree[currentParent]; MenuNode sel = menu_tree[GetSiblingId(currentParent, currentSibling)]; if (sel.child != 0xFF) { // 有子节点,进入下一级 uint8_t oldParent = currentParent; currentParent = sel.id; currentSibling = 0; // 默认选中第一个子项 LCD_DrawMenu(currentParent, currentSibling); // 如果需要记住来路,可以在这里把 oldParent 压入数组 } else if (sel.enter != NULL) { sel.enter(); // 叶节点,执行实际功能 } } void Menu_Back(void) { if (currentParent == 0xFF) return; // 已经在根层,不处理 // 直接回到 parent 所在层:父节点的坐标由上一级菜单计算 uint8_t parentId = menu_tree[currentParent].parent; currentParent = parentId; // 把光标定位到之前进入时所在的项 // 如果没有显式记忆,就默认回到第一个 currentSibling = 0; LCD_DrawMenu(currentParent, currentSibling); }

“把 oldParent 压入数组”这句要展开说。多级菜单在返回时如果能记住“我是从哪一项进来的”,体验会好很多,否则每次返回都停在第一项。常见做法是用一个固定大小的数组模拟栈,进入时 push 当前 parent 和 currentSibling,返回时 pop:

uint8_t menu_stack[8][2]; // 8 层嵌套足够,每层存 parent 和 sibling uint8_t stack_top = 0; void Menu_Enter(void) { // 进入子菜单前,先把当前状态压栈 menu_stack[stack_top][0] = currentParent; menu_stack[stack_top][1] = currentSibling; stack_top++; if (stack_top >= 8) stack_top = 0; // 溢出保护,只保留最后 8 层 // 然后切换状态... }

这个栈的容量按菜单最大深度定,一般 8 层绰绰有余。注意数组越界保护:如果用户在调试时频繁进出菜单,栈顶不断增长,没有保护会写入非法地址。溢出后不做复杂处理,直接回绕即可,因为菜单不可能真的嵌套到 8 层以上。

4. 可编辑参数与滚动显示:多级菜单的“生产力”功能

4.1 让菜单项变成“设置项”:左右键直接改值

纯文字菜单只能“看”,不能“改”。真正的设备菜单最终都要落到参数编辑上,比如温度校准、背光亮度、波特率。我的做法是在节点结构体里增加一个“参数指针”和“参数范围”,叶节点的 enter 调用进入编辑模式:

typedef struct { int16_t *value; // 指向要修改的全局变量 int16_t min_value; int16_t max_value; int16_t step; } MenuParam; // 编辑模式下的按键处理 void Param_Edit(MenuParam *param) { uint8_t key; LCD_Clear(); // 显示当前值,格式如 "温度: 25" LCD_ShowChinese(0, 0, "温度校准"); LCD_ShowString(0, 2, "值:"); LCD_ShowInt(2, 2, *(param->value)); while (1) { key = Key_Scan(); if (key == 0x01) { // 上键:加 *(param->value) += param->step; if (*(param->value) > param->max_value) *(param->value) = param->max_value; LCD_ShowInt(2, 2, *(param->value)); } else if (key == 0x02) { // 下键:减 *(param->value) -= param->step; if (*(param->value) < param->min_value) *(param->value) = param->min_value; LCD_ShowInt(2, 2, *(param->value)); } else if (key == 0x04) { // 确认:保存退出 break; } delay_ms(50); } LCD_Clear(); }

这里的关键点是“值直接用指针操作”。菜单框架不关心这个参数具体控制什么,只关心改多大范围,这样菜单和业务逻辑就解耦了。LCD_ShowInt 需要自己写一个整数转字符串的函数,因为 ST7920 字库里没有 ASCII 的完整映射,数字在 12864 上是半角字符,占 8x16,一个汉字位可以塞两个数字。这个细节影响排版,下面的小节展开讲。

4.2 超过 4 个菜单项时的滚动窗口:topIndex 偏移

12864 只有 4 行,但实际菜单往往不止 4 个兄弟节点。比如一个“系统设置”下可能有“通信设置”“显示设置”“存储设置”“按键校准”“恢复出厂”“关于本机”6 项,一次画不下。解决办法是滚动窗口:维护一个 topIndex,代表当前显示窗口的第一项,光标移动时若超出窗口边界,就滚动窗口。

#define MENU_VISIBLE 4 // 屏幕最多显示 4 行汉字菜单 uint8_t topIndex = 0; // 窗口顶部在兄弟列表中的位置 void LCD_DrawScrollingMenu(uint8_t parentId, uint8_t cursorIndex, uint8_t totalCount) { uint8_t i; LCD_Clear(); // 根据光标位置调整窗口,保证光标始终可见 if (cursorIndex < topIndex) { topIndex = cursorIndex; // 光标上移超过窗口 } else if (cursorIndex >= topIndex + MENU_VISIBLE) { topIndex = cursorIndex - MENU_VISIBLE + 1; // 光标下移超出窗口 } // 顶到第一个或最后一个时就不再滚动 if (topIndex + MENU_VISIBLE > totalCount) { topIndex = totalCount - MENU_VISIBLE; } if (topIndex > totalCount) topIndex = 0; // 防御:菜单不足 4 项 for (i = 0; i < MENU_VISIBLE; i++) { if (i + topIndex >= totalCount) break; // 末尾不足 4 项 uint8_t itemId = GetSiblingId(parentId, i + topIndex); if (i + topIndex == cursorIndex) { LCD_ShowChinese_Reverse(0, i, menu_tree[itemId].text); } else { LCD_ShowChinese(0, i, menu_tree[itemId].text); } } }

滚动窗口的核心是“光标在窗口外时才滚动”。如果光标还在窗口内,即使按了上下键也不需要重绘,画面不闪。上面这个实现每次按键都会调用 LCD_Clear 再重画,实际项目优化时可以加一个“是否需要重绘”的判断,只在 topIndex 或 cursorIndex 变化时才调用。

注意边界:totalCount 小于 MENU_VISIBLE 时,topIndex 会调整成一个负值区域。我把topIndex > totalCount 时归零作为防御,避免小数组越界。这是从模拟项目X 的调试经历里记下的坑,当时的菜单只有两项,滚动完再按上键直接乱码到系统重启。

4.3 半角与全角混排:数字、英文字符在 12864 上的宽度陷阱

12864 的汉字模式是 16x16 点阵,一个汉字占一格;ASCII 字符是 8x16 半角,两个 ASCII 才占一个汉字格。菜单里写“波特率 9600”,如果按“波特率”和“9600”分别定位,9600 是 4 个半角字符,正好占 2 个汉字位。如果只留 1 个汉字位,数字就会被硬生生截一半,看起来像乱码。

void LCD_ShowString(uint8_t x, uint8_t y, const char *str) { // x: 汉字列坐标,一个汉字位可放两个英文字母 while (*str) { if (*str < 0x80) { // ASCII 字符 LCD_SetPos(x, y); uint8_t c = *str++; LCD_WriteData(0); // 高字节填 0 LCD_WriteData(c); // 低字节是 ASCII 码 x += 0; // 同一个汉字位内两个字符不增加 x // 这里只写了 ASCII 字符的 8x16 左半边 // 更完善的写法需要再写右半边,或者使用 8x16 模式 } else { // 汉字内码,占一个整列 LCD_SetPos(x, y); LCD_WriteData(*str++); LCD_WriteData(*str++); x++; } } }

上面这段是简化版,真正正确的做法是把 ASCII 当作“半宽字符”处理,连续两个 ASCII 都放在同一个汉字格内,第二个字符写完后再把 x 加 1。很多教程不讲这个,导致菜单里出现“数字叠在一起”或者“数字后面空一大块”的丑排版。我的经验是:在菜单框架里直接禁止混排,要么“温度:25℃”写成“温度”和“25”分别放在两个固定区域,要么在画数字时单独写一个函数让 x 始终以“半格”为单位移动。这样排版最可控,也不会被 ST7920 的地址连续性坑到。

5. 12864 菜单避坑记录:乱码、闪烁与按键抽风

5.1 汉字内码被截断:菜单文字随机错乱

现象:菜单某些项显示成“�”“烫”或半个汉字,重新上电后错乱的项不固定。

原因:ST7920 要求汉字内码一次性连续发送两个字节。如果发送两个内码字节之间执行了其他指令,比如刷新了一个图标、改了一次背光,控制器就把第二个字节当成新的指令或数据,后续显示全错。

解决:把所有“显示字符串”的操作集中到一个函数,函数内部保证两个内码字节紧挨着发送。我一般让 LCD_ShowChinese 内联发送内码,函数里不插入任何延时和状态修改代码。如果确实需要在汉字之间显示动态数字,先全部拼好到一个字符串缓冲区,再一次调显示函数,避免“写一个字、刷一个数字”的交错。

5.2 整屏刷新导致菜单闪烁,像打字机在输出

现象:每次按键,整个 12864 屏幕先白一下再显示新菜单,快速连按时看不清内容。

原因:LCD_DrawMenu 里先 LCD_Clear 再逐字重绘。ST7920 的清屏指令要等约 1.6ms,清屏后屏幕是全白的,相当于每次刷新都给人眼一次“闪白”刺激。

解决:能不整屏清就不清。菜单切换时,只有部分文字变化,可以只更新变化的那几行。做法是把菜单行拆成独立区域,每次重绘前记录一下上次每行显示的文字编号,只有变化的那行才写入新内容。如果只是光标移动,甚至只更新“旧光标行”和“新光标行”两行即可。这个优化做完之后,画面的稳定感会明显提升。

5.3 按下按键一次,菜单连跳五行

现象:手指按一下“下”,光标直接滚到底,或者跳到下一个页签后又弹回来。

原因:基本功不过关——检测的是电平而不是边沿,且没有消抖。机械按键按下时的抖动会在一瞬间产生多个“闭合-断开”脉冲,如果代码在抖动期间反复扫描,每次抖动都当成一次按键。

解决:使用前面 Key_Scan 的边沿检测逻辑。最关键的两点:一是只有“上一次为松开、本次为按下”才触发;二是每次按键处理完成后等待 50ms 再继续扫描,用来吸收抖动尾部。还有一类“长按连发”需求是故意要连跳的,比如按住 1 秒后快速滚动参数,那就需要单独的状态机区分“短按”和“长按”,而不是在普通按键扫描里放大招。

5.4 结构体数组互相引用,编译通过但一运行就跑飞

现象:进入子菜单正常,返回到某个层级时系统直接死机,或者菜单文字全变成“?”。

原因:某些编译器对 const 结构体数组做初始化时,如果结构体里包含指向其他结构体的指针,且数组元素之间互相引用,可能出现“申请地址后重新放置”导致指针失效。我早期在一个小项目里就遇到过——代码逻辑完全正确,但崩溃在节点跳转时。

解决:用索引代替指针,也就是本文的 MenuNode 结构体里保存的是“数组下标”,而不是“MenuNode*”。因为数组元素在编译期就确定地址,且 const 数据不会被搬移,数字下标在任何情况下都不会变成悬垂指针。如果一定要用指针,至少把所有数组元素定义成具名变量,再用取地址的方式填充结构体,不要用“先声明数组再取元素地址”的方式。这个区别在优化等级开高之后尤其明显。

5.5 对比度调到最大,菜单仍然发灰看不清

现象:12864 模块刚上电,能初始化但菜单字体很淡,调整模块上的电位器也不管用。

原因:ST7920 的对比度控制有两级:硬件上由 V0 引脚的电平决定(一般通过滑动变阻器分压),软件上可以用扩展指令 0x24 设置内部 VCR 电压。有些模块的电位器只接了“半电平”,软件里不设置对比度寄存器,VCR 电压太低,字就发灰。

解决:初始化序列里在进入扩展指令集后写对比度寄存器。常见设置是把 0x24 的后面接一个 0x3E(约 62,范围 0-63),这个值对多数模块在常温下看起来最清晰。还要确认 V0 引脚对地电压在 3V 到 4V 之间,低于 3V 显示会很淡,高于 4V 会出现“黑影”(鬼影)。这个值和屏幕尺寸、偏光片类型有关,需要实调一次后写死。

6. 从文字菜单升级到“图标 + 文字”:状态机框架的最后一公里

文字菜单跑通后,你会发现整个框架的核心只是一张表和两个函数:菜单节点表决定“去哪”,按键处理决定“怎么去”。这个状态机的扩展性比想象中强得多,我常做的一个升级是给节点加一个 icon 字段,在文字左边 16x16 像素区域画小图标,右边显示文字。图标不用提前取模,用一块 const 数组按位画即可:

// 一个 16x16 的箭头图标,直接按位取反写进 GDRAM const uint8_t icon_arrow[] = { 0x00, 0x00, 0x7F, 0x00, 0x3F, 0x00, 0x1F, 0x00, 0x0F, 0x00, 0x07, 0x00, 0x03, 0x00, 0x01, 0x00, // 具体内容按需调整 }; void LCD_DrawIcon(uint8_t x, uint8_t y, const uint8_t *icon) { // 按 8x16 为一列写入两个字节,循环写满 16 列 }

图标化之后,菜单占用的“横向空间”变了,文字要从第 1 列开始而不是第 0 列,光标反白区域也要跟着向右移 1 格。这里最容易翻车的是反白函数只处理文字区,导致图标区域永远不反色,视觉下标不在图标上。把反白逻辑单独封装成一个矩形区域函数,图标和文字共用这个区域,改起来就一次到位。

验证方法我一直习惯用“纸上推演 + 板级断点”。先把菜单树画在纸上,标出每个节点的 parent 和 child 关系,然后从根节点开始,一步一步模拟按键:上、下、确认、返回、再进入,每一步在纸上画一个箭头,并检查当前 parent 和 sibling 是否和预期一致。这套方法能筛掉大部分逻辑错误,尤其适合新手没有调试器时使用。板级验证则是在 Menu_Enter 和 Menu_Back 的入口处放一个延时或翻转 LED,确认跳转路径确实走了预期分支。

这个框架我曾经在模拟项目X 的多个小屏设备里复用:12864、1602、甚至 0.96 寸 OLED,核心节点表结构一模一样,只改底层的 LCD 绘制函数。做过一遍之后,再遇到其他屏幕不过是换个驱动。对我来说最受益的一个习惯是“永远让菜单的当前状态集中在几个变量里”,不要散落在一堆页面标志位中,这样任何时候排查跳转问题,只需要看这几个变量的变化。希望帮到你,动手先把第一个三级菜单跑起来,剩下的坑基本都是甜的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询