简介:这份资源面向嵌入式开发初学者与进阶工程师,聚焦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 菜单切换后屏幕残留旧内容
现象:从子菜单返回主菜单,子菜单的文字还留在屏幕上。原因:只重绘了变化行,但返回时整个页面结构变了,旧行没被覆盖。解决:页面切换时强制全屏重绘一次,或者记录页面 ID,ID 变化时清屏。
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 清掉再重启。
最后说个血泪经验:菜单系统的复杂度不在显示,而在状态管理。我早期项目把菜单逻辑和业务逻辑混在一起,后来改一个选项要翻五个文件。现在我会强制自己把菜单节点、按键事件、显示刷新分成三个独立模块,接口只有事件队列和当前索引。这个习惯帮我省了无数个调试的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取