这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » 【nRF54L15-DK测评】低功耗BLE电脑性能监控副屏——阶段二:Zephy

共1条 1/1 1 跳转至

【nRF54L15-DK测评】低功耗BLE电脑性能监控副屏——阶段二:Zephyr下移植墨水屏驱动并适配LVGL

菜鸟
2026-05-17 15:46:43     打赏

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


显示效果图

mmexport1778991558033.jpg


一、为什么选墨水屏?

在开始写代码之前,先聊聊为什么这个项目一定要用墨水屏,而不是 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中的板卡配置器:

config.jpg

将VDD电压调整到3.3V

VDD.jpg



三、墨水屏工作原理深度解析

这是本篇的核心理论部分,搞懂原理才能真正理解驱动代码为什么这样写。

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(快速刷新,可能有轻微鬼影)

3.3 全刷与局刷策略
上电初始化
    │
    ▼
【全刷模式】───► 清屏为全白 ───► 等待稳定(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.

七、阶段成果与效果展示

第二阶段完成后的实物效果

竖屏风格效果

3.jpg

横屏风格效果

1932123002.jpg

串口日志输出:

[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/内存占用超阈值时的屏幕告警提示 + 蜂鸣器声音告警,并支持通过板载按键动态调整阈值,完成整个项目的最终闭环。





关键词: nRF54L15-DK     Zephyr     墨水屏     监控    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]