1. 项目缘起为什么我们需要一个菜单系统大家好我是老陈一个在嵌入式行业摸爬滚打了十多年的“老码农”。今天想和大家聊聊一个在智能硬件开发中几乎避不开的话题——人机交互界面特别是如何用STM32单片机从零开始构建一个灵活、好用的OLED多层菜单系统。回想我刚入行那会儿做个小设备界面要么是几个LED灯闪烁要么就是1602液晶屏显示两行固定的字符。用户想调个参数得按着说明书记住“长按A键3秒进入设置再按B键切换选项”这种复杂的组合操作不仅用户头疼我们开发者调试起来也费劲。后来项目越来越复杂功能越来越多这种“土办法”就完全不够用了。这时候一个清晰的图形化菜单系统就显得至关重要。它就像我们手机上的APP界面把复杂的功能分门别类通过上下选择、进入返回的逻辑让用户一目了然操作起来也直觉自然。比如一个温控器主菜单显示当前温度进入一级菜单可以设置目标温度、定时开关再进入二级菜单还能调整PID参数、查看历史曲线。没有菜单这些功能根本无从组织。而OLED屏幕以其高对比度、自发光、超薄和低功耗的特性成为了这类小型嵌入式设备的绝配。搭配上STM32这类性能强大、生态丰富的ARM单片机实现一个流畅的菜单系统就变得非常可行。所以今天我就手把手地带大家从最基础的数据结构设计开始到驱动适配、按键逻辑最后完成一个模块化、可移植的菜单框架并附上完整的STM32工程源码。无论你是刚接触STM32的新手还是想优化现有项目的开发者相信都能从中获得启发。2. 核心设计菜单的“骨架”如何搭建在动手写代码之前我们必须先把菜单的“骨架”——也就是数据结构设计好。这就像盖房子先画图纸结构清晰了后面砌砖写代码才不会乱。2.1 选择合适的数据结构链表还是树菜单的本质是层级关系。最直观的模型就是一棵树。有一个根节点主菜单它下面有若干子节点一级菜单项每个子节点可能还有自己的子节点二级菜单项以此类推。同时同一层级的节点是平行关系可以互相切换。在C语言中我们常用结构体和指针来构建这种关系。这里我强烈推荐使用“双向链表”来组织同一层级的菜单项用“孩子-兄弟表示法”来构建整个菜单树。听起来有点学术别怕我画个图你就明白了。假设我们的菜单结构如下主菜单 ├── 系统设置 │ ├── 亮度调节 │ ├── 音量设置 │ └── 语言选择 ├── 网络配置 │ ├── WiFi连接 │ └── 蓝牙配对 └── 设备信息用代码可以这样定义菜单项的结构体typedef struct menu_item { char name[16]; // 菜单项显示的名称 void (*action)(void); // 菜单项对应的执行函数如进入设置、保存参数 struct menu_item *parent; // 指向父菜单的指针 struct menu_item *child; // 指向第一个子菜单的指针 struct menu_item *next; // 指向下一个兄弟菜单的指针 struct menu_item *prev; // 指向上一个兄弟菜单的指针 // 可以扩展其他属性如图标ID、使能状态等 } menu_item_t;我来解释一下这几个指针是如何工作的parent指向当前菜单的上级。比如“亮度调节”的parent就是“系统设置”。从子菜单返回时全靠它。child指向当前菜单的第一个子菜单。比如“系统设置”的child就是“亮度调节”。next和prev这俩构成一个双向链表把同一层级的所有菜单项串起来。比如“系统设置”、“网络配置”、“设备信息”就是通过next指针连接在一起的兄弟。prev则让你能向前遍历。这种设计的好处是极其灵活。遍历子菜单顺着child再遍历next链表就行。返回上级直接找parent。插入或删除一个菜单项操作链表即可。整个菜单树通过指针有机地连接在一起。2.2 定义菜单项的行为不仅仅是跳转每个菜单项除了显示名字还需要定义它的行为。我通常把行为分为三类跳转子菜单最常见的类型比如点击“系统设置”行为就是进入它的子菜单列表。执行函数叶子节点菜单项比如点击“重启设备”就触发一个重启函数。参数设置这也是一个难点和重点。比如“亮度调节”菜单进入后可能是一个数字按上下键调整数值按确认键保存。这类菜单需要特殊处理我通常会给结构体增加一个联合体union来存储参数的类型和当前值。为了处理第三种情况我们可以丰富一下结构体typedef enum { MENU_TYPE_LIST, MENU_TYPE_FUNC, MENU_TYPE_VAR_INT, MENU_TYPE_VAR_FLOAT } menu_type_t; typedef struct menu_item { char name[16]; menu_type_t type; // 菜单项类型 union { void (*func)(void); // 类型为FUNC时执行的函数 int *p_int_val; // 类型为VAR_INT时指向整型变量的指针 float *p_float_val; // 类型为VAR_FLOAT时指向浮点变量的指针 } attribute; int min_val; // 参数最小值 int max_val; // 参数最大值或步进值 struct menu_item *parent; struct menu_item *child; struct menu_item *next; struct menu_item *prev; } menu_item_t;这样当我们导航到一个MENU_TYPE_VAR_INT类型的菜单时界面可以自动切换为数值调整模式并限制调整范围。这个设计能让你的菜单系统瞬间变得专业和实用。3. 硬件驱动让菜单在OLED上“活”起来数据结构是大脑OLED屏幕就是脸面。大脑想得再清楚脸面显示不出来也白搭。这一部分我们解决显示问题。3.1 OLED驱动基础与适配层市面上最常见的0.96寸OLED多是SSD1306驱动芯片使用I2C或SPI通信。网上有很多现成的驱动库比如“中景园”的驱动。但很多驱动库直接暴露了底层OLED_ShowString()这样的函数这不利于我们做菜单的抽象。我的做法是封装一个显示适配层。菜单逻辑不应该关心具体是OLED还是LCD是SSD1306还是SH1106。它只需要调用统一的接口去画东西。我们定义一套简单的图形抽象接口// display_adapter.h typedef struct { void (*init)(void); void (*clear)(void); void (*refresh)(void); // 将显存刷新到屏幕 void (*draw_string)(uint8_t x, uint8_t y, const char* str); void (*draw_rect)(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t is_filled); void (*draw_hline)(uint8_t x, uint8_t y, uint8_t len); // ... 其他基本绘图函数 } display_driver_t; extern display_driver_t oled_driver; // 在具体驱动中实现这个结构体然后针对我们手头的SSD1306 OLED实现oled_driver里的所有函数。菜单的绘制代码只调用display-draw_string()这样的通用接口。哪天想换一块屏幕只需要重写这个驱动适配层菜单逻辑代码一行都不用改。这就是模块化的好处。3.2 菜单页面的绘制逻辑有了绘图接口我们来思考怎么画一页菜单。一页菜单通常包含标题栏显示当前所在菜单的名称如“系统设置”。列表区显示当前可选的若干个子菜单项如“亮度调节”、“音量设置”。选中标识高亮或用一个箭头指向当前选中的项。滚动指示如果菜单项一屏显示不完需要有滚动条或箭头提示。绘制流程可以固化成一个函数menu_draw_page(menu_item_t *current_menu)清屏。在顶部绘制current_menu-name作为标题。获取current_menu-child即第一个子项。通过一个循环和next指针遍历子项链表。同时计算当前选中项current_selection在列表中的位置。根据屏幕能显示的行数比如OLED128x64显示4行8x16字体决定从哪个子项开始绘制处理滚动。被选中的那一行用反色显示或前面加。调用refresh函数更新屏幕。这个函数是菜单显示的核心它将被按键处理逻辑反复调用以刷新界面。4. 交互逻辑按键如何操控菜单菜单动起来了接下来就要让它听指挥。我们通过按键或编码器来与菜单交互。4.1 按键扫描与状态机读取按键最怕的就是抖动和连按。我习惯用状态机的方式来处理按键既稳定又清晰。我们不为每个按键单独写if而是定义一个按键事件枚举。typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_UP, KEY_EVENT_DOWN, KEY_EVENT_ENTER, KEY_EVENT_BACK, KEY_EVENT_LONG_PRESS_ENTER // 可以支持长按事件 } key_event_t; key_event_t key_scan(void) { // 这个函数内部实现按键扫描、消抖并返回上述事件之一 // 例如检测到GPIO下降沿后启动一个定时器消抖稳定后确定按键值 // 再根据按压时间判断是短按还是长按返回对应事件 }在定时器中断里周期性地调用这个扫描函数或者在主循环中快速扫描它只负责返回发生了什么“事件”而不直接操作菜单。4.2 菜单导航的状态迁移菜单系统本身也是一个状态机。它的状态就是“当前所在的菜单项”和“当前选中的子项”。按键事件则是触发状态迁移的输入。我们在主循环中这样处理key_event_t event key_scan(); menu_item_t *current_menu get_current_menu(); // 获取当前所在菜单如“系统设置” menu_item_t *current_sel get_current_selection(); // 获取当前选中项如“亮度调节” switch(event) { case KEY_EVENT_UP: current_sel current_sel-prev; // 选中上一个兄弟 if(current_sel NULL) { // 如果是第一个则循环到最后一个 current_sel get_last_sibling(current_menu-child); } set_current_selection(current_sel); menu_draw_page(current_menu); // 刷新显示 break; case KEY_EVENT_DOWN: current_sel current_sel-next; // 选中下一个兄弟 if(current_sel NULL) { // 如果是最后一个则循环到第一个 current_sel current_menu-child; } set_current_selection(current_sel); menu_draw_page(current_menu); break; case KEY_EVENT_ENTER: if(current_sel-type MENU_TYPE_LIST) { // 如果是列表型进入子菜单 set_current_menu(current_sel); // 当前菜单变为选中的这个项 set_current_selection(current_sel-child); // 选中项变为它的第一个孩子 menu_draw_page(current_sel); // 绘制新的菜单页 } else if(current_sel-type MENU_TYPE_FUNC) { // 如果是功能型执行函数 if(current_sel-attribute.func ! NULL) { current_sel-attribute.func(); } } else if(current_sel-type MENU_TYPE_VAR_INT) { // 如果是整型参数进入数值编辑模式 enter_edit_mode(current_sel); } break; case KEY_EVENT_BACK: if(current_menu-parent ! NULL) { // 如果有父菜单则返回上级 set_current_menu(current_menu-parent); // 返回后可以尝试选中之前在本级选中的那个项体验更好 set_current_selection(current_menu); menu_draw_page(current_menu-parent); } break; }对于参数编辑模式enter_edit_mode你需要临时切换状态此时KEY_UP/KEY_DOWN用于增减数值KEY_ENTER保存并退出编辑模式KEY_BACK取消修改并退出。这相当于在菜单状态机里又嵌套了一个小状态机。5. 工程实战模块化整合与源码解析前面我们把骨架、脸面和神经都设计好了现在要把它们组装成一个有机体并注入灵魂业务逻辑。5.1 工程目录结构一个清晰的目录结构是项目可维护性的基础。我的STM32工程以HAL库为例通常会这样组织Project/ ├── Core/ │ ├── Inc/ │ ├── Src/ │ └── main.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── MenuSystem/ # 我们的菜单系统核心 │ ├── Inc/ │ │ ├── menu_core.h # 菜单数据结构、类型定义 │ │ ├── menu_ui.h # 菜单绘制接口 │ │ └── menu_action.h# 菜单动作函数声明 │ ├── Src/ │ │ ├── menu_core.c # 菜单树创建、导航逻辑 │ │ ├── menu_ui.c # 菜单绘制实现 │ │ └── menu_action.c# 具体功能函数如SetBrightness() │ └── menu_config.c # 菜单内容的具体定义谁是谁的子项 ├── Hardware/ │ ├── Inc/ │ │ ├── oled_driver.h # OLED驱动适配层 │ │ └── key_scan.h # 按键扫描 │ └── Src/ │ ├── oled_driver.c │ └── key_scan.c └── ...menu_config.c这个文件是菜单内容的“配置表”它不包含逻辑只负责用我们前面定义的结构体把所有的菜单项像搭积木一样组装起来。这样当你需要增删一个功能菜单时只需要修改这个文件非常方便。5.2 菜单树的创建与初始化让我们看看menu_config.c里的一部分内容// 首先定义所有的菜单项变量作为叶子节点或分支节点 menu_item_t menu_root {主菜单, MENU_TYPE_LIST, {0}, 0, 0, NULL, NULL, NULL, NULL}; menu_item_t menu_system {系统设置, MENU_TYPE_LIST, {0}, 0, 0, menu_root, NULL, NULL, NULL}; menu_item_t menu_brightness {亮度调节, MENU_TYPE_VAR_INT, {.p_int_val system_brightness}, 0, 100, menu_system, NULL, NULL, NULL}; menu_item_t menu_network {网络配置, MENU_TYPE_LIST, {0}, 0, 0, menu_root, NULL, NULL, NULL}; menu_item_t menu_device_info {设备信息, MENU_TYPE_FUNC, {.func show_device_info}, 0, 0, menu_root, NULL, NULL, NULL}; // 然后在初始化函数里组装它们的关系 void menu_system_init(void) { // 组装“主菜单”的子项 menu_root.child menu_system; menu_system.next menu_network; menu_network.next menu_device_info; menu_device_info.prev menu_network; menu_network.prev menu_system; // 组装“系统设置”的子项 menu_system.child menu_brightness; // ... 其他组装关系 // 设置全局变量指向当前菜单和选中项 g_current_menu menu_root; g_current_selection menu_root.child; }在main.c的初始化部分依次调用硬件初始化OLED、按键、定时器和menu_system_init()整个菜单的骨架就立起来了。5.3 主循环与前后台系统最后我们来看main.c里的主循环它是整个系统的调度中心int main(void) { HAL_Init(); SystemClock_Config(); // 初始化硬件 key_init(); oled_init(); // 初始化菜单系统 menu_system_init(); oled_clear(); menu_draw_page(g_current_menu); // 绘制第一屏 while (1) { // 1. 扫描按键获取事件 key_event_t event key_scan(); // 2. 处理按键事件更新菜单状态 if(event ! KEY_EVENT_NONE) { menu_process_key(event); // menu_process_key内部会调用menu_draw_page来刷新界面 } // 3. 处理其他后台任务比如定时刷新某些动态信息 if(need_refresh_dynamic_info) { update_dynamic_info_on_screen(); // 局部刷新避免全屏闪烁 } // 4. 短延时降低CPU占用率 HAL_Delay(10); } }这就是一个典型的前后台系统。后台是while(1)大循环前台是按键中断或定时扫描触发的事件。所有菜单的响应和绘制都在主循环中顺序完成结构简单清晰。6. 优化与进阶让你的菜单更出色一个能跑起来的菜单只是开始要做得体验好还需要不少优化。滚动动画当菜单项超过一屏时翻页不要直接跳变可以尝试让列表平滑滚动一小段距离视觉效果会好很多。这需要绘制函数支持指定起始绘制项。焦点记忆从子菜单返回上级时最好能记住之前选中的是哪一个项而不是每次都重置到第一个。这需要在进入子菜单前保存上级菜单的选中索引。菜单回调与参数保存对于参数设置菜单调整后的值应该能自动保存到Flash中。可以为菜单项增加一个on_exit回调函数指针在退出编辑模式时调用用于保存参数。低功耗考虑在电池供电的设备中可以设置无操作一段时间后关闭OLED背光或进入睡眠模式按任意键唤醒。这需要全局状态机来管理。使用旋转编码器编码器在菜单交互中比按键更流畅。你可以将编码器的“左旋/右旋”映射为KEY_UP/KEY_DOWN按下映射为KEY_ENTER。编码器通常自带硬件消抖处理起来更简单。7. 避坑指南与源码获取在项目开发中我踩过不少坑这里分享几个最常见的指针未初始化的野指针在组装菜单树时务必把每个结构体的parent、child、next、prev指针都显式地初始化为NULL否则在遍历时很容易跑飞。屏幕刷新闪烁避免在每次按键后都清屏重绘。可以只重绘发生变化的部分如选中项和之前选中项的区域或者使用双缓冲机制在内存中完成整幅画面绘制再一次性刷到屏幕。按键处理阻塞主循环不要在按键扫描函数里使用HAL_Delay这样的阻塞延时来消抖。应该用定时器标志位或系统滴答计时器SysTick来记录时间实现非阻塞的状态机消抖。菜单层级过深设计菜单时层级最好不要超过3-4层否则用户操作起来会觉得很繁琐。尽量让常用功能在1-2次点击内到达。为了方便大家学习和实践我已经将完整的、基于STM32F103C8T6和0.96寸OLED的菜单系统工程源码整理好了。代码采用了模块化设计注释详尽并且实现了上面讲到的大部分功能包括参数设置和保存。你可以在我的GitHub仓库找到它。拿到源码后建议你先在Proteus里仿真一下或者烧录到你的“蓝桥杯”开发板、“野火”或“正点原子”的板子上跑跑看。从理解代码结构开始然后尝试修改menu_config.c添加你自己的菜单项和功能这是最快的学习方式。