STM32F407按键驱动LCD多级菜单:数据结构、消抖与局部刷新

发布时间:2026/10/14 18:10:09
STM32F407按键驱动LCD多级菜单:数据结构、消抖与局部刷新
简介这份资源面向嵌入式开发初学者与进阶工程师聚焦STM32F407平台下按键输入与12864液晶屏联动的多级菜单实现可应用于智能家居面板、工业控制界面、车载信息终端等场景。压缩包共110个文件以50个c源文件与50个h头文件为主体覆盖外设驱动、菜单逻辑与工程配置另含hex烧录文件、uvprojx工程文件、s汇编文件及bat批处理脚本整体约621KB结构完整便于直接编译与二次开发。资源围绕按键消抖检测、菜单树形结构定义、LCD界面渲染、上下左右导航与确认返回逻辑、功能函数回调等环节展开读者可借此掌握从GPIO读取到菜单状态机切换的完整链路并理解SPI/I2C驱动12864屏的命令与数据传输方式。目前已有2109人学习下载适合作为嵌入式UI设计与外设综合练习的参考工程。1. 按键加 LCD 多级菜单为什么它是嵌入式 UI 的必修课很多人第一次在 STM32F407 上做带屏交互都会卡在同一个地方屏幕能点亮字也能显示但一旦要「按一下进子菜单、再按一下改参数、长按返回」代码立刻变成一团乱麻。这个标题讲的正是这件事——用按键输入驱动 LCD 显示一套多级菜单系统。它解决的不是「怎么点亮屏幕」而是「怎么把状态、输入、显示三者组织成可维护的结构」。适合已经跑通 GPIO 和 FSMC 或 SPI 屏驱动、准备做产品交互界面的嵌入式开发者。我见过太多项目把菜单逻辑写成几百行 switch 嵌套改一个选项就要动全身最后没人敢碰。这篇就把这套东西从数据结构到按键消抖、从页面刷新到参数落盘一层层拆开讲清楚。2. 菜单结构怎么设计从链表到状态机的选型对比菜单系统的核心矛盾是界面是树状的但单片机的执行是线性的。你得选一种方式把「树」映射到「顺序执行」上。常见做法有三种我一般会先看菜单深度和选项数量再决定。2.1 三种主流菜单数据结构对比方案结构优点缺点适用场景二维数组行页面列选项查表快RAM 占用固定增删页面要改数组不灵活菜单固定、选项少链表每个节点存父子兄弟指针动态增删结构清晰指针操作易出错RAM 碎片菜单层级深、需动态状态机表状态号转移表逻辑与数据分离易测试前期设计成本高中大型交互系统对 STM32F407 这种有 192KB SRAM 的芯片如果菜单不超过 5 层、每层不超过 10 项我推荐「结构体数组 索引跳转」的折中方案。它比纯链表好调试比二维数组灵活。2.2 用结构体数组定义菜单节点先定义菜单项的类型和节点结构。每个节点知道自己是谁、父节点是谁、按下确认键跳到哪里。// menu.h typedef enum { ITEM_ACTION, // 执行动作如切换开关 ITEM_SUBMENU, // 进入子菜单 ITEM_VALUE, // 可调参数如亮度 ITEM_BACK // 返回上级 } ItemType; typedef struct MenuNode { const char *title; // 显示文本 ItemType type; // 节点类型 int parent; // 父节点索引-1 表示根 int firstChild; // 首个子节点索引-1 表示无 int nextSibling; // 下一个兄弟节点索引 void (*action)(void); // 动作回调可为 NULL int *valuePtr; // 参数指针可为 NULL int valueMin; // 参数下限 int valueMax; // 参数上限 } MenuNode;逻辑说明parent、firstChild、nextSibling三个索引把树结构压进一维数组避免了指针。action用函数指针实现「选中即执行」valuePtr让参数调节直接改内存里的变量不用额外映射。参数说明valueMin和valueMax只在ITEM_VALUE类型下有效用来限制调节范围防止越界。2.3 菜单树的初始化与索引规划数组定义时索引顺序建议按「根 → 第一子 → 第一子的子 → 兄弟」的深度优先顺序排这样firstChild和nextSibling填起来不容易错。// menu.c int g_brightness 50; int g_volume 30; MenuNode g_menu[] { // 索引 0根菜单 {主菜单, ITEM_SUBMENU, -1, 1, -1, NULL, NULL, 0, 0}, // 索引 1显示设置 {显示设置, ITEM_SUBMENU, 0, 2, 5, NULL, NULL, 0, 0}, // 索引 2亮度 {亮度, ITEM_VALUE, 1, -1, 3, NULL, g_brightness, 0, 100}, // 索引 3对比度 {对比度, ITEM_VALUE, 1, -1, 4, NULL, g_volume, 0, 100}, // 索引 4返回 {返回, ITEM_BACK, 1, -1, -1, NULL, NULL, 0, 0}, // 索引 5系统信息 {系统信息, ITEM_ACTION, 0, -1, 6, ShowInfo, NULL, 0, 0}, // 索引 6返回 {返回, ITEM_BACK, 0, -1, -1, NULL, NULL, 0, 0}, };逻辑说明根节点索引 0 的firstChild指向 1索引 1 的nextSibling指向 5形成「显示设置 → 系统信息」的兄弟链。索引 1 的firstChild指向 2索引 2 到 4 通过nextSibling串成子链。参数说明g_brightness和g_volume是实际被调节的全局变量valuePtr直接指向它们调节时无需查表。3. 按键驱动怎么写消抖、长短按与事件分发菜单的输入源是按键但机械按键有抖动直接读 GPIO 会一次按下触发多次。更麻烦的是菜单需要区分「短按确认」和「长按返回」。这一章把按键从硬件到事件的链路讲透。3.1 定时器扫描实现 20ms 消抖我一般用 SysTick 或一个通用定时器每 10ms 调一次按键扫描连续两次读到相同电平才认账。// key.c #define KEY_COUNT 3 #define DEBOUNCE_MS 20 typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t stable; // 稳定电平 uint8_t last; // 上次采样 uint8_t cnt; // 连续计数 uint16_t pressTick; // 按下持续时长 uint8_t longSent; // 长按是否已触发 } KeyState; KeyState g_keys[KEY_COUNT] { {GPIOA, GPIO_PIN_0, 1, 1, 0, 0, 0}, {GPIOA, GPIO_PIN_1, 1, 1, 0, 0, 0}, {GPIOA, GPIO_PIN_2, 1, 1, 0, 0, 0}, }; void Key_Scan10ms(void) { for (int i 0; i KEY_COUNT; i) { uint8_t raw HAL_GPIO_ReadPin(g_keys[i].port, g_keys[i].pin); if (raw g_keys[i].last) { if (g_keys[i].cnt DEBOUNCE_MS / 10) g_keys[i].cnt; if (g_keys[i].cnt DEBOUNCE_MS / 10 raw ! g_keys[i].stable) { g_keys[i].stable raw; if (raw 0) { // 按下 g_keys[i].pressTick 0; g_keys[i].longSent 0; } else { // 抬起 if (!g_keys[i].longSent g_keys[i].pressTick 100) Key_PostEvent(i, KEY_SHORT); } } } else { g_keys[i].cnt 0; g_keys[i].last raw; } if (g_keys[i].stable 0) { g_keys[i].pressTick 10; if (g_keys[i].pressTick 100 !g_keys[i].longSent) { g_keys[i].longSent 1; Key_PostEvent(i, KEY_LONG); } } } }逻辑说明cnt累计连续相同采样次数达到 2 次20ms才更新stable。按下时记录pressTick每 10ms 累加超过 100 即 1 秒触发长按longSent防止重复触发。抬起时若未触发长按且按下时长小于 1 秒发短按事件。参数说明DEBOUNCE_MS可按按键质量调整劣质按键可加到 30ms长按阈值 100 对应 1 秒改小会更灵敏但易误触。3.2 事件队列解耦按键与菜单按键扫描在中断或定时器里跑菜单刷新在主循环里跑两者用环形队列通信避免在中断里做耗时操作。// event.c #define EVENT_QUEUE_SIZE 8 typedef enum { KEY_SHORT, KEY_LONG } KeyEventType; typedef struct { uint8_t keyId; KeyEventType type; } KeyEvent; static KeyEvent g_queue[EVENT_QUEUE_SIZE]; static uint8_t g_head 0, g_tail 0; void Key_PostEvent(uint8_t id, KeyEventType type) { uint8_t next (g_head 1) % EVENT_QUEUE_SIZE; if (next ! g_tail) { // 队列未满 g_queue[g_head].keyId id; g_queue[g_head].type type; g_head next; } } uint8_t Key_GetEvent(KeyEvent *ev) { if (g_head g_tail) return 0; *ev g_queue[g_tail]; g_tail (g_tail 1) % EVENT_QUEUE_SIZE; return 1; }逻辑说明g_head是写入位置g_tail是读取位置next ! g_tail判断队列未满满了就丢弃新事件保证不会覆盖未处理数据。参数说明EVENT_QUEUE_SIZE取 8 足够因为人按键速度远低于 10ms 扫描周期队列几乎不会满。3.3 菜单状态机响应按键事件主循环里取出事件根据当前菜单节点类型决定行为。这是整个系统的调度中心。// menu.c int g_currentIndex 0; // 当前高亮节点 int g_displayTop 0; // 屏幕顶部显示的节点索引 void Menu_HandleEvent(KeyEvent *ev) { MenuNode *cur g_menu[g_currentIndex]; if (ev-type KEY_SHORT) { switch (ev-keyId) { case 0: // 上 if (cur-parent 0 g_currentIndex g_menu[cur-parent].firstChild) g_currentIndex--; break; case 1: // 下 if (cur-nextSibling 0) g_currentIndex cur-nextSibling; break; case 2: // 确认 if (cur-type ITEM_SUBMENU cur-firstChild 0) g_currentIndex cur-firstChild; else if (cur-type ITEM_ACTION cur-action) cur-action(); else if (cur-type ITEM_BACK cur-parent 0) g_currentIndex cur-parent; break; } } else if (ev-type KEY_LONG) { if (ev-keyId 2 cur-parent 0) g_currentIndex cur-parent; // 长按确认键返回上级 } }逻辑说明短按上键时先判断当前节点是否有父节点且不是父节点的第一个子节点才允许上移防止跨到别的分支。短按下键直接跳到nextSibling。确认键按节点类型分派子菜单进入、动作执行、返回跳父级。长按确认键统一返回上级。参数说明g_currentIndex是全局高亮位置g_displayTop用于滚动显示当高亮项超出屏幕行数时调整。4. LCD 显示刷新局部更新与滚动窗口屏幕刷新最容易犯的错是「每次按键全屏重绘」结果就是肉眼可见的闪烁。STM32F407 配 FSMC 屏全屏填充要几十毫秒必须做局部更新。4.1 只重绘变化行的策略菜单界面通常只有高亮行在变其他行内容不变。记录上一次的高亮行只擦除旧行、绘制新行。// lcd_menu.c #define LINE_HEIGHT 24 #define VISIBLE_LINES 6 static int g_lastHighlight -1; void Menu_DrawItem(int index, int line, uint8_t highlight) { MenuNode *node g_menu[index]; uint16_t bg highlight ? 0x001F : 0xFFFF; // 蓝底或白底 uint16_t fg highlight ? 0xFFFF : 0x0000; LCD_FillRect(0, line * LINE_HEIGHT, 240, LINE_HEIGHT, bg); LCD_DrawString(8, line * LINE_HEIGHT 4, node-title, fg, bg); if (node-type ITEM_VALUE node-valuePtr) LCD_DrawInt(180, line * LINE_HEIGHT 4, *node-valuePtr, fg, bg); } void Menu_Refresh(void) { int line 0; int idx g_displayTop; while (idx 0 line VISIBLE_LINES) { uint8_t hl (idx g_currentIndex) ? 1 : 0; if (hl ! (idx g_lastHighlight)) Menu_DrawItem(idx, line, hl); idx g_menu[idx].nextSibling; line; } g_lastHighlight g_currentIndex; }逻辑说明Menu_Refresh从g_displayTop开始沿兄弟链遍历只对高亮状态发生变化的行调用Menu_DrawItem。g_lastHighlight记录上次高亮索引避免重复绘制。参数说明LINE_HEIGHT和VISIBLE_LINES根据屏幕分辨率和字体高度调整240x320 屏配 16 号字体时 6 行比较合适。4.2 滚动窗口的边界处理当菜单项超过可见行数时需要滚动。关键是判断高亮项是否在窗口内不在就移动g_displayTop。void Menu_EnsureVisible(void) { int line 0; int idx g_displayTop; while (idx 0 idx ! g_currentIndex) { idx g_menu[idx].nextSibling; line; } if (idx 0) { // 高亮项在窗口下方 g_displayTop g_currentIndex; for (int i 1; i VISIBLE_LINES g_menu[g_displayTop].parent 0; i) { // 回退到能显示 VISIBLE_LINES 行的位置 } } else if (line VISIBLE_LINES) { g_displayTop g_currentIndex; } }逻辑说明先沿兄弟链找高亮项在窗口中的行号找不到说明在下方直接把g_displayTop设为高亮项行号超界说明在下方同样调整。参数说明回退逻辑需要根据实际树结构补全简单场景可直接把高亮项置顶。5. 避坑与排查那些让我熬夜的菜单 Bug这一章记录几个真实踩过的坑每个都按「现象 → 原因 → 解决」写希望你别再走一遍。5.1 按键一次触发多次菜单跳转现象按一下确认键菜单连跳两级。原因按键扫描在中断里跑事件入队后主循环还没处理完下一次扫描又入队。解决在Key_PostEvent里加状态锁同一按键在抬起前只发一次事件或者主循环处理事件时先清空队列中同键事件。5.2 长按返回时误触发短按动作现象长按确认键返回上级结果同时执行了当前项的确认动作。原因抬起时判断longSent标志的时机不对长按已触发但短按分支仍执行。解决在长按触发时立即置longSent抬起时先检查该标志已置位则跳过短按发送。5.3 参数调节越界导致显示异常现象亮度调到 100 后再按加数值变成 101 甚至负数。原因valuePtr调节时没做边界钳位。解决在调节逻辑里加if (*ptr max) *ptr max;和下限判断或者用取模实现循环调节。5.4 菜单切换后屏幕残留旧内容现象从子菜单返回主菜单子菜单的文字还留在屏幕上。原因只重绘了变化行但返回时整个页面结构变了旧行没被覆盖。解决页面切换时强制全屏重绘一次或者记录页面 IDID 变化时清屏。5.5 FSMC 屏刷新时出现撕裂现象快速按键时屏幕出现半截旧内容半截新内容。原因FSMC 写显存和 LCD 控制器读显存冲突。解决在刷新前关闭 LCD 显示或使用双缓冲STM32F407 的 FSMC 可以配等待周期适当增加FSMC_DataSetupTime也能缓解。6. 进阶技巧把菜单配置成可落盘的数据菜单做到最后一定会遇到「参数掉电保存」和「菜单项动态增减」的需求。我一般会把菜单节点的静态部分和参数值分开存静态结构编译进 Flash参数值存到外部 EEPROM 或内部 Flash 的指定扇区。这样改菜单不用动存储改参数不用重编译。具体做法是给每个ITEM_VALUE节点分配一个唯一 ID参数值按 ID 顺序打包成结构体写入时算 CRC。上电时先读存储校验通过就覆盖默认值。下面是一个简化的落盘示例。// storage.c #define PARAM_MAGIC 0x5A5A typedef struct { uint16_t magic; int16_t brightness; int16_t volume; uint16_t crc; } ParamBlock; ParamBlock g_param; void Param_Load(void) { HAL_FLASH_Read(ADDR_PARAM, (uint8_t*)g_param, sizeof(g_param)); if (g_param.magic ! PARAM_MAGIC || !Param_CheckCRC()) { g_param.magic PARAM_MAGIC; g_param.brightness 50; g_param.volume 30; } g_brightness g_param.brightness; g_volume g_param.volume; } void Param_Save(void) { g_param.brightness g_brightness; g_param.volume g_volume; g_param.crc Param_CalcCRC(); HAL_FLASH_Write(ADDR_PARAM, (uint8_t*)g_param, sizeof(g_param)); }逻辑说明magic用于判断存储区是否被初始化过crc校验数据完整性校验失败就回退默认值。参数说明ADDR_PARAM要选在 Flash 末尾扇区避免和代码区冲突STM32F407 的 Flash 写前必须先擦除整个扇区。验证方法很简单调几个参数断电再上电看值是否保持再把存储区手动擦掉看是否回默认值。我习惯在菜单里加一个「恢复出厂」项直接调Param_Reset()把 magic 清掉再重启。最后说个血泪经验菜单系统的复杂度不在显示而在状态管理。我早期项目把菜单逻辑和业务逻辑混在一起后来改一个选项要翻五个文件。现在我会强制自己把菜单节点、按键事件、显示刷新分成三个独立模块接口只有事件队列和当前索引。这个习惯帮我省了无数个调试的夜晚。希望帮到你。本文还有配套的精品资源点击获取