嵌入式OLED表驱动菜单:用一张表、一个栈和一个窗口重写表驱动菜单

0 阅读14分钟

经典三问

Q: 表驱动菜单是什么?
A: 它把标题、层级、返回关系和动作整理成数据表,由一段通用代码去解释这些表项。

Q: 为什么菜单变大以后要表驱动?
A: 页面增加时主要是新增数据而不是改控制流程,结构能检查、能扩展、能复用。

Q: 表驱动设计常见使用场景?
A: 多级设置页、参数列表、设备诊断界面,以及需要频繁增删功能的嵌入式界面。


之前的菜单只有三项时,问题还不明显。然后我加了 Car Menu、Setting、About,屏幕上的字没有乱,按键却开始像在开盲盒:光标停在 Speed,按下确认却跑进了传感器页面;改了一个选项的编号,另一个页面的回调跟着错位。代码里到处是 switch (selected),每一处都说自己记得“第二项是什么”。

这一次不再给每个页面单独写一套编号。要给菜单记住的东西一共有三样:现在在哪一层、这一层的光标在哪一项、这一项该做什么。 它们分别对应三件事:

  • 用一张表描述页面和菜单项,表项自己带类型,不再有外挂的编号;
  • 用一个固定深度的栈记录“从根页面走到当前页面”的路径,返回时不重新初始化菜单;
  • 用一个滚动窗口处理项数超过屏幕行数的页面,让“菜单一多”真的成立。

硬件与按键约定

硬件沿用前面:STM32F103C8T6,A 键接 PB11、B 键接 PB1,两个按键都用内部上拉、按下接地;OLED 的 PB8/PB9 还是软件 I2C;串口用 PA9/PA10,115200,接 CH340。

操作约定在这里定死,后面所有页面都按它写:

操作含义
A 单击光标下移;在数值项上是加一档
按住 B 再单击 A在数值项上是减一档(组合键,来自按键那一回)
B 单击确认当前项:进子页、执行动作、保存数值、翻转开关
B 长按返回上一层;在数值页上是放弃这次修改

这里有一个细节要单独说:B 长按返回时,不能先触发一次“确认”。 按键那一回已经把这个坑填掉了——长按成立之后松手不再补发 CLICK,所以长按只会产生 KEY_EVENT_LONG_HOLD,不会顺手带出一次 KEY_EVENT_CLICK。菜单这一层不需要再加防抖或延时,直接按事件类型分派就行。

菜单项为什么要带类型

只有“名字 + 回调”的表,最多解决“显示哪一项”和“确认做什么”。要能进子页面、能改数值、能开关,就得让表项自己说明它是什么:

typedef enum
{
    MENU_ITEM_ACTION = 0,       /* 一项就干一件事 */
    MENU_ITEM_SUBMENU,          /* 进入一张子页面 */
    MENU_ITEM_VALUE,            /* 一个可加减的数值 */
    MENU_ITEM_TOGGLE            /* 一个开关 */
} MenuItemType;

typedef struct MenuPage MenuPage;       /* 先声明名字,下面还要用它 */

typedef struct
{
    const char *name;                   /* 屏幕上显示的名字 */
    MenuItemType type;                  /* 这一项是什么 */
    void (*on_confirm)(void);           /* ACTION:确认时做什么 */
    const MenuPage *child;              /* SUBMENU:进哪一页 */
    uint8_t *value;                     /* VALUE / TOGGLE:指向那个变量 */
    uint8_t min;                        /* VALUE:下限 */
    uint8_t max;                        /* VALUE:上限 */
    uint8_t step;                       /* VALUE:每次加减多少 */
} MenuItem;

struct MenuPage
{
    const char *title;                  /* 页面标题 */
    const MenuItem *items;              /* 这一页的表 */
    uint8_t item_count;                 /* 这一页有几项 */
};

typedef struct MenuPage MenuPage; 那一行不能省:MenuItem 里要用 MenuPage *,而 MenuPage 里又要用 MenuItem *,两者互相引用,得先给其中一个起名。

页面表于是变成纯数据。四个页面互相引用,先把名字和动作声明出来:

/* 页面之间互相引用:先声明名字 */
static const MenuPage car_page;
static const MenuPage setting_page;
static const MenuPage about_page;
static const MenuPage speed_page;

/* 表里要用到动作函数,也先声明(它们的定义在应用层那一节) */
static void Action_Start(void);
static void Action_Stop(void);
static void Action_ShowSensor(void);
static void Action_ShowAbout(void);

static uint8_t speed_target = 30U;          /* 目标速度,百分比 */
static uint8_t line_track_enabled = 0U;     /* 循迹使能,0/1 */

/* 数值页:只有一项,A 加减、B 保存、长按 B 放弃 */
static const MenuItem speed_items[] =
{
    {"Target", MENU_ITEM_VALUE, NULL, NULL, &speed_target, 0U, 100U, 5U}
};

static const MenuPage speed_page = { "Speed", speed_items, 1U };

/* 设置页:一项进数值页,一项是动作 */
static const MenuItem setting_items[] =
{
    {"Speed",  MENU_ITEM_SUBMENU, NULL,              &speed_page, NULL, 0U, 0U, 0U},
    {"Sensor", MENU_ITEM_ACTION,  Action_ShowSensor, NULL,        NULL, 0U, 0U, 0U}
};

static const MenuPage setting_page = { "Setting", setting_items, 2U };

/* 关于页 */
static const MenuItem about_items[] =
{
    {"STM32 Car Blog", MENU_ITEM_ACTION, Action_ShowAbout, NULL, NULL, 0U, 0U, 0U}
};

static const MenuPage about_page = { "About", about_items, 1U };

/* 小车页:两个动作 + 一个开关 */
static const MenuItem car_items[] =
{
    {"Start",      MENU_ITEM_ACTION, Action_Start, NULL, NULL,                0U, 0U, 0U},
    {"Stop",       MENU_ITEM_ACTION, Action_Stop,  NULL, NULL,                0U, 0U, 0U},
    {"Line track", MENU_ITEM_TOGGLE, NULL,         NULL, &line_track_enabled, 0U, 1U, 1U}
};

static const MenuPage car_page = { "Car Menu", car_items, 3U };

/* 根页面 */
static const MenuItem root_items[] =
{
    {"Car Menu", MENU_ITEM_SUBMENU, NULL, &car_page,     NULL, 0U, 0U, 0U},
    {"Setting",  MENU_ITEM_SUBMENU, NULL, &setting_page, NULL, 0U, 0U, 0U},
    {"About",    MENU_ITEM_SUBMENU, NULL, &about_page,   NULL, 0U, 0U, 0U}
};

static const MenuPage root_page = { "Main Menu", root_items, 3U };

name、type、child 三列大致就能看出结构;value/min/max/step 只有数值项和开关项用得上,其他类型填 NULL 和 0。

页面表有一个书写上的讲究:static const MenuPage car_page; 这一行是声明,真正的定义在后面 static const MenuPage car_page = { ... }; 那一句(带初始值)。名字先出现,表才能引用它。

注意 Target 这一项自己没有回调:它的“保存”就是把值留在变量里,引擎不需要额外动作。这就是表驱动的好处——该显示什么、能改到多少、改一步走多远,全在这一行里。

子页面不是递归调用

进入 Setting 之后,程序不能在确认函数里再调用一次自己,让函数一层层套下去——那样返回路径藏在调用栈里,层数一多就看不清了。

这里用一个固定深度的栈,每一格只放两样东西:页面地址、这一页的选中位置。

#define MENU_MAX_DEPTH  4U

typedef struct
{
    const MenuPage *page;
    uint8_t selected;
} MenuStackEntry;

static MenuStackEntry menu_stack[MENU_MAX_DEPTH];
static uint8_t menu_depth = 0U;
static uint8_t menu_dirty = 1U;

进子页面就是入栈,返回就是出栈:

static uint8_t Menu_Push(const MenuPage *page)
{
    if ((menu_depth + 1U) >= MENU_MAX_DEPTH)
    {
        return 0U;                      /* 栈满了:拒绝进入,绝不越界 */
    }

    menu_depth++;
    menu_stack[menu_depth].page = page;
    menu_stack[menu_depth].selected = 0U;
    menu_dirty = 1U;

    return 1U;
}

static void Menu_Pop(void)
{
    if (menu_depth == 0U)
    {
        return;                         /* 已经在根页面,没有上一层 */
    }

    menu_depth--;
    menu_dirty = 1U;
}

出栈只把深度减一,不动上一层的 selected。所以从 About 返回时,光标还停在 About 上,不会跳回第一项——这就是“保存页面位置”的意义。

菜单引擎的全部代码

把上面这些片段接起来,引擎一共就这么几个函数。读的时候注意每句话只做一件事:

void Menu_Init(const MenuPage *root)
{
    menu_depth = 0U;
    menu_stack[0].page = root;
    menu_stack[0].selected = 0U;
    menu_dirty = 1U;
}

const MenuPage *Menu_CurrentPage(void)
{
    return menu_stack[menu_depth].page;
}

uint8_t Menu_CurrentIndex(void)
{
    return menu_stack[menu_depth].selected;
}

const MenuItem *Menu_CurrentItem(void)
{
    const MenuPage *page = Menu_CurrentPage();

    return &page->items[menu_stack[menu_depth].selected];
}

void Menu_Next(void)
{
    const MenuPage *page = Menu_CurrentPage();

    if (page->item_count == 0U)
    {
        return;                         /* 空页面:不动,也不除零 */
    }

    menu_stack[menu_depth].selected =
        (uint8_t)((menu_stack[menu_depth].selected + 1U) % page->item_count);
    menu_dirty = 1U;
}

uint8_t Menu_IsDirty(void)
{
    return menu_dirty;
}

void Menu_ClearDirty(void)
{
    menu_dirty = 0U;
}

两个只服务于数值项的小函数——进页面时备份原值,放弃时还原:

static uint8_t menu_value_backup = 0U;

static void Menu_BackupValue(void)
{
    const MenuItem *item = Menu_CurrentItem();

    if ((item->type == MENU_ITEM_VALUE) && (item->value != NULL))
    {
        menu_value_backup = *item->value;
    }
}

static void Menu_RestoreValue(void)
{
    const MenuItem *item = Menu_CurrentItem();

    if ((item->type == MENU_ITEM_VALUE) && (item->value != NULL))
    {
        *item->value = menu_value_backup;
        menu_dirty = 1U;
    }
}

这两个函数按“数值页只有一项”来写:备份的是入栈后当前页第一项的值。一页里要放好几个数值项时,得在栈的每一格里再存一份备份值——那时候“备份”就是每一层自己的事了。

确认、加减、返回,三个动作按类型分派:

void Menu_Confirm(void)
{
    const MenuItem *item = Menu_CurrentItem();

    if (item->type == MENU_ITEM_SUBMENU)
    {
        if ((item->child != NULL) && (Menu_Push(item->child) != 0U))
        {
            Menu_BackupValue();         /* 数值页:进页面先记下原值 */
        }
    }
    else if (item->type == MENU_ITEM_ACTION)
    {
        if (item->on_confirm != NULL)
        {
            item->on_confirm();
        }
    }
    else if (item->type == MENU_ITEM_TOGGLE)
    {
        if (item->value != NULL)
        {
            *item->value = (uint8_t)((*item->value == 0U) ? 1U : 0U);
            menu_dirty = 1U;
        }
    }
    else
    {
        if (item->value != NULL)
        {
            menu_value_backup = *item->value;  /* 确认即刷新备份:返回时还原的是刚确认的值 */
        }

        menu_dirty = 1U;                /* VALUE:值已经在变量里,确认即是保存 */
    }
}

void Menu_Adjust(int8_t direction)
{
    const MenuItem *item = Menu_CurrentItem();

    if ((item->type != MENU_ITEM_VALUE) || (item->value == NULL))
    {
        return;
    }

    if ((direction > 0) && (*item->value <= (uint8_t)(item->max - item->step)))
    {
        *item->value = (uint8_t)(*item->value + item->step);
    }
    else if ((direction < 0) && (*item->value >= (uint8_t)(item->min + item->step)))
    {
        *item->value = (uint8_t)(*item->value - item->step);
    }

    menu_dirty = 1U;
}

void Menu_Back(void)
{
    Menu_RestoreValue();                /* 数值页:长按 B 是放弃 */
    Menu_Pop();
}

限幅写在 Menu_Adjust() 里:*item->value <= (max - step) 保证加完不超过上限,减的那一支保证不低于下限。参数能改成什么范围,由页面表里的 min/max 决定,不写在绘制或按键代码里。

页面项多了要滚动

页面最多画四行,选中项超出窗口时窗口跟着滚:

#define MENU_VISIBLE_ROWS  4U

uint8_t Menu_FirstVisible(void)
{
    uint8_t selected = Menu_CurrentIndex();

    if (selected < MENU_VISIBLE_ROWS)
    {
        return 0U;                      /* 还在第一屏,窗口不动 */
    }

    return (uint8_t)(selected - (MENU_VISIBLE_ROWS - 1U));
}

选中项落在窗口的最后一行,窗口再跟着往下挪——光标永远不会跑出屏幕。表底下再显示一个位置提示(比如 3/7),让人知道后面还有。

应用层:只组合,不接管菜单栈

引擎不知道“Car Menu”是什么,也不知道 About 里该写谁。它只负责页面、选中项、入栈和出栈。把业务拼起来的是应用层:

static const char *menu_on_text  = "ON";
static const char *menu_off_text = "OFF";

/* 三个动作:各自只做一件小事,绝不在这里等 */
static void Action_Start(void)
{
    next_mode = LED_MODE_SLOW;              /* 灯的六档模式框架来自前面 */
    Uart_SendString("menu action: start\r\n");
}

static void Action_Stop(void)
{
    Motor_Stop();
    Uart_SendString("menu action: stop\r\n");
}

static void Action_ShowSensor(void)
{
    Uart_SendString("menu action: sensor\r\n");
}

static void Action_ShowAbout(void)
{
    Uart_SendString("menu action: about\r\n");
}

void MenuApp_Init(void)
{
    Menu_Init(&root_page);
}

void MenuApp_Update(void)
{
    KeyEvent event;

    while (Key_GetEvent(&event) != 0U)
    {
        if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
        {
            if (Key_IsDown(KEY_B) != 0U)
            {
                Menu_Adjust(-1);                    /* 按住 B 再单击 A:减 */
            }
            else if (Menu_CurrentItem()->type == MENU_ITEM_VALUE)
            {
                Menu_Adjust(1);                     /* 数值页里 A 是加 */
            }
            else
            {
                Menu_Next();                        /* 其他页面 A 是移动光标 */
            }
        }
        else if ((event.id == KEY_B) && (event.type == KEY_EVENT_CLICK))
        {
            Menu_Confirm();
        }
        else if ((event.id == KEY_B) && (event.type == KEY_EVENT_LONG_HOLD))
        {
            Menu_Back();
        }
    }
}

Key_IsDown(KEY_B) 读的是“B 现在还按着没有”,这就是组合键能成立的原因;next_mode 和 Motor_Stop() 分别来自灯的模式框架和电机那几篇,菜单只把它们当成“动作”调用。

绘制也只在应用层,而且一行对应一项:

void MenuApp_Draw(void)
{
    const MenuPage *page = Menu_CurrentPage();
    uint8_t selected = Menu_CurrentIndex();
    uint8_t first = Menu_FirstVisible();
    uint8_t row;

    OLED_Clear();
    OLED_ShowString(0, 0, (char *)page->title, OLED_6X8);

    for (row = 0U; row < MENU_VISIBLE_ROWS; row++)
    {
        uint8_t index = (uint8_t)(first + row);
        uint8_t y = (uint8_t)(10U + (row * 10U));
        const MenuItem *item;

        if (index >= page->item_count)
        {
            break;                              /* 项数不够就不画了 */
        }

        item = &page->items[index];

        OLED_ShowString(0, y, (index == selected) ? ">" : " ", OLED_6X8);
        OLED_ShowString(8, y, (char *)item->name, OLED_6X8);

        if (item->type == MENU_ITEM_VALUE)
        {
            OLED_ShowNum(72, y, *item->value, 3U, OLED_6X8);
        }
        else if (item->type == MENU_ITEM_TOGGLE)
        {
            OLED_ShowString(72, y,
                            (*item->value != 0U) ? (char *)menu_on_text
                                                 : (char *)menu_off_text,
                            OLED_6X8);
        }
    }

    OLED_ShowNum(92, 0, (uint32_t)(selected + 1U), 2U, OLED_6X8);
    OLED_ShowString(104, 0, "/", OLED_6X8);
    OLED_ShowNum(110, 0, page->item_count, 2U, OLED_6X8);
    OLED_ShowString(0, 54, "B:ok  hold B:back", OLED_6X8);

    OLED_Update();
}

右上角那两个数字就是“第几项/共几项”,页脚是按键提示。(位置提示按两位数写的:92 起两位、110 起两位,加起来到 122,128 像素的屏刚好放得下。)

OLED_Clear()、OLED_ShowString()、OLED_ShowNum() 只改驱动的显存,最后那句 OLED_Update() 才把显存整块送出去——这是前面几篇反复出现过的一条。

主循环于是很短:

int main(void)
{
    GPIO_InitTypeDef gpio;

    Timer_Init1ms();                            /* 1ms 节拍,按键靠它计时 */
    OLED_Init();
    Uart_Init();

    /* 按键引脚:PB11、PB1,内部上拉,按下接地 */
    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);

    MenuApp_Init();

    while (1)
    {
        MenuApp_Update();

        if (Menu_IsDirty() != 0U)
        {
            MenuApp_Draw();
            Menu_ClearDirty();
        }
    }
}

注意 MenuApp_Draw() 里已经包含 OLED_Update(),所以主循环只管“脏了就画一次”。要再挂一个新页面——电机、循迹、传感器——都只是在表里加一行、再准备一张页面表。

五个最容易犯的错

下面五件事都该在工程里各复现一次。它们有一个共同点:现象看起来都像“菜单坏了”,但坏在不同的一层。

一、数量写死,新增一项就越界

把绘制循环临时改成:

for (row = 0U; row < 5U; row++)
{
    OLED_ShowString(8, (uint8_t)(10U + (row * 10U)),
                    (char *)page->items[row].name, OLED_6X8);
}

root_items 只有 3 项,第四、第五次读已经离开数组了。屏幕可能显示乱码,也可能什么都不显示,甚至把旁边的变量当成字符串地址去读。改回 row < MENU_VISIBLE_ROWS,并在循环里加上 index >= page->item_count 就停——数组本身的长度和屏幕能画几行,是两个各自的边界,都要管。

二、child 写错,进入页面后读到别人的数据

把根表里 {"Setting", ..., &setting_page, ...} 的 &setting_page 换成一个不属于本菜单的地址,按下确认之后,遍历、绘制、限幅全都会照着那块内存里的“项”去读。现在的写法让页面自己的 item_count 负责遍历,父表只提供一个 child 指针,引擎只做两件事:child != NULL 才允许入栈,item_count 为 0 就不动选中项。“这一页有几项”永远只有一个来源,不会出现父表说是三项、子表只有两项的情况。

三、返回后光标位置丢了

最简单的错误写法是返回时重新初始化:

Menu_Init(&root_page);          /* 回到根页了,但光标被清成第一项 */

这样确实回到了根页面,可用户在 About 上按返回,多半还想继续看相邻那一项。固定栈保存了每一层的 selected,所以正确做法是 menu_depth--,不重新初始化菜单。

四、回调里死等,整个小车一起卡住

把动作临时改成阻塞两秒:

static void Action_Start(void)
{
    uint32_t start = tick_ms;

    while ((uint32_t)(tick_ms - start) < 2000U)
    {
    }

    Uart_SendString("car start\r\n");
}

(tick_ms 就是那条 1ms 时间轴,每毫秒加一。)

按下确认之后,OLED 两秒不刷新,按键也没有新事件;如果电机更新、编码器采样、蜂鸣器节拍和菜单共用主循环,它们会一起停。菜单回调只发出“请求”或改变状态,真正耗时的动作应该交给业务层的非阻塞写法。

五、临时字符串离开作用域后变成乱码

这段代码看起来很方便:

static const char *MakeName(uint8_t value)
{
    char text[12];

    sprintf(text, "Speed %u", value);
    return text;                    /* 返回的是局部数组的地址 */
}

text 在函数返回时就失效了。OLED 可能刚好显示正确,也可能下一次按键后变成乱码。菜单表里的名字必须用字符串常量;需要显示变化的值,就在绘制函数里直接调 OLED_ShowNum(),不要把局部数组的地址塞进菜单项。(真要拼接字符串,用 snprintf() 并给它长度,别用 sprintf() 赌缓冲区够大。)

最后橘猫说

菜单好不好看先放一边,页面一多,代码得始终知道自己在哪一层、哪一项、要执行哪个动作。

回头看这三样东西各自管什么:

  • 表管“有什么”:名字、类型、进哪一页、改哪个变量、能改到多少;
  • 栈管“在哪”:根到当前的路径,以及每一层的光标;
  • 窗口管“看得见什么”:项数多于屏幕行数时,光标永远落在可见的四行里。

往后再加功能,动的几乎都是数据:加一页、加一项、加一个动作函数。引擎、按键分派和绘制循环都不用再复制一份。