
128x64的OLED屏大概是电子工程师手里出现率最高的一块屏幕。容量不大不小分辨率不高不低做温控器、仪表盘、小玩具都正合适。但每次用它做带菜单的东西总有人栽在同一个坑里菜单层级一多代码就开始失控。我第一次做三层菜单的时候也干过这种傻事整个逻辑全写在按键中断里if-else嵌套到第五层自己写完三天后就不敢碰了。后来痛定思痛把所有菜单逻辑推翻重写改成一套基于查表法的轻量级多级菜单框架用纯C实现不依赖操作系统不依赖动态内存在AVR、STM32这些常规单片机上都能跑得非常顺。这篇文章就把这套方案的完整思路和实现细节拆开讲清楚适合正在被OLED菜单折磨的嵌入式开发者也适合想理解菜单系统底层设计的初学者。1. 先搞清楚为什么OLED菜单总是被写成一团乱麻1.1 用if-else堆菜单的典型死法很多人的第一个菜单版本长这样在按键回调里判断当前处于哪个界面然后往OLED上打印对应的字符串。界面上只有两三个页面的时候这套代码完全够用甚至挺清晰。但一旦加进系统设置显示设置参数校准这类子菜单情况就开始失控了。void key_handle(uint8_t key) { if (current_page PAGE_MAIN) { if (key KEY_UP) { // 切换选中项 } else if (key KEY_DOWN) { // 切换选中项 } else if (key KEY_ENTER) { if (selected_item 0) { current_page PAGE_SETTING; oled_clear(); oled_draw_string(0, 0, SETTING); } else if (selected_item 1) { current_page PAGE_ABOUT; // ... } } } else if (current_page PAGE_SETTING) { // 又是重复的按键判断 } // 后续还有更多页面代码继续膨胀 }这种写法的核心问题在于页面和页面之间的跳转逻辑被揉进了同一个函数里新增一个页面需要动一堆已有的代码。到了三层菜单的时候你就得不断 switch 再嵌套 switch任何一个分支写错整个菜单系统就可能直接卡死或者跳到错误页面。这种代码结构本质上就是在用平铺的方式表达树状的关系当然会越来越难以维护。1.2 为什么不直接用图形库你可能会问u8g2、LVGL 这些现成的图形库不是自带菜单控件吗用它们不就行了u8g2 内置了菜单实现LVGL 更是有完整的对象树和事件系统功能确实强大。但代价是两个第一库的体积很大LVGL 的裁剪配置本身就要花不少功夫Flash 小的芯片根本塞不下第二这些库为了通用性会做很多抽象你要理解它的菜单回调机制、事件传递机制学习成本不低。而且很多场景下我们只是需要在一个128x64的小屏上做一个能上下翻、能进出子菜单、能返回的简单列表为了这个去引入一个图形框架属于杀鸡用牛刀。更重要的是自己动手实现一套你能完全掌控每一行代码。菜单的数据结构、按键响应方式、显示刷新策略全在你的脑子里出了问题可以立刻定位。这种掌控感在后期维护和功能扩展时会带来非常大的优势。1.3 轻量级方案的核心思路表驱动真正靠谱的嵌入式菜单设计思路是把菜单抽象成一张表每个菜单项只是一个节点节点里记录它叫什么、父节点是谁、子节点是谁、兄弟节点是谁。菜单切换的本质就是在这张表上移动指针。整个逻辑用一个状态机来驱动按键只是触发事件状态机负责决定往哪走。这样做下来不管你的菜单有多少层核心代码只需要几十行。这个思路说白了就是一句话用数据代替逻辑。菜单结构长什么样完全由初始化数据决定代码里不需要写死任何页面跳转关系。这正是轻量级菜单框架最迷人的地方——同一个引擎换个菜单表就能驱动完全不同的产品界面。2. 菜单核心数据结构用两张索引串起整棵菜单树2.1 每个菜单项只保存四个索引我之前在STM32上写过一个温控器的菜单结构大概是这样的Main ├── Setting │ ├── Display │ │ ├── Brightness │ │ └── Contrast │ └── TempCal └── About如果用指针来保存父子关系结构体大概是typedef struct MenuNode { const char *label; const struct MenuNode *parent; const struct MenuNode *child; const struct MenuNode *next; void (*on_click)(void); } MenuNode;在 GCC 环境下这样写没问题但在 AVR 这类哈佛架构的 MCU 上const 指针指向的 Flash 地址和 RAM 地址是两套空间直接解引用容易出乱子。后来我把指针全部换成索引用 int16_t 数组下标来模拟指针。这样有几个好处结构体体积更小、不涉及段地址差异、调试时打印索引比打印指针直观得多。typedef struct { const char *label; /* 菜单显示文本 */ int16_t parent; /* 父节点索引根节点为-1 */ int16_t child; /* 第一个子节点索引叶子为-1 */ int16_t next; /* 下一个兄弟节点索引-1表示没有 */ void (*on_click)(void); /* 确认键按下时的回调 */ } MenuNode;这个结构体有两点值得注意。第一我故意用next而不是prev是因为通知逻辑里最常见的操作是跳到下一个可选项而返回上一级走parent就够了。第二child保存的是第一个子节点其余子节点通过next链表串起来。这本质上是一个长子-兄弟表示法每个节点最多只需要三个索引就能完整表达任意层级的树。2.2 静态初始化一棵菜单树上述温控器菜单的初始化表是这样的enum { IDX_MAIN 0, IDX_SETTING, IDX_DISPLAY, IDX_BRIGHTNESS, IDX_TEMPCAL, IDX_ABOUT, IDX_CONTRAST }; static void on_brightness_click(void); static void on_contrast_click(void); static void on_tempcal_click(void); static void on_about_click(void); static const MenuNode menu_table[] { /* 索引 0: Main */ {Main, -1, IDX_SETTING, -1, NULL}, /* 索引 1: Setting */ {Setting, IDX_MAIN, IDX_DISPLAY, IDX_ABOUT, NULL}, /* 索引 2: Display */ {Display, IDX_SETTING, IDX_BRIGHTNESS, IDX_TEMPCAL, NULL}, /* 索引 3: Brightness */ {Brightness, IDX_DISPLAY, -1, IDX_CONTRAST, on_brightness_click}, /* 索引 4: TempCal */ {TempCal, IDX_SETTING, -1, -1, on_tempcal_click}, /* 索引 5: About */ {About, IDX_MAIN, -1, -1, on_about_click}, /* 索引 6: Contrast */ {Contrast, IDX_DISPLAY, -1, -1, on_contrast_click}, };注意看IDX_DISPLAY的next是IDX_TEMPCAL而IDX_BRIGHTNESS的next是IDX_CONTRAST。也就是说Setting的子菜单列表是Display - TempCalDisplay的子菜单列表是Brightness - Contrast。同级菜单靠next串成链表进入子菜单时拿到第一个子节点的索引自动就学会了整棵树的形状。这种用静态数组初始化的方式最大的好处是编译期就能确定菜单结构。万一你哪个子节点忘记在数组里定义编译器直接报错运行期不会出现空指针崩溃。2.3 为什么返回时要维护一条路径栈如果只靠parent字段从Brightness返回Display时没问题但返回后光标会固定在Display的第一个子项上。要是用户之前选的是Contrast返回后却看到高亮在Brightness上体验就很生硬。我的做法是加一个小栈专门记录进入路径#define MENU_PATH_MAX 8 static int16_t g_current; /* 当前所在节点的索引 */ static int16_t g_selected; /* 当前高亮节点的索引 */ static int16_t g_path_stack[MENU_PATH_MAX]; static uint8_t g_path_depth; static void menu_push_path(int16_t idx) { if (g_path_depth MENU_PATH_MAX) { g_path_stack[g_path_depth] idx; } } static int16_t menu_pop_path(void) { if (g_path_depth 0) { return g_path_stack[--g_path_depth]; } return -1; }进入子菜单时压栈返回时弹栈。这样8层深度的菜单足够覆盖绝大多数场景而 RAM 占用不过16字节。如果你做的产品菜单层级超过8层把宏改大一点就行。3. 状态机导航引擎四个按键走遍整棵菜单树3.1 事件定义与状态划分菜单在运行过程中其实可以抽象成两种状态普通浏览状态和编辑状态。普通浏览状态负责在菜单树里移动编辑状态负责让用户调整数值。把这两种状态拆开状态机就清晰了。按键事件只分四类typedef enum { EV_NONE 0, EV_UP, /* 向上选择 */ EV_DOWN, /* 向下选择 */ EV_ENTER, /* 确认/进入 */ EV_BACK /* 返回 */ } MenuEvent;浏览状态下的逻辑可以用一张转移表表示当前状态事件动作下一状态浏览UP高亮跳到上一个兄弟节点浏览浏览DOWN高亮跳到下一个兄弟节点浏览浏览ENTER若高亮节点有子节点则进入若有回调则执行回调浏览或编辑浏览BACK弹栈返回父节点浏览编辑UP数值加一编辑编辑DOWN数值减一编辑编辑ENTER保存数值浏览编辑BACK放弃修改浏览3.2 核心事件分发函数有了转移表核心逻辑就非常紧凑了static int16_t get_next_sibling(int16_t idx) { if (idx 0) return -1; return menu_table[idx].next; } static int16_t get_first_child(int16_t idx) { if (idx 0) return -1; return menu_table[idx].child; } void menu_event(MenuEvent evt) { if (g_edit_mode) { menu_event_edit(evt); return; } switch (evt) { case EV_UP: { int16_t prev g_selected; /* 同级菜单是单链表向上需要从头遍历 */ int16_t first get_first_child(g_current); int16_t node first; int16_t last first; while (node 0 node ! g_selected) { last node; node get_next_sibling(node); } g_selected (node first) ? -1 : last; if (g_selected 0) g_selected first; break; } case EV_DOWN: { int16_t next get_next_sibling(g_selected); if (next 0) { next get_first_child(g_current); } g_selected next; break; } case EV_ENTER: { if (g_selected 0) break; int16_t child get_first_child(g_selected); if (child 0) { menu_push_path(g_current); g_current g_selected; g_selected child; } if (menu_table[g_selected].on_click) { menu_table[g_selected].on_click(); } break; } case EV_BACK: { int16_t parent menu_pop_path(); if (parent 0) { g_current parent; g_selected get_first_child(parent); } break; } default: break; } }这段代码有几个细节值得说明。向上移动时由于同级节点是单向链表我得从头遍历找到前一个这个操作的时间复杂度是O(n)但菜单同级项一般不超过10个完全不构成性能问题。向下移动到最后一个时我让它回绕到第一个这样用户不会在高亮项上按 DOWN 时没反应交互上更顺手。ENTER的判定顺序也有讲究先看有没有子节点如果有就进入子菜单进入之后再看on_click是否需要执行。因为进入子菜单后g_selected已经更新了所以回调读取的是新节点的索引不会拿到旧值。3.3 编辑模式怎么复用这套框架像Brightness这种需要调数值的菜单项on_click回调可以切换到一个编辑模式static void on_brightness_click(void) { g_edit_target IDX_BRIGHTNESS; g_edit_value g_brightness; g_edit_mode 1; } void menu_event_edit(MenuEvent evt) { switch (evt) { case EV_UP: g_edit_value; if (g_edit_value 255) g_edit_value 255; break; case EV_DOWN: g_edit_value--; if (g_edit_value 0) g_edit_value 0; break; case EV_ENTER: g_brightness g_edit_value; g_edit_mode 0; break; case EV_BACK: g_edit_mode 0; break; default: break; } }这里我用g_edit_target记录正在编辑哪个菜单项但当前实现里并没有用到它。实际产品中可以用来做正在编辑XX的提示显示。真正要紧的是编辑模式下菜单树导航完全被冻结所有按键都走数值调整逻辑这样避免用户在编辑过程中误触方向键跳去了别的菜单。4. 128x64屏上的渲染与交互细节4.1 屏幕布局顶部标题、中间列表、底部按键提示128x64 像素的屏幕高度只有64像素如果使用8x16的英文字体一行正好16像素屏幕一共能显示4行。但扣掉顶部标题栏和底部按键提示栏留给菜单列表的区域一般只有48像素也就是3个完整行。我的推荐布局是顶部第0~15像素显示当前菜单的标题中间第16~47像素显示3个菜单项底部第56~63像素显示按键提示比如UP/DOWN选择 ENTER进入 BACK返回。中间每行菜单项高16像素12像素文字加4像素间距刚好排下3行第4行用来显示滚动提示。绘制菜单列表的核心函数static void oled_draw_menu(void) { char buf[17]; int16_t item; uint8_t y; /* 清除中间区域只清列表区不整屏清 */ oled_fill_rect(0, 16, 127, 47, OLED_CLEAR); /* 绘制当前高亮项的背景 */ item g_selected; if (item 0) { uint16_t idx (uint16_t)item; oled_draw_rect(0, 18 g_cursor * 16, 127, 30 g_cursor * 16, OLED_FILL); } /* 从第一个可见项开始逐个绘制 */ item menu_table[g_current].child; uint8_t i 0; while (item 0 i 3) { if (item g_selected) { /* 高亮项反白显示 */ oled_set_color(OLED_BLACK); } else { oled_set_color(OLED_WHITE); } menu_copy_label(buf, menu_table[item].label, sizeof(buf)); oled_draw_string(2, 18 i * 16, buf, OLED_NORMAL); item menu_table[item].next; i; } oled_refresh_region(0, 16, 127, 47); }这里有个通用性问题menu_copy_label是干什么的在 AVR 上menu_table[item].label指向 Flash 空间不能直接当普通字符串传给oled_draw_string。即使是 STM32如果编译器把字符串放到只读段直接传 const char* 一般没问题但为了跨平台统一写一个拷贝函数更稳妥static void menu_copy_label(char *dst, const char *src, uint8_t len) { #if defined(__AVR__) strncpy_P(dst, src, len); #else strncpy(dst, src, len); #endif dst[len - 1] \0; }用这个宏配合pgm_read_word系列的 Flash 读取函数代码就能在 AVR、STM32、ESP32 上无缝编译。4.2 高亮反白与滚动条128x64 的 OLED 是高对比度器件反白显示非常明显。反白的做法很简单先在整个选中行填充白色矩形再用黑色绘制文字。这样视觉上白底黑字非常清晰。菜单项超过3个时用户看不见剩余项这时有两种选择一种是让列表滚动另一种是缩行高。考虑到128x64的屏幕显示密度有限我推荐滚动方案。滚动需要管理一个当前显示窗口顶部的索引配合g_cursor光标所在行号static int16_t g_top_index; /* 列表窗口第一个可见项的索引 */ static uint8_t g_cursor; /* 高亮在窗口内的第几行 */当用户按下 DOWN 时如果光标已经在底部且下面还有项就g_top_index下移一行光标不动否则光标下移一行。这样视觉上菜单整体向上滚了一行高亮项始终可见。代码逻辑不算复杂但交互体验比只能在3个项之间翻页自然得多。4.3 整屏刷新还是局部刷新SSD1306 控制器有三种寻址模式推荐使用水平寻址模式0x20 0x00这样你可以在显存缓冲区里连续更新多个页。我最开始做菜单的时候直接整屏刷新每次按键都往 SSD1306 发送1024字节数据在100kHz的I2C总线上耗时接近100ms肉眼可见地闪烁。后来的做法是维护一块 1024 字节的framebuffer所有绘制操作只修改 framebuffer然后只在需要的时候把变化的区域刷到屏幕。SSD1306 支持设置窗口只更新指定矩形区域void oled_refresh_region(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) { uint8_t page0 y0 / 8; uint8_t page1 y1 / 8; oled_send_cmd(0x21); /* 设置列地址 */ oled_send_cmd(x0); oled_send_cmd(x1); oled_send_cmd(0x22); /* 设置页地址 */ oled_send_cmd(page0); oled_send_cmd(page1); for (uint8_t page page0; page page1; page) { for (uint8_t col x0; col x1; col) { uint16_t idx page * 128 col; oled_send_data(framebuffer[idx]); } } }这样菜单列表变化时只刷新列表区域单次 I2C 传输的数据量从1024字节降到48字节耗时不到5ms闪烁问题彻底消失MCU 的负担也小很多。这套显存缓冲 局部刷新的思路是所有小屏 UI 的基本功。5. 内存和Flash账本轻量级到底有多轻5.1 只用静态内存拒绝malloc嵌入式菜单框架最大的敌人就是堆内存。malloc和free在 PC 上用得爽到单片机上就成了隐患内存碎片、分配失败、空闲链表开销哪个都够你喝一壶。更关键的是菜单树的形状在编译期就已经完全确定了根本不存在运行到一半才知道有几个菜单项的情况。那为什么还要动态分配我们用static const数组定义菜单表意味着所有菜单节点都躺在 Flash 里不占一丝 RAM。导航需要的g_current、g_selected、路径栈这些变量加起来不到几十字节。这套设计保证了 RAM 占用是常数级别的不管菜单加得多大RAM 占用都不变。5.2 一个典型的资源占用核算拿温控器菜单来说总共7个节点每个MenuNode的label指针 2 字节AVR 上指针是16位加上三个int16_t索引和一个函数指针一共10字节。7个节点总共70字节 Flash字符串常量和菜单标签加起来100字节左右整个菜单表不超过200字节 Flash。RAM 侧的大头反而是显存缓冲1024字节。把这个也考虑进去资源项大小位置菜单节点表7个节点70BFlash菜单字符串~100BFlash显存缓冲1024BRAM导航变量current/selected/path/cursor32BRAM编辑临时值4BRAM合计RAM~1.06KB合计Flash不含OLED驱动~170B这个账目很说明问题在 ATmega328P 这种只有2KB RAM、32KB Flash 的老古董上这套方案也能轻松运行在 STM32F103C8T6 这种 20KB RAM、64KB Flash 的芯片上更是毫无压力。轻量级不是一句口号是真能放进最抠门的MCU里。5.3 进一步压缩内存的备选方案如果你的芯片连1024字节的显存缓冲都嫌大可以考虑只保留半屏缓冲或者直接按页刷新。SSD1306 内部自带显存最省内存的做法是逐页绘制、逐页发送MCU 侧不留缓冲代价是绘制闪烁和复杂操作如反白、滚动的实现会困难许多。折中方案是只开一个 128x8 的单页缓冲按需刷新。菜单列表通常落在中间几行一次只处理一页。这样显存缓冲从1024字节降到128字节代价是滚动和反白需要跨页计算。如果你在做像 ATtiny 这种 RAM 不到1KB 的极端条件下这套精简版是可行的。但我个人建议只要 RAM 能放下1024字节就老老实实地用完整缓冲绘制逻辑简单清晰后期绝对省心。6. 实测中的坑从按键到SSD1306的血泪经验6.1 按键消抖:20ms状态机扫描菜单的按键处理看起来简单实际最容易出问题。常见的机械按键抖动时间在5~20ms如果直接用电平跳变触发一次按下可能触发两三次事件菜单一次跳两格非常烦躁。我用的方案是连续两次扫描确认法每10ms扫描一次按键电平连续两次读到相同状态才算稳定否则重新计数。这样等效消抖时间20ms既不影响响应速度又能滤掉大部分抖动#define KEY_SCAN_INTERVAL 10 /* ms */ uint8_t key_scan(void) { static uint8_t last_state 0xFF; static uint8_t confirm_count 0; uint8_t current_state read_key_pins(); /* 读取四个按键电平 */ uint8_t changed current_state ^ last_state; if (changed) { last_state current_state; confirm_count 0; return 0; } if (confirm_count 2) { confirm_count; return 0; } /* 连续两次确认只在按键刚稳定时返回一次事件 */ static uint8_t prev_confirm 0; uint8_t event (current_state ~prev_confirm); prev_confirm current_state; return event; }主循环每10ms调用一次key_scan返回的事件被翻译成EV_UP、EV_DOWN等再交给menu_event。这套扫描逻辑把消抖和事件过滤合在一起代码很短实测下来非常稳定。要特别注意按键扫描和菜单事件处理不要在中断服务函数里做中断里只置标志位主循环去处理否则中断嵌套会导致菜单状态错乱。6.2 SSD1306初始化序列的一个坑很多人在网上照抄SSD1306初始化代码跑起来发现屏幕要么全黑要么花屏。问题常常出在初始化顺序上必须在开启显示0xAF之前完成电荷泵、对比度、列地址等配置。如果先开显示再设对比度屏幕可能会闪一下或者显示异常。我实测可用的初始化序列是static void oled_init(void) { oled_send_cmd(0xAE); /* 关闭显示 */ oled_send_cmd(0xD5); oled_send_cmd(0x80); /* 时钟分频 */ oled_send_cmd(0xA8); oled_send_cmd(0x3F); /* 64行驱动 */ oled_send_cmd(0xD3); oled_send_cmd(0x00); /* 显示偏移0 */ oled_send_cmd(0x40); /* 起始行0 */ oled_send_cmd(0x8D); oled_send_cmd(0x14); /* 电荷泵使能 */ oled_send_cmd(0x20); oled_send_cmd(0x00); /* 水平寻址 */ oled_send_cmd(0xA1); /* 段重映射 */ oled_send_cmd(0xC8); /* COM扫描方向 */ oled_send_cmd(0xDA); oled_send_cmd(0x12); /* COM引脚配置 */ oled_send_cmd(0x81); oled_send_cmd(0xCF); /* 对比度 */ oled_send_cmd(0xD9); oled_send_cmd(0xF1); /* 预充电 */ oled_send_cmd(0xDB); oled_send_cmd(0x40); /* VCOMH */ oled_send_cmd(0xA4); /* 全局显示开启 */ oled_send_cmd(0xA6); /* 正常显示 */ oled_send_cmd(0xAF); /* 开启显示 */ }另外SSD1306的I2C地址通常是0x3C但有些模块的地址跳线焊上之后会变成0x3D。如果你的代码一直写地址0x3C却没有任何反应先用 I2C 扫描程序确认实际地址别一上来就怀疑芯片坏了。6.3 长文本和中文显示的处理建议128x64 屏幕上放中文通用做法是使用16x16点阵字库。但把整个GB2312字库全塞进Flash会占据几百KB空间对小芯片不现实。轻量级方案有两条路一是只提取需要的汉字做一个精简字库表二是用画图工具把菜单文字转成图片直接贴到 framebuffer 上。如果你的产品需要频繁改菜单文案建议在烧录时用一个专门的小工具把字符串生成点阵数组然后编译进固件。这样 Flex 性和体积就能平衡。英文菜单则简单得多内置一个8x6的ASCII字库一个字符占6~8字节256个字符也才2KB左右随代码一起编进Flash毫无压力。6.4 扩展思路旋转编码器、开机动画和主题切换这套菜单框架的导航核心是事件分发和具体按键硬件完全解耦。所以接入旋转编码器时只需把编码器的CW/CCW中断翻译成EV_UP/EV_DOWN按键翻译成EV_ENTER菜单代码一行都不用改。接入触摸屏也是同理触摸回调产生EV_UP/EV_DOWN/EV_ENTER事件即可。我后来在这个框架上加了开机动画和一个简单的主题切换功能一种主题是白色背景黑色文字另一种是黑色背景白色文字。因为反白绘制本来就是对 framebuffer 做异或操作只要在绘制前设置一个全局颜色变量所有绘制函数都读取它切换主题只需要翻转一个标志位然后重新绘制整屏。这套设计之所以扩展起来这么顺手正是因为最开始的抽象只暴露了事件和节点两个概念硬件细节全被挡在了外面。如果你现在正被 OLED 菜单折腾得焦头烂额不妨试试把菜单结构从代码里彻底剥离出来用一张静态表描述整棵菜单树再用一个轻量状态机驱动导航。这套方案我前后在三个项目里复用从温控器到小型示波器都没出过问题。