最近有朋友问了我一个问题:128x64的OLED屏怎么才能不只用它显示个固定字符串?他手头有一块0.96寸的屏,配STM32的HAL库和几个矩阵按键,想做一个能上下选择、确认进入、返回上一级的菜单,但一搜资料全是各种GUI库的教程,装进单片机后Flash和RAM都不太够用。
这个问题其实很有代表性。128x64 OLED因为功耗低、体积小、显示清晰,在仪表、小家电、传感器调试器上到处都是,但很多人在驱动上没问题,卡在了“怎么把一个多级菜单做出来”。用纯C写一套轻量级菜单,不是为了炫技,而是因为很多MCU资源有限,连标准GUI库都跑不动。这篇文章就把我常用的做法拆开讲,从OLED驱动的基础、菜单数据结构、导航逻辑到踩坑记录,尽量让你看完就能直接抄进自己的项目。
1. 先搞明白:这套菜单到底解决什么问题
1.1 在OLED上做交互,为什么让那么多人头大
一块128x64的点阵屏,最多也就显示8行8x8字体,或者4行16x16字体,本质上是一个“窄窗口”。想让它变成可交互的设备,就必须配合按键做菜单。很多初学者第一反应是if-else嵌套:
if (key == KEY_UP) { if (current == 0) current = 1; else if (current == 1) current = 2; ... }这套写法在两三级菜单以内勉强能跑,一旦菜单数量上去,代码就开始失控。比如“设置”里面有“亮度”、“对比度”、“语言”,每个子项又有自己的参数调整页,这时状态变量越来越多,返回逻辑、边界判断、页面刷新散落在各个分支里,改一个菜单项可能影响三个功能。更麻烦的是,这种写法很难在另一个项目里复用,换一块屏、换一个平台,整个逻辑等于重写。
用纯C实现多级菜单的核心诉求是:让菜单结构清晰可维护,同时占用的资源足够小,小到在只有几KB RAM的单片机上也能跑得很稳。它不是要跟TouchGFX、LVGL这类GUI框架比视觉效果,而是解决“资源受限设备上的基础人机交互”问题。
1.2 轻量级方案的核心:把菜单当数据处理
我平时做这类菜单,核心思路就一句话:菜单不是一段逻辑,而是一张数据表。
也就是说,先定义一套统一的数据结构,把每个菜单项、每个子菜单、每个动作都描述成静态数据,代码只需要做一个很机械的事——遍历数据表、显示当前项、处理按键事件。这样做的优势非常明显:
- 增加菜单项时,只改数据定义,不动业务逻辑;
- 菜单项不在任何地方硬编码,逻辑代码极其精简;
- 所有菜单数据都放在Flash里,不占用宝贵的RAM;
- 换项目时,数据表可以整体搬走,逻辑层几乎不用动。
打个比方:这就好比查字典。字典内容是数据,查字典的人只需要按页码和索引去找,而不需要为每一个词单独写一段查找逻辑。菜单表就是字典,导航代码就是那个查字典的人。
2. 驱动层先打好底:SSD1306初始化与你要避开的3个坑
2.1 I2C通信与OLED的显存组织
市面上常见的0.96寸、0.91寸OLED模块,驱动芯片基本都是SSD1306,接口以I2C为主。这里先把一些基础知识点理清楚,后面排查问题用得上。
SSD1306内部有一块显示RAM,128x64分辨率对应1024字节。它把屏幕分成8页(Page),每页8行像素,每页128列。你往对应地址写入1字节,就点亮了某一页中的8个像素点。I2C通信时,器件地址一般是0x3C或0x3D,默认多数是0x3C。写入时,紧跟地址后面的控制字节很重要:控制字0x00表示接下来发送的是命令,0x40表示接下来发送的是显示数据。
如果你用STM32的HAL库,发送命令和数据一般是这么写的:
#define OLED_ADDR 0x3C #define OLED_CMD 0x00 #define OLED_DATA 0x40 void oled_write_cmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, OLED_CMD, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); } void oled_write_data(uint8_t data) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, OLED_DATA, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); }在ESP-IDF环境下则换成i2c_master_write_to_device,原理完全一样,只是HAL接口不同。底层驱动只需要保证“能写命令、能写数据”这两个能力,菜单部分不关心你用的是什么平台。
2.2 初始化时序与常见坑点
SSD1306刚上电时处于关闭状态,需要发送一串初始化命令才能点亮。这里给一份我常用且测过比较稳定的初始化流程:
void oled_init(void) { oled_write_cmd(0xAE); // 关闭显示 oled_write_cmd(0xD5); // 设置显示时钟分频 oled_write_cmd(0x80); oled_write_cmd(0xA8); // 设置多路复用比 oled_write_cmd(0x3F); // 64行 oled_write_cmd(0xD3); // 显示偏移 oled_write_cmd(0x00); oled_write_cmd(0x40); // 起始行 oled_write_cmd(0x8D); // 电荷泵设置 oled_write_cmd(0x14); // 开启电荷泵 oled_write_cmd(0x20); // 内存寻址模式 oled_write_cmd(0x00); // 水平寻址模式 oled_write_cmd(0xA1); // 段重映射 oled_write_cmd(0xC8); // 扫描方向 oled_write_cmd(0xDA); // COM引脚配置 oled_write_cmd(0x12); oled_write_cmd(0x81); // 对比度 oled_write_cmd(0xCF); oled_write_cmd(0xD9); // 预充电周期 oled_write_cmd(0xF1); oled_write_cmd(0xDB); // VCOMH电平 oled_write_cmd(0x40); oled_write_cmd(0xA4); // 显示全部RAM内容 oled_write_cmd(0xA6); // 正常显示(非反色) oled_write_cmd(0xAF); // 打开显示 }很多“OLED点了不亮”并不是模块坏了,而是几个基础问题没处理好。最大的坑是电荷泵没打开,屏幕完全没有供电;其次是器件地址搞错,I2C一直ACK失败;还有模块的SDA/SCL上拉电阻,有些模块板载了,有些不带,不带的话就得自己在外部加4.7k左右的上拉电阻。这些放到后面第5节统一排查。
2.3 上层只依赖四个函数的抽象设计
驱动层写完之后,不要直接让菜单代码满天飞地调用oled_write_cmd,而是做一个薄薄的抽象接口,让菜单模块只依赖几个最基本的函数:
typedef struct { void (*init)(void); void (*clear)(void); void (*draw_string)(uint8_t x, uint8_t y, const char *str, uint8_t inverted); void (*refresh)(void); } OledOps;init负责初始化屏幕,clear负责清空显示缓冲,draw_string负责在指定坐标画字符串,refresh负责把缓冲刷新到屏幕。菜单模块只认这4个函数,完全不关心底层是SSD1306还是SH1106,也不关心是I2C还是SPI。
这个抽象层的好处是:以后从STM32 HAL库换到ESP32 IDF,或者换成国产GD32、华大单片机,只需要重写这4个函数,菜单逻辑一行不用改。我在多个项目里验证过这套结构,移植一块新屏通常半天就能搞定。
3. 菜单数据结构这样设计,代码量至少省一半
3.1 一个结构体搞定菜单树
轻量级菜单的核心数据结构,我用一个结构体就能描述整个菜单树:
typedef struct MenuItem MenuItem; typedef void (*MenuCallback)(void); struct MenuItem { const char *label; // 菜单显示文本 const MenuItem *parent; // 父菜单项,根节点为NULL const MenuItem *children; // 子菜单项数组 uint8_t childCount; // 子菜单项个数 MenuCallback onEnter; // 进入该菜单时触发(可选) MenuCallback onAction; // 按确认键时触发(可选) };这里的关键点是:子菜单项是一个数组,而不是单个指针。为什么要数组?因为菜单天然是“一组一组”的,比如主菜单有4个选项,那么这4个选项就是主菜单项下的4个子节点。用数组描述,配合childCount,遍历和上下选择都非常自然,且不依赖链表、不依赖动态内存。
parent指针用来实现“返回上一级”,非常直接。onEnter和onAction是回调函数,用来扩展功能。比如进入“系统信息”子菜单时,可以现场读取传感器数据;在某个参数项上按确认键时,可以进入参数编辑模式。
3.2 Flash里的菜单表怎么定义
下面是一个实际可用的菜单定义示例。这里有个C语言的前向声明细节要先踩平:因为子菜单数组会被父菜单引用,而父菜单又可能反过来引用子菜单,编译器需要一个先见之明,所以要用前向声明:
static const MenuItem menu_main[]; static const MenuItem menu_settings[]; static const MenuItem menu_info[]; static void set_brightness(void) { /* 亮度调节逻辑 */ } static void set_contrast(void) { /* 对比度调节逻辑 */ } static const MenuItem menu_info[] = { {"固件版本", menu_main, NULL, 0, NULL, NULL}, {"运行时间", menu_main, NULL, 0, NULL, NULL}, }; static const MenuItem menu_settings[] = { {"亮度调节", menu_main, NULL, 0, NULL, set_brightness}, {"对比度", menu_main, NULL, 0, NULL, set_contrast}, {"系统信息", menu_main, menu_info, 2, NULL, NULL}, }; static const MenuItem menu_main[] = { {"温度显示", NULL, NULL, 0, NULL, NULL}, {"湿度显示", NULL, NULL, 0, NULL, NULL}, {"系统设置", NULL, menu_settings, 3, NULL, NULL}, };注意每个子菜单数组的parent指向的是它的上一级菜单,而不是它自己。比如menu_settings里的“亮度调节”,它的parent是menu_main,因为在UI语义上,按返回键要从“亮度调节”回到主菜单。而menu_settings数组作为一个整体,它的“父级”其实是menu_main里的“系统设置”这一项,这种情况通过menu_main里面的“系统设置”项的children指向menu_settings来建立,回调时再结合menu_settings里各项的parent还原菜单层级即可。
这套设计里,根菜单menu_main的parent为NULL,返回到头时不会再往上跳,导航逻辑里要注意这个边界。
3.3 为什么不用动态内存与状态机
可能有人会说,为什么不用malloc动态分配?在PC上当然没问题,但在资源紧张的单片机上,动态内存是能避则避。菜单这种结构生命周期是静态的,从开机到关机都存在,用静态常量描述最合理:数据放在Flash,不占RAM,没有内存碎片问题,也不存在忘记释放的事故。
关于状态机,这套菜单其实天然是一个状态机,只是状态没有用散落的变量表达,而是集中在“当前菜单节点指针+当前选中索引”这两个变量上。等到第4节你看到导航函数时会发现,整个状态转换逻辑只有几十行,这正是表驱动带来的好处。
4. 导航、渲染与按键:三级联动实现多级菜单
4.1 菜单导航逻辑
菜单导航是整个功能的心脏。全局只需要维护两个变量:
static const MenuItem *currentMenu; // 当前所在的菜单数组 static uint8_t cursor; // 当前选中的项索引进入菜单时,初始化currentMenu指向menu_main,cursor为0。然后处理按键:
void menu_handle_key(uint8_t key) { const MenuItem *item = ¤tMenu[cursor]; if (key == KEY_UP) { if (cursor == 0) { cursor = currentMenu->childCount - 1; // 循环上翻 } else { cursor--; } } else if (key == KEY_DOWN) { cursor = (cursor + 1) % currentMenu->childCount; // 循环下翻 } else if (key == KEY_ENTER) { if (item->childCount > 0) { // 有子菜单,进入下一级 currentMenu = item->children; cursor = 0; if (item->onEnter) item->onEnter(); } else if (item->onAction) { item->onAction(); } } else if (key == KEY_BACK) { if (item->parent != NULL) { // 返回上一级:找到父菜单数组,并定位到当前项 int i = 0; const MenuItem *parentMenu = item->parent; while (parentMenu[i].children != currentMenu && i < 16) i++; currentMenu = item->parent; cursor = (uint8_t)i; } } }这里有两个细节值得展开。
第一,上下选择采用“循环翻页”策略。列表到底后再按DOWN会回到第一项,到顶后再按UP会跳到最后一项,这在小型设备上非常顺手,不用一直按着反向翻。但要注意:有些场景用户不需要循环,比如选数值时希望到边界停下。我的做法是默认循环,如果某个界面需要非循环,就单独对该界面写特殊处理,不要把这个逻辑写进通用导航里。
第二,返回上一级的定位。KEY_BACK处理里用了一个while循环在父菜单里查找哪个子菜单数组是当前currentMenu,找到了就把光标定位到这一项上。这一步保证了返回后焦点不错乱,比如你从“系统设置”进入它的子菜单,在子菜单里操作一番后按返回,仍会回到“系统设置”这一项,而不是跳到第一项。
在实际项目中,childCount可能不止0和正整数两种情况,而且C语言里数组长度信息容易丢失,所以建议在结构体里把childCount显式存起来,不用sizeof运算,避免数组退化指针的经典坑。
4.2 页面渲染与局部刷新策略
菜单导航逻辑处理好之后,渲染就相对简单。128x64屏幕高度是64,如果用8x8字体,一屏能显示8行;如果用16x16字体,只能显示4行。我平时做菜单首选用8x8字体,信息密度高一些,如果屏幕大或者需要更好看,就用12x6或16x16。渲染函数只需要做一件事:把可视区域内的菜单项画出来,并在当前选中项上画一个指示符。
void menu_render(void) { oled_clear(); uint8_t start = 0; uint8_t visibleRows = 8; if (cursor >= visibleRows) { start = cursor - visibleRows + 1; } for (uint8_t i = 0; i < visibleRows; i++) { uint8_t idx = start + i; if (idx >= currentMenu->childCount) break; char line[24]; if (idx == cursor) { // 当前项前面加光标,并反白显示 snprintf(line, sizeof(line), ">%s", currentMenu[idx].label); oled_draw_string(0, i * 8, line, 1); // inverted=1 } else { snprintf(line, sizeof(line), " %s", currentMenu[idx].label); oled_draw_string(0, i * 8, line, 0); } } oled_refresh(); }这段代码里做了一个“滚动窗口”处理:当光标滚到第8项之后,可视区域整体向下平移,这样菜单项很多时也能保证光标始终落在屏幕内。
关于刷新策略,我强烈建议采用先画缓冲,再一次刷新的方式,不要每画一个字符就往屏幕写一次。I2C本身速度不快,且SSD1306不是边写边刷新的,你一个字符一个字符地写,屏幕会闪不说,CPU还一直被I2C占用,按键扫描和处理都会受影响。OLED模块如果带缓冲,比如SSD1306本身就有1KB显存,那么把整个菜单画进本地RAM数组里,最后调一次oled_refresh把整帧推上去,效果最干净。
4.3 按键消抖与事件处理
按键处理是菜单系统最容易翻车的地方。很多朋友在裸机程序里写按键,直接在主循环里HAL_GPIO_ReadPin然后立刻判断,结果发现菜单偶尔跳两格,或者按键反应迟钝。这里面的原因几乎都是消抖没做好。
我常用的按键消抖思路是:用状态机代替简单的延时消抖。每10ms扫描一次按键,连续两次检测到同一状态才认为按键有效。这套逻辑写起来不复杂:
typedef enum { KEY_STATE_IDLE, KEY_STATE_PRESSED, KEY_STATE_RELEASED } KeyState; uint8_t key_scan(void) { static uint8_t lastState = 0; uint8_t raw = read_key_gpio(); // 按位表示各按键 // 简单的两步消抖:连续两次读到相同电平才更新 if (raw == lastState) { // 状态稳定 } else { lastState = raw; return 0; } // 检测上升沿/下降沿,生成单击事件 ... }还有一点容易踩坑:按键扫描和菜单导航不要都放在中断里。我的建议是,中断里只做“标记事件”,主循环里统一处理。比如定时器中断里扫描按键,发现有效事件后置一个keyEvent标志位,主循环判断到标志位后调用menu_handle_key,之后再调用menu_render。这样菜单逻辑不会被中断嵌套干扰,也方便以后扩展长按、短按、组合键。中断里直接调用snprintf这类事情,能避免就尽量避免,开销不小。
5. 实战排查记录:OLED点不亮、卡死、按键失灵怎么办
5.1 批量点不亮:逐项排查清单
“OLED 0.96批量点不亮”是热搜里的高频词,我收到过很多类似的咨询。这里把排查思路整理成一张清单,照着做基本能定位问题。
| 排查方向 | 具体检查项 | 解决办法 |
|---|---|---|
| 供电 | VCC是否在3.3V左右,GND是否共地 | 用万用表量模块供电两端 |
| I2C地址 | 0x3C还是0x3D?模块背面电阻决定 | 先试0x3C,再试0x3D |
| 上拉电阻 | SDA/SCL是否有上拉 | 模块不带时外部加4.7k到VCC |
| 电荷泵 | 初始化命令里有没有开电荷泵 | 检查0x8D 0x14命令 |
| 复位脚 | RESET有没有被拉高 | 不用的模块可接VCC,或初始化时拉低再拉高 |
| I2C引脚 | 是否复用冲突 | 确认引脚没被其他外设占用 |
| 初始化时序 | 上电后是否立即发命令 | 上电后延时10ms以上再初始化 |
“批量点不亮”这个词很有意思,它说明不是个别模块的问题,而是设计或流程上的系统性问题。最常见的批量现象其实是:第一块屏单独驱动能亮,但做成PCB批量生产后点不亮。这时候多半是I2C上拉电阻没焊、模块地址焊错、或者VCC供电能力不足。如果全部都是同一个现象,优先怀疑共因;如果个别不亮,才考虑模块本体不良。
5.2 加了OLED函数系统卡死:先查这三个位置
“加了oled函数卡死”也是高频词。很多人的代码原本跑得好好的,把OLED初始化、显示字符串加进去之后,程序就卡死了。排除屏幕硬件问题之后,最常见的有三个原因。
第一,I2C等待超时导致死等。HAL库的HAL_I2C_Mem_Write如果一直发送不成功,超时时间设成HAL_MAX_DELAY就会永远等下去。建议所有I2C操作都设置一个明确超时,比如100ms,并在返回值非HAL_OK时做错误处理,而不是干等。
第二,初始化或刷新过程里调用了阻塞延时,并且这个调用发生在中断或临界区里。比如在定时器中断里绘制菜单,oled_refresh内部又有多个HAL_Delay,中断一直被占着,主循环和优先级低的中断全部饿死。解决方法是把OLED刷新放在主循环里做,中断里只置标志位。
第三,缓冲区越界或者栈溢出。很多OLED驱动库喜欢开一个很大的缓冲区,比如uint8_t buffer[1024]。如果你在一个任务里定义这个数组,任务栈不够大,直接爆栈。又或者菜单字符串拼接时snprintf写到了char line[16]之外,破坏了相邻的指针变量。排查时可以先注释掉绘图部分,只保留菜单结构体的赋值和遍历,看看卡死是否还存在,用二分法缩小范围。
5.3 按键在OLED上没反应:别急着怀疑屏
“矩阵按键在oled没有反应怎么回事”——这种问题的根源往往不在OLED,而在按键扫描的时序上。加了OLED显示后,主循环要花大量时间刷屏和等待I2C,如果按键扫描代码放在显示之后,就被严重拖慢,甚至出现扫描间隔远大于消抖窗口的情况,按键事件自然丢失。
我的经验是:把按键扫描从显示代码中彻底解耦,用固定频率的定时器扫描,比如每5ms扫一次,事件通过队列或标志位传递。主循环只是消费按键事件,而不是每次循环都去读一次按键。这样无论显示多忙,按键响应都能保持在一个稳定的节奏上。
另一个常见原因是按键引脚悬空或没有配置内部上拉/下拉。矩阵按键的行列扫描有交叉点,需要确保没有按键按下时读到的电平是固定的,否则按键状态一直在抖动,消抖状态机永远到不了稳定状态。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 上电后完全不亮 | 供电、电荷泵、地址 | 对照5.1清单逐项查 |
| 亮但白屏花屏 | 初始化命令不完整、对比度太低 | 重新核对初始化序列 |
| 显示正常但菜单操作几次后卡死 | 缓冲区越界、I2C死等 | 查缓冲区数组长度、加超时 |
| 按键偶尔没反应 | 消抖不足、显示阻塞主循环 | 按键扫描放到定时器 |
| 返回上级时焦点错乱 | parent指针指错或返回定位逻辑问题 | 检查菜单表parent字段 |
| 屏幕闪烁 | 逐字符写屏、刷新分块 | 改成整帧缓冲刷新 |
| 某些菜单项显示不全 | 中文字符编码问题或字符串过长 | 控制label长度,使用8x8 ASCII |
6. 让这套菜单能长期用下去的扩展思路
6.1 图标、翻页与参数子界面
纯文字的菜单够用,但实际产品里经常需要显示状态图标,比如WiFi信号强度、电池电量、箭头指示。128x64虽然小,但画几个16x16的图标还是很容易的。你可以给MenuItem扩展一个icon字段,指向一张位图数据的指针:
struct MenuItem { ... const uint8_t *icon; // 图标位图,指向Flash常量 };渲染时,如果icon不为空,就在文本左边画图标,否则留出空白。这样菜单界面会友好很多。位图可以用取模软件生成,注意字节序跟屏幕驱动匹配即可。
翻页是另一个常见扩展。当某个菜单数组的子项超过一屏能显示的行数时,我前面给的滚动窗口会自动处理,但如果你希望加上“第1页/共3页”这种指示,可以在渲染时根据cursor和visibleRows计算当前页码,显示在屏幕右上角。这个逻辑放在渲染层就行,导航层不用改。
参数子界面是重点。比如“亮度调节”菜单,选中后按确认应该进入一个“值编辑界面”,可能显示当前亮度值,左右键减小增大,确认键保存,返回键取消。这个界面跟普通菜单的导航逻辑不太一样,我的做法是定义一个简单的小状态机:全局有个uiMode变量,UI_MODE_MENU表示普通菜单导航,UI_MODE_EDIT表示参数编辑。menu_handle_key里先判断uiMode,如果是编辑模式就调用另一套处理函数。这样既复用底层渲染,又不会把参数编辑逻辑混进通用菜单导航。
6.2 把经验沉淀成一个可复用的menu模板
如果你打算在多个项目里复用这套菜单,建议把它整理成两个文件:menu.c和menu.h,对外只暴露这几个接口:
void menu_init(const MenuItem *root); void menu_handle_key(uint8_t key); void menu_render(void); void menu_set_ops(const OledOps *ops);menu.c内部包含结构体定义、导航逻辑、渲染逻辑,不依赖任何平台;OledOps作为驱动接口由用户在具体平台适配。这样无论是STM32、ESP32、AVR还是其他MCU,复制menu.c/menu.h进来,再写4个OLED适配函数,菜单就能跑起来。
我的习惯是把菜单数据表也单独放一个app_menu.c,这样业务菜单跟通用菜单框架彻底分离。以后新项目如果要重新定义菜单,只改app_menu.c,框架代码一个字节都不用动。这套设计我陆陆续续用了好几年,从简单的温湿度表到带好几层参数设置的采集设备,都是同一套核心代码在跑。
最后分享一个我个人的小习惯:每次拿到一块新OLED模块,我会先不写任何菜单,只用驱动层点亮屏幕、写一行测试字符串,确认硬件没问题,再往上加菜单数据表。要是硬件问题都没确认就急着调菜单,遇到卡死、点不亮时,排查范围会大很多。OLED这种东西,一旦驱动清楚、数据结构清晰,后面加什么都顺;反过来,底层没打牢就堆代码,迟早会遇到那些“加了函数就卡死”的玄学问题。