最终的目标是实现低功耗蓝牙温湿度计,最终的目标就是通过蓝牙实现温湿度数据的上传,所以最基础的功能就是蓝牙通信功能,也是最重要的功能,这里我们还是使用SDK中的例程,避免一些配置问题,同时通过对例程的学习能够清晰了解蓝牙功能的使用。
这里需要注意MCXW72是一个双核芯片,也就是说一个是咱们用来写控制工程的,另一个通过固定的固件化身蓝牙模块了,所以不要忘了下载蓝牙固件。
本次使用的开发板是FRDM-MCXW72,加载例程:

接下来我们将通过这个例程修改成为我们之后将要使用的工程。我们本次的实践的基本情况如下:
平台: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多、官方例程往往"已经做完了所有事情"导致看不到骨架。
我要赚赏金
