从零构建嵌入式菜单库(三):变量绑定系统——让菜单"活"起来

系列定位:这是一套编写教程
前两篇我们建立了菜单的骨架,但现在菜单项只能看不能改——本篇要解决的核心问题:让菜单项直接操作内存中的变量。


前言:从"显示变量"到"绑定变量"

回顾原型,显示变量值靠 sprintf 手动拼字符串:

sprintf(outBuf, "c:%d", count);
u8g2_DrawStr(u8g2, x, y, outBuf);

这在原型阶段够用,但有两个致命缺陷:

  1. 显示与编辑分离——修改 count 的代码在别处,导致逻辑分散
  2. 类型不安全——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 强制转换后解引用——因为实际类型可能是 floatuint8_t 等,错误转换会导致非法值。正确的用法是先用 p == &变量名 判断是哪个变量,再按该变量自身的类型去读取它。


7. 小结:变量绑定设计的得与失

决策 收益 代价
联合体统一类型 一个字段存所有类型 无法编译期验证类型正确性
每类型一个函数 API 明确、IDE 友好 12 个函数签名雷同
MenuADDK 宏 消除 9×2 重复代码 展开后调试困难,错误信息晦涩
MENU_Attribute 状态机 编辑态与非编辑态独立 选择器回调需要 switch 4 种状态
button 仅捕获不拦截 灵活,可覆盖默认行为 新手容易困惑
弱定义值变化回调 不改源码注入逻辑 void* 类型不安全

核心教训:12 个雷同的函数签名的冗余是值得的——因为 C 没有模板,宏虽然能减少代码量但严重降低可调试性。在嵌入式领域,"能定位到具体行号"比"少写几行代码"重要得多。

下一篇,我们将构建按键系统——消抖、长按、环形事件队列,让菜单真正响应用户输入。


上一篇:从零构建嵌入式菜单库(二):架构设计
下一篇:从零构建嵌入式菜单库(四):按键系统与事件驱动

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐