用两个按键做一个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,两个按键都用内部上拉、按下接地。

下面按四版递进,每一版都能单独烧进板子跑起来:

  1. 第一版:主菜单只有三项,A 移动光标、B 进入;
  2. 第二版:加上子页面和返回;
  3. 第三版:子页面里加参数调整;
  4. 第四版:把"什么时候重画屏幕"管起来。

第一版:主菜单只有三项

屏幕上先放三项:

text 复制代码
Car Menu
> Start
  Speed
  Sensor

菜单项的名字、当前选中的下标、以及当前在哪一页,各用一个变量或一张表记住:

c 复制代码
#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}。

第一版的按键处理只有两条分支:

c 复制代码
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 改成对应的页面:

c 复制代码
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() 里,先清、再写、最后提交:

c 复制代码
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() 才把画布整块送出去。

初始化只做两件事------把菜单回到初始状态,然后画一次:

c 复制代码
/* 回到主菜单、光标停在第一项,然后画一帧 */
static void MenuSimple_Init(void)
{
    page = MENU_PAGE_MAIN;
    selected = 0U;
    MenuSimple_Draw();
}

主循环把三件事排好:

c 复制代码
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 确实变了,可屏幕上看不出任何变化------因为这一版的绘制函数只会画主菜单。变量动了、画面没动,这正是第二步要补的东西:先把"页面"这个概念立起来,再给它内容。

第二版:加上子页面和返回

现在给页面配上内容。速度页显示一个目标速度,传感器页暂时显示占位信息:

c 复制代码
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() 都要跟着改,整个函数重放一遍:

c 复制代码
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();
}
c 复制代码
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() 读另一个键的稳定状态来判断。速度页里就用它:

c 复制代码
#define SPEED_STEP       5U
#define SPEED_TARGET_MAX 100U

MenuSimple_Update() 再重放一遍,速度页的分支加进去:

c 复制代码
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() 重放一遍:

c 复制代码
/* 子页面:每一页只画自己那一屏的内容 */
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 算,也要二十几毫秒。按一下键就停二十几毫秒,主循环里别的任务都得等着。

更省的做法是记住"画面脏了没有":

c 复制代码
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(),而真正的绘制挪到主循环:

c 复制代码
while (1)
{
    MenuSimple_Update();

    if (MenuSimple_IsDirty() != 0U)         /* 只有举过牌子才真正刷一次屏 */
    {
        MenuSimple_Draw();
        MenuSimple_ClearDirty();
    }
}
c 复制代码
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 个字节在线上走完的时间。

画布和提交,在代码里就是两件事:

c 复制代码
/* 驱动里那块显存: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() 会碰总线,所以"改了状态没改屏幕"这类现象,永远先怀疑它没有被调到。

所以下面这两种写法看起来差不多,结果差得很远:

c 复制代码
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() 里临时改成:

c 复制代码
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == Bit_RESET)   /* 按住不放就会一直加 */
{
    selected = (uint8_t)((selected + 1U) % MENU_ITEM_COUNT);
    MenuSimple_Draw();
}

烧进去,按住 A 不放:光标会像失控一样飞快循环。换回 Key_GetEvent(),现象立刻恢复成"一次按下移动一项"。

根源不在菜单,而在输入层:它需要把持续的"电平"翻译成一次性的"动作"。 电平在你按住的每一毫秒里都是低电平,而菜单要的是"刚刚按下了"这一个瞬间。之前把这件事做成了事件队列,这一版直接读电平,等于把已经翻译好的东西又退回原料状态。

三个故障各自只破坏一层:显示提交、画布清理、输入事件。这样看现象时,你能知道是哪一层没有完成自己的工作,而不是笼统地"屏幕不动了"。

最后橘猫说

现在主菜单只有三项,每一项进哪个页面一眼能看懂,速度页的加减也只是一段直白的判断。再往后加"循迹模式、手动模式、PID 参数、传感器查看",这些判断就会开始复制,页面资料和动作也会散到各处。后面我们就来解决这个问题。

相关推荐
Good白10193 小时前
嵌入式:1.嵌入式入门|初识嵌入式、处理器、ARM与STM32
arm开发·stm32·嵌入式硬件
小李不想当小白4 小时前
HAL库之UART(STM32 HAL库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
Dev_XH4 小时前
【成品设计】基于物联网的旅游生态农业互动系统
stm32·单片机·嵌入式硬件·物联网·旅游
lzj_pxxw20 小时前
STM32移植FreeRTOS(Cmake+CubeMX)
vscode·stm32·嵌入式硬件
Zw-awa1 天前
屏幕不止一种:OLED、LCD1602字符屏、TFT/IPS图形屏和串口屏怎么用,这与TTY和PTY又有什么关系
stm32
无敌贵点大王1 天前
从寄存器层面重学STM32(7)——定时器
stm32·单片机·嵌入式硬件
wuyk5552 天前
Keil工具链-第05讲-启动文件与__main到底做了什么
c语言·stm32·单片机·嵌入式硬件·物联网
Zw-awa2 天前
OLED为什么能显示字:软件I2C换成硬件I2C
stm32
Zw-awa3 天前
协议是人为约定:同一个字节能有三种意思
stm32