尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32驱动12864多级菜单:表驱动状态机框架与完整源码解析

STM32驱动12864多级菜单:表驱动状态机框架与完整源码解析 简介本资源是一份面向嵌入式初学者与中级开发者的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引脚说明CSPB12片选低电平有效SIDMOSIPB15数据线SCLKPB13时钟线A0RS/DCPB10数据/命令选择RSTPB11复位低电平复位BLK3.3V背光串电阻可调节亮度VDD3.3V电源VSSGND地ST7565的数据命令引脚叫法比较多有的板子上标A0有的标RS有的标DC都是一个意思——高电平写数据低电平写命令。RST引脚可以通过RC电路实现上电复位但用单片机引脚控制更可靠尤其调试时方便软件复位。1.3 为什么不用FSMC和硬件SPISTM32F103有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主菜单-130NULL1系统设置040openSettings2参数校准020openCalibration3数据查看030openDataView每次按键后只要更新当前节点的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个点为一个字节然后下一列继续每行显示数1616x16的汉字取模后是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字符字体8x16lcdDispChinese(x, y, str, font_size)显示汉字字符串lcdDispString(x, y, str)综合显示中英文混合字符串lcdDispChinese这个函数需要注意中英文混合显示GB2312编码下汉字每个字符占两个字节两个字节都大于0x7FASCII是单字节小于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.h16x16汉字字模、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是一对关键组合。有些屏幕是A0C8有些是A1C8设置反了会出现上下镜像或者左右镜像的诡异现象。屏幕型号不同镜像方向可能不同如果发现镜像就改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只有20KB16x16汉字的临时缓冲就要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秒无操作关背光的逻辑仅仅用了一个定时器计数变量侵入性非常小。这套多级菜单框架我前后在三个不同项目上用过每次改动的都只是菜单表里的数据和回调函数核心的菜单状态机、按键分发、刷新逻辑基本没动过。按我自己的体会最值的投入就是在数据结构设计阶段——先把菜单层级画清楚再动手写代码会少走一半弯路。最后分享一个小技巧开发初期把菜单表和字模文件多分几个文件编译的时候模块独立遇到问题也好定位等稳定了再考虑优化合并调试体验会好很多。本文还有配套的精品资源点击获取
返回列表