从零构建嵌入式菜单库(五):图表、图片、控件与图层

系列定位:这是一套编写教程
前几篇我们完成了"框架"——菜单能导航、能编辑变量、能响应按键。本篇要解决:如何在 128×64 的小屏上画折线图、显示图标、做滑块条,以及叠加多层图形。


前言:小屏幕的可视化挑战

一块 128×64 的单色 OLED 能做的事情比你想象的多——但每一项都需要在极其有限的硬件资源上精打细算。图表需要双缓冲防止闪烁,位图需要高效存储格式,控件的位置映射需要归一化……

原型的 u8g2_DrawVSliderBar 只是一个起点。最终库扩展出了 4 个完整的可视化模块。


知识点预备

1.1 双缓冲 (Double Buffering)

单缓冲问题:                    双缓冲方案:
  帧1: 数据更新中...               data[]      ← 数据线程写入
  帧2: 绘制读到一半新一半旧 → 闪烁   data_dis[]  ← 绘制线程读取
                                  u8g2_chart_update() 同步

在嵌入式单色屏上,闪烁尤其明显——因为一个像素只有 0 和 1 两个状态。

1.2 XBM 格式

XBM (X BitMap) 是 C 语言可嵌入的位图格式,每个字节编码 8 个横向像素:

// 一个 8×8 的"笑脸"图标
static const uint8_t smile_bits[] = {
    0x3C,  // ..XXXX..
    0x42,  // .X....X.
    0xA5,  // X.X..X.X
    0x81,  // X......X
    0xA5,  // X.X..X.X
    0x99,  // X..XX..X
    0x42,  // .X....X.
    0x3C   // ..XXXX..
};

每个字节对应一行 8 像素。位 0(LSB)对应最右像素(取决于 U8g2 模式)。

1.3 图层叠加的位运算

AND:  result = A & B   ← 交集(需要两者都是 1)
OR:   result = A | B   ← 并集(有一方是 1 就行)
XOR:  result = A ^ B   ← 差异(相同为 0,不同为 1)
XNOR: result = ~(A ^ B) ← 相似(相同为 1)

在帧缓冲上逐字节应用位运算,实现图层效果。


2. 图表系统:双缓冲 + 自动极值

2.1 数据结构

struct u8g2_chart_struct {
    float *data;         // 数据缓冲(实时接收新数据)
    float *data_dis;     // 显示缓冲(绘制用,防闪烁)
    float data_max;      // 当前数据最大值
    float data_min;      // 当前数据最小值
    uint16_t data_len;   // 数据长度
};

为什么 data_lenuint16_t 而不是动态分配?

uint16_t 的最大值是 65535——对于嵌入式数据采集来说完全足够(折线图成百上千个点均可支持)。采用固定长度数组而非 malloc 的原因有三:嵌入式 MCU 的堆可能只有几 KB,动态分配容易碎片化;float data[data_len] 在编译期确定大小,栈分配或静态分配都可以;而且 memcpy 固定大小缓冲比逐元素拷贝快得多。

2.2 双缓冲同步

void u8g2_chart_update(u8g2_chart_t *chart)
{
    memcpy(chart->data_dis, chart->data,
           chart->data_len * sizeof(float));
    u8g2_chart_autoRange(chart);  // 更新极值
}

拷贝时机在每次绘制开始前(由菜单绘制循环自动调用),用户不需要手动同步——数据线程只管往 data[] 写。

2.3 折线图绘制算法

void u8g2_drawLineChart(u8g2_t *u8g2, u8g2_chart_t *chart,
                        u8g2_int_t x, u8g2_int_t y,
                        u8g2_uint_t w, u8g2_uint_t h)
{
    if (chart->data_len < 1) return;

    float range = chart->data_max - chart->data_min;
    if (range < U8G2_MENU_MIN_VALUE_DIFF) range = U8G2_MENU_MIN_VALUE_DIFF;

    float x_step = (float)w / (chart->data_len - 1);

    for (uint16_t i = 1; i < chart->data_len; i++) {
        u8g2_int_t x0 = x + (i - 1) * x_step;
        u8g2_int_t x1 = x + i * x_step;
        u8g2_int_t y0 = y + h - (chart->data_dis[i-1] - chart->data_min)
                        * h / range;
        u8g2_int_t y1 = y + h - (chart->data_dis[i] - chart->data_min)
                        * h / range;
        u8g2_DrawLine(u8g2, x0, y0, x1, y1);
    }
}

关键公式:

y_screen = y_top + h - (data[i] - data_min) * h / range
         = y_bottom - 归一化(data[i]) * h

Y 轴翻转是因为屏幕坐标系 Y 向下增长,而数据通常期望大值在顶部。

2.4 使用时序

// 初始化(一次)
float data_buf[50] = {0};
float display_buf[50] = {0};
u8g2_chart_t chart;
u8g2_chart_init(&chart, data_buf, display_buf, 50);

// 数据采集(定时中断或主循环)
u8g2_chart_addData(&chart, sensor_read());

// 菜单项绘制
void menuChart(void) {
    u8g2_MenuDrawItemLineChart(&chart, 40, 0, 0);
    // max=0, min=0 触发自动极值
}

2.5 组合图表

// 折线图 + 散点图叠加
u8g2_menu_drawChart_t drawChart[] = {
    {u8g2_drawLineChart,  &chart1, 0, 0},
    {u8g2_drawPointChart, &chart2, 0, 0},
};
u8g2_MenuDrawItemChart(drawChart, 2, 60);

同一坐标系下叠加多个图表——传感器原始值 + 滤波后的值同时显示,非常实用。


3. XBM 图片绘制

void u8g2_MenuDrawItemXBM(u8g2_uint_t w, u8g2_uint_t h,
                          const uint8_t *bitmap)
{
    u8g2_menu_t *menu = u8g2_MenuDrawItemStart();
    if (!menu) return;

    u8g2_int_t x = u8g2_MenuGetX(menu);
    u8g2_int_t y = u8g2_MenuGetY(menu);

    u8g2_DrawXBM(menu->u8g2, x, y, w, h, bitmap);

    u8g2_MenuDrawItemSetSize(menu, w, h);
    u8g2_MenuDrawItemEnd(menu);
}

XBM vs XBMP

  • XBM:位图数据随固件在 Flash 中——const uint8_t bitmap[]
  • XBMP:位图数据在外部存储器(SD 卡 / SPI Flash)——需要通过 u8g2_DrawXBMP 逐字节读取

4. 滑块条与进度条

4.1 归一化接口

原型中的滑块需要传入归一化的位置 0~1,最终库继承了这个设计:

// 底层绘制——位置归一化到 [0, 1]
void u8g2_DrawHSliderBar(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y,
                          u8g2_uint_t w, u8g2_uint_t h,
                          float schedule, float proportion);

// proportion: 滑块占滑轨的比例 (0~1)
// schedule:   滑块当前位置 (0~1,0=最左,1=最右)

4.2 绑定实际值的版本

void u8g2_MenuDrawItemSlider_bind(int *value, int adjValue,
                                   int minValue, int maxValue)
{
    u8g2_menu_t *menu = u8g2_MenuDrawItemStart();

    // 第一步:绑定变量(使 Up/Down 可编辑)
    u8g2_MenuItemValue_int(value, adjValue, minValue, maxValue);

    // 第二步:绘制滑块
    float schedule = (float)(*value - minValue) / (maxValue - minValue);
    u8g2_DrawHSliderBar(menu->u8g2,
        u8g2_MenuGetX(menu), u8g2_MenuGetY(menu),
        u8g2_MenuGetW(menu), u8g2_MenuGetH(menu),
        schedule, 0.1f);

    u8g2_MenuDrawItemSetSize(menu, u8g2_MenuGetW(menu),
                             u8g2_MenuGetH(menu));
    u8g2_MenuDrawItemEnd(menu);
}

一行同时做两件事:绑定变量 + 绘制控件。这是 u8g2_menu 中常见的"组合调用"模式。

4.3 进度条 vs 滑块条

// 进度条:填充指示条,无手柄
void u8g2_DrawHProgressBar(u8g2_t *u8g2, ...);

// 滑块条:填充指示条 + 可调节大小的手柄
void u8g2_DrawHSliderBar(u8g2_t *u8g2, ...);

两者的功能逻辑完全一样——都支持编辑态下 Up/Down 调整绑定值,都支持归一化的 schedule 位置参数。唯一区别是外形:滑块条多了 proportion 参数控制手柄宽度(占滑轨的比例),视觉上更接近"可拖动"的暗示;进度条则是一个连续填充条,视觉上更像"只读指示器"。两者可以互换使用,按 UI 审美选择即可。


5. 图层系统:像素级的位运算叠加

这是最"硬核"的模块——直接操作帧缓冲。

5.1 核心机制

uint8_t *layerBuff = NULL;
u8g2_t   *layerU8g2 = NULL;

u8g2_t *u8g2_MenuStartLayer(u8g2_t *u8g2)
{
    uint8_t *buf = u8g2_MenuGetLayerBuff();  // 获取备用缓冲(1024B)
    layerU8g2 = u8g2;
    layerBuff = u8g2_GetBufferPtr(u8g2);     // 保存原缓冲指针
    u8g2_GetBufferPtr(u8g2) = buf;           // 切换到备用缓冲
    u8g2_ClearBuffer(u8g2);                  // 清空备用缓冲
    return u8g2;
}

原理:把 U8g2 的帧缓冲指针"劫持"到备用缓冲区。之后的所有绘制操作都作用在备用缓冲上。EndLayer 时用位运算合并两个缓冲。

5.2 四种叠加模式

void u8g2_MenuEndLayer(Layer_t layer)
{
    uint8_t *layerBuf = u8g2_GetBufferPtr(layerU8g2);

    switch (layer) {
    case LayerAND:   // 交集:两边都是 1 才显示
        for (int i = 0; i < buf_size; i++)
            layerBuff[i] &= layerBuf[i];
        break;
    case LayerOR:    // 并集:叠加
        for (int i = 0; i < buf_size; i++)
            layerBuff[i] |= layerBuf[i];
        break;
    case LayerXOR:   // 差异:相同归 0,不同归 1
        for (int i = 0; i < buf_size; i++)
            layerBuff[i] ^= layerBuf[i];
        break;
    case LayerXNOR:  // 相似:相同归 1,不同归 0
        for (int i = 0; i < buf_size; i++)
            layerBuff[i] = ~(layerBuff[i] ^ layerBuf[i]);
        break;
    }
    u8g2_GetBufferPtr(layerU8g2) = layerBuff;  // 恢复原缓冲
}

5.3 使用示例

// 基础层:画一个矩形
u8g2_DrawFrame(&u8g2, 10, 10, 50, 30);

// 覆盖层:在矩形上画交叉线
u8g2_MenuStartLayer(&u8g2);
u8g2_DrawLine(&u8g2, 10, 10, 60, 40);
u8g2_DrawLine(&u8g2, 60, 10, 10, 40);

// XOR 叠加 → 交叉线在矩形"外面"的部分显示,"里面"的部分被擦除
u8g2_MenuEndLayer(LayerXOR);

6. 自定义画板

void u8g2_MenuDrawItemBoard(u8g2_MenuDrawBoard_cb drawBoard,
                             u8g2_uint_t width, u8g2_uint_t height)
{
    u8g2_menu_t *menu = u8g2_MenuDrawItemStart();

    u8g2_SetClipWindow(menu->u8g2,
        u8g2_MenuGetX(menu), u8g2_MenuGetY(menu),
        u8g2_MenuGetX(menu) + width,
        u8g2_MenuGetY(menu) + height);

    if (drawBoard) drawBoard(menu->u8g2);

    u8g2_MenuDrawItemSetSize(menu, width, height);
    u8g2_MenuDrawItemEnd(menu);
}

用户回调只能用传入的 u8g2 实例——这是有意设计的约束,防止依赖全局变量导致多实例冲突。


7. 小结

模块 核心技术 适用场景
图表 双缓冲 + 归一化映射 传感器曲线、实时数据
图片 XBM 格式嵌入 图标、Logo、表情
滑块/进度条 归一化 + 绑定值 音量、亮度、配置参数
图层 帧缓冲位运算 叠加图形、蒙版效果
画板 回调 + 裁剪窗口 自定义仪表盘

核心教训:嵌入式可视化开发的核心矛盾是"有限的显存 vs 无限的创意"。双缓冲防闪烁的 memcpy 在 STM32F103 上几乎无感(128×64 单色 = 1024 字节),但一旦移到彩色屏(128×64×16bit = 16KB),就得重新评估。设计中预留的 data_dis 独立缓冲目前为 float 而非 int,是考虑到易用性与性能的均衡也可进一步进行定点数优化。

下一篇,我们将进入导航与动画的深水区——子菜单追溯、超长文本自动滚动、消息框和效果器。


上一篇:从零构建嵌入式菜单库(四):按键系统与事件驱动
下一篇:从零构建嵌入式菜单库(六):导航、动画与消息框

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐