一、写在前面
很多嵌入式设备都是采用c语言编写,而很多涉及到人机交互的设备都避免不了菜单设计,虽然(包括我本人),以前都是写循环嵌套这种方式的菜单,在简单的项目中这种做法快速,但是如果在大型项目中,比如实现三级菜单,就会显得整个代码太庞杂,可维护性基本没有,还会被同事歧视,本文记录我学习和实现一个多级菜单的过程,涉及C 语言的结构体、函数指针以及栈结构,代码运行平台是espidf
二、灵活应用Struct
学过面向对象的人可能都会觉得,这个只需要定义一个类来封装菜单不就行了,在C语言中,虽然没有直接的类概念,但可以通过结构体和函数指针来模拟类的行为。
例如,在 C 语言中,类可以用结构体来模拟。结构体中可以包含属性(成员变量)和方法(函数指针)
c
// 定义一个结构体作为类
typedef struct {
// 属性
int age;
char name[50];
// 方法(函数指针)
void (*sayHello)(void*); // 用于打招呼的方法
} Man;
// 方法的实现
void sayHelloFunc(void* Man) {
Man* c = (Man*)Man;
printf("Meow! My name is %s and I'm %d years old.\n", c->name, c->age);
}
// 初始化 Man 类对象
void initMan(Man* Man, const char* name, int age) {
Man->age = age;
strcpy(Man->name, name);
Man->sayHello = sayHelloFunc; // 将方法指针指向实现函数
}
同样的,我们也可以把一个菜单抽象起来,比如一个菜单应该具有名称,下属子节点数量(属性),也包括触发功能(方法函数)
c
// 定义动作回调函数类型:相当于 C++ 中的虚函数接口
typedef void (*menu_action_t)(void);
// 菜单项结构体:相当于"类"的数据成员定义
typedef struct menu_item {
const char *name; // 显示名称
struct menu_item *children; // 指向子菜单数组的指针(构成树状结构)
int child_count; // 子菜单的项目数量
menu_action_t action; // 绑定的执行动作(叶子节点行为)
} menu_item_t;
为了满足嵌套,此处使用了递归定义的结构体,children 指针指向同类型的结构体数组,从而实现无限套娃
三、栈实现导航逻辑
为了支持"进入子菜单"和"返回上一层"的导航逻辑,系统必须记录用户的路径。使用"栈"结构是管理分层状态的最佳实践。
我们在操作菜单的时候,其实就是一个栈操作,比如
- 用户在"主菜单"选择了"设置"(压栈,Push)。
- 在"设置"里选择了"WiFi"(再压栈,Push)。
- 用户按"返回"键,期望回到"设置"(出栈,Pop)。
- 再次按"返回",期望回到"主菜单"(再出栈,Pop)。
"栈"的作用就是"保存现场"。 当我们深入下一级时,必须把当前这一级的状态(我是谁?光标停在哪里?)封存起来。当我们回来时,解封这个状态,用户就能无缝继续操作。如果不使用栈,当你从"WiFi设置"返回时,你可能被迫回到"主菜单"的第1个位置,或者丢失刚才的光标位置,这会让用户体验极其糟糕。
c
#define MAX_MENU_DEPTH 5 // 最大支持层级
// 菜单运行时状态上下文
typedef struct {
menu_item_t *menu_list; // 当前层级的菜单项列表
int count; // 当前层级项目总数
int cursor; // 当前选中的索引
} menu_state_t;
// 历史状态栈
static menu_state_t s_menu_stack[MAX_MENU_DEPTH];
static int s_stack_ptr = 0; // 栈顶指针
这段代码有点难理解,代入业务场景中理解比较好
设定:多级菜单结构
- 一级(主界面) :[图片, 音乐, 文档, 设置]
- 二级(文档) :[工作, 学习, 娱乐]
- 三级(学习) :[C语言, Python, Java]
第一阶段:在主界面选择"文档"
(状态:ptr = 0)
- 用户操作 :用户按"下"键,光标移到了第3个选项 "文档"(索引为 2)。
- 内存定格 :
- 此时
s_menu_stack[0].cursor= 2。
- 此时
第二阶段:进入"文档"文件夹
(状态:用户按 Enter -> ptr 变成 1)
- 动作:屏幕切换显示"文档"文件夹的内容。
- 用户操作 :用户想找资料,按"下"键,选中了第2个选项 "学习"(索引为 1)。
- 内存定格 :
s_menu_stack[0].cursor依然是 2(被冻结在第0层)。s_menu_stack[1].cursor变成了 1。
第三阶段:进入"学习"文件夹
(状态:用户按 Enter -> ptr 变成 2)
- 动作:屏幕切换,显示"C语言, Python, Java"。
- 用户操作:用户选中了"Python"(索引 1)。
- 内存定格 :
s_menu_stack[0].cursor= 2 (还在冻结)s_menu_stack[1].cursor= 1 (还在冻结)s_menu_stack[2].cursor= 1 (当前正在操作的)
不管你在第2层(学习文件夹)如何操作、甚至翻了100页的时候,第1层(文档文件夹)的索引
cursor = 1就像被锁在保险柜里一样,完全不会受到影响。
第四阶段:返回上一级
(状态:用户发现进错目录了,按 Back -> ptr 变成 1)
- 系统动作 :
ptr从 2 减到了 1。这意味着系统不再关心第2层发生了什么(第2层的数据被"抛弃"了)。- 系统现在看向
s_menu_stack[1]。
- 数据复原 :
- 此时系统检索第一层的索引,解除冻结
- 屏幕渲染 :
- 重画"文档"文件夹的列表:[工作, 学习, 娱乐]。
- 根据 cursor = 1,直接高亮 "学习"。
通过这样一个实际的业务逻辑,就能理解为什么要这样设计
三、 静态表的初始化构建
利用 C 语言的静态初始化特性,我们可以清晰地看到菜单的拓扑结构。同时减少ram的使用
c
/* --- 模拟业务层回调函数 --- */
void Handler_SystemInfo(void) { LOG_INFO("执行:查看系统信息"); }
void Handler_WiFiConfig(void) { LOG_INFO("执行:WiFi配置"); }
void Handler_FactoryReset(void) { LOG_INFO("执行:恢复出厂设置"); }
/* --- 2. 定义子菜单 (数据段) --- */
// 设置子菜单
static menu_item_t s_submenu_settings[] = {
{"WiFi配置", NULL, 0, Handler_WiFiConfig},
{"恢复出厂", NULL, 0, Handler_FactoryReset}
};
/* --- 1. 定义根菜单 --- */
static menu_item_t s_main_menu[] = {
{"系统信息", NULL, 0, Handler_SystemInfo}, // 叶子节点:直接执行动作
{"系统设置", s_submenu_settings, 2, NULL} // 目录节点:包含子菜单
};
通过这种方式,数据(菜单结构)与逻辑(调度器)实现了物理上的分离。
四、 调度器
调度器(Engine)是整个系统的核心,它不关心具体的业务(是设置WiFi还是调节音量),它只负责处理输入事件并操作栈。
c
void menu_task_loop(sys_event_t event) {
// 获取当前栈顶的状态(Context)
menu_state_t *curr = &s_menu_stack[s_stack_ptr];
switch (event) {
case KEY_UP:
// 循环光标逻辑,取模运算来实现光标的循环滚动
curr->cursor = (curr->cursor - 1 + curr->count) % curr->count;
render_ui(curr); // 刷新界面
break;
case KEY_DOWN:
curr->cursor = (curr->cursor + 1) % curr->count;
render_ui(curr);
break;
case KEY_ENTER:
{
menu_item_t *selected = &curr->menu_list[curr->cursor];
// 压栈进入
if (selected->children != NULL) {
if (s_stack_ptr < MAX_MENU_DEPTH - 1) {
s_stack_ptr++; // 栈深 +1
// 状态入栈
s_menu_stack[s_stack_ptr].menu_list = selected->children;
s_menu_stack[s_stack_ptr].count = selected->child_count;
s_menu_stack[s_stack_ptr].cursor = 0;
render_ui(&s_menu_stack[s_stack_ptr]);
}
}
// 或执行多态回调
else if (selected->action != NULL) {
selected->action(); // 调用函数指针
}
break;
}
case KEY_BACK:
if (s_stack_ptr > 0) {
s_stack_ptr--; // 出栈,恢复上一级状态
render_ui(&s_menu_stack[s_stack_ptr]);
}
break;
}
}
个人体会
这个代码的实现是真的好,代码我都看得懂,觉得写得精妙,但让我自己从零开始写,我只会写 switch-case 堆砌的流水账。我想这和我平时写嵌入式都是面向过程有关,就是先想第一步,再想第二步,整套代码就变得特别臃肿了。在此之后,应该在写代码之前,先想好怎么去设计数据结构,怎么抽象出来一个对象或者类,用struct表示,可能会更好。
当然我阅读源码的机会也很少,所以就不停地这样写流水账,现在多数操作都是用lvgl实现,大多数人也不用手搓菜单,但是这种应用还是很重要的,是时候把读源码给提上日程了
还有就是本人不是专科出生,对于计算机的一些架构设计缺失不太明白,我想《C嵌入式编程设计模式》和《嵌入式系统设计》也应该看一下
代码
c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 模拟 FreeRTOS/系统依赖,方便读者理解
#define LOG_TAG "MENU_CORE"
#define LOG_INFO(fmt, ...) printf("[%s] " fmt "\n", LOG_TAG, ##__VA_ARGS__)
// --- 核心结构体定义 ---
// 1. 定义动作回调函数指针 (C语言模拟多态)
typedef void (*menu_action_t)(void);
// 2. 菜单项结构体 (树形结构节点)
typedef struct menu_item {
const char *name; // 菜单显示名称
struct menu_item *children; // 子菜单指针 (如果为NULL,则说明是功能项)
int child_count; // 子菜单节点数量
menu_action_t action; // 具体执行的动作 (如果是文件夹,此项为NULL)
} menu_item_t;
// 3. 菜单运行时状态 (保存现场)
#define MAX_MENU_DEPTH 5 // 支持的最大层级深度
typedef struct {
menu_item_t *menu_list; // 当前层级的菜单表
int count; // 当前层级总数
int cursor; // 当前光标位置
} menu_state_t;
// --- 全局状态管理 ---
static menu_state_t s_menu_stack[MAX_MENU_DEPTH]; // 历史状态栈
static int s_stack_ptr = 0; // 栈顶指针 (0表示根目录)
// --- 模拟业务功能函数 (实际项目中替换为具体逻辑) ---
void Action_WiFi_Scan(void) { LOG_INFO("执行: 扫描WiFi网络..."); }
void Action_WiFi_Reset(void) { LOG_INFO("执行: 重置网络配置"); }
void Action_Audio_Mute(void) { LOG_INFO("执行: 静音"); }
void Action_Audio_Max(void) { LOG_INFO("执行: 最大音量"); }
void Action_Sys_Reboot(void) { LOG_INFO("执行: 系统重启"); }
void Action_Sys_Info(void) { LOG_INFO("执行: 显示版本号 v1.0.0"); }
// --- 菜单树构建 (静态表驱动) ---
// 二级菜单:网络设置
static menu_item_t s_submenu_net[] = {
{"扫描网络", NULL, 0, Action_WiFi_Scan},
{"重置网络", NULL, 0, Action_WiFi_Reset}
};
// 二级菜单:音频设置
static menu_item_t s_submenu_audio[] = {
{"静音模式", NULL, 0, Action_Audio_Mute},
{"最大音量", NULL, 0, Action_Audio_Max}
};
// 一级菜单 (根目录)
static menu_item_t s_main_menu[] = {
{"网络设置", s_submenu_net, 2, NULL}, // 文件夹节点
{"音频设置", s_submenu_audio, 2, NULL}, // 文件夹节点
{"系统信息", NULL, 0, Action_Sys_Info}, // 功能节点
{"重启设备", NULL, 0, Action_Sys_Reboot}// 功能节点
};
// --- 核心逻辑引擎 ---
// 辅助:模拟UI渲染或TTS播报
static void render_ui_state() {
menu_state_t *state = &s_menu_stack[s_stack_ptr];
const char *current_name = state->menu_list[state->cursor].name;
// 实际项目中这里是驱动屏幕刷新或发送TTS指令
printf(">> UI渲染: [层级:%d] 选中: [%s] (%d/%d)\n",
s_stack_ptr,
current_name,
state->cursor + 1,
state->count);
}
// 菜单系统初始化
void menu_service_init() {
s_stack_ptr = 0;
s_menu_stack[0].menu_list = s_main_menu;
s_menu_stack[0].count = sizeof(s_main_menu) / sizeof(menu_item_t);
s_menu_stack[0].cursor = 0;
LOG_INFO("菜单系统已就绪");
render_ui_state();
}
// 菜单调度任务 (通常在 while(1) 或 消息队列回调中调用)
// 参数 event 模拟按键事件: 0:UP, 1:DOWN, 2:ENTER, 3:BACK
void menu_process_event(int event_id) {
menu_state_t *curr = &s_menu_stack[s_stack_ptr]; // 获取当前栈顶状态
switch (event_id) {
// --- 导航:上移 ---
case 0: // KEY_UP
curr->cursor--;
if (curr->cursor < 0) curr->cursor = curr->count - 1; // 循环卷动
render_ui_state();
break;
// --- 导航:下移 ---
case 1: // KEY_DOWN
curr->cursor++;
if (curr->cursor >= curr->count) curr->cursor = 0; // 循环卷动
render_ui_state();
break;
// --- 动作:确认 ---
case 2: // KEY_ENTER
{
menu_item_t *selected = &curr->menu_list[curr->cursor];
// 分支逻辑A: 如果是文件夹 -> 压栈 (Push Stack)
if (selected->children != NULL) {
if (s_stack_ptr < MAX_MENU_DEPTH - 1) {
s_stack_ptr++; // 栈深 +1
s_menu_stack[s_stack_ptr].menu_list = selected->children;
s_menu_stack[s_stack_ptr].count = selected->child_count;
s_menu_stack[s_stack_ptr].cursor = 0; // 进入子菜单默认选中第1个
LOG_INFO("进入子菜单: %s", selected->name);
render_ui_state();
} else {
LOG_INFO("错误: 达到最大层级深度");
}
}
// 分支逻辑B: 如果是功能 -> 执行回调 (Callback)
else if (selected->action != NULL) {
LOG_INFO("触发功能: %s", selected->name);
selected->action();
}
break;
}
// --- 动作:返回 ---
case 3: // KEY_BACK
if (s_stack_ptr > 0) {
s_stack_ptr--; // 出栈 (Pop Stack),自动恢复上一级的光标位置
LOG_INFO("返回上一级");
render_ui_state();
} else {
LOG_INFO("已在根目录,无法返回");
}
break;
}
}