系列简介:本系列共三篇,记录我用 Nordic nRF54L15-DK 开发板 + 墨水屏,从零实现一个低功耗 PC 性能监控副屏的完整过程。本篇是第一篇,聚焦 BLE 通信链路的建立:从协议原理讲起,到 Zephyr 侧 GATT Server 实现,再到 PC 端上位机采集与发送,手把手带你调通整条数据链路。
一、为什么做这个项目?
打游戏或者跑深度学习训练的时候,CPU/内存/GPU 占用蹭蹭往上涨,但主屏幕被游戏或 IDE 占满,根本没位置放性能监控悬浮窗。市面上的副屏方案要么用 USB 连接(功耗高、占口),要么 Wi-Fi 方案待机能耗感人。
痛点归纳:
传统方案 | 问题 |
USB 小屏 | 占用 USB 口,常亮功耗高 |
Wi-Fi 副屏 | 待机电流 >100mA |
软件悬浮窗 | 遮挡主屏,影响使用 |
我的方案:
PC ──BLE──► nRF54L15-DK ──SPI──► 墨水屏 (ePaper)
BLE:免驱、无线、连接态电流约 5~10mA
墨水屏:刷新后断电也能保留画面,真正实现超低待机
nRF54L15:Nordic 最新 Cortex-M33 低功耗 SoC,内置多协议无线
二、硬件平台介绍
2.1 nRF54L15-DK 核心参数
┌───────────────────────────┐ │ nRF54L15 │ │ │ │ CPU: Cortex-M33 @ 128MHz │ │ RAM: 256KB │ │ Flash: 1MB │ │ 无线: BLE 5.4 / Zigbee / Thread │ │ 外设: SPI / I2C / UART / PWM / GPIO │ └───────────────────────────┘
nRF54L15 是 Nordic 2024 年推出的新一代低功耗 SoC,相比 nRF52840 在计算性能和功耗控制上均有显著提升,非常适合这种"平时待机、有数据才刷屏"的场景。
2.2 整体架构图
┌───────────────────────────────────────┐ │ PC (Windows) │ │ │ │ ┌────────┐ ┌───────────────────┐ │ │ │ psutil 采集 │───►│ Python BLE 上位机 (bleak) │ │ │ │ CPU/MEM/GPU │ │ GATT Write Without Response │ │ │ └────────┘ └───────────┬───────┘ │ └─────────────────────────── │ BLE ───────┘ │ (4字节数据包) ┌─────────▼─────────┐ │ nRF54L15-DK │ │ │ │ Zephyr RTOS │ │ BLE GATT Server │ │ ble_gatt.c │ └───────────────────┘
三、Zephyr 固件侧实现(GATT Server)
蓝牙数据接收流程图

3.1 环境准备(基于nRF Connect SDK)
prj.conf 关键配置:
# BLE 基础配置 CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_DEVICE_NAME="nRF54L15-Monitor" CONFIG_BT_DEVICE_NAME_DYNAMIC=n # GATT CONFIG_BT_GATT_CLIENT=n # 日志 CONFIG_LOG=y CONFIG_BT_LOG_LEVEL_INF=y # 栈大小(BLE 需要足够的栈) CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048 CONFIG_MAIN_STACK_SIZE=4096
3.2 数据结构定义 (ble_gatt.h)
// src/ble_gatt.h
struct pc_stats {
uint8_t cpu_freq; /* ×100 MHz,例如 36 = 3.6GHz */
uint8_t cpu_usage; /* 0-100 % */
uint8_t mem_usage; /* 0-100 % */
uint8_t gpu_usage; /* 0-100 % */
uint32_t last_update; /* 距开机秒数,用于检测数据新鲜度 */
};
int ble_gatt_init(void);
void ble_get_stats(struct pc_stats *out);
bool ble_is_connected(void);3.3 GATT Server 核心实现 (ble_gatt.c)
数据通信类型选择 Write Without Response,原因是:
性能监控数据是"fire and forget",不需要 ACK
减少往返时延,降低 BLE 空中包数量
进一步降低功耗(省去 Server 发送 ACK 的时机)
① 定义 UUID
// 自定义 128-bit UUID(随机生成,确保唯一) static struct bt_uuid_128 service_uuid = BT_UUID_INIT_128( BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0)); static struct bt_uuid_128 char_uuid = BT_UUID_INIT_128( BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef1));
小知识:128-bit UUID 是自定义服务的标准做法。BLE SIG 分配的 16-bit UUID(如 0x180D 心率)是官方标准服务专用,自研项目必须用 128-bit。
② 广播数据
// 广播包 (AD): 放 Service UUID,让设备能被发现
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS,
(BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA_BYTES(BT_DATA_UUID128_ALL,
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678,
0x1234, 0x56789abcdef0)),
};
// 扫描响应包 (SD): 放设备名称
static const struct bt_data sd[] = {
BT_DATA(BT_DATA_NAME_COMPLETE,
CONFIG_BT_DEVICE_NAME,
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};广播包结构示意:
┌───────────────────────────┐ │ AD (Advertising Data, 31字节上限) │ │ ├── Flags: General Discoverable │ │ └── UUID128: 自定义 Service UUID │ ├───────────────────────────┤ │ SD (Scan Response Data, 31字节上限) │ │ └── Complete Name: "nRF54L15-Monitor" │ └───────────────────────────┘
③ GATT 特征值 Write 回调
static ssize_t on_write(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
const void *buf, uint16_t len,
uint16_t offset, uint8_t flags)
{
if (len < 3) {
// 最少需要 cpu_freq, cpu_usage, mem_usage
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
}
const uint8_t *p = (const uint8_t *)buf;
// 互斥锁保护(BLE 回调可能与主循环并发)
k_mutex_lock(&stats_mutex, K_FOREVER);
current_stats.cpu_freq = p[0];
current_stats.cpu_usage = p[1];
current_stats.mem_usage = p[2];
current_stats.gpu_usage = (len > 3) ? p[3] : 0U;
current_stats.last_update =
(uint32_t)(k_uptime_get() / 1000U);
k_mutex_unlock(&stats_mutex);
return (ssize_t)len;
}重点:on_write 是在 BLE 协议栈的系统线程上下文中执行的,主循环也会读取 current_stats,必须用 mutex 保护,否则会出现数据撕裂(torn read)。
④ GATT 服务注册(Zephyr 静态宏)
BT_GATT_SERVICE_DEFINE(epd_svc, // 主服务声明 BT_GATT_PRIMARY_SERVICE(&service_uuid), // 特征值声明:只写、无响应 BT_GATT_CHARACTERISTIC(&char_uuid.uuid, BT_GATT_CHRC_WRITE_WITHOUT_RESP, BT_GATT_PERM_WRITE, NULL, // read handler (不需要) on_write, // write handler NULL), // user data );
Zephyr 的 BT_GATT_SERVICE_DEFINE 宏会在编译期将服务描述符放入专用 linker section,运行时自动注册,无需手动调用注册 API,非常优雅。
⑤ 连接/断开事件处理
static void on_connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
LOG_ERR("Connect failed (%d)", err);
return;
}
current_conn = bt_conn_ref(conn); // 引用计数+1
is_connected = true;
char addr[BT_ADDR_LE_STR_LEN];
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
LOG_INF("Connected: %s", addr);
}
static void on_disconnected(struct bt_conn *conn, uint8_t reason)
{
LOG_INF("Disconnected (0x%02x)", reason);
bt_conn_unref(current_conn); // 引用计数-1,防止内存泄漏
current_conn = NULL;
is_connected = false;
start_advertising(); // 断开后重新广播,等待重连
}
// Zephyr 静态注册连接回调
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = on_connected,
.disconnected = on_disconnected,
};⑥ 初始化入口
int ble_gatt_init(void)
{
// 使能 BLE 协议栈(同步等待初始化完成)
int err = bt_enable(NULL);
if (err) {
LOG_ERR("bt_enable failed (%d)", err);
return err;
}
// 开始广播
return start_advertising();
}3.4 整体初始化流程
│ ├── ble_gatt_init() │ ├── bt_enable(NULL) ← 启动协议栈 │ └── start_advertising() ← 开始广播 │ └── bt_le_adv_start(...) │ └── while(1) ├── lv_timer_handler() ← LVGL 定时刷新 ├── ble_get_stats(&stats) ← 读取最新数据(带锁) └── k_sleep(K_MSEC(5)) ← 让出 CPU
四、PC 端上位机实现(Python)
PC 端我用 Python 的 bleak 库(跨平台 BLE 客户端)+ psutil 采集系统数据。
4.1 上位机完整代码
# pc_monitor.py
import asyncio
import psutil
import platform
from bleak import BleakClient, BleakScanner
# 与固件端完全一致的 UUID
SERVICE_UUID = "12345678-1234-5678-1234-56789abcdef0"
CHAR_UUID = "12345678-1234-5678-1234-56789abcdef1"
DEVICE_NAME = "nRF54L15-Monitor"
def get_cpu_freq_encoded() -> int:
"""获取 CPU 频率,编码为 ×100MHz 的 uint8"""
try:
freq = psutil.cpu_freq()
if freq:
# current 单位 MHz,转换为 ×100MHz
val = int(freq.current / 100)
return min(val, 255)
except Exception:
pass
return 0
def get_gpu_usage() -> int:
"""获取 GPU 使用率(0-100),失败返回 0"""
try:
import GPUtil
gpus = GPUtil.getGPUs()
if gpus:
return int(gpus[0].load * 100)
except ImportError:
pass
return 0
def build_packet() -> bytes:
"""
构建 4 字节数据包:
[cpu_freq×100MHz, cpu_usage%, mem_usage%, gpu_usage%]
"""
cpu_freq = get_cpu_freq_encoded()
cpu_usage = int(psutil.cpu_percent(interval=None))
mem_usage = int(psutil.virtual_memory().percent)
gpu_usage = get_gpu_usage()
# 钳位到 0~255
packet = bytes([
min(cpu_freq, 255),
min(cpu_usage, 100),
min(mem_usage, 100),
min(gpu_usage, 100),
])
return packet
async def find_device():
"""扫描并查找目标设备"""
print(f"[BLE] 扫描设备: {DEVICE_NAME} ...")
device = await BleakScanner.find_device_by_name(
DEVICE_NAME, timeout=10.0
)
if device is None:
raise RuntimeError(f"未找到设备: {DEVICE_NAME}")
print(f"[BLE] 发现设备: {device.address}")
return device
async def monitor_loop(client: BleakClient):
"""主监控循环,每秒发送一次数据"""
# 预热 psutil 的 CPU 采样(第一次调用总是返回 0)
psutil.cpu_percent(interval=None)
await asyncio.sleep(1)
print("[Monitor] 开始发送数据...")
while client.is_connected:
packet = build_packet()
# 打印调试信息
print(f"[Data] freq={packet[0]*100}MHz "
f"CPU={packet[1]}% "
f"MEM={packet[2]}% "
f"GPU={packet[3]}%")
# Write Without Response,不等待 ACK
await client.write_gatt_char(
CHAR_UUID,
packet,
response=False
)
await asyncio.sleep(1.0) # 1 秒更新一次
async def main():
device = await find_device()
async with BleakClient(device) as client:
print(f"[BLE] 已连接: {client.address}")
try:
await monitor_loop(client)
except Exception as e:
print(f"[Error] {e}")
finally:
print("[BLE] 断开连接")
if __name__ == "__main__":
asyncio.run(main())4.2 数据采集说明
psutil.cpu_percent() → CPU 总体使用率 (0~100%) psutil.cpu_freq() → CPU 当前频率 (MHz) psutil.virtual_memory().percent → 内存使用率 (0~100%) GPUtil.getGPUs()[0].load → GPU 使用率 (0.0~1.0)
五、通信时序与调试
5.1 完整通信时序图
PC端 (bleak) nRF54L15 (Zephyr) │ │ │ ADV_IND 广播包 │ │◄──────────────────────│ │ │ │ CONNECT_IND (连接请求) │ │──────────────────────►│ │ │ on_connected() 触发 │ │ │ GATT Service Discovery │ │◄─────────────────────►│ │ (自动发现 UUID) │ │ │ │ ATT_WRITE_CMD (每 1 秒) │ │──────────────────────►│ │ [36][75][60][45] │ on_write() 触发 │ │ 解包存入 current_stats │ ATT_WRITE_CMD │ │──────────────────────►│ │ [36][78][62][48] │ │ ... │5.2 nRF 侧调试日志(RTT)
使用 nRF Connect for Desktop 的 RTT Viewer 或 VS Code 的串口终端查看:
[00:00:00.123] <inf> ble_gatt: BT stack enabled [00:00:00.145] <inf> ble_gatt: Advertising started [00:00:05.312] <inf> ble_gatt: Connected: C0:4E:30:11:22:33 (random) [00:00:06.401] <inf> main: Tick! CPU=75% MEM=60% GPU=45% [00:00:07.402] <inf> main: Tick! CPU=78% MEM=62% GPU=48% [00:00:08.401] <inf> main: Tick! CPU=71% MEM=61% GPU=44%5.3 用 nRF Connect App 验证
在手机上安装 nRF Connect App,可以不写任何 PC 代码,直接手动向特征值写数据验证固件逻辑:
扫描 → 找到 "nRF54L15-Monitor" → 连接 → 展开 Custom Service → 找到 Characteristic (UUID ...def1) → 点击写入按钮 → 输入: 24 5A 3C 2D (HEX) → 观察 RTT 日志
输入 24 5A 3C 2D 解码为:
CPU频率:0x24 = 36 → 3.6 GHz
CPU使用率:0x5A = 90 → 90%
内存使用率:0x3C = 60 → 60%
GPU使用率:0x2D = 45 → 45%
六、阶段成果展示
第一阶段完成后,效果如下(使用 nRF Connect App 手动测试):

PC 端上位机运行输出:
[BLE] 扫描设备: nRF54L15-Monitor ... [BLE] 发现设备: EB:3D:xx:xx:xx:xx [BLE] 已连接: EB:3D:xx:xx:xx:xx [Monitor] 开始发送数据... [Data] freq=3600MHz CPU=23% MEM=58% GPU=12% [Data] freq=3600MHz CPU=31% MEM=58% GPU=15% [Data] freq=3600MHz CPU=89% MEM=61% GPU=78%
七、本篇小结与下篇预告
本篇实现了:
✅ Zephyr 下实现 BLE GATT Server,注册自定义服务
✅ 处理连接/断开事件,实现断线自动重广播
✅ Python 上位机采集 CPU/内存/GPU 数据并通过 BLE 发送
下一篇:将在 Zephyr 下移植 Waveshare 2.66 寸 ePaper 驱动,深入解析墨水屏全刷/局刷 LUT 原理,并接入 LVGL 图形库,把数字实实在在地显示到屏幕上。
我要赚赏金
