简介:一套面向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[] = { /* id=0,根节点:主菜单,含两个子页,1个动作 */ { 0, 0xFFFF, MENU_TYPE_ROOT, "MAIN", NULL, 1, 2 }, /* id=1,子页面:灯光设置页,含2个参数项 */ { 1, 0, MENU_TYPE_ITEM, "Light Config", NULL, 3, 2 }, /* id=2,动作:显示开 */ { 2, 0, MENU_TYPE_ITEM, "Display On", cb_display_on, 0, 0 }, /* id=3,子页面:亮度参数,参数回调用回调函数读取变量 */ { 3, 1, MENU_TYPE_PARAM, "Brightness", cb_enter_light_cfg, 0, 0 }, /* id=4,子页面:对比度参数 */ { 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(含 ACK),1KB 约 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,纯粹是编译期护栏,但能拦截掉实际项目中最常见的一类“改动菜单表导致越界”的问题。维护菜单的人员多了之后,这个检查会远比一个调试断点可靠。
本文还有配套的精品资源,点击获取