系列回顾:上一篇我们调通了 nRF54L15-DK 与 PC 的 BLE 通信链路,PC 端数据已经能稳定推送到开发板。本篇是第二篇,聚焦墨水屏驱动移植:从 ePaper 显示原理讲起,到 Zephyr 驱动框架适配,再到 LVGL 图形库集成,最终在 2.66 寸墨水屏上显示出清晰的性能数据界面。
显示效果图

一、为什么选墨水屏?
在开始写代码之前,先聊聊为什么这个项目一定要用墨水屏,而不是 OLED 或 LCD。
不同于OLED和TFT LCD屏幕,墨水屏只有在画面变更的时候消耗电。
对于性能监控副屏来说,1秒刷新一次完全够用,墨水屏的慢刷新不是问题,超低待机功耗反而是核心优势。
二、墨水屏硬件连接本项目为了实现显示的低功耗,使用 2.66 寸黑白墨水屏,它是从价签上拆下来的,价格很便宜,分辨率 152×296,SPI 接口。
2.1 引脚连接(nRF54L15-DK)墨水屏引脚 nRF54L15-DK 引脚 ───────────────────────────────────── VCC ──► 3.3V GND ──► GND DIN (MOSI) ──► P0.09 (SPI MOSI) CLK (SCK) ──► P0.08 (SPI SCK) CS ──► P0.11 (SPI CS) DC ──► P0.12 (Data/Command 控制) RST ──► P0.13 (硬件复位) BUSY ──► P0.14 (忙信号,高电平=忙)
2.2 VDD电压配置
nRF54L15-DK开发板搭载了一颗nPM1300电源管理芯片,可以动态配置其输出电压。
由于开发板默认配置的VDD电压是1.8V,为了能稳定驱动墨水屏,我们需要将其调整为3.3V。
我们首先打开nRFConnect Desktop中的板卡配置器:

将VDD电压调整到3.3V

三、墨水屏工作原理深度解析
这是本篇的核心理论部分,搞懂原理才能真正理解驱动代码为什么这样写。
3.1 电泳显示原理墨水屏的显示介质是微胶囊电泳液,每个微胶囊里悬浮着带电荷的黑色和白色粒子,我们通过电场控制粒子改变显像。
关键特性:粒子一旦移动到位就会保持,断电后画面依然保留。这就是墨水屏待机功耗接近零的物理原因。
3.2 LUT(Look-Up Table)波形表LUT 是控制电泳粒子运动的"时序程序",这是墨水屏驱动最复杂也最关键的部分。
五张 LUT 表的含义LUT_VCOM: 公共电极 (VCOM) 的驱动波形 LUT_WW: 白→白 (White to White),维持白色不变 LUT_BW: 黑→白 (Black to White),像素从黑变白 LUT_WB: 白→黑 (White to Black),像素从白变黑 LUT_BB: 黑→黑 (Black to Black),维持黑色不变
这五张表共同描述了所有可能的像素状态转换路径。
全刷 vs 局刷 LUT 对比// 全刷 LUT(完整驱动波形,时间较长)
static const uint8_t LUT_FULL_WB[] = {
0x80,0x08,0x00,0x00,0x00,0x02, // 强正压,持续 8 帧,重复 2 次
0x90,0x28,0x28,0x00,0x00,0x01, // 正→负交替,各 40 帧
0x80,0x14,0x00,0x00,0x00,0x01, // 正压收尾
0x50,0x12,0x12,0x00,0x00,0x01, // 弱化处理,减少残影
// ... 剩余补零
};
// 局刷 LUT(简化波形,速度更快但有轻微残影)
static const uint8_t LUT_PART_WB[] = {
0x40,0x19,0x01,0x00,0x00,0x01, // 单阶段驱动,共 25 帧
// 仅 6 字节有效数据,其余补零
};时间对比:
全刷:约 2000ms(粒子完全翻转,无残影)
局刷:约 500ms(快速刷新,可能有轻微鬼影)
上电初始化 │ ▼ 【全刷模式】───► 清屏为全白 ───► 等待稳定(1s) │ ▼ 【切换局刷模式】 │ ▼ ┌──────────────────────────────────────┐ │ 正常运行期间:始终局刷 │ │ 每次 BLE 收到新数据 → 局刷更新 │ │ 只刷新变化区域,速度快,功耗低 │ └──────────────────────────────────────┘
本项目的策略:整个运行期间只做一次全刷(上电清屏),此后全程使用局刷。这是兼顾刷新速度和显示质量的最优解。
四、Zephyr 驱动框架适配
Zephyr 有标准的显示驱动框架(display_driver_api),只要实现规定的接口函数,LVGL 等上层库就能透明地使用驱动,不需要关心底层硬件细节。
4.1 Devicetree 配置Zephyr 驱动通过设备树(Devicetree)描述硬件,这是 Zephyr 与裸机开发最大的不同之一。
boards/nrf54l15dk_nrf54l15_cpuapp.overlay:
/ {
chosen {
zephyr,display = &epd0;
};
};
&spi00 {
status = "okay";
cs-gpios = <&gpio0 11 GPIO_ACTIVE_LOW>;
epd0: epd@0 {
compatible = "waveshare,epd-2in66";
reg = <0>;
spi-max-frequency = <2000000>; /* 2MHz */
dc-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>;
rst-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>;
busy-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>;
width = <152>;
height = <296>;
};
};4.2 驱动头文件结构 (epd_2in66.h)// 屏幕物理尺寸常量
#define EPD_WIDTH 152U // 像素宽度
#define EPD_HEIGHT 296U // 像素高度
#define EPD_BPR (EPD_WIDTH / 8U) // 每行字节数 = 19
#define EPD_BUF_SIZE (EPD_BPR * EPD_HEIGHT) // 帧缓冲大小 = 5624 字节
// 刷新模式枚举
typedef enum {
EPD_MODE_UNINIT = 0, // 未初始化
EPD_MODE_FULL, // 全刷模式
EPD_MODE_PARTIAL, // 局刷模式
} epd_mode_t;
// 驱动配置(来自设备树)
struct epd_config {
struct spi_dt_spec spi; // SPI 总线 + CS 引脚
struct gpio_dt_spec dc_gpio; // Data/Command 选择
struct gpio_dt_spec rst_gpio; // 硬件复位
struct gpio_dt_spec busy_gpio; // 忙信号输入
};
// 运行时数据
struct epd_data {
uint8_t screen_buf[EPD_BUF_SIZE]; // 帧缓冲(5624 字节)
epd_mode_t mode;
uint8_t partial_count;
bool dirty;
// 脏区域记录(优化局刷范围)
uint16_t dirty_x, dirty_y, dirty_w, dirty_h;
};4.3 SPI 通信底层函数墨水屏控制器通过 DC 引脚区分命令和数据:
DC = 0 → 后续字节是命令 (Command) DC = 1 → 后续字节是数据 (Data)
// 发送命令字节
static inline int epd_cmd(const struct device *dev, uint8_t cmd)
{
const struct epd_config *cfg = dev->config;
struct spi_buf tx = { .buf = &cmd, .len = 1 };
struct spi_buf_set txs = { .buffers = &tx, .count = 1 };
gpio_pin_set_dt(&cfg->dc_gpio, 0); // DC=0:命令模式
return spi_write_dt(&cfg->spi, &txs);
}
// 发送数据字节流
static inline int epd_data(const struct device *dev,
const uint8_t *buf, size_t len)
{
const struct epd_config *cfg = dev->config;
struct spi_buf tx = { .buf = (void *)buf, .len = len };
struct spi_buf_set txs = { .buffers = &tx, .count = 1 };
gpio_pin_set_dt(&cfg->dc_gpio, 1); // DC=1:数据模式
return spi_write_dt(&cfg->spi, &txs);
}
// 等待屏幕就绪(BUSY 引脚高电平表示忙)
static void epd_wait_busy(const struct device *dev)
{
const struct epd_config *cfg = dev->config;
while (gpio_pin_get_dt(&cfg->busy_gpio) == 1) {
k_msleep(5); // 每 5ms 轮询一次,让出 CPU
}
}4.4 硬件复位时序static void epd_hw_reset(const struct device *dev)
{
const struct epd_config *cfg = dev->config;
// RST 拉高 RST 拉低(复位) RST 再拉高(释放)
// 20ms 100ms 200ms
gpio_pin_set_dt(&cfg->rst_gpio, 1); k_msleep(20);
gpio_pin_set_dt(&cfg->rst_gpio, 0); k_msleep(100);
gpio_pin_set_dt(&cfg->rst_gpio, 1); k_msleep(200);
}⚠️ 复位时序很关键:时间太短会导致控制器初始化不完整,出现花屏或白屏。100ms 的低电平时间是经过实测确认的安全值。
4.5 寄存器初始化序列static int epd_reg_init(const struct device *dev)
{
epd_hw_reset(dev);
k_msleep(100);
// 0x01: Power Setting
// 03=VGH/VGL±20V, 00=VSH1/VSL配置,
// 2B=VDHR, 2B=VDHL, 03=VDNL
epd_cmd(dev, 0x01);
epd_data1(dev, 0x03); epd_data1(dev, 0x00);
epd_data1(dev, 0x2b); epd_data1(dev, 0x2b);
epd_data1(dev, 0x03);
// 0x06: Booster Soft-start
// 强制使用内部升压电路,所有相位均选择强驱动
epd_cmd(dev, 0x06);
epd_data1(dev, 0x17); epd_data1(dev, 0x17);
epd_data1(dev, 0x17);
// 0x00: Panel Setting
// 0xBF=分辨率配置(296行), 0x0D=KW模式+黑白像素格式
epd_cmd(dev, 0x00);
epd_data1(dev, 0xbf); epd_data1(dev, 0x0d);
// 0x30: PLL Control (帧率)
// 0x3A = 50Hz 刷新率
epd_cmd(dev, 0x30);
epd_data1(dev, 0x3a);
// 0x61: Resolution Setting
// 设置分辨率:宽152, 高296 (0x128)
epd_cmd(dev, 0x61);
epd_data1(dev, EPD_WIDTH);
epd_data1(dev, (uint8_t)(EPD_HEIGHT >> 8));
epd_data1(dev, (uint8_t)(EPD_HEIGHT & 0xFF));
// 0x82: VCOM 电压设置
epd_cmd(dev, 0x82);
epd_data1(dev, 0x1c);
// 0x50: VCOM and Data interval setting
// 0x17 = VCOM 7帧间隔,白色边框
epd_cmd(dev, 0x50);
epd_data1(dev, 0x17);
return 0;
}4.6 加载 LUT 波形表static int epd_load_lut(const struct device *dev, uint8_t reg,
const uint8_t *lut, size_t lut_len,
size_t full_len)
{
// 发送寄存器地址
epd_cmd(dev, reg);
// 发送有效 LUT 数据
epd_data(dev, lut, lut_len);
// 补零至要求的完整长度(控制器要求固定长度)
uint8_t zero = 0x00;
for (size_t i = lut_len; i < full_len; i++) {
epd_data1(dev, zero);
}
return 0;
}
// 切换到全刷模式:加载 5 张全刷 LUT
static int epd_switch_full(const struct device *dev)
{
epd_reg_init(dev);
epd_load_lut(dev, 0x20, LUT_FULL_VCOM,
sizeof(LUT_FULL_VCOM), LUT_VCOM_SIZE); // VCOM LUT
epd_load_lut(dev, 0x21, LUT_FULL_WW,
sizeof(LUT_FULL_WW), LUT_WX_SIZE); // WW LUT
epd_load_lut(dev, 0x22, LUT_FULL_BW,
sizeof(LUT_FULL_BW), LUT_WX_SIZE); // BW LUT
epd_load_lut(dev, 0x23, LUT_FULL_WB,
sizeof(LUT_FULL_WB), LUT_WX_SIZE); // WB LUT
epd_load_lut(dev, 0x24, LUT_FULL_BB,
sizeof(LUT_FULL_BB), LUT_WX_SIZE); // BB LUT
epd_cmd(dev, 0x04); // Power ON
epd_wait_busy(dev);
data->mode = EPD_MODE_FULL;
return 0;
}LUT 寄存器地址对应关系:
寄存器 0x20 ← VCOM LUT (公共电极波形) 寄存器 0x21 ← WW LUT (白→白) 寄存器 0x22 ← BW LUT (黑→白) 寄存器 0x23 ← WB LUT (白→黑) 寄存器 0x24 ← BB LUT (黑→黑)4.7 全刷流程
static int epd_do_full_refresh(const struct device *dev)
{
struct epd_data *data = dev->data;
// 步骤1: 向寄存器 0x10 写入"旧帧"(全白)
// 这告诉控制器:上一帧所有像素都是白色
uint8_t wrow[EPD_BPR];
memset(wrow, 0xFF, EPD_BPR); // 0xFF = 全白(每 bit=1 表示白)
epd_cmd(dev, 0x10);
for (uint16_t r = 0; r < EPD_HEIGHT; r++) {
epd_data(dev, wrow, EPD_BPR);
}
// 步骤2: 向寄存器 0x13 写入"新帧"(目标画面)
epd_cmd(dev, 0x13);
epd_data(dev, data->screen_buf, EPD_BUF_SIZE);
// 步骤3: 触发刷新
epd_cmd(dev, 0x12); // Display Refresh
epd_wait_busy(dev); // 等待刷新完成
k_msleep(400); // 额外等待,确保电泳完成
return 0;
}全刷时序流程图:
写旧帧(0x10) 写新帧(0x13) 触发刷新(0x12) BUSY拉低(完成) │ │ │ │ ────┴───────┴────────┴───────────┴────► ~300ms ~300ms ~1500ms 完成 (电泳粒子翻转)4.8 局刷流程(核心优化)
局刷只更新屏幕的指定矩形区域,避免全屏翻转带来的闪烁和等待:
static int epd_do_partial_refresh(const struct device *dev,
uint16_t x, uint16_t y,
uint16_t w, uint16_t h,
const uint8_t *new_data,
uint16_t src_bpr)
{
struct epd_data *data = dev->data;
uint16_t col_byte = x / 8U;
uint16_t nbytes = w / 8U;
// 1. 设置局部刷新窗口
epd_cmd(dev, 0x91); // Enter partial mode
epd_cmd(dev, 0x90); // Partial window setting
epd_data1(dev, (uint8_t)x); // X start
epd_data1(dev, (uint8_t)(x + w - 1)); // X end
epd_data1(dev, (uint8_t)(y >> 8)); // Y start High
epd_data1(dev, (uint8_t)(y & 0xFF)); // Y start Low
epd_data1(dev, (uint8_t)((y+h-1) >> 8)); // Y end High
epd_data1(dev, (uint8_t)((y+h-1) & 0xFF));// Y end Low
epd_data1(dev, 0x28); // 边框 VCOM 设置
// 2. 写旧帧(从 screen_buf 中读当前内容)
epd_cmd(dev, 0x10);
for (uint16_t row = y; row < y + h; row++) {
epd_data(dev,
&data->screen_buf[row * EPD_BPR + col_byte],
nbytes);
}
// 3. 更新 screen_buf(同步 CPU 侧的帧缓冲)
for (uint16_t row = 0; row < h; row++) {
memcpy(&data->screen_buf[(y + row) * EPD_BPR + col_byte],
&new_data[row * src_bpr],
nbytes);
}
// 4. 写新帧(从更新后的 screen_buf 读)
epd_cmd(dev, 0x13);
for (uint16_t row = y; row < y + h; row++) {
epd_data(dev,
&data->screen_buf[row * EPD_BPR + col_byte],
nbytes);
}
// 5. 触发局部刷新
epd_cmd(dev, 0x12);
epd_wait_busy(dev);
k_msleep(400);
// 6. 退出局部模式
epd_cmd(dev, 0x92);
return 0;
}局刷窗口设置示意图:
完整屏幕 (152×296) ┌─────────────────────────┐ │ │ │ ┌───────────────┐ │← y_start │ │ 局刷区域 │ │ │ │ (只有这里 │ │ │ │ 会刷新) │ │ │ └───────────────┘ │← y_end │ ↑ ↑ │ │ x_start x_end │ │ │ └─────────────────────────┘ 只有窗口内的电泳粒子会被驱动,其他区域保持不变4.9 实现 Zephyr 显示驱动 API
这是将驱动接入 Zephyr 框架的关键一步:
// epd_write 是 LVGL 调用的核心入口
static int epd_write(const struct device *dev,
const uint16_t x, const uint16_t y,
const struct display_buffer_descriptor *desc,
const void *buf)
{
// 对 X 坐标做 8 像素对齐(SPI 传输以字节为单位)
uint16_t aligned_x = (x / 8U) * 8U;
uint16_t right = x + desc->width;
right = ((right + 7U) / 8U) * 8U;
uint16_t aligned_w = right - aligned_x;
// 钳位,防止越界
if (aligned_x + aligned_w > EPD_WIDTH) {
aligned_w = EPD_WIDTH - aligned_x;
}
// 无条件走局刷(初始化时已完成唯一一次全刷)
return epd_do_partial_refresh(dev, aligned_x, y,
aligned_w, desc->height,
(const uint8_t *)buf,
desc->pitch / 8U);
}
// 注册到 Zephyr 显示驱动框架
static const struct display_driver_api epd_2in66_api = {
.blanking_on = epd_blanking_on, // 空操作
.blanking_off = epd_blanking_off, // 空操作
.write = epd_write, // ← LVGL 调用此函数
.read = NULL, // 墨水屏不支持读取
.get_framebuffer = NULL,
.set_brightness = NULL,
.set_contrast = NULL,
.get_capabilities = epd_get_capabilities,
.set_pixel_format = epd_set_pixel_format,
.set_orientation = NULL,
};报告屏幕能力(让 LVGL 知道像素格式):
static void epd_get_capabilities(const struct device *dev,
struct display_capabilities *caps)
{
caps->x_resolution = EPD_WIDTH; // 152
caps->y_resolution = EPD_HEIGHT; // 296
// MONO10: 单色,1=黑, 0=白,MSB 优先
caps->supported_pixel_formats = PIXEL_FORMAT_MONO10;
caps->current_pixel_format = PIXEL_FORMAT_MONO10;
// MONO_MSB_FIRST: 最高位对应最左边像素
// EPD: 告知 LVGL 这是墨水屏,禁用动画
caps->screen_info = SCREEN_INFO_MONO_MSB_FIRST
| SCREEN_INFO_EPD;
}4.10 初始化入口(完整流程)static int epd_2in66_init(const struct device *dev)
{
// 1. 检查 SPI 总线就绪
if (!spi_is_ready_dt(&cfg->spi)) return -ENODEV;
// 2. 配置 GPIO 方向
gpio_pin_configure_dt(&cfg->dc_gpio, GPIO_OUTPUT_INACTIVE);
gpio_pin_configure_dt(&cfg->rst_gpio, GPIO_OUTPUT_ACTIVE);
gpio_pin_configure_dt(&cfg->busy_gpio, GPIO_INPUT);
// 3. 帧缓冲初始化为全白 (0xFF)
memset(data->screen_buf, 0xFF, EPD_BUF_SIZE);
// 4. 唯一一次全刷:清屏为白色
epd_switch_full(dev);
epd_do_full_refresh(dev);
// 5. 关键:等待墨粉完全沉降
k_msleep(1000);
// 6. 切换到局刷模式,此后永不切回全刷
epd_switch_partial(dev);
LOG_INF("EPD 2.66\" ready (%dx%d), partial mode active",
EPD_WIDTH, EPD_HEIGHT);
return 0;
}
// Zephyr 设备实例化宏
#define EPD_2IN66_DEFINE(inst) \
static struct epd_data epd_data_##inst; \
static const struct epd_config epd_cfg_##inst = { \
.spi = SPI_DT_SPEC_INST_GET(inst, \
SPI_OP_MODE_MASTER | \
SPI_TRANSFER_MSB | \
SPI_WORD_SET(8), 0), \
.dc_gpio = GPIO_DT_SPEC_INST_GET(inst, dc_gpios), \
.rst_gpio = GPIO_DT_SPEC_INST_GET(inst, rst_gpios),\
.busy_gpio= GPIO_DT_SPEC_INST_GET(inst, busy_gpios),\
}; \
DEVICE_DT_INST_DEFINE(inst, epd_2in66_init, NULL, \
&epd_data_##inst, &epd_cfg_##inst, \
POST_KERNEL, CONFIG_DISPLAY_INIT_PRIORITY, \
&epd_2in66_api);
DT_INST_FOREACH_STATUS_OKAY(EPD_2IN66_DEFINE)五、LVGL 图形库集成
驱动写好之后,LVGL 的接入出乎意料地简单,因为 Zephyr 已经内置了 LVGL 模块。
5.1 LVGL Kconfig 配置在 prj.conf 中追加:
# LVGL 基础 CONFIG_LVGL=y CONFIG_LV_Z_DISPLAY_DEV_NAME="EPD_2IN66_0" CONFIG_LV_Z_VDB_SIZE=32 # 虚拟显示缓冲区 (%) CONFIG_LV_Z_BITS_PER_PIXEL=1 # 墨水屏:1 bit/pixel # 字体(选择节省 Flash 的等宽字体) CONFIG_LV_FONT_UNSCII_8=y CONFIG_LV_FONT_DEFAULT_SMALL=y # 禁用墨水屏不需要的特性 CONFIG_LV_USE_ANIMATION=n # 无动画 CONFIG_LV_USE_GPU=n CONFIG_LV_USE_FILESYSTEM=n # 内存 CONFIG_LV_MEM_SIZE=16384 # 16KB LVGL 堆内存5.2 LVGL 与 Zephyr 驱动的对接原理
LVGL 绘图流程: ┌─────────────────────────────────────────────────────┐ │ lv_obj 脏区标记 │ │ ↓ │ │ LVGL 渲染到 VDB (Virtual Display Buffer) │ │ ↓ │ │ flush_cb 回调 (Zephyr 自动注册) │ │ ↓ │ │ display_write() → epd_write() → epd_do_partial_refresh()│ │ ↓ │ │ SPI 传输像素数据到屏幕 │ └─────────────────────────────────────────────────────┘
LVGL 的 VDB(虚拟显示缓冲区)是关键设计:
LVGL 先把内容渲染到 RAM 中的 VDB
渲染完成后调用 flush_cb 把 VDB 内容推送到屏幕
不需要完整的帧缓冲,节省 RAM
VDB (Virtual Display Buffer): ┌─────────────────────────────────────┐ │ 大小 = 屏幕总像素 × 位深 × VDB% │ │ = 152×296×1bit × 32% │ │ = 44992 bit ≈ 5.5KB │ └─────────────────────────────────────┘5.3 UI 界面实现 (ui.c)
关键 UI 创建代码:
void ui_create(void)
{
lv_obj_t *scr = lv_screen_active();
// 屏幕背景:纯白(墨水屏基色)
lv_obj_set_style_bg_color(scr, lv_color_white(), 0);
/* ── 标题 ── */
lv_obj_t *lbl_title = lv_label_create(scr);
lv_label_set_text(lbl_title, "PC Monitor");
lv_obj_set_style_text_font(lbl_title, &lv_font_unscii_8, 0);
lv_obj_set_style_text_color(lbl_title, lv_color_black(), 0);
lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 0, 2);
/* ── CPU 进度条 ── */
bar_cpu = lv_bar_create(scr);
lv_obj_set_size(bar_cpu, 148, 10);
lv_obj_set_pos(bar_cpu, 2, 46);
// 白底黑边框
lv_obj_set_style_bg_color(bar_cpu, lv_color_white(),
LV_PART_MAIN);
lv_obj_set_style_border_color(bar_cpu, lv_color_black(),
LV_PART_MAIN);
lv_obj_set_style_border_width(bar_cpu, 1, LV_PART_MAIN);
// 黑色填充
lv_obj_set_style_bg_color(bar_cpu, lv_color_black(),
LV_PART_INDICATOR);
lv_bar_set_range(bar_cpu, 0, 100);
/* ── CPU 历史折线图 ── */
chart_cpu = lv_chart_create(scr);
lv_obj_set_size(chart_cpu, 148, 120);
lv_obj_set_pos(chart_cpu, 2, 128);
lv_chart_set_type(chart_cpu, LV_CHART_TYPE_LINE);
lv_chart_set_point_count(chart_cpu, 60); // 60个数据点=60秒
lv_chart_set_range(chart_cpu,
LV_CHART_AXIS_PRIMARY_Y, 0, 100);
// 适配墨水屏的样式:黑白、无圆点、细线
lv_obj_set_style_bg_color(chart_cpu, lv_color_white(),
LV_PART_MAIN);
lv_obj_set_style_border_color(chart_cpu, lv_color_black(),
LV_PART_MAIN);
lv_obj_set_style_width(chart_cpu, 0, LV_PART_INDICATOR); // 隐藏圆点
lv_obj_set_style_height(chart_cpu, 0, LV_PART_INDICATOR);
ser_cpu = lv_chart_add_series(chart_cpu, lv_color_black(),
LV_CHART_AXIS_PRIMARY_Y);
// 初始化历史数据为 0
for (int i = 0; i < 60; i++) {
lv_chart_set_next_value(chart_cpu, ser_cpu, 0);
}
}数据更新函数:
void ui_update_stats(const struct pc_stats *stats,
uint8_t cpu_thr, uint8_t mem_thr,
bool alert_en)
{
char buf[32];
/* CPU 标签:显示当前值和阈值 */
snprintf(buf, sizeof(buf), "CPU %3d%%[%2d%%]",
stats->cpu_usage, cpu_thr);
lv_label_set_text(lbl_cpu, buf);
lv_bar_set_value(bar_cpu, stats->cpu_usage, LV_ANIM_OFF);
/* MEM */
snprintf(buf, sizeof(buf), "MEM %3d%%[%2d%%]",
stats->mem_usage, mem_thr);
lv_label_set_text(lbl_mem, buf);
lv_bar_set_value(bar_mem, stats->mem_usage, LV_ANIM_OFF);
/* GPU */
snprintf(buf, sizeof(buf), "GPU %3d%%", stats->gpu_usage);
lv_label_set_text(lbl_gpu, buf);
lv_bar_set_value(bar_gpu, stats->gpu_usage, LV_ANIM_OFF);
/* 折线图:追加最新 CPU 数据点(自动滚动) */
lv_chart_set_next_value(chart_cpu, ser_cpu, stats->cpu_usage);
}5.4 主循环中的 LVGL 调用int main(void)
{
// ... 初始化 BLE、蜂鸣器等
// 获取 LVGL 显示对象(Zephyr 自动初始化)
lv_display_t *lvgl_disp = lv_display_get_next(NULL);
// 创建 UI 控件
ui_create();
// 初始渲染(触发一次 flush,显示初始界面)
lv_refr_now(lvgl_disp);
int64_t last_tick = k_uptime_get();
while (1) {
lv_timer_handler(); // 处理 LVGL 内部定时器
int64_t now = k_uptime_get();
if ((now - last_tick) >= 1000LL) { // 每 1 秒更新
last_tick = now;
struct pc_stats stats;
ble_get_stats(&stats);
ui_update_stats(&stats, cpu_thr, mem_thr, alert_en);
// 让 LVGL 决定是否需要刷新(脏区域检测)
lv_refr_now(lvgl_disp);
}
k_sleep(K_MSEC(5)); // 让出 CPU 给 BLE 协议栈
}
}六、CMake 构建配置
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(epd_monitor)
# 自定义驱动目录
zephyr_include_directories(drivers/display)
target_sources(app PRIVATE
src/main.c
src/ble_gatt.c
src/ui.c
src/fb.c
src/buzzer.c
src/buttons.c
src/settings_fsm.c
drivers/display/epd_2in66.c # 墨水屏驱动
)驱动的 CMakeLists.txt(放在 drivers/display/):
# drivers/display/CMakeLists.txt zephyr_library_sources_ifdef(CONFIG_EPD_2IN66 epd_2in66.c)
Kconfig 配置选项:
# drivers/display/Kconfig config EPD_2IN66 bool "Waveshare 2.66 inch ePaper display driver" depends on SPI help Driver for Waveshare 2.66 inch e-Paper display using SPI interface.
七、阶段成果与效果展示
第二阶段完成后的实物效果:
竖屏风格效果

横屏风格效果

串口日志输出:
[00:00:00.012] <inf> epd_2in66: EPD 2.66" init start [00:00:00.015] <inf> epd_2in66: EPD hw reset done [00:00:02.518] <inf> epd_2in66: Full refresh done [00:00:03.520] <inf> epd_2in66: EPD 2.66" ready (152x296), partial mode active [00:00:03.521] <inf> main: Initial render... [00:00:03.948] <inf> main: Initial render done [00:00:08.401] <inf> main: Tick! CPU=75% MEM=60% GPU=45% [00:00:09.401] <inf> main: Tick! CPU=78% MEM=62% GPU=48%
八、本篇小结与下篇预告
本篇实现了:
✅ 深入理解 ePaper 电泳原理和 LUT 波形表
✅ 在 Zephyr 驱动框架下移植 Waveshare 2.66" ePaper 驱动
✅ 实现全刷/局刷两种模式,运行期全程局刷提升响应速度
✅ 接入 LVGL,实现进度条 + 折线图的性能监控 UI
下一篇(终篇):将在现有 UI 基础上设计完善的告警状态机,实现 CPU/内存占用超阈值时的屏幕告警提示 + 蜂鸣器声音告警,并支持通过板载按键动态调整阈值,完成整个项目的最终闭环。
我要赚赏金
