从零构建嵌入式菜单库(三):变量绑定系统——让菜单“活“起来
从零构建嵌入式菜单库(三):变量绑定系统——让菜单"活"起来
系列定位:这是一套编写教程。
前两篇我们建立了菜单的骨架,但现在菜单项只能看不能改——本篇要解决的核心问题:让菜单项直接操作内存中的变量。
前言:从"显示变量"到"绑定变量"
回顾原型,显示变量值靠 sprintf 手动拼字符串:
sprintf(outBuf, "c:%d", count);
u8g2_DrawStr(u8g2, x, y, outBuf);
这在原型阶段够用,但有两个致命缺陷:
- 显示与编辑分离——修改
count的代码在别处,导致逻辑分散 - 类型不安全——
sprintf不检查格式串与参数匹配,运行时崩
我们需要一个机制:在菜单项绘制时"声明"这一行绑定到哪个变量、什么类型、什么范围、什么步长,然后按键自动调整。
这就是 u8g2_MenuItemValue_* 系列 API 的设计目标。
知识点预备
1.1 C 的联合体 (union)——用同一块内存解释不同类型
union Data {
uint8_t u8;
int i;
float f;
};
union Data d;
d.i = 42; // 写入 int
printf("%d\n", d.i); // 42
d.f = 3.14f; // 写入 float——覆盖了之前的 int
printf("%f\n", d.f); // 3.14
联合体的所有成员共享同一块内存,大小等于最大成员的大小。这非常适合"同一时刻只知道一种类型"的场景——恰好就是菜单项的值绑定。
1.2 宏 (Macro) 的代码生成能力
C 的预处理器宏可以极大减少重复代码。例如:
#define DEFINE_ADD(type, field) \
void add_##field(type *val, type adj) { \
*val += adj; \
}
DEFINE_ADD(int, int)
DEFINE_ADD(float, float)
// 展开为:void add_int(int *val, int adj) { ... }
## 是标记粘贴操作符,把 add_ 和参数拼接成一个标识符。
优点:减少手写重复代码
缺点:调试困难(展开后的代码不在源码中),出错信息晦涩
1.3 C 的状态机模式
typedef enum {
STATE_IDLE,
STATE_EDITING,
STATE_CONFIRMED
} State;
State state = STATE_IDLE;
void onEnter() {
switch (state) {
case STATE_IDLE: state = STATE_EDITING; break;
case STATE_EDITING: state = STATE_CONFIRMED; break;
}
}
菜单项的选中/编辑就靠状态机驱动。
2. 核心设计:联合体统一 13 种类型
2.1 类型枚举
typedef enum {
MENU_V_uint8 = 0, MENU_V_uint16, MENU_V_uint32,
MENU_V_int8, MENU_V_int16, MENU_V_int32,
MENU_V_int, MENU_V_float, MENU_V_double,
MENU_V_switch, MENU_button,
MENU_menu, MENU_menu_enter, MENU_menu_back,
MENU_str, MENU_NC
} MENU_V_type_t;
前 9 种是数值类型,后 6 种是"行为型"(开关/按钮/子菜单/字符串)。MENU_NC(Not Connected)表示"未绑定"。
2.2 联合体定义
每一种类型不只是存一个值指针,还需要步长、最小值、最大值:
struct u8g2_menu_int_struct {
int *value; // 指向实际变量的指针
int adjValue; // 每次调整的步长
int minValue; // 最小值
int maxValue; // 最大值
};
// uint8/16/32, int8/16/32, float, double 同理
// switch 只需要 openValue(开关打开时的值)
// button 需要函数指针 + ID
// menu 需要 menuItem_cb 函数指针
// str 需要 char * + 长度
union u8g2_menu_value_uniom {
struct u8g2_menu_uint8_struct v_uint8;
struct u8g2_menu_uint16_struct v_uint16;
// ... 共 13 个成员
struct u8g2_menu_button_struct button;
struct u8g2_menu_menu_struct menu;
struct u8g2_menu_str_struct str;
};
然后把联合体放进主结构体:
struct u8g2_menu_struct {
// ...
MENU_V_type_t u8g2_menuValueType; // 当前值类型
u8g2_menu_value_t u8g2_menuValue; // 当前值(联合体)
};
2.3 绑定 API:一行搞定
// 数值类型——12 个函数,模式完全相同
void u8g2_MenuItemValue_int(int *value, int adjValue,
int minValue, int maxValue)
{
u8g2_menu_t *menu = u8g2_getMenuItemValue(MENU_Writable);
if (!menu) return;
menu->u8g2_menuValueType = MENU_V_int;
menu->u8g2_menuValue.v_int.value = value;
menu->u8g2_menuValue.v_int.adjValue = adjValue;
menu->u8g2_menuValue.v_int.minValue = minValue;
menu->u8g2_menuValue.v_int.maxValue = maxValue;
}
为什么是 12 个独立函数而不是一个泛型函数?
初版尝试过泛型(void* 传递),但遇到了三个问题:
- 无法在编译期检查类型是否正确
- 联合体赋值需要知道具体字段名
- 嵌入式 IDE 的代码补全对独立函数更友好
所以退回到"每个类型一个函数"的方案——用宏生成来减少手写量。
3. 值调整引擎:MenuADDK / MenuSUBK 宏
这是整个值绑定系统最精妙的部分——用一个宏统一处理 9 种数值类型的加减法:
void u8g2_MenuItemAddS(u8g2_menu_t *u8g2_menu, u8g2_uint_t k)
{
#define MenuADDK(s, k) \
do { \
*(u8g2_menu->u8g2_menuValue.s.value) \
+= (u8g2_menu->u8g2_menuValue.s.adjValue) * (k); \
if (*(u8g2_menu->u8g2_menuValue.s.value) \
< (u8g2_menu->u8g2_menuValue.s.minValue)) \
*(u8g2_menu->u8g2_menuValue.s.value) \
= (u8g2_menu->u8g2_menuValue.s.minValue); \
if (*(u8g2_menu->u8g2_menuValue.s.value) \
> (u8g2_menu->u8g2_menuValue.s.maxValue)) \
*(u8g2_menu->u8g2_menuValue.s.value) \
= (u8g2_menu->u8g2_menuValue.s.maxValue); \
} while (0)
switch (u8g2_menu->u8g2_menuValueType) {
case MENU_V_uint8: MenuADDK(v_uint8, k); break;
case MENU_V_uint16: MenuADDK(v_uint16, k); break;
// ... 共 9 个 case
case MENU_V_switch:
*(u8g2_menu->u8g2_menuValue.v_switch.value)
= u8g2_menu->u8g2_menuValue.v_switch.openValue;
break;
default: return;
}
u8g2_menuValueChange_weak(u8g2_menu->u8g2_menuValue.v_uint8.value);
#undef MenuADDK
}
宏里 s 参数的技巧:s 是联合体的字段名。调用 MenuADDK(v_int, k) 时,宏展开成对 u8g2_menu->u8g2_menuValue.v_int.value 的操作。
do {...} while(0) 的作用:让它成为一个"语句块",可以安全地放在 case: 后面而不会被悬挂 else 问题。
为什么 switch 不是加减而是直接赋值?
开关量只有开/关两个状态,不需要"步长"概念。按 Enter 直接切换到 openValue,再按切回 0。
4. 选中状态机:让菜单项"知道"自己在被编辑
typedef enum {
MENU_None = 0, // 未选中
MENU_Fix, // 固定(只读)
MENU_Writable, // 可编辑(未进入编辑状态)
MENU_WritableSelect // 可编辑 + 正在编辑
} MENU_Attribute_t;
状态转换逻辑在 u8g2_MenuSelectorCall() 中:
// 当前绘制到选中项时
if (u8g2_menu->currentSetValue != -1
&& u8g2_menu->currentDrawItem == u8g2_menu->currentItem
&& u8g2_menu->currentItem == u8g2_menu->currentSetValue)
{
if (u8g2_menu->currentAttribute == MENU_Writable)
u8g2_menu->currentAttribute = MENU_WritableSelect; // 进入编辑
else if (u8g2_menu->currentAttribute == MENU_Fix)
u8g2_menu->currentSetValue = -1; // 固定属性不响应
}
状态转换图:
MENU_None ──(未绑定)──▶ (不显示选择器)
MENU_Fix ──(当前项)──▶ 显示标识,不可编辑
MENU_Writable ──(Enter)──▶ MENU_WritableSelect (编辑态)
MENU_WritableSelect ──(Return)──▶ MENU_Writable (退出编辑)
按键映射
在编辑状态下,方向键被重新映射——这是 menuEventKeyPre_weak 的职责:
WEAK void menuEventKeyPre_weak(u8g2_menu_t *u8g2_menu,
u8g2_menuKeyValue_t *key)
{
if (u8g2_MenuGetItemSelect(u8g2_menu) != -1) {
if (*key == MENU_Key_Up) *key = MENU_Key_Add; // 上→加
if (*key == MENU_Key_Down) *key = MENU_Key_Sub; // 下→减
if (*key == MENU_Key_Enter) *key = MENU_Key_Return; // 确认→退出
}
}
WEAK 意味着用户可以重写这个函数,自定义编辑态下的按键行为——比如改为按住 Enter 加速调整。
5. 特殊类型:button / menu / str
5.1 Button——“仅捕获不拦截”
void u8g2_MenuItem_button(u8g2_MenuButton_cb but, uint8_t ID)
{
u8g2_menu_t *menu = u8g2_getMenuItemValue(MENU_Writable);
if (!menu) return;
menu->u8g2_menuValueType = MENU_button;
menu->u8g2_menuValue.button.but = but;
menu->u8g2_menuValue.button.ID = ID;
}
Button 的"仅捕获不拦截"设计:按键仍然走正常流程(Enter 进入编辑状态),但在 u8g2_MenuKeys 末尾会额外调用 button 回调:
if (u8g2_menu->u8g2_menuValueType == MENU_button)
u8g2_menu->u8g2_menuValue.button.but(
u8g2_menu, u8g2_menu->u8g2_menuValue.button.ID,
u8g2_menuKeyValue);
这意味着业务逻辑可以在回调中决定是否继续使用库的默认行为——如果不需要进入编辑状态,在回调中调用 u8g2_MenuItemDeSelect 即可。
5.2 Menu——子菜单跳转
跳转行为被延迟到当前帧绘制完成后执行,防止在绘制循环中修改菜单项导致状态混乱:
// 在 u8g2_DrawMenu 末尾:
if (u8g2_menu->currentSetValue != -1) {
switch (u8g2_menu->u8g2_menuValueType) {
case MENU_menu:
u8g2_MenuReplaceItem(u8g2_menu,
u8g2_menu->u8g2_menuValue.menu.menuItem);
break;
case MENU_menu_enter:
u8g2_MenuItemEnter(u8g2_menu,
u8g2_menu->u8g2_menuValue.menu.menuItem);
break;
case MENU_menu_back:
u8g2_MenuItemBack(u8g2_menu);
break;
}
}
5.3 Str——字符串输入
void u8g2_MenuItem_str(char *str, uint16_t len)
配合 u8g2_MenuInChar 使用。编辑状态下接收字符输入,* 退格、# 清空。细节在第 4 篇展开。
6. 值变化回调:弱定义钩子
WEAK void u8g2_menuValueAdd_weak(void *p) { /* 空 */ }
WEAK void u8g2_menuValueSub_weak(void *p) { /* 空 */ }
WEAK void u8g2_menuValueChange_weak(void *p) { /* 空 */ }
p 统一传 u8g2_menu->u8g2_menuValue.v_uint8.value(联合体的第一个字段的地址,即实际绑定变量的指针)。这是一个不那么优雅的妥协——但避免了为每种类型单独声明弱定义函数。
用户在外部重写时,应通过与已知变量地址比对来分辨具体是哪个变量发生了变化:
// 用户代码——不修改库源码
int value1 = 0;
float value2 = 0.0f;
void u8g2_menuValueChange_weak(void *p) {
if (p == &value1) {
// value1 变化了
if (value1 > 80) buzzer_on();
} else if (p == &value2) {
// value2 变化了
if (value2 < 0.5f) led_warning();
}
}
注意:不要直接 (int *)p 强制转换后解引用——因为实际类型可能是 float、uint8_t 等,错误转换会导致非法值。正确的用法是先用 p == &变量名 判断是哪个变量,再按该变量自身的类型去读取它。
7. 小结:变量绑定设计的得与失
| 决策 | 收益 | 代价 |
|---|---|---|
| 联合体统一类型 | 一个字段存所有类型 | 无法编译期验证类型正确性 |
| 每类型一个函数 | API 明确、IDE 友好 | 12 个函数签名雷同 |
| MenuADDK 宏 | 消除 9×2 重复代码 | 展开后调试困难,错误信息晦涩 |
| MENU_Attribute 状态机 | 编辑态与非编辑态独立 | 选择器回调需要 switch 4 种状态 |
| button 仅捕获不拦截 | 灵活,可覆盖默认行为 | 新手容易困惑 |
| 弱定义值变化回调 | 不改源码注入逻辑 | void* 类型不安全 |
核心教训:12 个雷同的函数签名的冗余是值得的——因为 C 没有模板,宏虽然能减少代码量但严重降低可调试性。在嵌入式领域,"能定位到具体行号"比"少写几行代码"重要得多。
下一篇,我们将构建按键系统——消抖、长按、环形事件队列,让菜单真正响应用户输入。
更多推荐
所有评论(0)