简介:这份资源面向嵌入式初学者与单片机开发者,聚焦在STM32平台上驱动OLED显示屏并实现多级菜单交互的完整工程。项目以STM32F103为核心,通过GPIO模拟I2C或SPI时序与SSD1306类OLED通信,涵盖初始化序列、显示刷新、菜单项数据结构设计以及按键导航与选中状态切换等关键环节,帮助读者理解从底层驱动到用户交互的完整链路。压缩包共144个文件,约2.48MB,包含27个c源文件、24个h头文件,以及o、crf、d等编译中间产物,另有uvprojx、uvoptx工程配置、hex烧录文件与map映射文件,目录结构清晰,便于直接打开Keil工程编译验证。目前已有14737人学习下载,适合作为课程设计、毕业设计或自学练手参考,读者可从中获取OLED驱动与多级菜单框架的代码组织方式、HAL库调用思路及常见调试问题的排查方向。
1. 从一块 0.96 寸 OLED 说起:这套 STM32 多级菜单资源到底能省你多少事
如果你手上正捏着一块 STM32F103C8T6 最小系统板,屏幕用的是 I2C 接口的 0.96 寸 OLED,然后老板或者导师丢过来一句“做个多级菜单,按键能翻页、能进子菜单、能改参数”,你大概会经历这样一个过程:先把 OLED 点亮,显示几个汉字,然后开始写if(key==1){...},写着写着发现菜单层级一深,switch-case嵌套到自己都看不懂,改一个功能要动五个地方,最后代码变成一坨不敢碰的“屎山”。这套《STM32+OLED多级菜单.zip》解决的正是这个痛点——它不是教你点灯,而是给你一套能直接塞进项目里的菜单框架,把“界面显示”和“业务逻辑”拆开,用结构化的方式管理菜单树。
资源本身是一套基于 STM32 标准外设库(也有 HAL 库版本思路)的工程模板,核心是一套多级菜单的状态机实现,配合 OLED 驱动和按键扫描。它适合三类人:正在做毕业设计、课程设计,需要快速搭出人机交互界面的学生;做小型工控设备、仪器仪表,需要本地参数配置界面的嵌入式工程师;以及已经会点灯、会串口,但一写菜单就头大的初学者。你拿到手之后,最直接的收益是:不用再从零设计菜单数据结构,不用再纠结“返回上一级”怎么记录,不用再为“当前选中项高亮”写一堆标志位。它把菜单抽象成节点,节点之间用指针连起来,你只需要填内容,剩下的翻页、跳转、刷新,框架帮你做。
但先别急着解压。这套东西能不能跑起来,取决于你对 I2C 时序、OLED 显存布局、按键消抖这几个基础环节的理解程度。下面我从资源结构拆起,一步步带你把它跑通,再把里面几个容易翻车的地方拎出来说清楚。
2. 拆包看结构:菜单框架、OLED 驱动、按键扫描是怎么分工的
2.1 资源里到底有什么:文件树与模块划分
解压之后,你看到的目录结构大概率是这样(不同版本可能略有差异,但核心模块跑不掉):
STM32+OLED多级菜单/ ├── USER/ // 主函数、中断服务函数 │ ├── main.c │ ├── stm32f10x_it.c │ └── system_stm32f10x.c ├── HARDWARE/ // 硬件驱动层 │ ├── OLED/ │ │ ├── oled.c │ │ └── oled.h │ ├── KEY/ │ │ ├── key.c │ │ └── key.h │ └── MENU/ │ ├── menu.c │ └── menu.h ├── SYSTEM/ // 延时、串口等基础支持 │ ├── delay/ │ └── usart/ └── OBJ/ // 编译输出,不用管这个划分很典型:HARDWARE下面三个文件夹分别对应显示、输入、菜单逻辑。menu.c是整套资源的灵魂,它不直接操作硬件,而是通过函数指针或者回调,调用 OLED 显示和按键读取。这种分层的好处是,你换一块屏幕(比如换成 SPI 接口的 OLED,或者换成 LCD),只需要改 OLED 驱动层,菜单逻辑一行不用动。同样,按键从独立按键换成矩阵键盘,也只动 KEY 层。
我一般拿到这种包,第一件事不是编译,而是打开menu.h看数据结构。因为菜单框架的设计水平,全在这个头文件里。
2.2 菜单节点的数据结构:为什么用链表而不是数组
打开menu.h,你会看到类似这样的定义:
typedef struct MenuItem { uint8_t type; // 节点类型:0-父节点 1-子节点 2-参数项 char *text; // 显示文本 struct MenuItem *parent; // 父节点指针 struct MenuItem *child; // 子节点链表头 struct MenuItem *next; // 同级下一个节点 void (*action)(void); // 选中后执行的动作 void *param; // 参数指针,用于参数项 } MenuItem;这个结构是典型的双向链表 + 树的混合体。parent指向上一级,child指向下一级第一个节点,next指向同级下一个。为什么不用二维数组?因为数组要求你预先知道菜单有多少层、每层多少项,改一个菜单就要改数组维度,扩展性极差。链表的好处是,你可以在运行时动态增删节点,而且“返回上一级”只需要current = current->parent,一行搞定。
type字段用来区分节点行为:父节点选中后进入子菜单,子节点选中后执行action,参数项选中后进入编辑模式。param是个万能指针,可以指向一个int、float或者结构体,用来绑定实际变量。比如你要做一个“设置温度阈值”的菜单项,param就指向那个阈值变量,编辑时直接改内存。
注意:链表节点如果是静态分配(全局数组),那没问题;如果是
malloc动态分配,在单片机上要小心内存碎片。这套资源通常用静态数组预分配节点,然后手动链接,兼顾灵活性和安全性。
2.3 OLED 驱动层:I2C 时序与显存映射
OLED 驱动部分,常见做法是用软件模拟 I2C,因为 STM32F103 的硬件 I2C 有已知的“玄学”问题,很多人调不通就换软件模拟了。oled.c里你会看到OLED_WR_Byte、OLED_Init、OLED_ShowString这些函数。关键点在于显存映射:0.96 寸 OLED 分辨率通常是 128x64,SSD1306 驱动芯片内部有一块 128x8 字节的显存(8 页,每页 8 行像素)。你写一个汉字(16x16),要占两页,跨页写入时地址要算对。
// 常见做法:设置显示坐标后连续写入 void OLED_ShowChar(uint8_t x, uint8_t y, char chr, uint8_t size) { uint8_t c = chr - ' '; // 得到偏移后的字模索引 uint8_t i; if (size == 16) { OLED_Set_Pos(x, y); // 设置起始页和列 for (i = 0; i < 8; i++) OLED_WR_Byte(F8X16[c*16 + i], OLED_DATA); // 上半部分 OLED_Set_Pos(x, y+1); // 换到下一页 for (i = 0; i < 8; i++) OLED_WR_Byte(F8X16[c*16 + i + 8], OLED_DATA); // 下半部分 } }这段代码的逻辑是:先定位到字符左上角,写上半部分 8 列,然后把页地址加 1,写下半部分。参数x是列地址(0-127),y是页地址(0-7),size决定字宽。如果你显示乱码或者只显示一半,大概率是OLED_Set_Pos里的页地址算错了,或者字模取模方式不对(横向取模 vs 纵向取模)。
2.4 按键扫描:状态机消抖与长按短按
按键部分,key.c里通常是一个 10ms 定时器调用的扫描函数。简单做法是读 GPIO 电平,加个延时消抖;但菜单里经常需要“长按进入编辑”或者“连按快速翻页”,所以更稳妥的是状态机:
#define KEY_SHORT 1 #define KEY_LONG 2 #define KEY_NONE 0 uint8_t Key_Scan(void) { static uint8_t key_up = 1; // 按键松开标志 static uint16_t long_cnt = 0; uint8_t key_val = 0; if (key_up && (KEY1 == 0 || KEY2 == 0)) { // 有键按下 delay_ms(10); // 消抖 key_up = 0; if (KEY1 == 0) key_val = 1; if (KEY2 == 0) key_val = 2; } else if (KEY1 == 1 && KEY2 == 1) { // 松开 key_up = 1; long_cnt = 0; } if (!key_up) { // 按住期间计时 long_cnt++; if (long_cnt > 100) { // 1秒长按 long_cnt = 0; return KEY_LONG; } } return key_val ? KEY_SHORT : KEY_NONE; }这里key_up是松开标志,防止一次按下被重复触发。long_cnt在按住时累加,超过阈值返回长按事件。参数100取决于你的扫描周期,如果是 10ms 调用一次,100 就是 1 秒。这个逻辑比delay阻塞式按键高级的地方在于,它不占用 CPU,可以和其他任务共存。
提示:如果你用的是 HAL 库,按键扫描可以放在
HAL_GPIO_EXTI_Callback里,但中断里不要做延时消抖,最好还是定时器轮询。
3. 把菜单跑起来:从工程编译到三级菜单跳转的完整步骤
3.1 编译环境与工程配置:Keil 与标准库的版本匹配
这套资源大概率是 Keil MDK 工程(.uvprojx文件)。打开之前,确认两件事:第一,你装了 STM32F1 的器件支持包(Keil.STM32F1xx_DFP),否则打开工程会提示找不到器件;第二,工程用的标准外设库版本是 3.5.0,如果你电脑上装的是其他版本,编译可能报错。常见做法是直接使用工程目录里自带的库文件,不要指向外部路径。
# 如果你用命令行编译(比如 CI 环境),可以这样: # 假设 Keil 安装在默认路径 "C:\Keil_v5\UV4\UV4.exe" -b "STM32+OLED多级菜单\USER\Project.uvprojx" -o build_log.txt编译成功后,OBJ目录下会生成.hex文件。用 FlyMcu 或者 ST-Link Utility 下载到板子。如果你用的是 ST-Link,在 Keil 的Options for Target -> Debug里选 ST-Link Debugger,然后Settings -> Flash Download勾选Reset and Run,这样下载完自动运行。
3.2 菜单树的初始化:静态节点数组与链接顺序
菜单框架不会自动生成菜单,你需要自己定义节点并链接。常见做法是在menu.c里定义一个节点数组,然后在初始化函数里把指针连起来:
MenuItem menu_nodes[] = { // type, text, parent, child, next, action, param {0, "Main Menu", NULL, &menu_nodes[1], NULL, NULL, NULL}, {0, " Settings", &menu_nodes[0], &menu_nodes[2], &menu_nodes[5], NULL, NULL}, {2, " Brightness", &menu_nodes[1], NULL, &menu_nodes[3], NULL, &brightness}, {2, " Contrast", &menu_nodes[1], NULL, &menu_nodes[4], NULL, &contrast}, {1, " Back", &menu_nodes[1], NULL, NULL, Menu_Back, NULL}, {1, " About", &menu_nodes[0], NULL, NULL, Menu_About, NULL}, };这段代码定义了一个两级菜单:主菜单下有“Settings”和“About”,“Settings”下又有“Brightness”、“Contrast”和“Back”。type=0表示父节点,type=2表示参数项,type=1表示执行动作的叶子节点。parent和next指针把树形结构串起来。注意Back节点的action是Menu_Back,这个函数会把current指针指回parent。
初始化时,把current指向根节点:
MenuItem *current_menu = &menu_nodes[0]; uint8_t current_index = 0; // 当前选中项在同级中的序号3.3 按键驱动菜单跳转:上下翻页、确认、返回
主循环里,你不断扫描按键,根据按键事件更新current_menu和current_index,然后刷新显示:
void Menu_Process(uint8_t key) { MenuItem *p = current_menu; uint8_t idx = 0; switch (key) { case KEY_UP: if (current_index > 0) current_index--; else { // 跳到同级最后一个节点 while (p->next) { p = p->next; idx++; } current_index = idx; } break; case KEY_DOWN: if (p->next) current_index++; else current_index = 0; // 回到第一个 break; case KEY_ENTER: // 找到当前选中的节点 p = current_menu; for (uint8_t i = 0; i < current_index; i++) p = p->next; if (p->type == 0 && p->child) { // 父节点,进入子菜单 current_menu = p->child; current_index = 0; } else if (p->action) { // 叶子节点,执行动作 p->action(); } break; case KEY_BACK: if (current_menu->parent) { // 返回上一级 current_menu = current_menu->parent; current_index = 0; } break; } Menu_Display(current_menu, current_index); // 刷新显示 }这段逻辑里,KEY_UP和KEY_DOWN处理了循环翻页:到顶再按上,跳到底部;到底再按下,回到顶部。KEY_ENTER先遍历到当前节点,如果是父节点就进入子菜单,如果是叶子节点就执行action。KEY_BACK直接利用parent指针返回。Menu_Display负责把当前菜单的所有同级节点显示出来,并高亮current_index对应的那一行。
3.4 显示刷新策略:局部刷新与全屏刷新的取舍
OLED 刷新有两种做法:全屏刷新和局部刷新。全屏刷新就是每次按键都清屏重画,代码简单,但屏幕会闪,而且 I2C 速度慢的时候(比如 100kHz),刷一屏 128x64 要几十毫秒,按键快了会卡顿。局部刷新只更新变化的那几行,速度快,但代码复杂,要记录上一次的显示内容。
这套资源里,常见做法是全屏刷新 + 页地址优化。因为菜单项通常不超过 8 行,每行 16 像素高,正好占一页。刷新时只写有内容的页,不用的页不写。如果你觉得闪,可以在OLED_Refresh里加一个显存缓冲区,先在内存里画好,再一次性写入 OLED,这样屏幕不会看到中间过程。
uint8_t oled_buffer[8][128]; // 8页,每页128列 void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_Set_Pos(0, page); for (uint8_t col = 0; col < 128; col++) { OLED_WR_Byte(oled_buffer[page][col], OLED_DATA); } } }用缓冲区的好处是,你可以在内存里随意画点、画线、写汉字,最后统一刷屏。代价是多占 1KB RAM,对 STM32F103C8T6(20KB RAM)来说可以接受。
注意:如果你用的是硬件 I2C,写入速度可以到 400kHz,全屏刷新大概 10ms 左右,基本不闪。软件模拟 I2C 的话,把延时函数里的
delay_us调小,也能提速,但太快了 OLED 可能不响应,需要看时序图确认。
3.5 参数编辑模式:在菜单里改数值并保存
参数项(type=2)的编辑逻辑,通常是在KEY_ENTER时进入一个编辑状态,此时KEY_UP和KEY_DOWN不再翻页,而是加减数值,KEY_ENTER确认保存,KEY_BACK取消。实现上可以加一个全局标志edit_mode:
if (edit_mode) { int *val = (int *)p->param; if (key == KEY_UP) (*val)++; if (key == KEY_DOWN) (*val)--; if (key == KEY_ENTER) { edit_mode = 0; /* 保存到 Flash */ } if (key == KEY_BACK) { edit_mode = 0; /* 恢复原值 */ } Menu_Display(current_menu, current_index); return; }这里param被强制转换成int*,所以你的参数变量必须是int类型。如果是float,就要用float*,并且显示时用sprintf格式化。保存到 Flash 的话,STM32F103 内部 Flash 擦写要注意对齐和寿命,常见做法是单独开一页存参数,改一次擦一次,不要频繁写。
4. 避坑与排查:OLED 不亮、菜单乱跳、按键失灵的常见原因
4.1 OLED 上电不亮或只亮一半
现象:下载程序后,OLED 完全没反应,或者只显示上半屏,下半屏是黑的。
原因:第一种可能是 I2C 地址不对。0.96 寸 OLED 的 I2C 地址通常是0x78(写地址)或0x3C(7 位地址),但有些模块是0x7A。如果你用的是 0.9 寸 OLED,它的驱动芯片可能是 SSD1306 也可能是 SH1106,两者初始化命令不同,SH1106 需要额外设置显示偏移。第二种可能是初始化序列里对比度设置太低,或者OLED_Init里的延时不够,SSD1306 上电后需要至少 100ms 稳定。
解决:先用 I2C 扫描程序确认地址。如果是 SH1106,把OLED_Init里的0x00偏移改成0x02。对比度命令0x81后面跟的值调到0xCF以上。延时不够就在OLED_Init开头加delay_ms(200)。
4.2 菜单显示乱码或汉字显示不全
现象:菜单文字显示成方块、乱码,或者汉字只显示左半边。
原因:字模取模方式不对。常见的取模软件(PCtoLCD2002)有“阴码/阳码”、“逐行/逐列”、“顺向/逆向”几个选项。如果取模方式和OLED_ShowChar里的写入顺序不匹配,就会乱码。另外,汉字通常是 16x16,占两页,如果OLED_Set_Pos只设了一页,下半部分就丢了。
解决:确认取模设置:阴码、逐列式、顺向(或逆向,看驱动)。在OLED_ShowChinese函数里,确保写了两页。如果用的是 GBK 字库,还要注意 STM32 内部是 UTF-8 还是 GBK 编码,热词里有人问“stm32 gbk转utf8”,就是因为字库编码和源文件编码不一致导致汉字显示错误。简单办法是直接用取模软件生成数组,不依赖字库。
4.3 按键按一下跳好几级
现象:按一次“下”,菜单光标跳了三四行,或者直接冲到底部。
原因:按键没有消抖,或者主循环里Menu_Process被调用了多次。常见的是在while(1)里没有加延时,按键扫描函数每毫秒执行一次,一次按下被识别成多次。另外,如果按键扫描返回的是“按下”状态而不是“按下事件”,也会连续触发。
解决:在Key_Scan里加状态机,确保一次按下只返回一次KEY_SHORT。主循环里加delay_ms(10)或者用定时器标志位控制扫描周期。如果用的是中断方式,在中断里置标志,主循环清标志,不要直接在中断里处理菜单。
4.4 进入子菜单后返回不了或死机
现象:按“返回”没反应,或者直接卡死,屏幕不动。
原因:parent指针为空。根节点的parent是NULL,如果你在根菜单按返回,current_menu = current_menu->parent就把current_menu置空了,后续访问current_menu->next就会 HardFault。另外,如果菜单节点数组是局部变量,函数退出后栈内存被回收,指针就悬空了。
解决:在KEY_BACK处理里加判断if (current_menu->parent != NULL)。菜单节点数组一定要用static或者全局变量,不要放在函数内部。如果用了malloc,确保不要free还在用的节点。
4.5 参数修改后重启丢失
现象:在菜单里改了亮度或阈值,断电重启后恢复默认值。
原因:参数只存在 RAM 里,没有写入 Flash 或 EEPROM。STM32F103 没有内置 EEPROM,只能用内部 Flash 模拟,或者外挂 24C02。
解决:在KEY_ENTER确认编辑时,调用 Flash 写入函数。注意 Flash 写入前要先擦除整页,擦除时 CPU 会停顿,不要频繁操作。常见做法是定义一个结构体存所有参数,改完后整体写入。如果参数不多,也可以存在备份寄存器(BKP)里,但需要纽扣电池供电。
5. 进阶玩法:把菜单框架移植到 HAL 库并加上图标与滚动条
5.1 从标准库到 HAL 库:移植时改哪几个地方
这套资源如果原本是标准库的,你想移植到 HAL 库,核心改动就三处:GPIO 初始化、I2C 读写、延时函数。标准库的GPIO_Init换成HAL_GPIO_Init,GPIO_ReadInputDataBit换成HAL_GPIO_ReadPin,delay_ms换成HAL_Delay。I2C 部分,如果你用硬件 I2C,把OLED_WR_Byte里的软件时序换成HAL_I2C_Master_Transmit;如果用软件模拟,只需要把 GPIO 操作换成 HAL 的宏。
// HAL 库下的软件 I2C 写字节 void OLED_WR_Byte(uint8_t dat, uint8_t mode) { uint8_t i; if (mode) OLED_DC_Set(); else OLED_DC_Clr(); OLED_CS_Clr(); for (i = 0; i < 8; i++) { OLED_SCL_Clr(); if (dat & 0x80) OLED_SDA_Set(); else OLED_SDA_Clr(); OLED_SCL_Set(); dat <<= 1; } OLED_CS_Set(); OLED_DC_Set(); }这段代码和标准库版本几乎一样,只是OLED_SCL_Clr()这些宏在 HAL 里用HAL_GPIO_WritePin实现。移植完记得在main.c里调用HAL_Init()和系统时钟配置,否则延时不准。
5.2 给菜单加图标:用取模工具生成小图片
菜单项前面加个小图标,看起来会专业很多。0.96 寸 OLED 上,图标一般取 16x16 或者 8x8。用 PCtoLCD2002 取模,设置成“阴码、逐列式、顺向”,生成数组后存到menu_icon.h里。显示时,在Menu_Display里根据节点类型或者自定义字段选择对应图标。
const uint8_t icon_settings[32] = { /* 16x16 图标数据 */ }; void Menu_Display_Item(uint8_t y, char *text, uint8_t icon_idx, uint8_t selected) { if (selected) OLED_ShowChar(0, y, '>', 16); // 选中指示 if (icon_idx < ICON_MAX) { OLED_ShowPicture(16, y, 16, 16, icon_table[icon_idx]); } OLED_ShowString(32, y, text, 16); }图标数据占 Flash,16x16 的图标一个 32 字节,十个也就 320 字节,对 STM32F103C8T6 的 64KB Flash 来说毫无压力。
5.3 滚动条与层级指示:让用户知道自己在哪
多级菜单最怕用户迷路。加一个层级指示,比如左上角显示1/3表示当前是第三级菜单,或者右侧画一个滚动条表示当前选中项在列表中的位置。滚动条实现很简单:在屏幕最右边一列,根据current_index和总项数计算高度,画一个实心矩形。
void Menu_Draw_Scrollbar(uint8_t total, uint8_t current) { uint8_t bar_height = 64 / total; uint8_t bar_y = current * bar_height; OLED_DrawRectangle(126, 0, 2, 64, 0); // 清空滚动条区域 OLED_DrawRectangle(126, bar_y, 2, bar_height, 1); // 画滑块 }total是同级节点总数,current是当前索引。bar_height是滑块高度,bar_y是滑块起始页。这样用户一眼就能看出菜单有多长、自己在哪。
5.4 用双向链表动态增删菜单项
静态数组的菜单,增删节点要改数组和所有指针,很麻烦。进阶做法是用双向链表,每个节点malloc出来,插入和删除只改指针。比如要在“Settings”下面加一个“Language”选项:
MenuItem *new_item = (MenuItem *)malloc(sizeof(MenuItem)); new_item->type = 0; new_item->text = " Language"; new_item->parent = &menu_nodes[1]; new_item->child = &lang_nodes[0]; new_item->next = menu_nodes[2].next; // 插入到 Brightness 后面 new_item->action = NULL; new_item->param = NULL; menu_nodes[2].next = new_item;这样不用改数组定义,运行时就能调整菜单结构。但要注意,malloc在单片机上容易产生碎片,如果菜单是固定的,还是静态数组稳妥;如果菜单需要根据配置动态生成(比如根据传感器数量显示不同项),链表更合适。
5.5 验证菜单稳定性的土办法:按键压力测试
菜单写完了,怎么知道它稳不稳?我一般会做一个简单的压力测试:写个循环,让程序自动模拟按键,每 50ms 随机触发一次KEY_UP、KEY_DOWN、KEY_ENTER、KEY_BACK,连续跑 24 小时。如果中间死机、花屏、或者指针跑飞,就说明有边界没处理到。这个测试能抓出大部分空指针和数组越界问题。
// 压力测试伪代码 uint32_t tick = 0; while (1) { if (HAL_GetTick() - tick > 50) { tick = HAL_GetTick(); uint8_t fake_key = rand() % 4 + 1; Menu_Process(fake_key); } }跑之前把Menu_Process里的显示刷新暂时关掉,只测逻辑,速度更快。等逻辑没问题了,再开显示跑一遍。从那以后我每次写完菜单,都强制走一遍这个压力测试,至少跑一晚上,不然心里没底。希望这套资源和这些踩坑记录,能帮你把项目里的菜单部分一次做稳。
本文还有配套的精品资源,点击获取