用两个按键做一个OLED菜单:先把选择和执行分开
经典三问
Q: 嵌入式菜单是什么?
A: 菜单是把若干功能组织成页面与选项,并记住“现在在哪一页、光标指向哪一项”的交互界面。
Q: 为什么要把选择和执行分开?
A: 选择只改变光标,执行才修改参数或启动动作;分开以后,加一个保护条件不用把电机代码塞回按键判断里。
Q: OLED 菜单常见使用场景?
A: 小车模式切换、PID 参数调整、传感器数值查看,以及没有电脑时给设备做设置。
A 键按下去,光标往下走。B 键按下去,屏幕从主菜单切进子页面;再按一次 B,回到主菜单。
这几个动作看起来很小,却足够把“按键”“屏幕”“业务动作”之间的关系照出来。在这之前,OLED 更像是一个单纯用来显示数据的模块,现在让我们搭配上按键,让其能够像老人机一样。要制作菜单,首先就需要关注三件事:现在在哪一页、光标指向哪一项、屏幕有没有需要重新画。
接线沿用前面定下来的分配:0.96 寸 128×64 的 I2C OLED,SCL 接 PB8、SDA 接 PB9;A 键接 PB11、B 键接 PB1,两个按键都用内部上拉、按下接地。
下面按四版递进,每一版都能单独烧进板子跑起来:
- 第一版:主菜单只有三项,A 移动光标、B 进入;
- 第二版:加上子页面和返回;
- 第三版:子页面里加参数调整;
- 第四版:把“什么时候重画屏幕”管起来。
第一版:主菜单只有三项
屏幕上先放三项:
Car Menu
> Start
Speed
Sensor
菜单项的名字、当前选中的下标、以及当前在哪一页,各用一个变量或一张表记住:
#define MENU_ITEM_COUNT 3U /* 主菜单一共三项 */
/* 菜单项名字:每项最多 9 个字符,留一个字节放结尾的 0 */
static const char menu_items[MENU_ITEM_COUNT][10] =
{
"Start",
"Speed",
"Sensor"
};
typedef enum
{
MENU_PAGE_MAIN = 0,
MENU_PAGE_START,
MENU_PAGE_SPEED,
MENU_PAGE_SENSOR
} MenuPage;
static MenuPage page = MENU_PAGE_MAIN; /* 现在在哪一页 */
static uint8_t selected = 0U; /* 光标指向第几项 */
page 是“我在哪一页”,selected 是“光标在第几项”。这两个变量就是菜单的全部记忆——它不记录“用户上一次按了什么”,也不需要。
按键这一层直接用之前那套事件接口:Key_Tick() 挂在 1ms 节拍上负责消抖与识别,Key_GetEvent() 从队列里一次取一个事件出来,event.id 说是哪个键(KEY_A 或 KEY_B),event.type 说发生了什么(KEY_EVENT_CLICK 等)。“按一下 A”在应用层看来就是队列里的一个 {KEY_A, KEY_EVENT_CLICK}。
第一版的按键处理只有两条分支:
static void MenuSimple_Update(void)
{
KeyEvent event;
while (Key_GetEvent(&event) != 0U)
{
if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK)) /* A 只动光标 */
{
selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT); /* 十分常见的循环写法 */
MenuSimple_Draw();
}
else if ((event.id == KEY_B) && (event.type == KEY_EVENT_CLICK))
{
MenuSimple_Enter();
MenuSimple_Draw();
}
}
}
两个 if 对应两件事:A 只动光标,B 只改页面。 这里没有电机,也没有传感器;MenuSimple_Enter() 只是把 page 改成对应的页面:
static void MenuSimple_Enter(void)
{
if (selected == 0U)
{
page = MENU_PAGE_START;
}
else if (selected == 1U)
{
page = MENU_PAGE_SPEED;
}
else
{
page = MENU_PAGE_SENSOR;
}
}
画屏幕的函数集中在 MenuSimple_Draw() 里,先清、再写、最后提交:
static void MenuSimple_Draw(void)
{
uint8_t index;
OLED_Clear(); /* 先把上一屏擦干净 */
OLED_ShowString(0, 0, "Car Menu", OLED_8X16);
for (index = 0U; index < MENU_ITEM_COUNT; index++) /* 每一项占 10 像素高 */
{
OLED_ShowString(0, (uint8_t)(20U + (index * 10U)),
(index == selected) ? ">" : " ", OLED_6X8);
OLED_ShowString(8, (uint8_t)(20U + (index * 10U)),
(char *)menu_items[index], OLED_6X8);
}
OLED_Update();
}
OLED_Clear()、OLED_ShowString()、OLED_Update() 是前面一直在用的三个显示函数:前两个只改驱动里那块显存,最后一个才把显存整块送上屏幕。顺序不能颠倒,原因下一节会说。
之前提过:做菜单这种需要整屏刷新的地方,会换回带显存缓冲的那套驱动。这里用的就是它——两套驱动,同一批接口名,区别在于现在 OLED_ShowString() 这类函数不再立刻发 I2C,而是往内存里那块画布上写;OLED_Update() 才把画布整块送出去。
初始化只做两件事——把菜单回到初始状态,然后画一次:
/* 回到主菜单、光标停在第一项,然后画一帧 */
static void MenuSimple_Init(void)
{
page = MENU_PAGE_MAIN;
selected = 0U;
MenuSimple_Draw();
}
主循环把三件事排好:
int main(void)
{
/*
C89 风格要求变量声明在语句之前,写在中间会报
declaration may not appear after executable statement in block
*/
GPIO_InitTypeDef gpio;
Timer_Init1ms(); /* 建立 1ms 节拍,按键靠它计时 */
OLED_Init(); /* 那块 0.96 寸屏的初始化,前面每篇都有它 */
/* 按键引脚:PB11、PB1,内部上拉,按下接地(和之前一样)这里相信大家都已经有自己的 key_init() 函数了吧,直接换成自己的按键初始化即可 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_11 | GPIO_Pin_1;
gpio.GPIO_Mode = GPIO_Mode_IPU;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &gpio);
MenuSimple_Init();
while (1)
{
MenuSimple_Update();
}
}
Timer_Init1ms() 是定时器那篇里那个 1ms 初始化;Key_Tick() 每毫秒被调用一次,读一遍电平、滤掉抖动、把稳定变化整理成事件。两者一个提供时间,一个把“电平”翻译成“动作”,菜单这一层只消费动作。
烧进去,A 能把光标在三项之间循环。按 B 的时候,page 确实变了,可屏幕上看不出任何变化——因为这一版的绘制函数只会画主菜单。变量动了、画面没动,这正是第二步要补的东西:先把“页面”这个概念立起来,再给它内容。
第二版:加上子页面和返回
现在给页面配上内容。速度页显示一个目标速度,传感器页暂时显示占位信息:
static uint8_t speed_target = 30U;
/* 子页面:每一页只画自己那一屏的内容 */
static void MenuSimple_DrawPage(void)
{
if (page == MENU_PAGE_START)
{
OLED_ShowString(0, 0, "Start", OLED_8X16);
OLED_ShowString(0, 24, "car will run", OLED_6X8);
}
else if (page == MENU_PAGE_SPEED)
{
OLED_ShowString(0, 0, "Speed", OLED_8X16);
OLED_ShowString(0, 24, "Target", OLED_6X8);
OLED_ShowNum(48, 24, speed_target, 3, OLED_6X8);
}
else
{
OLED_ShowString(0, 0, "Sensor", OLED_8X16);
OLED_ShowString(0, 24, "Line: --", OLED_6X8);
OLED_ShowString(0, 34, "Battery: --", OLED_6X8);
}
OLED_ShowString(0, 54, "B: back", OLED_6X8);
}
OLED_ShowNum() 按位数把一个数字写进显存,和 OLED_ShowString() 一样,只改显存、不上屏。
页面分支出现了,MenuSimple_Draw() 和 MenuSimple_Update() 都要跟着改,整个函数重放一遍:
static void MenuSimple_Draw(void)
{
uint8_t index;
OLED_Clear(); /* 先把上一屏擦干净 */
if (page != MENU_PAGE_MAIN) /* 子页面交给 DrawPage,画完立刻提交 */
{
MenuSimple_DrawPage();
OLED_Update();
return;
}
OLED_ShowString(0, 0, "Car Menu", OLED_8X16);
for (index = 0U; index < MENU_ITEM_COUNT; index++) /* 每一项占 10 像素高 */
{
OLED_ShowString(0, (uint8_t)(20U + (index * 10U)),
(index == selected) ? ">" : " ", OLED_6X8);
OLED_ShowString(8, (uint8_t)(20U + (index * 10U)),
(char *)menu_items[index], OLED_6X8);
}
OLED_Update();
}
static void MenuSimple_Update(void)
{
KeyEvent event;
while (Key_GetEvent(&event) != 0U)
{
if (event.type != KEY_EVENT_CLICK)
{
continue; /* 只看单击 */
}
if (event.id == KEY_A)
{
if (page == MENU_PAGE_MAIN)
{
selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT);
MenuSimple_Draw();
}
}
else if (event.id == KEY_B)
{
if (page == MENU_PAGE_MAIN)
{
MenuSimple_Enter();
}
else
{
page = MENU_PAGE_MAIN; /* 子页面里按 B:返回 */
}
MenuSimple_Draw();
}
}
}
第二版的样子是:A 只在主菜单里移动光标,B 负责“进去”和“出来”。 同一颗 B 键在两种页面里干两件事,判断依据是最简单的那个变量——page。
第三版:在子页面里改参数
速度页要能改。这里会遇到一个很实际的问题:只有两个按键,却需要“加”和“减”两个方向。
最直接的答案是再加一颗键,但小车上的引脚是要数的。之前已经给出了现成的办法:那一版认出了组合键——按住一个键的同时单击另一个键,用 Key_IsDown() 读另一个键的稳定状态来判断。速度页里就用它:
#define SPEED_STEP 5U
#define SPEED_TARGET_MAX 100U
MenuSimple_Update() 再重放一遍,速度页的分支加进去:
static void MenuSimple_Update(void)
{
KeyEvent event;
while (Key_GetEvent(&event) != 0U)
{
if (event.type != KEY_EVENT_CLICK)
{
continue;
}
if (page == MENU_PAGE_MAIN)
{
if (event.id == KEY_A)
{
selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT);
MenuSimple_Draw();
}
else if (event.id == KEY_B)
{
MenuSimple_Enter();
MenuSimple_Draw();
}
}
else if (page == MENU_PAGE_SPEED)
{
if ((event.id == KEY_A) && (Key_IsDown(KEY_B) != 0U))
{
if (speed_target >= SPEED_STEP) /* 按住 B 单击 A:减 */
{
speed_target = (uint8_t)(speed_target - SPEED_STEP);
MenuSimple_Draw();
}
}
else if (event.id == KEY_A)
{
if (speed_target <= (SPEED_TARGET_MAX - SPEED_STEP)) /* 单击 A:加 */
{
speed_target = (uint8_t)(speed_target + SPEED_STEP);
MenuSimple_Draw();
}
}
else if (event.id == KEY_B)
{
page = MENU_PAGE_MAIN;
MenuSimple_Draw();
}
}
else
{
if (event.id == KEY_B)
{
page = MENU_PAGE_MAIN;
MenuSimple_Draw();
}
}
}
}
注意组合键那一条写在单击 A 的前面:按住 B 再点 A 时,队列里出现的事件仍然是“A 被单击”,只有先问一句“B 现在是不是还按着”,才能把这两种操作分开。这个顺序搞反了,减 5 就会被加 5 顶掉。
限幅的两句也别省。speed_target 是 uint8_t,减到 0 之后再减会绕回 255,加过头会冲出量程——参数修改的第一条规矩,是让它永远待在自己该在的范围里。
页面上的提示也跟着补上,MenuSimple_DrawPage() 重放一遍:
/* 子页面:每一页只画自己那一屏的内容 */
static void MenuSimple_DrawPage(void)
{
if (page == MENU_PAGE_START)
{
OLED_ShowString(0, 0, "Start", OLED_8X16);
OLED_ShowString(0, 24, "car will run", OLED_6X8);
}
else if (page == MENU_PAGE_SPEED)
{
OLED_ShowString(0, 0, "Speed", OLED_8X16);
OLED_ShowString(0, 24, "Target", OLED_6X8);
OLED_ShowNum(48, 24, speed_target, 3, OLED_6X8);
OLED_ShowString(0, 34, "A:+5 B+A:-5", OLED_6X8);
}
else
{
OLED_ShowString(0, 0, "Sensor", OLED_8X16);
OLED_ShowString(0, 24, "Line: --", OLED_6X8);
OLED_ShowString(0, 34, "Battery: --", OLED_6X8);
}
OLED_ShowString(0, 54, "B: back", OLED_6X8);
}
第四版:什么时候重画屏幕
前三版每改一次状态就调一次 MenuSimple_Draw()。屏幕确实跟得上,但代价被藏起来了:OLED_Update() 每次都把整块显存送出去,128×64 是 1024 个字节,就算按 400kHz 的 I2C 算,也要二十几毫秒。按一下键就停二十几毫秒,主循环里别的任务都得等着。
更省的做法是记住“画面脏了没有”:
static uint8_t menu_dirty = 1U; /* 1 表示画面已经过时,该重画了 */
static void MenuSimple_MarkDirty(void)
{
menu_dirty = 1U;
}
static uint8_t MenuSimple_IsDirty(void)
{
return menu_dirty;
}
static void MenuSimple_ClearDirty(void)
{
menu_dirty = 0U;
}
menu_dirty 就像一盏状态灯:亮着表示“屏幕上的内容可能已经过时”,灭着表示“上一次画面已经送上屏幕了”。于是 MenuSimple_Update() 里所有 MenuSimple_Draw() 都换成 MenuSimple_MarkDirty(),而真正的绘制挪到主循环:
while (1)
{
MenuSimple_Update();
if (MenuSimple_IsDirty() != 0U) /* 只有举过牌子才真正刷一次屏 */
{
MenuSimple_Draw();
MenuSimple_ClearDirty();
}
}
static void MenuSimple_Update(void)
{
KeyEvent event;
while (Key_GetEvent(&event) != 0U)
{
if (event.type != KEY_EVENT_CLICK)
{
continue;
}
if (page == MENU_PAGE_MAIN)
{
if (event.id == KEY_A)
{
selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT);
MenuSimple_MarkDirty();
}
else if (event.id == KEY_B)
{
MenuSimple_Enter();
MenuSimple_MarkDirty();
}
}
else if (page == MENU_PAGE_SPEED)
{
if ((event.id == KEY_A) && (Key_IsDown(KEY_B) != 0U))
{
if (speed_target >= SPEED_STEP)
{
speed_target = (uint8_t)(speed_target - SPEED_STEP);
MenuSimple_MarkDirty();
}
}
else if (event.id == KEY_A)
{
if (speed_target <= (SPEED_TARGET_MAX - SPEED_STEP))
{
speed_target = (uint8_t)(speed_target + SPEED_STEP);
MenuSimple_MarkDirty();
}
}
else if (event.id == KEY_B)
{
page = MENU_PAGE_MAIN;
MenuSimple_MarkDirty();
}
}
else
{
if (event.id == KEY_B)
{
page = MENU_PAGE_MAIN;
MenuSimple_MarkDirty();
}
}
}
}
改动的只有两处:状态变化时不再立刻画,而是举一下牌子;主循环看到牌子才画一次。这一改,一秒钟内按十下键,屏幕最多也就画十次;没按键的那些圈,一次 I2C 传输都不发生。
OLED 有一块看不见的画布
第四版里那句“举牌子”之所以能成立,前提是显示函数的行为:OLED_ShowString()、OLED_ShowNum() 这些函数改的是驱动内部那块显存,不是屏幕本身;只有 OLED_Update() 才会把整块显存通过 I2C 送出去。
这块画布是 8 页 × 128 列 = 1024 字节,一个字节竖着管 8 个像素。OLED_Update() 送的时候按页走:一页先把列地址设回 0,再连续写 128 个字节,八页就是八段这样的传输——整屏刷新要多久,算的就是这 1024 个字节在线上走完的时间。
画布和提交,在代码里就是两件事:
/* 驱动里那块显存:8 页 × 128 列,一个字节竖着管 8 个像素 */
static uint8_t OLED_DisplayBuf[8][128];
void OLED_Clear(void)
{
uint8_t page;
uint8_t column;
for (page = 0U; page < 8U; page++) /* 只擦内存,一个字节都不往外发 */
{
for (column = 0U; column < 128U; column++)
{
OLED_DisplayBuf[page][column] = 0x00U;
}
}
}
void OLED_Update(void)
{
uint8_t page;
uint8_t column;
for (page = 0U; page < 8U; page++)
{
OLED_SetCursor(page, 0U); /* 定位到这一页的第一个字节 */
for (column = 0U; column < 128U; column++)
{
OLED_WriteData(OLED_DisplayBuf[page][column]); /* 一页 128 个字节 */
}
}
}
OLED_ShowString()、OLED_ShowNum() 走的是同一条路——把字模逐位写进 OLED_DisplayBuf,同样一个字节都不往外发。全篇只有 OLED_Update() 会碰总线,所以“改了状态没改屏幕”这类现象,永远先怀疑它没有被调到。
所以下面这两种写法看起来差不多,结果差得很远:
OLED_Clear();
OLED_ShowString(0, 0, "Speed", OLED_8X16);
/* 少了 OLED_Update():变量变了,屏幕不动 */
调试时最容易被这件事骗到:串口打印出 page == MENU_PAGE_SPEED,OLED 却还停在主菜单。我第一次碰到时先怀疑按键接触不良,最后发现只是画布没有提交。
OLED_Clear() 的位置同样有讲究。它应该发生在一次完整绘制的开头,把上一页留下的字擦干净。只在初始化时清一次是不够的:速度页的 30 比传感器页的 -- 长,清屏漏掉时,回退到传感器页会在旧字符后面留下痕迹。
三个可以当场复现的故障
先烧录第四版,确认 A 能移动光标、B 能进出页面、速度页能加减。然后一次只改一处:
一、注释掉 MenuSimple_Draw() 里的 OLED_Update()。 按键照旧、page 照旧在变、串口打印也照旧,屏幕却一动不动地停在旧画面上。恢复这一行,画面立刻跟上。
二、注释掉 MenuSimple_Draw() 里的 OLED_Clear()。 先进速度页(上面有三位数字),再退回传感器页,看上一页较长的字符有没有残留。恢复清屏,页面之间就不再互相污染。
三、把事件接口换成直接读电平。 在 MenuSimple_Update() 里临时改成:
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == Bit_RESET) /* 按住不放就会一直加 */
{
selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT);
MenuSimple_Draw();
}
烧进去,按住 A 不放:光标会像失控一样飞快循环。换回 Key_GetEvent(),现象立刻恢复成“一次按下移动一项”。
根源不在菜单,而在输入层:它需要把持续的“电平”翻译成一次性的“动作”。 电平在你按住的每一毫秒里都是低电平,而菜单要的是“刚刚按下了”这一个瞬间。之前把这件事做成了事件队列,这一版直接读电平,等于把已经翻译好的东西又退回原料状态。
三个故障各自只破坏一层:显示提交、画布清理、输入事件。这样看现象时,你能知道是哪一层没有完成自己的工作,而不是笼统地“屏幕不动了”。
最后橘猫说
现在主菜单只有三项,每一项进哪个页面一眼能看懂,速度页的加减也只是一段直白的判断。再往后加“循迹模式、手动模式、PID 参数、传感器查看”,这些判断就会开始复制,页面资料和动作也会散到各处。后面我们就来解决这个问题。

只有两个按键,也能做出带子页面、还能加减参数的菜单。难点为什么不在画出文字,而在于让每一次按键都落到正确的状态上?
浙公网安备 33010602011771号