
简介针对STM32嵌入式开发中的菜单交互需求这份OLED多级菜单框架资源提供了基于软件IIC协议的完整实现方案适合希望快速搭建人机界面、提升开发效率的初中级STM32开发者。压缩包共215个文件包含大量c/h源文件及对应的o/d编译输出、链接脚本与工程配置并附有uvprojx工程、hex烧录文件、map映射与调试信息整体约5.91MB涵盖从驱动层到菜单逻辑的完整代码结构。资源使用软件模拟IIC控制OLED设计了可逐层深入的多级菜单支持菜单创建、状态记录与按键响应较完整地展示了菜单框架的搭建思路。已有1109人学习/下载参考者可从代码中理解STM32外设初始化、OLED显示驱动以及菜单状态机等实用技巧也能在此基础上扩展自己的功能界面。1. STM32 用 OLED 做菜单为什么最后都要回到「框架」做过几个 STM32 项目之后你会发现OLED 菜单最麻烦的从来不是点亮屏幕而是菜单层级多了以后代码会变成一团浆糊。一个三级菜单配一组按键如果每个页面都写成独立函数、循环里用一堆 if-else 判断当前页那么加一个菜单项就要动好几处代码改一次逻辑就要全量回归这种状态机写法和裸编码在项目变大之后维护成本会急剧上升。多级菜单框架的思路是不管菜单有多少层数据结构都统一成树形用节点挂接子节点的方式表达层级把「显示」「按键」「业务动作」拆成独立的层这样换屏幕、换按键方案、加功能项都不用动框架主体。内容面向两类人一类是刚接触 STM32 OLED 项目的开发者照着框架的目录结构能理解菜单是怎么转起来并逐步替换成自己需要的功能另一类是已经用硬编码方式写过菜单但被维护折磨过的人这个框架展示的栈式层级管理、回调函数挂接口、数组式节点存储都是可以直接抄走的设计。2. 树形菜单的数据结构数组式节点与策略对比2.1 节点结构体文本、子节点和回调三个要素一个菜单项在三层菜单里既可能是父节点有下级菜单也可能是叶子节点触发具体功能所以节点结构体要同时容纳这三类信息显示文本、子菜单入口、动作回调函数。这个框架里常见的定义方式如下typedef struct _MenuItem { const char *pText; // 菜单显示文本指针 const struct _MenuItem *pChildren; // 子菜单数组首地址 uint8_t childCount; // 子菜单数量0 表示叶子节点 void (*onEnter)(void); // 确认键要执行的回调 } MenuItem;childCount 是这里很关键的一个字段它决定了当前节点是继续下钻还是直接执行具体动作。框架判断的逻辑通常是这样当按键触发确认时先检查当前选中节点的 childCount如果大于 0就把当前层压栈切换到 pChildren 指向的那个数组如果等于 0就调用 onEnter 这个函数指针去执行真正的业务逻辑比如修改某个全局参数、启动某段数据采集流程。用数组而不是链表来组织子菜单在 STM32 这种嵌入式场景里有明确理由。数组是静态分配的编译器就能确定每个菜单表的地址和大小不需要 malloc不存在内存碎片问题。配合 const 修饰后整棵菜单树可以放在 Flash 而不是占宝贵的 RAM。另外数组下标天然可以配合 OLED 的行号来做滚动显示遍历时也不需要像链表那样一个个顺着指针走。2.2 菜单树怎么初始化用 const 数组在编译期建树菜单树的初始化不需要运行时代码去逐个 malloc、赋值直接把所有节点写成全局常量数组就可以比如一个四层菜单的局部定义static const MenuItem mainMenu[] { {系统信息, subMenuInfo, 2, NULL}, {参数设置, subMenuParam, 3, NULL}, {启动采集, NULL, 0, Action_StartSample}, {屏幕亮度, NULL, 0, Action_Brightness}, }; static const MenuItem subMenuParam[] { {温度上限, NULL, 0, Action_SetTempMax}, {温度下限, NULL, 0, Action_SetTempMin}, {恢复默认, NULL, 0, Action_RestoreDefault}, };注意前三个字段都是常量数据分别指定了子菜单指针、子菜单数量和选中时要执行的动作函数名。MainMenu 里前两个节点是父节点指向了 subMenuInfo 和 subMenuParam 这两个子数组后面两个节点 childCount 为 0说明它们是执行动作的叶子节点。使用这套方案新增一个菜单参数时只需要在这个文件里追加数组元素并保证 pText 指针指向的字符串常量和回调函数名不冲突即可框架本身完全不需要改动。这种编译期建树的方式还有个额外好处可以利用编译器检查出悬空的子菜单指针。如果子菜单数组被误删链接阶段通常能给出明显的错误提示比运行时才发现野指针要友好得多。2.3 不同层级的存储策略对比二级到四级的菜单存储选型上其实有差别。下面这张表对比了几种常见组织的性能与内存特征组织方式内存占用层级管理动态修改能力适用场景静态 const 数组最低全部放 Flash靠栈保存路径不支持运行期改动菜单结构固定的产品固件结构体数组 RAM 标志位中等文本留 Flash易做选中状态标记可动态隐藏菜单项需要根据权限显示不同项动态链表高节点散布在 RAM需要维护指针链可运行期增删菜单配置类手持设备、调试终端这个框架采用的方式本质是第一种但很多开发者在实际项目里会折中菜单文本、子菜单表用 const 数组存在 Flash另外用一小段 RAM 数组保存每个节点的翻译标记或者使能状态这样既能拿到 Flash 的低内存占用又能在运行期控制部分菜单项隐藏。OLED 屏幕行数一般只有 4 到 8 行滚动逻辑对数组作偏移裁剪即可不需要二维指针表那些链式结构在这种小屏场景里通常是过度设计。3. 软件 IIC 驱动 OLEDGPIO 时序、显示接口与显存搬运3.1 软件 IIC 的引脚选择与 GPIO 位操作框架名为「软件 IIC」意味着不依赖 STM32 硬件 I2C 外设直接用两个 GPIO 模拟时序。这样做的直接好处是引脚选择完全自由PA10/PA12、PB6/PB7、PC 口任意两个能输出高低电平的引脚都可以用画 PCB 时绕线会好走很多。相对于硬件 I2C软件 IIC 不涉及 CR/TRISE 寄存器配置、不争抢外设调试时序时可以直接用示波器点两根线观察工程上更容易定位问题。引脚初始化的标准操作用库函数配置为开漏输出并外加上拉两块常用屏都适用void OLED_IIC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin OLED_SCL_PIN | OLED_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, OLED_SCL_PIN | OLED_SDA_PIN); // 初始拉高 }开漏模式比推挽模式更适合 IIC当某个设备主动拉低总线时开漏输出天然允许线与逻辑不会形成电平冲突。如果你把 SCL 和 SDA 配成推挽输出碰到从机在应答阶段把 SDA 拉低的情况主机还往高里推可能出现短暂短路电流。多数初学者遇到 OLED 偶发花屏、初始化不稳定排查到最后往往就是这里用错了模式。SCL 惯用的引脚是 PA10SDA 用 PA12理由是这两个脚在标准外设库工程中一般不参与默认复用不会跟串口下载冲突。3.2 起始、停止与字节收发时序软件 IIC 的四个基础时序是起始、停止、发送字节、读取应答。起始条件是 SCL 为高时 SDA 从高拉低停止是 SCL 为高时 SDA 从低拉高数据线在 SCL 低电平期间改变状态在高电平期间保持稳定。下面给出一个可用的字节发送函数static void OLED_IIC_SendByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { OLED_SCL(0); if (dat 0x80) OLED_SDA(1); else OLED_SDA(0); dat 1; OLED_SCL(1); // SCL 上升沿时从机采样 SDA } OLED_SCL(0); }说明一下这个函数的设计要点每次进入循环先把 SCL 拉低是为了保证 SDA 可以在低电平期间切换状态避免数据变化刚好发生在 SCL 高电平区间导致从机误采样。dat 从高位开始发送每次向左位移一位8 次循环后一个字节就按 MSB-first 顺序送完。循环结束后把 SCL 拉低为下一个字节或停止信号做准备。发送完一个字节后应该读取从机的 ACK。严谨的写法是先把 SDA 释放为高置 SCL 为高然后读引脚电平低电平表示应答正常高电平说明从机没有应答可能是地址写错或者屏没上电。实战中如果 OLED 完全无反应优先查这个应答位。这里要注意时序延时IIC 标准模式要求 SCL 高电平时间不低于 4us用 72MHz 主频的 STM32 时可以在 SCL 置高后加一个简单的延时循环一般空转十来个 NOP 就够不需要死板地卡微秒。3.3 SSD1306 显示接口与显存搬运的拆分0.96 寸 OLED 的控制器多数是 SSD1306显存是 1024 字节按列地址和页地址访问一共有 8 页每页 128 列。驱动层直接暴露给菜单框架的接口建议只留四个不用让上层知道屏幕控制器的寄存器细节void OLED_Clear(void); void OLED_ShowString(uint8_t x, uint8_t y, const char *str, uint8_t size); void OLED_ShowChinese(uint8_t x, uint8_t y, uint16_t index); void OLED_Refresh(void);这四个接口覆盖了菜单绘制需要的全部能力清屏、字符串、汉字、整屏刷新。菜单框架在这之上作业时只需要知道屏幕的几何尺寸就行比如常用的是 128x64 或 128x32。实现 OLED_ShowString 时字模部分通常用取模软件生成 8x16 或 16x8 的 ASCII 码点阵数组一个字符占两个字节宽x 坐标与 y 坐标分别代表列地址和页地址写入时要先设置页地址再连续发数据这样内存写入指针会自动下移。整屏刷新和局部刷新要区分开。菜单操作时只改当前行如果每次按键都调用 OLED_Refresh 把整块缓冲刷一遍滚动时视觉上会有明显闪烁而且 IO 电平翻转频率高对低功耗场景不友好。正确的做法是保留一个 gram 数组做镜像绘制函数只改 gram按键后只对变化的区域调用局部写命令。SSD1306 的写命令像是设置页地址、设置列地址低四位、设置列地址高四位这套低层命令大家基本都是同一份模板重点在于把画点函数抽象出来。OLED_ShowImage 加载图片时会在数据搬运这一步比较费时128x64 的 1bpp 位图一次要连续写 1024 字节。软件 IIC 大约每个字节需要 100us 量级整屏图片刷新就要 100ms 以上所以开机 Logo 一般只刷一次菜单运行时尽量全用局部刷新。OLED 驱动层代码拆成三个文件比较合适oled_iic.c 管最底层时序oled_driver.c 管寄存器初始化和显存操作oled_gui.c 管字符、汉字与画图层次分明后上层菜单切任何屏只需要改前两个文件。4. 多级菜单核心索引栈、按键分发与工程文件分工4.1 菜单栈父菜单指针和选中索引要成对保存多级菜单的本质是深度优先地打开某个子节点然后在返回时恢复上一层之前的选中位置。如果只保存「当前菜单数组地址」而忘记保存「上一层选中的是第几项」就会出现返回后永远停在第一个菜单项的情况这是一个很容易犯的设计错误。所以框架里用两个栈数组分别保存父菜单指针和父菜单选中索引#define MENU_MAX_DEPTH 5 static const MenuItem *menuStack[MENU_MAX_DEPTH]; // 父菜单数组指针栈 static uint8_t indexStack[MENU_MAX_DEPTH]; // 父菜单选中项栈 static uint8_t stackDepth 0; static const MenuItem *pCurrentMenu mainMenu; // 当前菜单数组 static uint8_t currentIndex 0; // 当前选中项当用户按下确认键且当前项是父节点时入栈操作要同时写入两个数组然后让 currentMenu 指向子菜单数组currentIndex 清 0。返回操作则逆过来先取出上一层的父菜单指针再恢复上一层的选中索引最后才把 stackDepth 减少。这块逻辑是整个框架的正确性核心出栈顺序写反很容易导致多返回一层或者菜单滞留调试时用 Keil 的 watch 窗口观察这两组数组的变化最直观。菜单深度一般不会超过 5 层所以数组分配 5 个元素就够了溢出保护可以直接检查 stackDepth 与 MENU_MAX_DEPTH 的关系。如果菜单层级真的超过了 5 层需要展开那说明交互设计本身可能需要重新考虑嵌入式设备屏幕上同时可见的项目很有限过深的层级对用户体验是反效果。4.2 按键处理上、下、确认、返回的分发逻辑按键输入检测放在主循环里轮询用矩阵键盘时扫描周期通常 10ms 左右。菜单框架对外只暴露一个入口函数把按键事件传进来即可具体按键值由硬件层转化完毕再交给框架菜单模块不需要知道 GPIO 细节void Menu_HandleKey(uint8_t key) { switch (key) { case KEY_UP: if (currentIndex 0) { currentIndex--; Menu_ShowList(0); // 刷新整屏列表 } break; case KEY_DOWN: if (currentIndex pCurrentMenu-childCount - 1) { currentIndex; Menu_ShowList(0); } break; case KEY_ENTER: if (pCurrentMenu[currentIndex].childCount 0) { if (stackDepth MENU_MAX_DEPTH) { menuStack[stackDepth] pCurrentMenu; indexStack[stackDepth] currentIndex; stackDepth; pCurrentMenu pCurrentMenu[currentIndex].pChildren; currentIndex 0; Menu_ShowList(0); } } else if (pCurrentMenu[currentIndex].onEnter ! NULL) { pCurrentMenu[currentIndex].onEnter(); } break; case KEY_BACK: if (stackDepth 0) { stackDepth--; pCurrentMenu menuStack[stackDepth]; currentIndex indexStack[stackDepth]; Menu_ShowList(0); } break; default: break; } }这段代码是菜单框架的主干注意 KEY_ENTER 分支里判断 childCount 和调用回调的顺序不能颠倒。先判断有没有子菜单有就下钻一层没有子菜单才去调用 onEnter 回调这样同一个按键既能在一个参数项上触发设置动作也能在父节点上打开下一级列表。按键一直没反应时用万用表量一下按钮连接的 GPIO 电平矩阵按键在 oled 没有反应的问题九成不是出在框架逻辑而是按键扫描没消抖或引脚初始化成了模拟输入。4.3 从工程文件反推框架的模块划分工程目录里出现的文件名能比较清楚地暗示原框架的分层风格stm32f10x_rcc.c 负责时钟配置stm32f10x_gpio.c 负责引脚模式stm32f10x_i2c.c 预留了硬件 I2C 的备用路径stm32f10x_usart.c 则给调试输出留着口子。这套文件体系对应的是标准外设库不是 HAL 库所以直接编译时要确认 Keil 工程 include path 里引用的头文件与源码版本一致否则会出现函数声明不匹配的编译错误。MCU.uvguix.Administrator 是 Keil MDK 的 GUI 布局文件里面记录的是窗口布局、断点位置、调试配置之类的 UI 状态它不影响编译结果提交版本控制时通常从仓库里删掉或忽略。MCU.axf 是链接生成的调试产物保存着带符号表的完整 ELF 信息J-Link 单步调试依赖它。MCU_sct.Bak 是 scatter 文件备份用于定义 ROM/RAM 的加载区和执行区标准外设库工程一般用默认配置只有需要把菜单文本专门放进某个 Flash 段时才需要改动。框架的显示刷新策略建议放在主循环而不是中断里主循环周期 10ms每轮扫描一次按键并刷新菜单局部区域OLED 屏不常改动不需要进入 TFT 那种全程 DMA 刷新。如果项目里同时还有 ADC 采样、定时器计数主循环用一个状态标志区分「菜单活跃」和「业务运行」两种模式进入菜单时停下业务任务退出菜单时恢复业务任务这是工程上最常见的主副界面切换逻辑。5. 进阶回调动作、参数存储与向 HAL 库迁移5.1 回调函数里挂真实业务框架的叶子节点回调最好统一成一个固定签名参数通过结构体指针传递可以这样设计typedef void (*MenuCallback)(MenuItem *node); static void Action_SetTempMax(MenuItem *node) { g_config.tempMax AdjustValue(g_config.tempMax, 20, 80); SaveConfigToFlash(g_config); }节点把自己的数据地址传给回调回调里就能直接操作对应的配置项编辑完立即写 Flash。stm32f10x_flash.c 正好承担这个职责保存参数时要注意先擦后写STM32F103 的 Flash 页擦除前必须确保整页数据有备份或能重建防止掉电丢配置。5.2 从标准外设库迁移到 HAL 库的改动面如果要把这个框架迁到 cubemx 生成的 hal 工程只需要改动 oled_iic.c、按键扫描、延时函数三个位置菜单结构体和回调逻辑完全可以原样保留因为框架上层只依赖函数接口不依赖库函数本身。HAL 库的点屏初始化流程里GPIO 初始化的写法从 GPIO_Mode_Out_OD 换成 HAL_GPIO_WritePin 加 GPIO_Init 结构体参数延时从 Delay(NOP) 换成 HAL_Delay再确认 SCL 与 SDA 引脚时钟使能文件里保留的 stm32f10x_flash.c 如果迁移到 HAL 库要对应改成 HAL_FLASHEx_Erase 等接口。对于只想快速验证屏幕显示效果的用户也可以先用 cubeMX 自动生成工程再把 oled_iic.c、oled_driver.c、oled_gui.c 三个文件直接添加进 src 目录手动补齐头文件包含即完成整屏幕适配。不同驱动 IC 之间主要差别在初始化寄存器的长度SSD1306 和 SH1106 在 0.96 寸屏上显示效果几乎一致唯一注意点是有少量 SH1106 屏默认偏移两个像素这属于驱动器单屏的灰度问题验证方法是在菜单列表里观察顶部选中提示符与文字是否对齐若不对齐则把页起始地址加 2。最终确认框架状态最简单实用的一招是在 Menu_ShowList 里向串口打印当前 currentMenu 和 currentIndex 数值配合 OLED 画面一起对比状态栈有没有及时回收一目了然。本文还有配套的精品资源点击获取