这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 【e起DIY】Zephyr蓝牙透传改造实战——从peripheralhr例程到N

共2条 1/1 1 跳转至

【e起DIY】Zephyr蓝牙透传改造实战——从peripheralhr例程到NUS自定义通信

工程师
2026-06-04 20:48:15     打赏

        最终的目标是实现低功耗蓝牙温湿度计,最终的目标就是通过蓝牙实现温湿度数据的上传,所以最基础的功能就是蓝牙通信功能,也是最重要的功能,这里我们还是使用SDK中的例程,避免一些配置问题,同时通过对例程的学习能够清晰了解蓝牙功能的使用。

        这里需要注意MCXW72是一个双核芯片,也就是说一个是咱们用来写控制工程的,另一个通过固定的固件化身蓝牙模块了,所以不要忘了下载蓝牙固件。

        本次使用的开发板是FRDM-MCXW72,加载例程:

d1fc011f-ffc0-4ba2-8ace-4883586fa80f.png

        接下来我们将通过这个例程修改成为我们之后将要使用的工程。我们本次的实践的基本情况如下:

            平台:FRDM-MCXW72(NXP官方评估板,板载调试器)

            框架:Zephyr RTOS

            例程入口:samples/bluetooth/peripheral_hr

            最终效果:连接后每秒通过NUS透传 "hello\n",断线自动恢复广播

        很多Zephyr/BLE入门者都有一个共同的痛点:拿到官方例程跑起来后,不知道从哪里下手改成自己的应用。官方例子一般是peripheral_hr这种"心率计模拟器"成品项目,对刚入门的人来说两条路之间的鸿沟往往最大。

        本次以peripheral_hr这个"看起来跟透传没关系"的例程为起点,手把手带大家:

1、梳理例程的蓝牙工作流程—— 看懂 Zephyr BLE 工程到底在干什么;

2、理解GAT 服务的设计思想—— 为什么 HR 例程要"模拟"心率而不直接发 hello;

3、完成从HRS到NUS的完整改造—— 把一个标准服务应用改造成通用透传通道;

4、真正烧到板子上跑通—— 不止于编译通过,还要用手机调试助手验证。

几个必须先搞清楚的 BLE 概念

BLE ≠ 经典蓝牙

        经典蓝牙(BR/EDR)是耳机、键鼠那种长距离、点对点"流式"连接。BLE(Bluetooth Low Energy)则是短包、事件驱动、低功耗的协议,专为低功耗场景设计。手机和Zephyr 默认走BLE,本文也是。

两种角色:Peripheral(外设)vs Central(中心)

Peripheral(外设):通常是传感器、手环那种"小、被连接、广播自己"的设备。

Central(中心):通常是手机、PC 那种"大、主动扫描、连别人"的设备。

        Zephyr例程peripheral_*全部是外设角色。

GATT:BLE 的核心数据模型

GATT(Generic Attribute Profile)是 BLE 数据交换的"协议层",它的模型像一棵

Service(服务) ← 一个独立的功能模块
└── Characteristic(特征) ← 数据的最小单位
├── Value(值) ← 实际数据
├── Property(属性) ← 可读/可写/可通知
└── Descriptor(描述) ← CCCD(通知开关)等

        举个例子,心率服务(HR Service)下面有一个"心率测量"特征(Heart Rate Measurement),它有个Notify属性 —— 也就是说手机只要订阅一次,设备就可以随时把新数据push上来,不用手机轮询。

NUS:通用透传通道的事实标准

        NUS 全称 Nordic UART Service,是Nordic在其SDK里定义的一个GATT服务,专门用于"模拟串口":

项目

UUID

方向

说明

NUS Service

6E400001-B5A3-F393-E0A9-E50E24DCCA9E

容器

TX Characteristic

6E400003-...

设备 → 手机

Notify

,设备主动发数据

RX Characteristic

6E400002-...

手机 → 设备

Write

,手机发命令给设备

        UUID是128位全球唯一标识符。前24位(6E4000XX)是Nordic申请的OUI段,Zephyr把它作为官方服务模块收录:CONFIG_BT_ZEPHYR_NUS=y 即可使能。

        为什么选 NUS? 因为几乎所有手机端的"蓝牙调试助手"(nRF Connect、EFR Connect、蓝牙调试宝、LightBlue 等)都内置了NUS解析模板,连接后会自动列出 TX/RX 特征,点一下Notify就能看到数据流 —— 不需要自己写手机App

peripheral_hr 例程解剖

工程文件一览

peripheral_hr/

├── CMakeLists.txt # 项目构建脚本
├── prj.conf # Kconfig 配置(决定编译哪些功能模块)
├── README.rst # 官方说明
├── boards/ # 板级 overlay(本例不需改)
├── overlay-*.conf # 可选 Kconfig 覆盖
└── src/
└── main.c # 全部蓝牙逻辑

        整个工程就一个 main.c,没有别的 .c / .h。这正是 Zephyr 例程的特点:把所有逻辑放在一个文件里,方便阅读。

main.c 里的4块核心逻辑

main.c 按职责切成 4 块:

段落

职责

服务定义

广播数据:告诉外界"我有 HR/Battery/DeviceInfo 这三种服务"

连接回调

设备连上/断开时干啥的钩子

服务回调

手机的"通知开关"被切换时干啥的钩子 + 模拟数据生成

主循环

1 秒一次,模拟心率、模拟电量、处理状态机

广播数据:自我介绍的"广告牌"

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_UUID16_ALL,
BT_UUID_16_ENCODE(BT_UUID_HRS_VAL), // 0x180D 心率服务
BT_UUID_16_ENCODE(BT_UUID_BAS_VAL), // 0x180F 电池服务
BT_UUID_16_ENCODE(BT_UUID_DIS_VAL)), // 0x180A 设备信息服务
};
 
static const struct bt_data sd[] = {
BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME, ...), // 设备名
};

连接回调:状态机的事件源

static ATOMIC_DEFINE(state, STATE_BITS);
 
static void connected(struct bt_conn *conn, uint8_t err) {
...
(void)atomic_set_bit(state, STATE_CONNECTED);
}
 
static void disconnected(struct bt_conn *conn, uint8_t reason) {
...
(void)atomic_set_bit(state, STATE_DISCONNECTED);
}
 
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
};

        注意一个关键点:connected / disconnected 是在 蓝牙协议栈的工作线程里被调用的,而你不能在那个线程里做耗时操作(比如 printk 大量数据、I2C 读取传感器)。Zephyr 的惯用做法是:

        在回调里只置一个原子标志位,到主循环里再处理真正的业务。这就是 STATE_CONNECTED / STATE_DISCONNECTED 这两个位的意义。主循环每次 tick 检查一下"有没有新事件",有就处理,处理完清标志。

服务回调:通知开关 + 数据模拟

static void hrs_ntf_changed(bool enabled) {
hrf_ntf_enabled = enabled; // 记录手机是否订阅
printk("HRS notification status changed: %s\n", enabled ? "enabled" : "disabled");
}
 
static struct bt_hrs_cb hrs_cb = { .ntf_changed = hrs_ntf_changed };
bt_hrs_cb_register(&hrs_cb) 把这个回调挂到 HRS 服务上。每当手机在调试助手点击 Notify 开关,Zephyr 就会调 hrs_ntf_changed(true),告诉我们"现在可以发数据了"。
心率数据本身是 hrs_notify() 模拟出来的:
static void hrs_notify(void) {
static uint8_t heartrate = 90U;
heartrate++;
if (heartrate == 160U) heartrate = 90U;
if (hrf_ntf_enabled) {
bt_hrs_notify(heartrate); // 真正的"塞到蓝牙 FIFO"的动作
}
}

    bt_hrs_notify(heartrate) 是Zephyr HRS服务模块的API,它会按照心率服务的标准协议格式打包(包含一个字节的 flag + 实际心率值),然后通过Notify推给手机。这就是为什么手机端看到的是符合标准profile的数据。

主循环:状态机驱动的事件循环

while (1) {
k_sleep(K_SECONDS(1));
hrs_notify(); // 模拟心率
bas_notify(); // 模拟电池
 
if (atomic_test_and_clear_bit(state, STATE_CONNECTED)) {
// 刚连上,停止 LED 闪烁
} else if (atomic_test_and_clear_bit(state, STATE_DISCONNECTED)) {
// 刚断开,重新启动广播 —— 关键!
err = bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
...
}
}

        BT_LE_ADV_CONN_FAST_1 是 Zephyr 预定义的"快连广播参数":间隔 30~60ms,持续时间短,方便手机快速发现。广播一停,手机就再也找不到设备了 —— 所以断开时重启广播是 "支持断线重连" 的关键。

总结:peripheral_hr的本质

        心率例程没有"hello"这种应用数据。它有的是:

            标准的 HRS(心率)服务

            标准的 BAS(电池)服务

            标准的 DIS(设备信息)服务

        这三个服务在手机上对应的是心率、电池、设备信息三个 App。如果我们想发自定义的 "hello",必须要么换一个能装自定义数据的服务,要么新加一个服务

改造思路:HR → NUS

要把"模拟心率"换成"透传 hello",有3条路:

方案

优点

缺点

A.

替换为纯 NUS

工程最简洁,调试助手直接支持,代码 ~130 行

不能显示心率(如果以后真接传感器就需要重写)

B. 保留 HRS + 加 NUS

心率和透传共存

广播包更复杂,无用代码多

C. 完全自定义 GATT

最轻量

手机调试助手识别不到,得自己写 App

既然现在没有真传感器,只是要测试透传通道,那就干净利落地把 HRS/BAS/DIS全部换掉,只留NUS一个服务。等真接传感器时,再决定是加回HRS还是继续在NUS上做私有协议。

改造前后对比一览

项目

改造前

改造后

服务数量

3 个(HRS + BAS + DIS)

1 个(NUS)

设备名

Zephyr Heartrate Sensor

Zephyr_NUS

配对

启用 SMP(需要配对)

关闭(裸连接)

发送频率

1s/次心率

1s/次 "hello\n"

LED 指示

闪烁/常亮

保留

断线重连

已支持

保留

第一步:改 prj.conf

        prj.conf是Kconfig 配置文件,决定哪些Zephyr模块会被编译进来。注释就是它的最佳文档

改成:

# 新 prj.conf
CONFIG_BT=y
CONFIG_LOG=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_ZEPHYR_NUS=y # ★ 新增:使能 NUS 服务模块
CONFIG_BT_DEVICE_NAME="Zephyr_NUS" # 改设备名,调试助手好认

        5行解决,干净利落。CONFIG_BT_ZEPHYR_NUS=y会拉进来 subsys/bluetooth/services/nus这个子系统,给我们提供 bt_nus_send()/ bt_nus_cb_register()等 API。

第二步:重写 main.c

头文件

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/hci.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/services/nus.h> /* ← 新增 NUS 服务头文件 */

        把 <services/bas.h> 和 <services/hrs.h> 删掉,加 <services/nus.h>。其余保留。

广播数据:声明"我提供 NUS"

static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};
 
static const struct bt_data sd[] = {
BT_DATA_BYTES(BT_DATA_UUID128_ALL, BT_UUID_NUS_SRV_VAL),
};

        注意NUS用的是128位UUID,所以是 BT_DATA_UUID128_ALL 而不是 BT_DATA_UUID16_ALL。BT_UUID_NUS_SRV_VAL是Zephyr预定义的NUS服务UUID字节数组。

NUS 回调:处理"通知开关"和"收到的数据"

static void notif_enabled(bool enabled, void *ctx) {
ARG_UNUSED(ctx);
printk("NUS notification status changed: %s\n",
enabled ? "enabled" : "disabled");
}
 
static void received(struct bt_conn *conn, const void *data, uint16_t len, void *ctx) {
ARG_UNUSED(conn);
ARG_UNUSED(ctx);
printk("NUS received %u bytes: %.*s\n", len, len, (char *)data);
}
 
struct bt_nus_cb nus_cb = {
.notif_enabled = notif_enabled,
.received = received,
};

两个回调作用:

        notif_enabled:received 之外,notif_enabled 表示手机的"订阅开关"。在手机端 nRF Connect 点 Notify 开关时,会调用此函数。在我们的例子里,我们关心的是"从手机发数据到设备",所以需要确保这个回调正确处理。

        received:手机通过 RX characteristic 写数据时触发,参数里是完整的数据。

        重要约定:这两个回调都在蓝牙协议栈工作线程里执行,不要做耗时操作(比如 I2C 读传感器、长延时)。

连接管理:保存 current_conn

static struct bt_conn *current_conn;
 
static void connected(struct bt_conn *conn, uint8_t err) {
if (err) {
printk("Connection failed, err 0x%02x %s\n",
err, bt_hci_err_to_str(err));
return;
}
printk("Connected\n");
current_conn = bt_conn_ref(conn); /* ★ ref 一下,防止被释放 */
(void)atomic_set_bit(state, STATE_CONNECTED);
}
 
static void disconnected(struct bt_conn *conn, uint8_t reason) {
printk("Disconnected, reason 0x%02x %s\n",
reason, bt_hci_err_to_str(reason));
if (current_conn) {
bt_conn_unref(current_conn); /* ★ 用完记得 unref */
current_conn = NULL;
}
(void)atomic_set_bit(state, STATE_DISCONNECTED);
}

    为什么bt_conn_ref / bt_conn_unref?因为conn这个指针的所有权归协议栈,它可能在任何时候被释放。我们主循环里要 bt_nus_send(current_conn, ...) 用它,所以必须 ref 一次让协议栈"hold 住",断开时再unref。这是Zephyr蓝牙代码里非常容易踩的坑。

主循环:定时发 hello + 断线重启广播

int main(void) {
int err;
static const char hello_msg[] = "hello\n";
 
err = bt_enable(NULL); /* 初始化协议栈 */
if (err) { printk("Bluetooth init failed (err %d)\n", err); return 0; }
printk("Bluetooth initialized\n");
 
err = bt_nus_cb_register(&nus_cb, NULL);
if (err) { printk("Failed to register NUS callback (err %d)\n", err); return 0; }
 
start_advertising(); /* 启动广播 */
 
while (1) {
k_sleep(K_SECONDS(1));
 
if (current_conn) { /* 已连接? */
err = bt_nus_send(current_conn, hello_msg,
sizeof(hello_msg) - 1);
if (err < 0 && err != -EAGAIN && err != -ENOTCONN) {
printk("bt_nus_send failed (err %d)\n", err);
}
}
 
if (atomic_test_and_clear_bit(state, STATE_DISCONNECTED)) {
start_advertising(); /* ★ 断线重启广播 */
}
}
}

手机调试助手验证

        打开 nRF Connect / EFR Connect / 蓝牙调试宝 / LightBlue

        扫描 → 找到Zephyr_NUS → 点击Connect

        连接成功后Services列表里能看到Nordic UART Service(6E400001-...)展开它:

                点 TX Characteristic(6E400003-...)右侧的 ↓↓↓ 图标

        Subscribe / Notify,此时串口会打 NUS notification status changed: enabled

        每隔 1 秒调试助手的"接收"窗口应出现一行 hello\n

实际接收效果如下:

        蓝牙开发入门难主要难在:协议本身的术语多、Zephyr的API多、官方例程往往"已经做完了所有事情"导致看不到骨架。





关键词: Zephyr     蓝牙     透传     通信    

助工
2026-06-08 21:40:35     打赏
2楼

这篇贴文写的太用心了,极其专业,介绍得也比较直观,学习了,谢谢!


共2条 1/1 1 跳转至

回复

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