从零构建嵌入式菜单库(五):图表、图片、控件与图层
从零构建嵌入式菜单库(五):图表、图片、控件与图层
系列定位:这是一套编写教程。
前几篇我们完成了"框架"——菜单能导航、能编辑变量、能响应按键。本篇要解决:如何在 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_len 是 uint16_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,是考虑到易用性与性能的均衡也可进一步进行定点数优化。
下一篇,我们将进入导航与动画的深水区——子菜单追溯、超长文本自动滚动、消息框和效果器。
更多推荐



所有评论(0)