简介:本资源是一份面向嵌入式初学者与中级开发者的STM32人机交互实战项目,聚焦STM32F103微控制器驱动12864点阵LCD并实现多级菜单系统的核心能力训练,解决工业控制、智能家居等场景中图形界面开发入门难、代码整合度低的问题。压缩包为RAR格式,大小1.64MB,包含完整可编译工程源码(含Keil MDK项目文件、C源文件、头文件及LCD底层驱动模块),涵盖LCD初始化、SPI通信配置、字符/图形显示函数、树形菜单逻辑、按键扫描与消抖处理等关键实现。已有4001人学习下载,适合通过动手调试深入理解STM32外设协同、状态机菜单设计及嵌入式GUI基础架构。读者可直接烧录运行,观察多级菜单导航效果,并基于现有结构快速扩展功能模块,如参数设置页、实时数据显示区或图标化界面优化。 之前给一台设备做功能升级,面板上原本是4位数码管加两个按键,功能一多,光靠数码管已经没法把信息呈现清楚了。换屏是肯定的,一开始想直接上TFT彩屏,后来评估了一下成本、功耗和实际需求,还是选了12864点阵LCD。原以为难点在屏幕驱动,写了一天点亮了,真正折磨人的是多级菜单——菜单一旦超过两层,代码就开始失控,到处都是if else套if else,加一个子菜单就要改一堆逻辑。
这篇博文就把这套基于STM32F103的12864多级菜单完整方案整理出来,包括硬件接线、菜单状态机设计、数据结构、按键处理、LCD驱动细节,以及完整的源码解析。整个方案核心是一个可配置的菜单表框架,菜单层级可以自由扩展,不用改主逻辑,只要往表里加节点就行。适合正在学STM32、需要给项目加人机交互界面的朋友,也适合想搞明白“多级菜单到底怎么写才不乱”的开发者参考。
1. 驱动12864之前,先搞清楚这个屏的性格
1.1 12864的几个版本:带字库、不带字库、串行并行
12864这个屏,市面上能买到的其实有好几个版本,长得差不多,驱动芯片却不一样,写代码的方式也完全不同。最常见的两类是ST7920和ST7565/UC1701,前者多半带中文字库,接口支持并行和串行;后者通常不带字库,纯点阵驱动,想显示汉字必须自己做字模。
带字库的ST7920好处是显示中文方便,直接给GB2312编码就能出汉字,但如果要自定义图形界面、画曲线、做大图标,它的指令系统反而别扭。不带字库的版本更接近“裸点阵”,每个像素都能自由控制,适合做菜单、波形、动画这类内容——代价是每个汉字都要取模,用字模数据拼出来。
接口模式也是个大问题。并行模式(接DB0~DB7加RS、RW、E)速度快,但占引脚多,F103引脚多倒还好,问题是一堆杜邦线接起来非常痛苦,特别是插拔几次后虚焊、接触不良就是灾难。串行模式(CS、SID、SCLK三根线)速度稍慢,但接线少、稳定,对菜单这种刷新频率要求不高的场景完全够用。
1.2 我最终选定的硬件方案与接线表
这套方案用的是不带字库的12864,驱动芯片是ST7565,串行SPI接口方式。引脚分配上尽量绕开了JTAG占用的PB3、PB4,避免调试时出幺蛾子。实际接线是这样的:
| 屏幕引脚 | STM32F103引脚 | 说明 |
|---|---|---|
| CS | PB12 | 片选,低电平有效 |
| SID(MOSI) | PB15 | 数据线 |
| SCLK | PB13 | 时钟线 |
| A0(RS/DC) | PB10 | 数据/命令选择 |
| RST | PB11 | 复位,低电平复位 |
| BLK | 3.3V | 背光,串电阻可调节亮度 |
| VDD | 3.3V | 电源 |
| VSS | GND | 地 |
ST7565的数据命令引脚叫法比较多,有的板子上标A0,有的标RS,有的标DC,都是一个意思——高电平写数据,低电平写命令。RST引脚可以通过RC电路实现上电复位,但用单片机引脚控制更可靠,尤其调试时方便软件复位。
1.3 为什么不用FSMC和硬件SPI
STM32F103有FSMC,可以无缝驱动并口TFT和并口12864,速度非常快,但那是给大屏、高刷新率场景准备的。12864本身响应速度一般,像素才128x64,刷新率需求远低于TFT彩屏,用FSMC属于“杀鸡用牛刀”。
硬件SPI其实也可以用,但会遇到一个麻烦:ST7565串行协议的时钟极性和相位跟标准SPI模式0不完全一样,虽然能用但容易踩坑,而且硬件SPI的引脚是固定的(比如SPI2的SCK在PB13、MOSI在PB15),布局不够灵活。软件模拟SPI时钟频率拉低,但对12864来说毫无压力,而且GPIO想接哪个脚就接哪个脚,布线和后续改版都更灵活。实测下来,软件模拟SPI驱动12864完全不会卡顿。
2. 多级菜单的本质,是一个状态机
2.1 菜单系统在技术上到底难在哪
菜单这玩意儿,看着简单——不就是按上下键移动光标、按确认进子菜单、按返回回上一级吗?真正写起来就发现,如果每个菜单页都写一套独立的显示和按键处理函数,代码会膨胀得非常快。比如主菜单有3个选项,每个选项下有3个子菜单,子菜单下可能还有参数设置页,随便一算就是十几个界面,每个界面都要处理按键、刷新屏幕、更新状态,写到最后自己都记不清哪个状态对应哪个变量。
更坑的是,菜单层级深了以后,返回操作要能准确回到上一层菜单且恢复之前的选中位置。如果只用一个全局变量记当前菜单索引,返回就会丢失上下文。这些本质问题归纳起来就一句话:菜单系统是一个典型的状态机,需要一套统一的状态管理机制,而不是散落的if else。
2.2 菜单数据结构的“表驱动”设计
一开始我走了弯路,用switch case区分所有页面,写了两百多行就开始晕。后来彻底推倒,换成“表驱动”的方式:把每个菜单项抽象成一张表,表里记录菜单文字、当前项选中索引、父节点、子节点指针、以及进入该菜单时的处理函数。一个简单的结构体是这样:
typedef struct MenuItem { const char* text; // 菜单项显示文字 int parentId; // 父菜单ID,-1表示根菜单 int childCount; // 子菜单项个数 int currentIndex; // 当前选中的子项索引 void (*onEnter)(void); // 进入该菜单时的回调 void (*onExit)(void); // 退出该菜单时的回调 const struct MenuItem* child; // 指向子菜单表(实际用数组实现) } MenuItem;这里的核心思想是:菜单本身不需要被放在一个固定层级里,每个节点知道自己属于谁、有多少子项、当前选到哪儿了。菜单跳转就是在一个总表里根据ID找到对应节点,然后改变当前显示状态的过程。所有页面的按键逻辑变成同一套代码,区别只是当前节点不同。
用表格形式来看,这套设计维护起来非常直观:
| 菜单ID | 菜单文字 | 父ID | 子项数 | 当前选中 | 回调函数 |
|---|---|---|---|---|---|
| 0 | 主菜单 | -1 | 3 | 0 | NULL |
| 1 | 系统设置 | 0 | 4 | 0 | openSettings |
| 2 | 参数校准 | 0 | 2 | 0 | openCalibration |
| 3 | 数据查看 | 0 | 3 | 0 | openDataView |
每次按键后,只要更新当前节点的currentIndex,或者根据按键事件切换到父节点/子节点,菜单状态自然就迁移了。这也是整个多级菜单框架最关键的一步:无论菜单多深,状态迁移的代码只有一份。
2.3 状态迁移与栈回溯:怎么保证返回不乱
多级菜单返回有个经典bug:从“设置-第4项”返回主菜单后,主菜单光标永远停在第一项,用户每次进入都要重新往下翻,体验很糟糕。我的方案是在每个菜单节点内保存currentIndex,这个值记录最近一次离开该菜单时用户选中了哪一项。这样返回时直接恢复,就是回到用户离开时的上下文。
比如进入“主菜单-系统设置-通信参数”,此刻系统设置节点的currentIndex应该从0变成2(假设通信参数是第三项)。退出通信参数回到系统设置时,读取系统设置节点的currentIndex,就能看到上次选中的是第3项。要实现这个,关键是在菜单切换发生前,把当前的currentIndex写回到当前节点的结构体里。
void menuGoBack(void) { MenuItem* current = getCurrentMenuItem(); current->currentIndex = currentSelectedIndex; int parentId = current->parentId; if (parentId == -1) { // 已经是最顶层,无法再返回 return; } setCurrentMenuItem(parentId); currentSelectedIndex = getCurrentMenuItem()->currentIndex; refreshMenu(); }这种方式比维护一个独立的“返回栈”简单可靠。每个节点的currentIndex天然就是“历史记录”,不需要额外分配栈空间,也不容易出现栈溢出。
3. 按键输入与事件分发:别让按键裸奔
3.1 按键扫描与防抖的几种方法
多级菜单成不成熟,很大程度取决于按键响应。按键处理常见方案是轮询、外部中断、定时器扫描三种。轮询最简单,但主循环一旦被占用就会丢按键;外部中断最灵敏,但按键抖动会在中断里反复触发,需要额外的防抖处理,而且每个按键都要占用一个EXTI线,资源占用大。
我用的方案是定时器中断扫描:定时器每5ms扫一次按键GPIO,记录按键状态变化,并维护一个“去抖计数器”。只有连续N次扫描都读到同一个电平,才认为按键状态真的变了。这样既省CPU,又天然兼容了防抖逻辑。
void keyScanTimer_IRQHandler(void) { static uint8_t keyBuf = 0xFF; uint8_t keyStatus = readKeyGPIO(); // 读取按键电平,按下为0 keyBuf = (keyBuf << 1) | (keyStatus & 0x01); if ((keyBuf & 0x07) == 0x07) { // 连续3次读到高电平,确认无按键按下 } else if ((keyBuf & 0x07) == 0x00) { // 连续3次读到低电平,确认按键被按下 keyEvent |= KEY_UP; // 例:UP键按下 } }去抖计数取3次,对应约15ms的防抖窗口,效果在机械按键上非常稳。这个方案不会在按键抖动时误触发,也不会让按键响应特别迟滞。
3.2 按键短按与长按的区分
菜单里经常需要区分短按和长按:短按翻页、长按返回或快速滚动。区分方法是在按键按下时记录按下时间,按下的时间超过阈值(比如500ms)就认为是长按。
具体实现可以用状态机:检测到按下后进入“等待长按”状态,计时器开始计数。如果计数期间按键释放,说明是短按,触发确认事件;如果计数值超过阈值,触发长按事件,然后把状态切到“长按已触发”,避免重复触发。菜单还可以配合“按键重复”功能,长按上下键连续滚动,这在调整数值时特别实用——每次短按加1,长按则每100ms加1,调整效率高得多。
3.3 事件分发:把按键动作转为菜单动作
按键扫描拿到的是“KEY_UP”“KEY_DOWN”“KEY_CONFIRM”“KEY_BACK”这些原始事件,但菜单系统不应该直接跟按键GPIO耦合。现在把按键事件抽象成“向上移动”“向下移动”“确认”“返回”四个指令,再把指令分发给当前菜单节点。
这种设计最大的好处是按键映射和菜单逻辑完全解耦。如果产品改成触摸屏,只需要把触摸事件翻译成这四个指令,菜单核心代码一行都不用改。同理,如果把上下键改成旋钮编码器,在编码器驱动里产生一样的指令即可。事件分发代码如下:
void handleUserInput(uint8_t keyEvent) { MenuItem* current = getCurrentMenuItem(); switch (keyEvent) { case KEY_UP: if (currentSelectedIndex > 0) { currentSelectedIndex--; refreshMenu(); } break; case KEY_DOWN: if (currentSelectedIndex < current->childCount - 1) { currentSelectedIndex++; refreshMenu(); } break; case KEY_CONFIRM: menuEnterChild(currentSelectedIndex); break; case KEY_BACK: menuGoBack(); break; } }注意KEY_CONFIRM是进入子菜单,但如果当前选中项是一个设置了onEnter回调的“叶子动作”(比如“读取温度”),那就不进入子菜单,而是直接执行onEnter回调。这里用onEnter是否存在来判断即可。
4. 12864驱动核心:汉字显示与菜单绘制优化的实现细节
4.1 不带字库屏的字模制作
ST7565这种不带字库的屏,所有显示内容本质上都是点阵数据。汉字显示需要先把字转成点阵数组。常用的取模软件是PCtoLCD2002,界面复古但功能完全够。
取模参数设置很关键,设错了显示出来就是乱的。我的经验设置如下:
- 点阵格式:阴码(有笔画的地方是1,没笔画的地方是0)
- 取模走向:列行式(也叫逐列式)
- 取模走向详解:先从左上角开始,向下取8个点为一个字节,然后下一列继续
- 每行显示数:16(16x16的汉字取模后是32个字节)
以16x16汉字为例,取模结果是32字节数组,前16字节是左半边的16列,后16字节是右半边的16列。写入屏幕时,按列写入到对应位置即可。ASCII字符更简单,8x16的字符取模后是16字节,一列一个字节。
// 16x16汉字字模示例,这里用"菜"字的前8字节做示意 static const unsigned char code_font_cai[] = { 0x01, 0x00, 0x01, 0x04, 0x7F, 0xFE, 0x01, 0x00, // ... 后面还有24字节 };4.2 LCD核心绘图函数:清屏、画点、字符串、汉字
12864驱动的最底层是向ST7565发命令和数据的函数,一切绘图都建立在画点的基础上。ST7565是列扫描方式,一列有64个像素,分成8页(每页8个像素),所以“画一个点”其实就是把该点所在列的8字节中找到对应的页,置位对应的位。
基础驱动函数我建议封装成这几个:
- lcdInit():初始化屏幕,设置偏压、ADC方向、显示开关等
- lcdClear():整屏清空
- lcdDrawPoint(x, y, color):绘制或清除指定像素点
- lcdDispChar(x, y, ch):显示ASCII字符,字体8x16
- lcdDispChinese(x, y, str, font_size):显示汉字字符串
- lcdDispString(x, y, str):综合显示中英文混合字符串
lcdDispChinese这个函数需要注意中英文混合显示:GB2312编码下,汉字每个字符占两个字节,两个字节都大于0x7F;ASCII是单字节小于0x7F。解析字符串时逐个字节判断,就可以自动区分汉字和英文。
void lcdDispString(uint8_t x, uint8_t y, const char* str) { while (*str) { if ((uint8_t)*str < 0x80) { // ASCII字符 lcdDispChar(x, y, *str); x += 8; str++; } else { // 汉字,两个字节 lcdDispChinese(x, y, (const uint8_t*)str); x += 16; str += 2; } } }4.3 菜单界面的绘制与刷新优化
最初实现菜单刷新时,整屏清空再全部重画,屏幕会明显闪烁。这在12864上特别突出,因为点阵屏的响应速度不如TFT,而且串行接口一帧128x64的数据要写1KB左右,整体重画时间几十毫秒,肉眼可见。
后来改成按需刷新,闪烁问题基本消失。思路是:
- 菜单框架变化时(移动光标、切换页面),只重画变化区域
- 光标移动只擦除旧光标、绘制新光标,不动其他内容
- 进入新页面时才全屏清空然后绘制一次
比如绘制菜单列表时,最多显示4行汉字(12864高度64像素,一行16像素),当前页面最多几十个汉字的字形变化。把变化区域裁剪出来,重画的数据量减少到原来的1/10,刷新速度明显提升。
void drawMenuItem(int index, int selected) { int y = menuStartY + index * 16; // 清除该行 lcdClearArea(0, y, 128, 16); if (selected) { // 在行首画一个三角形箭头指示当前位置 drawTriangleArrow(0, y + 4); } // 显示菜单文字 lcdDispStringM(12, y, menuItemText(index)); }5. 完整源码与工程结构解析
5.1 工程文件划分
整套源码按模块拆分,文件结构清晰,方便移植:
| 文件名 | 职责 |
|---|---|
| main.c | 系统初始化、主循环 |
| lcd12864.c / lcd12864.h | 底层GPIO模拟SPI、ST7565命令、画点、清屏、字符串 |
| menu.c / menu.h | 菜单表数据结构、状态迁移、菜单绘制 |
| key.c / key.h | 按键扫描、防抖、事件产生 |
| font.c / font.h | 16x16汉字字模、8x16英文/数字字模 |
5.2 LCD初始化序列
ST7565初始化时序有严格要求,初始化顺序错了可能直接黑屏或花屏。我的初始化序列如下,实测稳定:
void lcdInit(void) { // 硬件复位 LCD_RST_L(); delay_ms(10); LCD_RST_H(); delay_ms(10); lcdWriteCmd(0xE2); // 内部复位 delay_ms(100); lcdWriteCmd(0xA2); // 偏压比1/9 lcdWriteCmd(0xA0); // 列方向:正向 lcdWriteCmd(0xC8); // 行方向:反向(配合A0使用,不然镜像) lcdWriteCmd(0xA6); // 正常显示,不反白 lcdWriteCmd(0x2F); // 内部电源:全部打开 lcdWriteCmd(0x26); // 粗调对比度 lcdWriteCmd(0x81); // 微调对比度命令 lcdWriteCmd(0x17); // 微调对比度值,可调整 lcdWriteCmd(0xAF); // 开显示 lcdClear(); lcdSetCursor(0, 0); }这里的0xA0和0xC8是一对关键组合。有些屏幕是A0+C8,有些是A1+C8,设置反了会出现上下镜像或者左右镜像的诡异现象。屏幕型号不同,镜像方向可能不同,如果发现镜像就改A0/A1,基本能解决。
5.3 菜单核心逻辑的代码走读
菜单构建的核心是一个全局菜单表,表里按顺序排列所有页面。每个页面是一个“节点”,页面里的每一行是一个菜单项。节点之间通过child指针级联:
// 叶子菜单:真正执行动作的节点 void openTemperature(void) { // 显示温度读数 lcdClear(); lcdDispStringM(30, 2, "温度:"); // ... } // 菜单表定义 static MenuItem menuRoot[] = { {"系统设置", -1, 3, 0, NULL, NULL, menuSettings}, {"参数校准", -1, 2, 0, NULL, NULL, menuCalibration}, {"数据查看", -1, 3, 0, NULL, NULL, menuDataView}, }; static MenuItem menuSettings[] = { {"通信设置", 0, 2, 0, NULL, NULL, menuCommSettings}, {"屏幕设置", 0, 2, 0, NULL, NULL, menuDisplaySettings}, {"恢复默认", 0, 0, 0, factoryReset, NULL, NULL}, }; static MenuItem menuCommSettings[] = { {"波特率", 1, 4, 0, NULL, NULL, menuBaudrate}, {"站号", 1, 0, 0, NULL, NULL, NULL}, };注意菜单表中每个子菜单的第一个字段(MenuItem* child)指向的是子菜单的数组首地址。这里用的静态数组,不是动态分配内存,所以菜单不会产生内存碎片,对F103这种小内存MCU非常友好。缺点是菜单表是静态的,运行时不能动态增删菜单项——对绝大多数嵌入式产品来说这根本不是问题,菜单结构在编译期就确定了。
5.4 主循环流程
main函数里的主循环非常薄,初始化完成后就是不断扫描按键事件并分发:
int main(void) { delay_init(); lcdInit(); keyInit(); menuInit(); lcdClear(); drawMainMenu(); while (1) { uint8_t evt = keyGetEvent(); if (evt) { handleUserInput(evt); } // 其他周期任务,比如传感器采集、看门狗喂狗 taskProcess(); } }这种结构下,项目后期加入传感器采集、功耗管理、通信任务,都不会跟菜单逻辑产生冲突。菜单的核心代码只依赖按键事件和LCD驱动,不需要知道其他模块的细节,耦合度很低。
6. 实测踩坑记录:代码能编译通过不代表能跑
6.1 坑一:GPIO引脚模式配置错误导致屏幕无反应
ST7565串行接口,SID和SCLK都是输出,但一开始我把它们配置成推挽输出后,屏幕完全没有响应。排查了好一会儿才意识到,问题出在调试器——SWD调试占用了部分引脚,而且SPI从设备对信号质量敏感,用杜邦线连接的话,走线长、干扰大,推挽输出强驱动在某些情况下反而会引入振铃。
后来把所有控制引脚配置为推挽输出且设置输出速度为2MHz(低速),并且尽量缩短杜邦线长度,问题解决。实际上ST7565对GPIO速度要求不高,根本不需要50MHz的高速模式。顺便提醒,CS、RST这些引脚千万不要漏配置,有些引脚默认是输入状态,SCLK还带内部上拉/下拉时,初始化时序会莫名其妙失败。
6.2 坑二:菜单表里的节点被修改后“迷路”
调试中间遇到一个非常妖的问题:进入子菜单后再按返回,有时会跳到完全不相关的菜单页面。查了半天,最后把菜单表在RAM中的地址dump出来才发现,子菜单的child指针被覆盖了。根本原因是在某个回调函数里定义了很大的局部数组,导致栈溢出,把相邻的菜单表数据踩了。
这其实暴露了一个问题:整个菜单表如果放在普通数组里,初始化和访问都可能出错。解决方案是在编译层面把菜单表定义成const只读常量,放在Flash区域:
static const MenuItem menuRoot[] = {...}; static const MenuItem menuSettings[] = {...};这样菜单表数据只读,子菜单指针指向的地址也在Flash区,任何写操作都会触发hardfault而不是悄悄破坏内存。另外回调函数里尽量少用超大局部数组,F103的RAM只有20KB,16x16汉字的临时缓冲就要1KB,多来几层嵌套就危险了。
6.3 坑三:中文字模显示成乱码
新项目里遇到一个情况:同一个字模数组,在A项目显示正常,拿到B项目就乱码。最后发现是两边的取模参数不一致——A项目用的列行式,B项目用的行列式(也叫逐行式)。这两个取模方向,在显示函数里的索引方式完全不同。
所以强烈建议:固定一套取模参数,并把参数写在字库文件的头文件注释里。我的字库文件统一备注:点阵格式阴码、取模走向列行式、16x16汉字用列行式每行16列、字符8x16用列行式每行8列。团队协作或代码拷贝时,这个备注能省下大量排查时间。
还有一个隐藏细节:ST7565的页地址和列地址,写入字模数据时要按“先列后页”的顺序操作。如果你把字模数据按行取模,写入时就要按行扫描方式处理,否则显示出来的汉字是“躺倒”的。
6.4 坑四:初始化时序不满足导致花屏或黑屏
ST7565对复位和上电时序比较敏感。一开始为了节省时间,在初始化前只delay了1ms,结果屏幕有概率花屏——大概10次中有2-3次。翻了芯片手册才注意到,电源稳定后至少需要等待一定时间才能发送初始化命令。
最终我做出稳定方案:上电后delay 10ms让电源稳定,再拉低RST保持10ms以上,释放RST后再delay 10ms,然后发送初始化序列。初始化命令中的0xE2内部复位指令之后,也要等待足够时间再发后续命令。这套流程实测下来无论冷启动还是按复位键,都能稳定显示,没有再出现花屏。
7. 这个框架后续还能怎么扩展
7.1 增加图标与二级子页面
菜单表已经支持了基础的文字导航,但如果想打造更直观的界面,可以给每个菜单节点增加一个图标索引,在绘制时把16x16小图标显示在文字左侧。方法是在MenuItem结构体里增加一个icon字段:
typedef struct MenuItem { // ... 原有字段 uint8_t icon; // 图标索引 } MenuItem;图标字模放进font.c里的一个图标表,绘制菜单行时先画图标,再画文字。这样主菜单可以变成“设置图标+设置文字”、“校准图标+校准文字”的视觉效果,一下就有产品味了。12864可以显示4行汉字图标,如果做横向排列的话每行可以放7-8个16x16图标,图标菜单模式也完全可行。
7.2 参数编辑与数值调整
多级菜单最终绕不开参数设置。比如设置波特率、设置阈值、校准偏移量。这套框架里,把参数编辑界面也当成一个“页面”,进入后启用“编辑模式”。编辑模式下,上下键变成加减数值,确认键保存并退出,返回键取消并退出。
数值调整建议做“加速度”效果:短按一次加减1,长按超过1秒后,每100ms自动加减1,超过3秒后每20ms自动加减10。这样既能精确调整,又能快速跨越较大的数值范围。实现思路是在按键模块里维护一个“长按重复触发”的计时器,事件循环里周期性地把KEY_UP/KEY_DOWN发给当前页面。
void editModeProcess(uint8_t evt) { if (evt == KEY_UP) { if (currentParam->value < currentParam->max) currentParam->value++; } else if (evt == KEY_DOWN) { if (currentParam->value > currentParam->min) currentParam->value--; } // 刷新当前数值显示 refreshValueDisplay(); }7.3 与RTOS和低功耗模式的配合
如果项目后续引入了RTOS(比如FreeRTOS),这套菜单框架可以直接跑在独立任务里。按键事件可以改成队列或信号量方式,从定时器中断里post消息,菜单任务再阻塞等待消息。主循环从轮询改成阻塞等待之后,CPU空闲时间变多,方便进入低功耗模式。
低功耗场景下,可以只在需要操作时打开LCD背光,无操作一段时间后自动关背光、进入睡眠。菜单状态机因为已经有清晰的“当前节点+当前索引”,唤醒后重新绘制当前页面即可,不会出现状态丢失。这部分我后来在另一个低功耗采集设备上实践过,基于这套框架加了一个10秒无操作关背光的逻辑,仅仅用了一个定时器计数变量,侵入性非常小。
这套多级菜单框架我前后在三个不同项目上用过,每次改动的都只是菜单表里的数据和回调函数,核心的菜单状态机、按键分发、刷新逻辑基本没动过。按我自己的体会,最值的投入就是在数据结构设计阶段——先把菜单层级画清楚,再动手写代码,会少走一半弯路。最后分享一个小技巧:开发初期把菜单表和字模文件多分几个文件,编译的时候模块独立,遇到问题也好定位;等稳定了再考虑优化合并,调试体验会好很多。
本文还有配套的精品资源,点击获取