
简介一套面向STM32嵌入式开发者的OLED多级菜单框架基于软件IIC模拟时序驱动OLED屏实现多级菜单的创建、切换与按键交互适合用于智能仪表、家电控制面板等需要本地界面的项目可大幅省去重复编写显示驱动和菜单状态机的开发成本。压缩包共215个文件以C源文件、头文件、Keil工程配置、编译产物.o/.axf/.hex及调试辅助文件为主整体5.91MB打开工程即可阅读完整代码。已有1109人学习下载。代码覆盖IIC初始化、OLED画点/字符/图形绘制、多级目录状态管理等模块各模块耦合度较低可根据项目需要灵活裁剪同时附带定时器、Flash、ADC等标准外设驱动方便在完整环境中验证。资源内还包含工程备份与链接脚本便于复位或重新生成工程适合嵌入式初学者对照学习也能让开发者快速移植到实际产品中。1. stm32 oled多级菜单框架现实需求与常见误区做 stm32 项目的人迟早会撞上一个需求OLED 屏上要显示多级菜单能进能退、能调参数、能保存选择。网上能搜到的方案多是把菜单写在 switch-case 里一层一层嵌下去——代码又臭又长加一个菜单项就要改三个函数。真正能用的多级菜单框架核心不是“画菜单”而是“索引与状态映射”把菜单项从代码里抽出来变成可配置的数据结构。这个标题讲的不是某个现成库的用法而是如何在 stm32 上从零搭一套可裁剪、可维护的多级菜单框架。适用对象是正在做带 OLED 屏的小型设备、想摆脱面条式 switch-case 的嵌入式开发者。框架不依赖特定 OLED 驱动芯片SSD1306、SH1106 都能接不依赖特定主控型号HAL 库和标准库都能用。核心设计只有三件事菜单数据结构怎么建模、渲染驱动怎么解耦、按键输入怎么驱动状态迁移。反直觉的结论是菜单框架难的不是 OLED 画图和按键扫描而是把动作与界面彻底分开。下面按“结构设计 → 渲染实现 → 事件处理 → 调参排错”的顺序把整套方案讲透。2. 菜单总线的数据建模从 switch-case 到索引表2.1 先理解菜单的本质不是界面是状态先把需求抽象掉。一个菜单系统无论多复杂最终用户只做四件事进入、返回、上一项、下一项。至于选中某项之后执行的是开灯、读 ADC 还是设置阈值那属于“动作”而不是“菜单”。把这句话想清楚框架就成功了一半。用状态机来建模每个页面是一个状态页面里的每个菜单项是一条“转移边”。用户按键后查表转移渲染层根据当前状态 ID 去画对应页面。这种设计与按键数量无关4 个按键和 1 个编码器都能驱动同一套状态表只是动作触发方式不同。网上常见的“框架”在这点上就走了弯路它们把菜单项画成树形结构用递归遍历去画子节点。递归在 PC 上没问题但在 stm32 上可复用栈小深层次遍历容易爆栈而且递归实现会让代码变得特别难读——出问题都不知道在哪一层挂的。实际做项目我建议用“扁平数组 父节点索引”来模拟树。2.2 核心结构体每个菜单项就是一个节点定义结构体时不要想着“通用”想着“够用”。STM32F103 的 Flash 也不过 64KB 起步结构体里塞一堆冗余字段是会要命的。最小可用的菜单项结构体包含当前节点 ID、父节点 ID、文本内容、动作函数指针、子节点起始索引。/* menu_node.h */ #ifndef __MENU_NODE_H #define __MENU_NODE_H #include stdint.h typedef void (*menu_cb_t)(void); /* 动作回调函数指针类型 */ typedef enum { MENU_TYPE_ROOT 0x01, /* 根节点只负责进子菜单 */ MENU_TYPE_ITEM 0x02, /* 普通项选中后执行动作 */ MENU_TYPE_PARAM 0x03 /* 参数节点进入后显示param */ } menu_type_t; typedef struct { uint16_t id; /* 当前节点ID全局唯一 */ uint16_t parent_id; /* 父节点ID根节点为0xFFFF */ menu_type_t type; /* 节点类型 */ const char *label; /* 菜单显示文本 */ menu_cb_t cb; /* 动作执行的函数指针 */ uint16_t first_child; /* 第一个子节点的索引 */ uint16_t child_count; /* 子节点总数 */ } menu_node_t; /* 节点查找、移动接口 */ uint16_t menu_get_current_id(void); const menu_node_t *menu_get_root(void); const menu_node_t *menu_get_node(uint16_t node_id); #endif这个结构体的设计意图是“一次查表不做递归”。id用 uint16_t 而不是 uint8_t是为了允许一张表里塞超过 255 个节点——实际项目里参数设置页可能有很多子项。first_child和child_count把树形结构的“儿子列表”压平成数组区间查子节点时直接按索引范围遍历不经过链表指针跳转速度确定性更好。动作函数menu_cb_t指向的函数不带参数这是刻意为之。不带参数意味着回调接口统一任何菜单项都能用同一种方式挂动作参数获取通过全局或单例结构传递。如果你用的是标准库而不是 HAL 库这套结构完全不需要改动——它的类型定义里没有依赖任何 MCU 外设。2.3 表驱动菜单项与函数分离数据结构的下一层是静态菜单表。所有菜单项放在一个const数组里编译后直接进 Flash不占 RAM。这一点对 stm32 尤其关键——RAM 只有 20KB 的 F103C8菜单数据放 RAM 等于白白浪费。/* menu_config.c */ #include menu_node.h /* 前置声明参数设置页的回调函数 */ static void cb_enter_light_cfg(void); static void cb_display_on(void); static void cb_display_off(void); /* 菜单项全局数组编译期确定ROM存储 */ static const menu_node_t menu_table[] { /* id0根节点主菜单含两个子页1个动作 */ { 0, 0xFFFF, MENU_TYPE_ROOT, MAIN, NULL, 1, 2 }, /* id1子页面灯光设置页含2个参数项 */ { 1, 0, MENU_TYPE_ITEM, Light Config, NULL, 3, 2 }, /* id2动作显示开 */ { 2, 0, MENU_TYPE_ITEM, Display On, cb_display_on, 0, 0 }, /* id3子页面亮度参数参数回调用回调函数读取变量 */ { 3, 1, MENU_TYPE_PARAM, Brightness, cb_enter_light_cfg, 0, 0 }, /* id4子页面对比度参数 */ { 4, 1, MENU_TYPE_PARAM, Contrast, cb_enter_light_cfg, 0, 0 }, }; const menu_node_t *menu_get_root(void) { return menu_table[0]; } const menu_node_t *menu_get_node(uint16_t node_id) { for (uint16_t i 0; i (sizeof(menu_table) / sizeof(menu_table[0])); i) { if (menu_table[i].id node_id) { return menu_table[i]; } } return NULL; /* 查不到必须处理不能NULL返回后继续解引用 */ }代码里id是数组索引的超集所以menu_get_node才需要遍历匹配。如果让id等于数组下标查找就能变成O(1)访问——实际项目里我会直接让id index来省掉遍历。保留 ID 字段是为了万一菜单重组后 Flash 中地址变化逻辑上的节点关系仍能保持稳定代价只是多那么几微秒查询时间这在菜单操作场景下完全可以接受。2.4 为什么用索引而不是链表链表在嵌入式菜单里是最常被新手选用的方案理由通常是“插入删除方便”。但这个理由在当前语境下站不住脚菜单是静态配置编译期就确定不需要运行时增删节点链表每个节点需要存储 next 指针和 prev 指针按 32 位 MCU 算至少多占 8 字节/节点100 个菜单项就是 800 字节 Flash这在 Flash 为 64KB 的芯片上不是小数;链表的遍历不能在编译期优化查表需要逐个跳指针执行时间不确定。索引数组可以在编译期用typeof检查数组长度、用sizeof精确获取节点数甚至用静态断言校验child_count是否越界。这一点做得好的框架连越界内存读取都在编译时就拦截了。3. OLED 渲染层让菜单与屏幕驱动互不打扰3.1 渲染层最容易犯的错菜单和驱动耦合死OLED 驱动代码每加一行菜单逻辑就要改一次驱动这是最典型的坏味道。菜单框架关心的是“第几行显示什么字符”而驱动关心的是“显存里第几字节写什么”。两者必须分开驱动层只要提供“在 x,y 坐标写字符串”“清屏”“刷新”“反白”四个原语就够了。再看 OLED 本身。市面上多数 0.96/0.91 屏用的还是 SSD1306通过 I2C 或 SPI 通信。IIC 接口只用四根线VCC、GND、SCL、SDA接线简单但不适合高速刷新。SPI 接口刷新快得多要占用的引脚也多。菜单框架和屏幕类型无关——这是驱动层抽象的意义所在。实际中常见做法是做一个oled_driver.h接口再按 SSD1306、SH1106 各实现一份。菜单层完全不知道底下是什么屏。3.2 双缓冲显存消除闪烁的唯一可靠手段SSD1306 内部有 1KB 显存128×64 像素8 页每页 128 字节。如果你把整个 1KB 内容先从 MCU 拷到屏幕会引入一个问题屏内部的 RAM 没有读回功能无法局部更新来判断是否需要重发。所以多数实现的选择是 MCU 侧同一份 1KB 副本作为 shadow buffer先改 shadow buffer 再整体刷新。/* oled_buffer.h */ #ifndef __OLED_BUFFER_H #define __OLED_BUFFER_H #include stdint.h #define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_PAGE_NUM 8 /* 全局shadow buffer注意stm32中这类大数组放RAM */ extern uint8_t oled_buf[OLED_PAGE_NUM][OLED_WIDTH]; /* 原语函数菜单层只能用这四个不要去碰SSD1306寄存器 */ void oled_draw_string(uint8_t page, uint8_t col, const char *str, uint8_t inverted); void oled_clear_buffer(void); void oled_flush(void); /* 整个buffer刷到屏 */ void oled_draw_selected(uint8_t page, const char *str, uint8_t inverted); #endif这里oled_clear_buffer()只清 shadow buffer不清屏幕。oled_flush()才把整个 128×64 的 1KB 数据打到 SSD1306。/* oled_ssd1306.c 简化实现 */ void oled_flush(void) { uint8_t page, col; for (page 0; page OLED_PAGE_NUM; page) { /* 发送页地址命令 0xB0 page */ oled_write_cmd(0xB0 page); /* 列地址低4位 */ oled_write_cmd(0x00 (0 0x0F)); /* 列地址高4位 */ oled_write_cmd(0x10 ((0 4) 0x0F)); for (col 0; col OLED_WIDTH; col) { oled_write_data(oled_buf[page][col]); } } }这段代码的核心是把 shadow buffer 全量发送。你可能会问每次按键都全量刷 1KB会不会太慢算一笔账SSD1306 I2C 在 400kHz 模式下发送 1 字节需要 9 个 bit含 ACK1KB 约 0.24 秒。如果用户按键触发刷新手速远低于这个频率感知不到延迟。SPI 模式更快因为它可以跑到 30MHz 以上。真正需要优化的场景是数值实时刷新比如亮度调整时连续变化那可以只刷新变化区域。做法是在 shadow buffer 基础上做一个 dirty 标记位图标记哪几个 page 有改动。观察 SSD1306 的页寻址模型它是按 8 像素一行page组织的所以最小刷新单位是“某一段 x 坐标整页”而不是任意像素矩形。实现时做一个 8bit 的 dirty_page 变量哪页改了刷哪页。3.3 菜单绘制策略全量重绘还是局部重绘菜单绘制有两套路线我分别说明利弊策略实现复杂度刷新耗时(I2C)适用场景全量重绘低clear逐行画约 240ms菜单结构简单、页面切换频繁局部重绘dirty标记中画完对比差异约 30-60ms数值参数实时调整、动画效果页内增量行级替换中高计算行地址约15ms/行每屏8行以内的纯文本列表我一般推荐前两种组合使用进入页面时全量重绘参数调整时局部只刷变化行。局部重绘的实现方式很直接旧内容画到旧 buffer新内容画到新 buffer逐字节比较只把 diff 过的页刷上去。SSD1306 的列地址是可以设置的但注意它一页包含 8 行像素你没法只刷新某一行的某几个像素最小单位就是“一个字节8像素高×横向若干列”所以局部刷新最小粒度是 8 像素高。3.4 字体与反白显示的取舍OLED 菜单常用两种字体6x8一屏 21 列字符8 行和 8x16一屏 16 列4 行。6x8 适合菜单内容多8x16 适合做标题或需要突出显示的场景。菜单选中项通常用“反白”黑底白字标记而不是弄一个光标闪烁——反白在 OLED 上视觉对比最强也不影响其他行的刷新差异计算。反白本质是把字模数据取反写入 buffer而不是读屏上的当前内容再取反。多级菜单框架里反白只影响某一行的显示不影响状态管理。绘制函数把行号、文本和是否反白三个参数传下来由 driver 层处理取反细节。4. 事件驱动与按键处理4.1 状态机的最小实现移动与选择现在把菜单状态机做成一个纯粹的“输入 → 输出”过程。按键输入经过防抖后变成三类事件前进、后退、确认。事件驱动一个有限状态机状态机的每次迁移都是查表操作。前面定义的menu_node_t已经包含parent_id和child_count。当前节点 cur 是数组下标。状态迁移规则只有三条/* menu_control.c */ static uint16_t cur_index 0; static uint16_t selected_index 0; /* 当前页面内选中子项序号 */ static uint16_t cur_parent 0; /* 当前所在父的id */ void menu_event_key(menu_event_t ev) { const menu_node_t *cur menu_get_node(cur_index); const menu_node_t *p menu_get_node(cur-parent_id); switch (ev) { case MENU_EVT_NEXT: if (selected_index cur-child_count - 1) { selected_index; /* 若支持循环则对child_count取模 */ } break; case MENU_EVT_PREV: if (selected_index 0) { selected_index--; } break; case MENU_EVT_ENTER: /* 确认当前选中项即 id 对应 (first_child selected_index) 那个节点 */ do_enter_into_item(cur, selected_index); break; case MENU_EVT_BACK: /* 返回父节点 */ do_go_back_to(cur-parent_id); break; default: break; } }do_enter_into_item内部逻辑是根据当前节点的first_child selected_index取到目标节点再检查type字段——如果是MENU_TYPE_ITEM且带回调则执行回调如果是MENU_TYPE_PARAM则进入参数界面如果它还有child_count 0则进入子页面。这个路由函数是整个框架的灵魂它决定了动作和界面的解耦程度。4.2 HAL 库下按键读取与防抖stm32 HAL 库的按键读取倒没难度但“加了 OLED 函数之后程序卡死”是搜索热词里高频出现的现象。这背后的原因通常是按键扫描函数里做了延时防抖而 OLED 的 I2C 通信又是阻塞式的。两个阻塞叠加外设中断没法及时响应看起来就像程序死了。正确的做法是“非阻塞轮询 软件定时器”。按键按下后记录时间戳不再用HAL_Delay阻塞而是每次主循环查当前时间是否过去了 10ms。事件产生后交给状态机处理。注意 OLED 的 I2C 通信本来也该异步化但常见的做法是只要保证每次 I2C 传输长度不是特别大阻塞式可以接受只是别在中断里调用。嵌入式菜单里主循环的典型结构是/* main.c 简化版主循环 */ while (1) { /* 按键状态机每10ms扫描一次 */ if (has_tick_elapsed(10)) { button_handle(); } /* OLED刷新检查dirty标记有变化才刷 */ if (is_display_dirty()) { oled_flush(); clear_dirty_flag(); } /* 其他业务模块合入主循环轮询 */ adc_update(); bsp_tick_inc(); }至于旋转编码器事件类型多两种左旋/右旋映射到 PREV/NEXT按下编码器映射到 ENTER。同一个事件接口就能覆盖。4.3 参数浏览数值显示与光标定位菜单里参数调整是另一块需求。进入 PARAM 节点后界面从上到下显示参数列表光标行反白左右键调整数值。这里需要让参数节点持有“当前值地址”和“上下限”而不是用回调函数返回。结构体在设计时把param_info_t加进去typedef struct { int16_t *value_ptr; /* 指向实际变量 */ int16_t min_val; int16_t max_val; int16_t step; /* 步进值按一次/-调整的量 */ void (*confirm_cb)(void);/* 按确认保存或应用 */ } param_info_t;参数节点的menu_node_t.cb可以保持 NULL框架检测到MENU_TYPE_PARAM时直接转向访问 param_info。这样实现参数修改只改一个变量不用改菜单状态机。5. 框架裁剪与常见排错把每一条经验都变成可执行的检查项多级菜单框架最容易被误用的地方是“框架万能论”。不少项目一开始就把菜单做得特别通用每个菜单项都挂一堆回调、每种节点都支持子节点树深增长。等编译完发现 Flash 烧不下、RAM 不够用、菜单第一层还没画出来再回去删功能。这个教训基本每个 stm32 开发者都要走一遍所以把这章放在这里收尾归纳几个可以直接对照检查的技术点。5.1 Flash 和 RAM 占用的两个实测数据以 128×64 的 SSD1306 为例shadow buffer 占 1KB RAM这跑不掉。如果还用 8×16 的字体库常见的 ASCII 字符集 95 个字符需要一个 8×16×95 约 1.5KB 的 Flash 字模表。加上菜单结构的每个节点约 16 字节含 4 字节对齐100 个节点也就 1.6KB。所以框架本身的 Flash 消耗主要是字模表和驱动代码RAM 消耗主要是 shadow buffer。你要是发现 RAM 紧张优先确认两件事一是oled_buf是否被定义成局部变量或作为全局数组放进了 stack 区——这直接导致 RAM 翻倍二是是否同时定义了多份 buffer 准备做局部刷新——这又是翻倍。正确做法是全局数组只定义一份局部刷新依靠 dirty 标记直接在原 buffer 上操作。5.2 OLED 白屏、花屏和卡死的三个自查方向白屏检查 I2C 地址是否正确SSD1306 一般有 0x78 和 0x7A 两个地址取决于 SA0 引脚接法。用逻辑分析仪看复位后有没有发出配置序列常见配置包括关闭 charge pump、设置分频和对比度。如果白屏但时钟线有波形十有八九是地址不对。花屏通常是显存读写越界。oled_buf[page][col]的下标在绘制字符串时最容易越界尤其是字符串长度超过该页剩余列数时。给字符绘制函数加一个长度检查或者提供snprintf截断避免越界写穿 buffer 破坏其他 RAM。卡死加了 OLED 函数后才卡死的先看是不是 OLED 初始化里用了长时间延时再看是不是清屏循环里把HAL_StatusTypeDef忽略后 I2C 总线错误没恢复。STM32 的 I2C 外设在总线错误后会锁住需要显式调用__HAL_I2C_CLEAR_ERROR或直接重新初始化 I2C。5.3 菜单数据表越界的编译期检查最后一个进阶技巧利用 C 语言的静态断言提前发现菜单表越界。menu_table中每个节点都声明了child_count它们的和必须等于节点总数否则说明有子节点漏配或者配多了。这个检查能直接体现在编译错误里而不是等到运行时菜单跳飞。/* 静态断言表内所有节点child_count之和不得超过数组范围 */ typedef char check_menu_table[( \ sizeof(menu_table) / sizeof(menu_table[0]) 0xFFFF \ ) ? 1 : -1];类似的宏还能检查first_child child_count是否溢出数组上界。这类宏在编译后不占任何 Flash 和 RAM纯粹是编译期护栏但能拦截掉实际项目中最常见的一类“改动菜单表导致越界”的问题。维护菜单的人员多了之后这个检查会远比一个调试断点可靠。本文还有配套的精品资源点击获取