如何使用 Zephyr OS 构建蓝牙应用:开发者手册

TL;DR · AI 摘要
Zephyr OS 是一个开源实时操作系统,专为资源受限的嵌入式设备设计,支持完整的蓝牙低功耗(BLE)协议栈,包括主机和控制器层,且通过蓝牙SIG认证。本文提供从零构建BLE应用的完整指南,涵盖GAP、GATT、服务与特征等核心概念,并结合实际代码演示广告、连接、传感器数据传输等关键功能,适合开发者深入掌握生产级BLE设备开发。
核心要点
- Zephyr OS 支持完整的蓝牙SIG认证BLE协议栈,包含主机(GAP/GATT)和控制器(Link Layer/HCI),适用于Nordic nRF系列芯
- 使用nRF52840 DK开发板配合nRF Connect for Mobile可高效测试BLE应用,成本约40美元,兼容Linux/macOS/WSL2环境。
- Zephyr支持Bluetooth 5.x特性(如2M PHY、Coded PHY)、LE Audio、Mesh网络及方向查找,是构建现代BLE产品的首选平台。
结构提纲
按章节快速跳转。
- §引言
蓝牙低功耗(BLE)广泛应用于无线耳机、智能手表和传感器设备,而Zephyr OS正成为其固件开发的核心平台。
- §前提条件
开发者需熟悉C语言编程,具备指针、结构体和回调函数基础,并准备nRF52840 DK开发板及支持BLE扫描的手机应用。
Zephyr OS 是由Linux基金会托管的开源实时操作系统,支持600+硬件平台,最小仅需8KB RAM运行。
Zephyr 提供完整的蓝牙SIG认证BLE协议栈,覆盖主机与控制器层,对Nordic芯片提供原生开源控制器实现。
Zephyr 支持Bluetooth 5.x特性(如2M PHY、Coded PHY)、LE Audio、Mesh网络及方向查找,适用于产品级开发需求。
建议按顺序阅读并动手编写代码,逐步构建广告、连接、传感器数据读取等功能,最终完成完整BLE外设。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Zephyr OS BLE 开发手册
- Zephyr OS 基础
- 开源实时操作系统
- 支持600+硬件平台
- BLE 协议栈
- 主机层:GAP/GATT/SMP
- 控制器层:Link Layer/HCI
- 高级功能支持
- Bluetooth 5.x 特性
- LE Audio 和 Mesh 网络
- 方向查找技术
- 开发实践
- 使用nRF52840 DK开发板
- 搭配nRF Connect测试
金句 / Highlights
值得收藏与分享的关键句。
Zephyr OS 包含完整的蓝牙SIG合格BLE协议栈,意味着它已通过官方一致性测试,符合规范要求,而非业余实现。
对于支持的无线电(主要是Nordic nRF系列),Zephyr提供自己的开源控制器实现,整个蓝牙堆栈从应用代码到射频寄存器全部开源。
Nordic Semiconductor在其nRF Connect SDK中基于Zephyr构建,其工程师在编写BLE固件时使用Zephyr,这是强有力的背书。
Zephyr支持Bluetooth 5.x特性(如2M PHY、Coded PHY)、LE Audio、Mesh网络及方向查找,满足现代BLE产品开发需求。

你的手机刚刚连接了无线耳机,智能手表同步了健康数据到应用程序,而建筑中的某个传感器也向网关报告了温度。所有这些交互都是通过蓝牙低功耗(BLE)实现的。而且,越来越多的设备固件是基于 Zephyr OS 构建的。
本手册将教你如何从零开始在 Zephyr 上构建蓝牙应用。你将从 BLE 的基础知识入手(GAP、GATT、服务和特征到底是什么意思),然后逐步进入实际编写 Zephyr 固件:让设备广播、创建自定义服务、处理连接、通过 BLE 读取传感器数据,并构建一个完整的 BLE 外设,使其能够与手机通信。
每个概念都配有可运行的代码,每个代码块都附有其功能和重要性的详细解释。
这是一篇详尽且内容丰富的指南。蓝牙涉及大量组件,大多数教程往往忽略那些在实际项目中容易让人出错的部分。
而本指南不会忽略这些细节。请按顺序阅读,边学边动手编写代码,最终你将掌握在 Zephyr 上构建生产级 BLE 设备所需的知识。
目录
前提条件
要跟上本教程,你应该熟悉 C 语言的阅读和编写。指针、结构体、函数指针和回调函数不应对你陌生。你需要一个命令行终端,并具备基本的 C 项目构建经验。无需事先具备蓝牙经验——本文会从零开始讲解 BLE 概念。
在硬件方面,Nordic Semiconductor nRF52840 DK 是最理想的开发板。Nordic 芯片在 Zephyr 中拥有最佳支持的蓝牙协议栈,nRF52840 DK 价格实惠(约 40 美元),易于获取,并内置调试器。
如果你有其他支持 Zephyr 且具备蓝牙功能的开发板(如 nRF52832 DK、nRF5340 DK 或任何带有 BLE 收发器的开发板),也可以使用。
为了测试 BLE 功能,你需要一部安装了 BLE 扫描应用的手机(nRF Connect for Mobile 在 iOS 和 Android 上均免费且非常出色)。
此外,你还需要一台运行 Linux(Ubuntu 22.04 或更新版本)、macOS 或 Windows + WSL2 的计算机。
什么是 Zephyr OS(以及为何用于蓝牙开发)?
Zephyr OS 是一个专为资源受限嵌入式设备设计的小型、开源实时操作系统。它由 Linux 基金会托管,采用 Apache 2.0 许可证,可在仅需 8 KB RAM 的微控制器上运行。它支持超过 600 种开发板,涵盖 ARM、RISC-V、x86、Xtensa 等多种架构。
但本文的重点是蓝牙,因此下面说明为什么 Zephyr 对 BLE 开发尤为重要。
Zephyr 包含一个完整且经过 Bluetooth SIG 认证的 BLE 协议栈。这意味着该协议栈已通过官方蓝牙认证流程,符合规范要求。你不是在基于业余实现之上构建,而是在一个通过一致性测试的协议栈基础上开发。
该协议栈覆盖了主机层(GAP、GATT、SMP、L2CAP、ATT)和控制器层(链路层、HCI)。对于受支持的射频芯片(主要是 Nordic nRF 系列),Zephyr 提供了其开源的控制器实现。这意味着从你的应用程序代码一直到射频寄存器,整个蓝牙协议栈都是开源的。没有二进制闭源模块,也没有闭源库。你可以阅读、调试并修改每一行代码。
Nordic Semiconductor(其芯片主导 BLE 市场)在其 nRF Connect SDK 上构建于 Zephyr 之上。当 Nordic 自己的工程师编写 BLE 固件时,他们使用的就是 Zephyr。这是强有力的背书。
Zephyr 的蓝牙协议栈支持蓝牙 5.x 特性(2M PHY、长距离编码 PHY、扩展广播)、蓝牙 Mesh(中继、代理、好友和低功耗节点)、LE Audio(最新的蓝牙音频标准,支持 LC3 编解码器、广播和助听器支持)、方向查找(到达角和出发角,用于室内定位),以及产品开发所需的所有标准 BLE 配置文件和服务。
简而言之:如果你现在正在开发 BLE 产品,Zephyr 是可用的最佳平台之一。Google 在 Chromebook 中使用它,Nordic 将其作为整个 SDK 的基础,数百家公司也基于它推出产品。
蓝牙低功耗基础
在编写任何代码之前,你需要建立对 BLE 工作原理的心理模型。如果你已经熟悉 BLE,可以跳过本节;否则,请仔细阅读,因为后续所有内容都建立在这些概念之上。
BLE 与“经典”蓝牙(即 LE Audio 出现前用于向扬声器传输音频的蓝牙)不同。经典蓝牙(BR/EDR)旨在实现持续、高吞吐量的数据流传输。而 BLE 则专为间歇性、低功耗通信设计。
一个 BLE 传感器可能每分钟唤醒一次,发送 20 字节数据,然后再次休眠。这种交互仅消耗微瓦级别的能量。正是这种根本性的设计差异,使得 BLE 设备可以依靠纽扣电池运行数年。
BLE 通信由两个主要层次构成,开发者通常与这两个层次进行交互:GAP 和 GATT。
GAP(通用访问配置文件) 控制设备如何相互发现并建立连接。可以将 GAP 理解为“在派对上认识他人”的层级。
一个设备可以处于几种 GAP 角色之一。外围设备(也称作广播者)会定期广播小数据包,宣告其存在和基本信息。中心设备(也称作扫描器)监听这些广播,并可以发起连接。你的手机通常是中心设备,而传感器、耳机或智能手表通常是外围设备。
GATT(通用属性配置文件) 控制两个设备连接后如何交换数据。可以将 GATT 理解为“进行对话”的层级。
GATT 定义了一个分层的数据结构。在顶层,设备暴露一个或多个 服务。服务用于将相关数据组织在一起。
例如,心率监测器可能有一个心率服务。每个服务内部包含一个或多个 特征值。特征值是一个带有值和元数据的单一数据点。心率服务可能包含心率测量特征值(实际的 BPM 值)和身体传感器位置特征值(传感器佩戴的位置)。
每个特征值都有 属性,用于定义你可以对它执行的操作。特征值可以是可读的(中心设备可请求其值)、可写的(中心设备可设置其值)、可通知的(外围设备可在未被请求的情况下主动推送更新给中心设备),或可指示的(类似通知,但需要确认)。一个特征值可以具有多个属性。
每个服务和特征值都通过一个 UUID 来标识。蓝牙 SIG 为常见服务和特征值定义了标准的 16 位 UUID(如心率服务为 0x180D,电池服务为 0x180F,等等)。对于自定义功能,你需要定义自己的 128 位 UUID。
这里有一个具体的思维模型。想象一个温度传感器设备:
设备:"我的温度传感器"
|
+-- 环境传感服务 (UUID: 0x181A)
| |
| +-- 温度特征值 (UUID: 0x2A6E)
| | 属性:可读、可通知
| | 值:23.5(摄氏度)
| |
| +-- 湿度特征值 (UUID: 0x2A6F)
| 属性:可读、可通知
| 值:65.2(百分比)
|
+-- 电池服务 (UUID: 0x180F)
|
+-- 电池电量特征值 (UUID: 0x2A19)
属性:可读、可通知
值:87(百分比)该图展示了一个拥有两个服务和三个特征值的设备。连接的手机可以读取温度、订阅湿度通知,并检查电池电量。服务和特征值就是你 BLE 设备的 API。
设计它们就像设计一个好的 REST API:思考你的设备暴露哪些数据,以及客户端如何与之交互。
还有一个概念:广播数据。当外围设备广播时,它会发送小数据包(传统广播最多 31 字节,扩展广播更大)。这些数据包包含结构化信息,如设备名称、支持的服务、厂商特定数据和标志。广播数据是扫描器在建立连接前看到的内容,相当于你设备的“名片”。
GAP 层:广播与连接
在构建 BLE 设备时,GAP 是你首先接触的层级。你的外围设备必须先广播其存在,才能进行后续操作。
广播的工作方式如下:外围设备的射频模块在固定间隔(“广播间隔”)唤醒,在三个广播信道(信道 37、38 和 39)中的某一个上传输短数据包,然后再次进入休眠状态。正在扫描这些信道的中心设备接收到数据包,从而了解该设备的信息。
广播间隔是一个权衡:较短的间隔(如 20 毫秒)使设备更容易被发现,但耗电更多;较长的间隔(如 1000 毫秒)节省电量,但发现速度变慢。对于大多数应用,100 到 500 毫秒是一个合理的范围。
广播数据包包含称为 AD(Advertising Data)结构的结构化字段。每个 AD 结构包含一个长度字节、一个类型字节和数据字节。常见类型包括标志(表示可发现性和 BR/EDR 支持)、完整或简化的本地名称、服务 UUID 列表、发射功率级别和厂商特定数据。
传统广播(Bluetooth 4.x)的有效载荷限制为 31 字节,因此无法容纳太多内容。你常常需要在包含设备名称和服务 UUID 之间做出选择,因为两者可能无法同时容纳。
蓝牙 5.0 引入了扩展广播(Extended Advertising),允许每片段广告有效载荷高达 254 字节(可通过链式组合成更大的载荷),在全部 40 个 BLE 信道上广播(而不仅仅是三个广播信道),并支持多个同时广播集。Zephyr 在硬件和控制器支持的情况下支持扩展广播。
当中心设备决定连接时,它会向外围设备发送连接请求。双方协商连接参数:连接间隔(通信频率,通常为 7.5 毫秒到 4 秒)、外围延迟(外围设备可跳过多少次连接事件以节省电量)和监控超时(在认为连接丢失前等待的时间)。
这些参数影响数据吞吐量和功耗。较短的连接间隔提供快速数据传输,但功耗更高;较高的外围延迟节省电量,但会增加数据交换的延迟。
GATT 层:服务与特征值
一旦连接建立,GATT 就开始发挥作用。连接的设备通过前面描述的服务/特征值层次结构交换数据。
在外围设备端,你需要定义 GATT 数据库:即你的设备所暴露的服务和特征值列表。在中心设备端,你需要执行服务发现:查询外围设备可用的服务和特征值。
GATT 数据库中的每个特征包含多个组成部分。值(value) 是实际的数据(一个字节数组)。属性(properties) 定义了允许的操作:读取(0x02)、无响应写入(0x04)、写入(0x08)、通知(0x10)和指示(0x20)是最常见的几种。权限(permissions) 是安全要求,规定读取或写入是否需要加密、认证或授权。
对于通知和指示,特征会有一个 客户端特征配置描述符(Client Characteristic Configuration Descriptor, CCCD)。这是一个特殊的 2 字节值,由中心设备写入以启用或禁用通知/指示。当中心设备向 CCCD 写入 0x0001 时,通知被启用;写入 0x0000 时则被禁用。在定义带有 notify 属性的特征时,Zephyr 会自动处理 CCCD。
GATT 操作流程如下:
- 对于读取操作:中心设备发送读取请求,外围设备返回特征值。
- 对于写入操作:中心设备发送包含新值的写入请求,外围设备更新该值并返回响应。
- 对于通知操作:外围设备在无需中心设备请求的情况下主动发送值给中心设备。但前提是中心设备之前已对该特征启用了通知功能。
这种请求/响应模型意味着 BLE 并不是一个流式协议,而是一个消息传递协议。如果你需要发送连续的传感器数据,可以使用固定间隔的通知;如果需要配置设备,则通过写入特征来实现。这决定了你设计 BLE 应用程序的方式。
设置 Zephyr 开发环境
本节将逐步指导你完成完整的环境搭建。如果你已经拥有 Zephyr 环境,请跳至下一节。
安装系统依赖项(Ubuntu):
sudo apt update
sudo apt install --no-install-recommends git cmake ninja-build gperf \
ccache dfu-util device-tree-compiler wget python3-dev python3-pip \
python3-setuptools python3-tk python3-wheel xz-utils file \
make gcc gcc-multilib g++-multilib libsdl2-dev libmagic1这些软件包提供了 Zephyr 构建过程所需的编译工具链、构建系统和实用工具。
设备树编译器(device-tree-compiler)对 Zephyr 尤为重要,因为它用于处理硬件描述文件,这些文件告诉构建系统你的开发板的外设和引脚分配情况。
安装 west,即 Zephyr 的命令行工具:
pip3 install westWest 管理 Zephyr 使用的多仓库工作区,并提供用于构建、烧录和调试固件的命令。它是你最常交互的单一工具。
初始化并更新工作区:
west init ~/zephyrproject
cd ~/zephyrproject
west updatewest init 命令创建工作区并克隆主 Zephyr 仓库。随后 west update 命令会获取所有模块依赖项:厂商 HAL(底层芯片支持包)、加密库、蓝牙控制器代码及其他组件。此过程会下载数 GB 数据,因此耗时较长。
安装 Python 依赖项:
pip3 install -r ~/zephyrproject/zephyr/scripts/requirements.txt这会安装 Zephyr 构建脚本和 west 扩展所依赖的 Python 包,包括设备树处理工具、Kconfig 前端以及各种代码生成工具。需求文件中指定了特定版本,以确保构建的可重现性。
安装 Zephyr SDK(为所有支持的架构提供交叉编译工具链):
cd ~
wget https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v0.16.8/zephyr-sdk-0.16.8_linux-x86_64.tar.xz
tar xvf zephyr-sdk-0.16.8_linux-x86_64.tar.xz
cd zephyr-sdk-0.16.8
./setup.shSDK 包含用于 ARM、RISC-V、x86、Xtensa 等架构的基于 GCC 的工具链。setup.sh 脚本会将其注册到 CMake 中。请查阅 Zephyr 发布页面以获取最新 SDK 版本号,因为自本手册编写以来可能已有更新。
设置环境变量(将以下内容添加到你的 ~/.bashrc 或 ~/.zshrc):
export ZEPHYR_BASE=~/zephyrproject/zephyr
source ~/zephyrproject/zephyr/zephyr-env.shZEPHYR_BASE 变量告诉构建系统 Zephyr 源码树的位置。zephyr-env.sh 脚本设置额外的路径。配置完成后,你可以从任意目录为任何支持的开发板进行构建。
你的第一个 BLE 应用:一个简单的信标
我们将从最简单的 BLE 应用开始:一个仅广播自身存在而不做其他任何事情的设备。不建立连接,不提供服务,不交换数据。只是一个广播信标。
创建项目结构:
mkdir -p ~/my_ble_apps/beacon/src这创建了标准的 Zephyr 应用目录结构。每个 Zephyr 应用都位于自己的目录中,其中 src/ 子目录存放 C 源文件,项目根目录存放构建配置文件。
创建 ~/my_ble_apps/beacon/CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ble_beacon)
target_sources(app PRIVATE src/main.c)find_package(Zephyr) 行加载整个 Zephyr 构建系统。target_sources 行将你的源文件添加到名为 app 的构建目标中,这是 Zephyr 应用的标准目标名称。
创建 ~/my_ble_apps/beacon/prj.conf:
CONFIG_BT=y
CONFIG_BT_BROADCASTER=y两行配置:CONFIG_BT=y 启用蓝牙子系统,引入主机栈、HCI 层以及(在支持的开发板上)控制器。CONFIG_BT_BROADCASTER=y 启用广播角色,这是仅用于广播设备的最小角色。由于该信标不接受连接,因此目前不需要外围设备角色。
创建 ~/my_ble_apps/beacon/src/main.c:
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>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_NAME_COMPLETE,
'M', 'y', 'B', 'e', 'a', 'c', 'o', 'n'),
};
int main(void)
{
int err;
printk("Starting BLE Beacon\n");
err = bt_enable(NULL);
if (err) {
printk("Bluetooth init failed (err %d)\n", err);
return 0;
}
printk("Bluetooth initialized\n");
err = bt_le_adv_start(BT_LE_ADV_NCONN, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
printk("Advertising failed to start (err %d)\n", err);
return 0;
}
printk("Beacon is advertising\n");
return 0;
}让我们逐段分析这段代码。
ad 数组定义了广播数据,包含两个 AD 结构。
第一个是标志字段(Flags),在 BLE 广播中是必需的。BT_LE_AD_GENERAL 表示设备处于通用可发现模式(对所有扫描器可见)。BT_LE_AD_NO_BREDR 表示设备不支持经典蓝牙(BR/EDR),仅支持 BLE。
第二个 AD 结构是完整的本地名称,逐字符拼写。BT_DATA_BYTES 宏将这些字符打包成正确的 AD 结构格式。
bt_enable(NULL) 调用初始化整个蓝牙子系统。它设置 HCI 传输、初始化控制器(如果使用板载射频)并准备主机栈。参数 NULL 表示这是一个同步调用:它会阻塞直到初始化完成。你也可以传递一个回调函数使其变为异步调用。
bt_le_adv_start 调用开始广播。第一个参数 BT_LE_ADV_NCONN 指定非连接型广播,这意味着扫描器可以看到信标但无法与其建立连接。ad 数组及其大小(ARRAY_SIZE(ad))提供广播数据。最后两个参数(NULL, 0)用于扫描响应数据,即当扫描器主动扫描(发送扫描请求)时发送的附加数据。在这个简单的例子中,你没有使用扫描响应数据。
当 main() 返回后,Zephyr 主线程终止,但系统继续运行。蓝牙子系统在后台持续广播,由控制器和蓝牙主机线程驱动。
构建并烧录:
cd ~/zephyrproject
west build -b nrf52840dk/nrf52840 ~/my_ble_apps/beacon
west flashwest build 命令为 nRF52840 DK 编译应用程序,在 build/ 目录中生成 ELF 二进制文件和 HEX 文件。west flash 命令通过板载 J-Link 调试器将该二进制文件烧录到开发板上,自动检测已连接的开发板并使用正确的编程协议。
在手机上打开 nRF Connect 应用程序,开始扫描,你应该能在发现的设备列表中看到 "MyBeacon"。这就是你的固件正在开发板上运行,并通过 BLE 进行广播。
构建带有自定义服务的 BLE 外设
一个广播信标对于某些应用(如 iBeacon、Eddystone、资产追踪)很有用。但大多数 BLE 设备需要具备可连接性,并通过 GATT 服务暴露数据。现在你将构建一个带有自定义服务的外设。
你将创建一个“LED 服务”,允许手机控制开发板上的 LED 并读取按钮状态。这是一个经典的 BLE 演示项目,能让你掌握在每个 BLE 项目中都会使用的模式。
创建项目:
mkdir -p ~/my_ble_apps/led_service/src与信标项目相同的目录结构:根目录用于构建配置,src/ 目录用于源代码。这种分离方式可以确保构建产物与源代码文件隔离。
创建 ~/my_ble_apps/led_service/prj.conf:
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Zephyr LED"
CONFIG_BT_DEVICE_APPEARANCE=0
CONFIG_GPIO=y
CONFIG_BT_GATT_DYNAMIC_DB=yCONFIG_BT_PERIPHERAL=y 启用外设角色,包括广播角色以及接受连接的能力。CONFIG_BT_DEVICE_NAME 设置默认设备名称,用于广播和 GAP 设备名称特征值。CONFIG_GPIO=y 启用 GPIO 驱动,以便控制 LED 和读取按钮。
创建 ~/my_ble_apps/led_service/CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ble_led_service)
target_sources(app PRIVATE src/main.c)这与信标项目的 CMake 模板相同。唯一的变化是项目名称(ble_led_service)。find_package(Zephyr) 调用加载完整的 Zephyr 构建系统,而 target_sources 注册你的应用程序源文件。
创建 ~/my_ble_apps/led_service/src/main.c:
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/drivers/gpio.h>
/* 自定义服务 UUID: 00001234-0000-1000-8000-00805f9b34fb */
#define BT_UUID_LED_SERVICE_VAL \
BT_UUID_128_ENCODE(0x00001234, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_LED_SERVICE BT_UUID_DECLARE_128(BT_UUID_LED_SERVICE_VAL)
/* LED 特征 UUID */
#define BT_UUID_LED_CHAR_VAL \
BT_UUID_128_ENCODE(0x00001235, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_LED_CHAR BT_UUID_DECLARE_128(BT_UUID_LED_CHAR_VAL)
/* 按钮特征 UUID */
#define BT_UUID_BUTTON_CHAR_VAL \
BT_UUID_128_ENCODE(0x00001236, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_BUTTON_CHAR BT_UUID_DECLARE_128(BT_UUID_BUTTON_CHAR_VAL)
#define LED0_NODE DT_ALIAS(led0)
#define SW0_NODE DT_ALIAS(sw0)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(SW0_NODE, gpios);
static uint8_t led_state;
static uint8_t button_state;static ssize_t read_led(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset)
{
return bt_gatt_attr_read(conn, attr, buf, len, offset,
&led_state, sizeof(led_state));
}
static ssize_t write_led(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
const void *buf, uint16_t len,
uint16_t offset, uint8_t flags)
{
if (len != 1) {
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
}
led_state = *((const uint8_t *)buf);
gpio_pin_set_dt(&led, led_state ? 1 : 0);
printk("LED %s\n", led_state ? "ON" : "OFF");
return len;
}
static ssize_t read_button(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset)
{
button_state = gpio_pin_get_dt(&button);
return bt_gatt_attr_read(conn, attr, buf, len, offset,
&button_state, sizeof(button_state));
}
BT_GATT_SERVICE_DEFINE(led_service,
BT_GATT_PRIMARY_SERVICE(BT_UUID_LED_SERVICE),
BT_GATT_CHARACTERISTIC(BT_UUID_LED_CHAR,
BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
read_led, write_led, NULL),
BT_GATT_CHARACTERISTIC(BT_UUID_BUTTON_CHAR,
BT_GATT_CHRC_READ,
BT_GATT_PERM_READ,
read_button, NULL, NULL),
);
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_LED_SERVICE_VAL),
};
int main(void)
{
int err;
if (!gpio_is_ready_dt(&led) || !gpio_is_ready_dt(&button)) {
printk("GPIO devices not ready\n");
return 0;
}
gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);
gpio_pin_configure_dt(&button, GPIO_INPUT);
err = bt_enable(NULL);
if (err) {
printk("Bluetooth init failed (err %d)\n", err);
return 0;
}
printk("Bluetooth initialized\n");
err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
sd, ARRAY_SIZE(sd));
if (err) {
printk("Advertising failed to start (err %d)\n", err);
return 0;
}
printk("Advertising as '%s'\n", CONFIG_BT_DEVICE_NAME);
return 0;
}这是一段相当长的代码,我们逐部分进行分析。
顶部的 UUID 定义为服务及其两个特性创建了自定义的 128 位 UUID。在构建自定义 BLE 设备时,通常会生成自己的 UUID,而不是使用标准的 Bluetooth SIG UUID(除非你正在实现心率等标准配置文件)。
在生产环境中,你会使用 uuidgen 等工具生成随机的 128 位 UUID。这里的 UUID 为了便于阅读而简化。BT_UUID_128_ENCODE 宏将 UUID 格式化为蓝牙栈所期望的字节顺序。BT_UUID_DECLARE_128 则根据编码值创建一个 struct bt_uuid_128。
led 和 button 的 GPIO 配置使用了设备树别名 led0 和 sw0,大多数 Zephyr 开发板都定义了这些别名。GPIO_DT_SPEC_GET 宏直接从设备树中提取引脚号、GPIO 控制器和标志,从而保证代码在不同开发板上的可移植性。
当连接的中心设备读取 LED 特性时,会调用 read_led 回调函数。它使用 bt_gatt_attr_read 辅助函数,该函数能正确处理偏移量和长度(GATT 读取可能不完整,如果值大于 MTU)。该函数返回当前 LED 状态作为一个字节。
当中心设备向 LED 特性写入数据时,会调用 write_led 回调函数。它验证是否恰好写入了一个字节,更新 led_state 变量,并调用 gpio_pin_set_dt 实际切换 LED。它返回消耗的字节数(len),如果长度错误则返回 GATT 错误。BT_GATT_ERR 宏将 ATT 错误码包装成栈期望的返回值。
read_button 回调函数在读取请求时读取按钮的当前物理状态。它调用 gpio_pin_get_dt 采样引脚,存储结果并返回。
BT_GATT_SERVICE_DEFINE 宏用于在编译时构建 GATT 数据库。第一个参数是服务变量的名称。BT_GATT_PRIMARY_SERVICE 声明了一个带有自定义 UUID 的主服务。每个 BT_GATT_CHARACTERISTIC 声明包含特性 UUID、属性(LED 为读+写,按钮为只读)、权限(谁可以读/写)、读回调、写回调以及用户数据指针(此处均为 NULL)。
ad 数组包含广告数据:标志和设备名称。sd 数组包含扫描响应数据:128 位服务 UUID。
将数据拆分到广告和扫描响应中很常见,因为 31 字节的广告包限制非常紧张。仅服务 UUID 就占 16 字节,这会使主广告包中几乎没有空间容纳其他数据。通过将 UUID 放入扫描响应中,可以保持广告包较小,并且只有在扫描仪明确请求时才提供 UUID。
bt_le_adv_start 调用使用的是 BT_LE_ADV_CONN 而不是 BT_LE_ADV_NCONN,这使得广告具有可连接性:扫描仪现在可以与你的设备建立连接。sd 和 ARRAY_SIZE(sd) 参数提供了扫描响应数据,在之前的信标示例中这些参数是空的。
构建、烧录并测试:
cd ~/zephyrproject
west build -b nrf52840dk/nrf52840 ~/my_ble_apps/led_service
west flash
构建命令会将应用程序与完整的蓝牙协议栈、GPIO 驱动程序以及 GATT 数据库链接成一个单一的二进制文件。flash 命令则将其烧录到开发板上。由于启用了 `CONFIG_BT_PERIPHERAL`,该二进制文件比信标(beacon)大得多(因为其中包含了可连接广播、GATT 服务器和 ATT 协议层)。
在手机上打开 nRF Connect 应用,扫描并找到 "Zephyr LED"。点击“连接”。连接成功后,你会看到 GATT 服务列表。找到自定义服务(UUID 以 00001234 开头)。你会看到两个特征值。读取按钮特征值可以查看按钮状态。向 LED 特征值写入 0x01 可以打开 LED,写入 0x00 则关闭 LED。你刚刚通过手机实现了对硬件的蓝牙控制。
## 处理连接与连接回调
在实际应用中,你需要知道设备何时连接和断开。也许你想在设备连接时停止广播(以节省电量),在断开连接时重新开始广播(以便重新连接),或者更新状态 LED 以指示连接状态。
Zephyr 通过注册机制提供连接回调:
#include <zephyr/bluetooth/conn.h>
static void connected(struct bt_conn *conn, uint8_t err) { if (err) { printk("Connection failed (err %u)\n", err); return; }
char addr[BT_ADDR_LE_STR_LEN]; bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); printk("Connected: %s\n", addr); }
static void disconnected(struct bt_conn *conn, uint8_t reason) { char addr[BT_ADDR_LE_STR_LEN]; bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); printk("Disconnected: s (reason %u)\n", addr, reason);
/* 断开后重新开始广播 */ bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); }
BT_CONN_CB_DEFINE(conn_callbacks) = { .connected = connected, .disconnected = disconnected, };
`BT_CONN_CB_DEFINE` 宏静态注册一组连接回调函数。`.connected` 回调在建立连接时触发。参数 `err` 表示连接是否成功(0 表示成功)。`.disconnected` 回调在连接终止时触发。参数 `reason` 是 HCI 断开原因码(0x13 表示“远程用户终止连接”,这是正常断开)。
在 `connected` 回调中,代码使用 `bt_conn_get_dst` 获取远程设备的蓝牙地址,并将其转换为可打印字符串。这对于日志记录和调试非常有用。
在 `disconnected` 回调中,代码重新启动广播。默认情况下,Zephyr 在建立连接后会停止广播(因为无线电现在用于维持连接)。当连接中断时,通常希望重新开始广播,以便设备能够被重新发现。
`bt_conn` 指针代表当前连接。你可以使用它来查询连接参数、请求参数更新、发起配对或编程方式断开连接。如果需要在回调之外使用该指针,请使用 `bt_conn_ref` 保留引用;使用完毕后,调用 `bt_conn_unref` 释放引用。
## 添加写支持:从手机接收数据
LED 服务已经包含一个写特征值,但让我们更深入地探讨写回调模式,以及如何处理更复杂的数据。
考虑这样一个场景:手机向设备发送一个配置结构体:
struct device_config { uint8_t mode; uint16_t interval_ms; uint8_t threshold; } __packed;
static struct device_config current_config = { .mode = 0, .interval_ms = 1000, .threshold = 50, };
static ssize_t write_config(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, uint16_t offset, uint8_t flags) { if (offset != 0) { return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); }
if (len != sizeof(struct device_config)) { return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); }
memcpy(¤t_config, buf, len);
printk("Config updated: mode=%u, interval=%u ms, threshold=%u\n", current_config.mode, current_config.interval_ms, current_config.threshold);
return len; }
static ssize_t read_config(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { return bt_gatt_attr_read(conn, attr, buf, len, offset, ¤t_config, sizeof(current_config)); }
结构体上的 `__packed` 属性确保字段之间没有填充字节,从而保证字节布局与手机发送的数据完全匹配。如果没有 `__packed`,编译器可能会在 `mode` 和 `interval_ms` 之间插入填充字节以对齐,导致数据不匹配。
写回调验证了两点。首先,检查偏移量是否为零(本例中不允许部分写入)。其次,检查长度是否与预期的结构体大小一致。如果任一检查失败,则返回一个 GATT 错误码,中心设备会将其作为写响应错误接收。
在 BLE 应用中,输入验证至关重要,因为中心设备是通过空中传输原始字节。任何格式错误的数据都应被拒绝,而不是盲目接受。
`memcpy` 将验证后的数据复制到配置结构体中。在实际应用中,你可能会根据新配置调整设备行为(例如更改传感器轮询间隔、切换工作模式等)。
写回调函数中的 `flags` 参数用于指示当前写操作是“带响应的写”(Write With Response,中心设备期望收到确认)还是“不带响应的写”(Write Without Response,即发送后不管)。你可以通过检查 `flags & BT_GATT_WRITE_FLAG_CMD` 来区分这两种情况。对于配置数据,通常需要使用“带响应的写”,以便中心设备知道写入操作已成功。
## 通知:主动向连接设备推送数据
读取和写入适用于按需获取的数据。但许多 BLE 应用程序需要外围设备主动向中心设备推送数据。例如,心率监测器不会每秒都等待手机请求心率值,而是通过通知机制主动推送数值。
以下是为特性添加通知支持的方法:
static uint8_t sensor_value; static bool notifications_enabled;
static void sensor_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) { notifications_enabled = (value == BT_GATT_CCC_NOTIFY); printk("Notifications %s\n", notifications_enabled ? "enabled" : "disabled"); }
static ssize_t read_sensor(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { return bt_gatt_attr_read(conn, attr, buf, len, offset, &sensor_value, sizeof(sensor_value)); }
BT_GATT_SERVICE_DEFINE(sensor_service, BT_GATT_PRIMARY_SERVICE(BT_UUID_LED_SERVICE),
BT_GATT_CHARACTERISTIC(BT_UUID_LED_CHAR, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, BT_GATT_PERM_READ, read_sensor, NULL, &sensor_value),
BT_GATT_CCC(sensor_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), );
该特性现在具有 `BT_GATT_CHRC_NOTIFY` 属性,告知已连接的中心设备此特性支持通知功能。
`BT_GATT_CCC` 宏添加了客户端特性配置描述符(Client Characteristic Configuration Descriptor, CCCD)。当中心设备通过写入 CCCD 启用或禁用通知时,会调用 `sensor_ccc_changed` 回调函数。参数 `value` 在通知启用时为 `BT_GATT_CCC_NOTIFY`(0x0001),禁用时为 0。
要实际发送通知:
void send_sensor_notification(void) { if (!notifications_enabled) { return; }
sensor_value = read_actual_sensor();
int err = bt_gatt_notify(NULL, &sensor_service.attrs[1], &sensor_value, sizeof(sensor_value)); if (err) { printk("Notify failed (err %d)\n", err); } }
`bt_gatt_notify` 函数会向所有已启用该特性通知的连接中心设备发送通知。
第一个参数为 `NULL`,表示通知所有连接(也可以传入特定的 `bt_conn` 指针仅通知一个连接)。第二个参数是指向 GATT 表中特性属性的指针。`&sensor_service.attrs[1]` 指向第一个特性值属性(索引 0 是服务声明,索引 1 是特性声明,其后是值)。第三和第四个参数分别是数据及其长度。
常见的模式是在定时器或工作队列中以固定间隔调用此函数:
void sensor_work_handler(struct k_work *work) { send_sensor_notification(); }
K_WORK_DELAYABLE_DEFINE(sensor_work, sensor_work_handler);
/* 在 main() 中,蓝牙初始化并开始广播后: */ k_work_schedule(&sensor_work, K_SECONDS(1));
/* 在工作处理函数中重新调度以实现周期性执行: */ void sensor_work_handler(struct k_work *work) { send_sensor_notification(); k_work_schedule(&sensor_work, K_SECONDS(1)); }
这种方法使用可延迟的工作项,每秒自动重新调度自己。每次触发时,它都会读取传感器值并发送通知(如果通知已启用)。工作项在系统工作队列线程中运行,而非中断上下文,因此可以安全地调用 `bt_gatt_notify` 和其他蓝牙 API。
## 构建完整的 BLE 传感器节点
现在我们将所有部分整合成一个完整应用。这是一个 BLE 环境传感器,模拟读取温度值,通过自定义 GATT 服务暴露读取和通知功能,处理连接与断开,并管理广播。
创建 `~/my_ble_apps/sensor_node/src/main.c`:
#include <zephyr/kernel.h> #include <zephyr/bluetooth/bluetooth.h> #include <zephyr/bluetooth/gatt.h> #include <zephyr/bluetooth/uuid.h> #include <zephyr/bluetooth/conn.h> #include <zephyr/drivers/gpio.h>
/* UUIDs */ #define BT_UUID_ENV_SERVICE_VAL \ BT_UUID_128_ENCODE(0xaabbccdd, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb) #define BT_UUID_ENV_SERVICE BT_UUID_DECLARE_128(BT_UUID_ENV_SERVICE_VAL)
#define BT_UUID_TEMP_CHAR_VAL \ BT_UUID_128_ENCODE(0xaabbccdd, 0x0001, 0x1000, 0x8000, 0x00805f9b34fb) #define BT_UUID_TEMP_CHAR BT_UUID_DECLARE_128(BT_UUID_TEMP_CHAR_VAL)
#define BT_UUID_INTERVAL_CHAR_VAL \ BT_UUID_128_ENCODE(0xaabbccdd, 0x0002, 0x1000, 0x8000, 0x00805f9b34fb) #define BT_UUID_INTERVAL_CHAR BT_UUID_DECLARE_128(BT_UUID_INTERVAL_CHAR_VAL)
/* LED 用于显示连接状态 */ #define LED0_NODE DT_ALIAS(led0) static const struct gpio_dt_spec status_led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
/* 传感器状态 */ static int16_t temperature_value = 2250; static uint16_t notify_interval_ms = 1000; static bool temp_notifications_enabled; static struct bt_conn *current_conn;
/* 前向声明 */ static void sensor_work_handler(struct k_work *work); K_WORK_DELAYABLE_DEFINE(sensor_work, sensor_work_handler);
static int16_t simulate_temperature(void) { static int16_t base = 2250; base += (k_uptime_get_32() % 11) - 5; if (base > 3500) base = 3500; if (base < 1000) base = 1000; return base; }
/* GATT 回调函数 */ static ssize_t read_temperature(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { temperature_value = simulate_temperature(); return bt_gatt_attr_read(conn, attr, buf, len, offset, &temperature_value, sizeof(temperature_value)); }
static void temp_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) { temp_notifications_enabled = (value == BT_GATT_CCC_NOTIFY); printk("Temperature notifications %s\n", temp_notifications_enabled ? "enabled" : "disabled");
if (temp_notifications_enabled) { k_work_schedule(&sensor_work, K_MSEC(notify_interval_ms)); } else { k_work_cancel_delayable(&sensor_work); } }
static ssize_t read_interval(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { return bt_gatt_attr_read(conn, attr, buf, len, offset, ¬ify_interval_ms, sizeof(notify_interval_ms)); }
static ssize_t write_interval(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, uint16_t offset, uint8_t flags) { if (len != sizeof(uint16_t)) { return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); }
uint16_t new_interval = *((const uint16_t *)buf);
if (new_interval < 100 || new_interval > 60000) { return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED); }
notify_interval_ms = new_interval; printk("Notification interval changed to %u ms\n", notify_interval_ms);
if (temp_notifications_enabled) { k_work_cancel_delayable(&sensor_work); k_work_schedule(&sensor_work, K_MSEC(notify_interval_ms)); }
return len; }
/* GATT 服务定义 */ BT_GATT_SERVICE_DEFINE(env_service, BT_GATT_PRIMARY_SERVICE(BT_UUID_ENV_SERVICE),
BT_GATT_CHARACTERISTIC(BT_UUID_TEMP_CHAR, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, BT_GATT_PERM_READ, read_temperature, NULL, NULL), BT_GATT_CCC(temp_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
BT_GATT_CHARACTERISTIC(BT_UUID_INTERVAL_CHAR, BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE, read_interval, write_interval, NULL), );
/* 通知发送器 */ static void sensor_work_handler(struct k_work *work) { temperature_value = simulate_temperature();
if (temp_notifications_enabled) { int err = bt_gatt_notify(NULL, &env_service.attrs[2], &temperature_value, sizeof(temperature_value)); if (err && err != -ENOTCONN) { printk("Notify error: %d\n", err); }
k_work_schedule(&sensor_work, K_MSEC(notify_interval_ms)); } }
/* 广播数据 */ 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_ENV_SERVICE_VAL), };
/* 连接回调函数 */ static void connected(struct bt_conn *conn, uint8_t err) { if (err) { printk("Connection failed (err %u)\n", err); return; }
current_conn = bt_conn_ref(conn); gpio_pin_set_dt(&status_led, 1);
char addr[BT_ADDR_LE_STR_LEN]; bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); printk("Connected: %s\n", addr); }
static void disconnected(struct bt_conn *conn, uint8_t reason) { char addr[BT_ADDR_LE_STR_LEN]; bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); printk("Disconnected: %s (reason %u)\n", addr, reason);
if (current_conn) { bt_conn_unref(current_conn); current_conn = NULL; }
temp_notifications_enabled = false; k_work_cancel_delayable(&sensor_work); gpio_pin_set_dt(&status_led, 0);
bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); }
BT_CONN_CB_DEFINE(conn_callbacks) = { .connected = connected, .disconnected = disconnected, };
int main(void) { int err;
if (!gpio_is_ready_dt(&status_led)) { printk("LED not ready\n"); return 0; } gpio_pin_configure_dt(&status_led, GPIO_OUTPUT_INACTIVE);
err = bt_enable(NULL); if (err) { printk("Bluetooth init failed (err %d)\n", err); return 0; }
printk("Bluetooth initialized\n");
err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); if (err) { printk("Advertising failed (err %d)\n", err); return 0; }
printk("Environmental sensor ready. Advertising as '%s'\n", CONFIG_BT_DEVICE_NAME);
return 0; }
该应用的 `prj.conf` 配置文件:
CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_DEVICE_NAME="Zephyr Sensor" CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_GPIO=y CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
此应用程序演示了 BLE 传感器设备的完整生命周期,建议仔细研究其设计。
该服务暴露了两个特性。温度特性支持读取和通知功能。当中心设备读取它时,会获取最新的模拟温度值;当中心设备启用通知后,设备将按可配置的时间间隔推送温度更新。
间隔特性允许中心设备读取和写入通知间隔,范围限定在 100 毫秒到 60000 毫秒之间。`write_interval` 中的验证逻辑会拒绝超出该范围的值,并返回 `BT_ATT_ERR_VALUE_NOT_ALLOWED` 错误码,中心设备会收到相应的错误响应。温度值以 int16_t 格式存储,单位为摄氏度的百分之一(例如 2250 表示 22.50 摄氏度)。这种定点表示法避免了浮点运算,在没有 FPU 的微控制器上可以节省计算资源,同时在 2 字节值中提供 0.01 度的分辨率。
连接回调函数管理完整的连接生命周期。连接时,代码会获取连接对象的引用(bt_conn_ref),并打开状态指示灯。断开连接时,它会释放引用(bt_conn_unref),取消任何待处理的通知任务,关闭指示灯,并重新开始广播。
引用计数非常重要,因为 bt_conn 指针仅在持有引用期间有效。在释放引用后继续使用该指针会导致未定义行为。
通知任务项会按照配置的时间间隔重新调度自身,从而形成一个周期性循环。当通知被禁用时(无论是由中心设备显式禁用,还是因断开连接而隐式禁用),该任务会被取消。这样可以避免在无人监听时浪费 CPU 周期和电池电量。
配对与安全
生产级 BLE 设备几乎总是需要安全机制。如果不进行配对,任何处于无线电范围内的设备都可以连接并访问你的 GATT 服务。配对建立加密链路,并可选地对设备进行相互认证。
BLE 支持多种配对方式。“Just Works” 提供加密但不提供认证,可防止被动窃听,但无法抵御主动中间人攻击。“Passkey Entry” 要求用户输入一个六位数字密码,从而实现认证。“Numeric Comparison” 在两个设备上显示相同数字,用户确认匹配即可完成配对。“Out of Band (OOB)” 则通过外部通道(如 NFC)交换配对信息。
在 prj.conf 中启用安全功能:
CONFIG_BT_SMP=y
CONFIG_BT_SETTINGS=y
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_NVS=y
CONFIG_SETTINGS=yCONFIG_BT_SMP=y 启用安全管理协议(Security Manager Protocol),用于处理配对过程。Settings、Flash 和 NVS 选项启用了持久化存储,使得配对过程中交换的密钥信息在重启后仍能保留。如果没有持久化存储,设备每次上电都需要重新配对,这会给用户带来极差的体验。
若要要求某个特征值必须在加密连接下才能读取,请修改其权限:
BT_GATT_CHARACTERISTIC(BT_UUID_TEMP_CHAR,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ_ENCRYPT,
read_temperature, NULL, NULL),BT_GATT_PERM_READ_ENCRYPT 表示该特征值只能通过加密连接读取。如果中心设备在未配对的情况下尝试读取,堆栈将自动触发配对流程。你也可以使用 BT_GATT_PERM_READ_AUTHEN 来要求经过认证的配对(即 Passkey 或 Numeric Comparison,而非 Just Works)。
注册认证回调函数以处理密码显示或输入:
static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey)
{
char addr[BT_ADDR_LE_STR_LEN];
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
printk("Passkey for %s: %06u\n", addr, passkey);
}
static void auth_cancel(struct bt_conn *conn)
{
char addr[BT_ADDR_LE_STR_LEN];
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
printk("Pairing cancelled: %s\n", addr);
}
static struct bt_conn_auth_cb auth_callbacks = {
.passkey_display = auth_passkey_display,
.cancel = auth_cancel,
};
/* 在 main() 中,bt_enable() 之后: */
bt_conn_auth_cb_register(&auth_callbacks);passkey_display 回调会在堆栈生成需要用户在中心设备(如手机)上输入的密码时触发。对于带有显示屏的设备,你可以将密码显示在屏幕上;对于无显示屏的设备(如传感器),在开发阶段可以通过串口打印密码,而在生产环境中则可能使用“Just Works”配对方式。
bt_conn_auth_cb_register 函数将这些回调函数注册到堆栈中。同一时间只能激活一组回调函数。
实现标准 BLE 服务(心率)
前面的例子都使用了自定义的 128 位 UUID。在实际生产中,许多 BLE 设备会实现由蓝牙 SIG 定义的标准配置文件。标准配置文件使用 16 位 UUID,占用更少的广播空间,并允许通用应用(如 nRF Connect)自动解析并以人类可读格式显示数据。心率配置文件是最常见的标准配置文件之一,展示了 Zephyr 中标准配置文件的工作方式。
心率服务(UUID 0x180D)包含一个心率测量特征值(UUID 0x2A37),该特征值通过通知机制推送心率数据。测量特征值的数据格式由蓝牙 SIG 明确定义:第一个字节是标志字段,其余字节包含心率值以及可选字段(如消耗的能量和 RR 间期)。
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/bluetooth/conn.h>
static uint8_t heart_rate_bpm = 72;
static bool hr_notifications_enabled;
static void hr_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
hr_notifications_enabled = (value == BT_GATT_CCC_NOTIFY);
}
static ssize_t read_body_sensor_location(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
void *buf, uint16_t len,
uint16_t offset)
{
uint8_t location = 0x01; /* 胸部 */
return bt_gatt_attr_read(conn, attr, buf, len, offset,
&location, sizeof(location));
}
BT_GATT_SERVICE_DEFINE(hr_service,
BT_GATT_PRIMARY_SERVICE(BT_UUID_HRS),BT_GATT_CHARACTERISTIC(BT_UUID_HRS_MEASUREMENT,
BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_NONE,
NULL, NULL, NULL),
BT_GATT_CCC(hr_ccc_changed,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
BT_GATT_CHARACTERISTIC(BT_UUID_HRS_BODY_SENSOR,
BT_GATT_CHRC_READ,
BT_GATT_PERM_READ,
read_body_sensor_location, NULL, NULL),
);
static void send_heart_rate(void)
{
if (!hr_notifications_enabled) {
return;
}
uint8_t hr_data[2];
hr_data[0] = 0x00; /* 标志位:uint8 格式,无额外字段 */
hr_data[1] = heart_rate_bpm;
bt_gatt_notify(NULL, &hr_service.attrs[1], hr_data, sizeof(hr_data));
}这段代码使用了 Zephyr 预定义的 UUID 宏(BT_UUID_HRS、BT_UUID_HRS_MEASUREMENT、BT_UUID_HRS_BODY_SENSOR),而不是自定义的 128 位 UUID。Zephyr 在 zephyr/bluetooth/uuid.h 中为所有标准蓝牙 SIG 服务和特征定义了宏。使用这些标准 UUID 意味着任何手机上的 BLE 心率应用都可以自动发现、连接并显示你的设备数据,无需开发定制应用。
心率测量特征的权限设置为 BT_GATT_PERM_NONE,因为它是仅通知类型。不允许读或写访问:数据仅通过通知方式传输。
通知负载的第一个字节(hr_data[0])是 SIG 规范中定义的标志字段。值为 0x00 表示心率以 uint8 格式表示(范围 0 到 255 BPM),且不包含可选字段。设置第 0 位将切换到 uint16 格式,用于超过 255 的心率值。设置其他位则表示存在能量消耗或 RR 间期数据。
身体传感器位置特征是一个简单的只读值。值 0x01 表示“胸部”。其他定义值包括 0x00(其他)、0x02(手腕)、0x03(手指)、0x04(手部)、0x05(耳垂)和 0x06(脚部)。
对于标准配置文件应用,prj.conf 不需要除基本蓝牙外围设备设置之外的特殊配置:
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Zephyr HR"当设置了 CONFIG_BT=y 时,标准 UUID 始终可用。使用 SIG 定义的服务和特征 UUID 不需要额外的 Kconfig 选项。
标准配置文件设备的广播数据通常在广播包中包含 16 位服务 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_UUID16_ALL, BT_UUID_16_ENCODE(0x180D)),
BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};BT_DATA_UUID16_ALL 类型会广播完整的 16 位服务 UUID 列表。BT_UUID_16_ENCODE(0x180D) 宏将以小端字节序编码心率服务 UUID。16 位 UUID 在广播包中仅占用 2 字节(而 128 位 UUID 占用 16 字节),为其他广播数据留出更多空间。这是使用标准配置文件的一个显著优势。
构建 BLE 中央设备
迄今为止的所有示例都构建的是外围设备(即广播并接受连接的设备)。BLE 连接的另一端是中央设备:负责扫描外围设备、发起连接并读写特征的设备。网关、集线器和数据采集器通常是中央设备。
在 Zephyr 上构建中央设备需要第二个开发板,但理解中央角色对于构建完整的 BLE 系统至关重要。
中央设备应用的 prj.conf 配置如下:
CONFIG_BT=y
CONFIG_BT_CENTRAL=y
CONFIG_BT_GATT_CLIENT=y
CONFIG_BT_SCAN=y
CONFIG_BT_DEVICE_NAME="Zephyr Central"CONFIG_BT_CENTRAL=y 启用中央角色(扫描和连接发起)。CONFIG_BT_GATT_CLIENT=y 启用 GATT 客户端 API,用于服务发现、读取、写入和订阅。CONFIG_BT_SCAN=y 启用扫描模块,提供带有过滤功能的高级扫描 API。
BLE 中央设备首先通过扫描查找外围设备:
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
static struct bt_conn *default_conn;
static void device_found(const bt_addr_le_t *addr, int8_t rssi,
uint8_t type, struct net_buf_simple *ad)
{
char addr_str[BT_ADDR_LE_STR_LEN];
bt_addr_le_to_str(addr, addr_str, sizeof(addr_str));
if (rssi < -70) {
return; /* 跳过距离较远的设备 */
}
printk("找到设备: %s (RSSI %d)\n", addr_str, rssi);
/* 连接前停止扫描 */
bt_le_scan_stop();
int err = bt_conn_le_create(addr, BT_CONN_LE_CREATE_CONN,
BT_LE_CONN_PARAM_DEFAULT,
&default_conn);
if (err) {
printk("连接失败 (err %d)\n", err);
bt_le_scan_start(BT_LE_SCAN_ACTIVE, device_found);
}
}
int main(void)
{
int err;
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败 (err %d)\n", err);
return 0;
}
printk("开始 BLE 扫描...\n");
err = bt_le_scan_start(BT_LE_SCAN_ACTIVE, device_found);
if (err) {
printk("扫描失败 (err %d)\n", err);
return 0;
}
return 0;
}bt_le_scan_start 函数开始扫描 BLE 广播。第一个参数 BT_LE_SCAN_ACTIVE 启用主动扫描,意味着扫描器会向广播者发送扫描请求包以获取其扫描响应数据(被动扫描仅监听)。第二个参数是回调函数,每当收到广播包时都会被调用。
device_found 回调函数接收广播者的地址、RSSI(信号强度,单位 dBm)、广播类型以及原始广播数据。
在此示例中,代码通过 RSSI 过滤以忽略远处的设备,然后尝试连接第一个找到的设备。在发起连接之前必须调用 `bt_le_scan_stop`,因为射频无法同时进行扫描和连接。
`bt_conn_le_create` 函数用于发起连接。它需要目标地址、创建参数(控制连接建立期间使用的扫描窗口)、连接参数(间隔、延迟、超时)以及一个用于存储连接引用的指针。`BT_LE_CONN_PARAM_DEFAULT` 使用默认连接参数,适用于大多数应用。
在实际应用中,您会更仔细地过滤扫描结果,通常通过检查广播的服务 UUID 或设备名称来实现。连接成功后,您将执行 GATT 服务发现,然后读取/写入/订阅特性:
static uint8_t discover_func(struct bt_conn *conn, const struct bt_gatt_attr *attr, struct bt_gatt_discover_params *params) { if (!attr) { printk("Discovery complete\n"); return BT_GATT_ITER_STOP; }
char uuid_str[BT_UUID_STR_LEN]; bt_uuid_to_str(params->uuid, uuid_str, sizeof(uuid_str)); printk("Discovered attribute: handle %u, UUID %s\n", attr->handle, uuid_str);
return BT_GATT_ITER_CONTINUE; }
static struct bt_gatt_discover_params discover_params;
static void start_discovery(struct bt_conn *conn) { discover_params.uuid = NULL; /* Discover all services */ discover_params.func = discover_func; discover_params.start_handle = BT_ATT_FIRST_ATTRIBUTE_HANDLE; discover_params.end_handle = BT_ATT_LAST_ATTRIBUTE_HANDLE; discover_params.type = BT_GATT_DISCOVER_PRIMARY;
int err = bt_gatt_discover(conn, &discover_params); if (err) { printk("Discovery failed (err %d)\n", err); } }
`bt_gatt_discover` 函数在已连接的外围设备上启动 GATT 服务发现。`discover_params` 结构体控制要发现的内容:将 `uuid` 设置为 NULL 可发现所有主服务。将 `type` 设置为 `BT_GATT_DISCOVER_PRIMARY` 可发现主服务。您也可以将其设置为 `BT_GATT_DISCOVER_CHARACTERISTIC` 来发现服务内的特性,或设置为 `BT_GATT_DISCOVER_DESCRIPTOR` 来发现特性内的描述符。
发现回调函数(`discover_func`)会在每个发现的属性上被调用一次,并在发现完成后再次被调用,此时 `attr` 被设为 NULL。返回 `BT_GATT_ITER_CONTINUE` 表示继续发现;返回 `BT_GATT_ITER_STOP` 则提前终止发现。
在发现服务和特性之后,您可以使用 `bt_gatt_read` 读取特性值,并使用 `bt_gatt_subscribe` 订阅通知。订阅函数接受一个 `bt_gatt_subscribe_params` 结构体,该结构体指定特性句柄、通知回调函数以及要写入的 CCC 值(BT_GATT_CCC_NOTIFY)。
## MTU 协商与数据吞吐量
BLE ATT 的默认 MTU(最大传输单元)为 23 字节。减去 3 字节的 ATT 协议开销后,每次 GATT 操作的实际有效载荷仅为 20 字节。对于单次温度读数,20 字节已经足够;但对于传输固件镜像或大型传感器数据缓冲区,每次操作仅传输 20 字节会非常缓慢。
MTU 协商允许两个连接设备协商更大的 MTU,最高可达 517 字节(BLE 最大值)。更大的 MTU 意味着每包传输更多数据、减少往返次数并提高吞吐量。在 nRF52840 上使用 2M PHY 时,23 字节 MTU 与 247 字节 MTU 相比,吞吐量可提升 5 到 10 倍。
在您的 `prj.conf` 中启用更大的 MTU:
CONFIG_BT_L2CAP_TX_MTU=247 CONFIG_BT_BUF_ACL_RX_SIZE=251 CONFIG_BT_BUF_ACL_TX_SIZE=251 CONFIG_BT_CTLR_DATA_LENGTH_MAX=251
`CONFIG_BT_L2CAP_TX_MTU=247` 设置设备在协商过程中请求的最大 ATT MTU。值 247 常被使用,因为它与链路层最大单包数据长度(251 字节减去 4 字节 L2CAP 头部)对齐。
`CONFIG_BT_BUF_ACL_RX_SIZE` 和 `CONFIG_BT_BUF_ACL_TX_SIZE` 设置 ACL 缓冲区大小,以容纳更大的数据包。`CONFIG_BT_CTLR_DATA_LENGTH_MAX` 在控制器级别启用数据长度扩展(DLE),允许链路层发送更长的数据包,而不是将其分片为 27 字节的块。
MTU 协商在连接建立后自动进行。如果配置的 MTU 大于默认值,Zephyr 栈将在连接时发起 MTU 交换。您也可以显式触发它:
static void exchange_func(struct bt_conn *conn, uint8_t att_err, struct bt_gatt_exchange_params *params) { if (att_err) { printk("MTU exchange failed (err %u)\n", att_err); return; }
uint16_t mtu = bt_gatt_get_mtu(conn); printk("MTU exchanged: %u bytes\n", mtu); }
static struct bt_gatt_exchange_params exchange_params = { .func = exchange_func, };
static void connected(struct bt_conn *conn, uint8_t err) { if (err) { return; }
bt_gatt_exchange_mtu(conn, &exchange_params); }
`bt_gatt_exchange_mtu` 函数向远程设备发送 MTU 交换请求。回调函数接收结果。成功交换后,`bt_gatt_get_mtu` 返回协商后的 MTU,即双方支持的 MTU 值中的较小者。每次通知或写入操作的有效载荷是协商后的 MTU 减去 3 字节的 ATT 头部。
在通过通知发送大量数据时,有效吞吐量取决于三个因素:MTU(越大意味着包越少)、连接间隔(越短意味着更多传输机会)以及 PHY(2M PHY 相比 1M PHY 将原始数据速率翻倍)。
在 247 字节的 MTU、7.5 毫秒的连接间隔以及 2M PHY 的配置下,你可以实现 800 kbps 到 1400 kbps 的吞吐量范围,具体数值取决于具体的控制器和射频条件。
一个实际考虑因素:iOS 和 Android 对 MTU 协商的处理方式不同。Android 允许通过应用层请求特定的 MTU 值,而 iOS 会自动协商最大支持的 MTU(通常为 185 或 251 字节,视 iOS 版本而定),无需应用干预。你的固件应始终能够处理协商出的任意 MTU 值,而不是假设某个固定值。
## 用于距离与速度的 PHY 选择
蓝牙 5.0 在原有的 1M PHY 基础上引入了两种新的 PHY(物理层)选项。选择合适的 PHY 是在距离、吞吐量和功耗之间进行权衡。
**1M PHY** 是默认选项,也是蓝牙 4.x 中唯一可用的 PHY。它以每秒 1 兆比特的速度传输数据,具有标准传输距离。所有 BLE 设备都支持 1M PHY。
**2M PHY** 将数据速率提升至每秒 2 兆比特。每个数据包的传输时间减半,意味着射频激活时间更短。这既提高了吞吐量(单位时间内传输更多数据),也降低了功耗(射频开启时间缩短)。代价是相比 1M PHY 略微减少了传输距离,因为接收端用于整合每个比特的时间更少。当中心设备与外围设备距离较近(几米内)且需要高吞吐量时,应使用 2M PHY。
**Coded PHY** 使用前向纠错技术显著扩展传输距离,约为 1M PHY 的 2 到 4 倍。它通过冗余编码发送每个比特(S=2 实现 2 倍距离,S=8 实现 4 倍距离)来实现这一点。代价是吞吐量降低:Coded PHY S=8 的有效数据速率为 125 kbps,仅为 1M PHY 的八分之一。适用于需要长距离传输(如户外资产追踪、整栋建筑传感器网络)且可容忍低数据速率的应用场景。
在 `prj.conf` 中启用 PHY 支持:
CONFIG_BT_USER_PHY_UPDATE=y CONFIG_BT_CTLR_PHY_2M=y CONFIG_BT_CTLR_PHY_CODED=y
`CONFIG_BT_USER_PHY_UPDATE=y` 允许应用程序在连接建立后请求 PHY 更新。`CONFIG_BT_CTLR_PHY_2M` 和 `CONFIG_BT_CTLR_PHY_CODED` 分别启用控制器对相应 PHY 的支持。
连接后请求 PHY 更新:
static void connected(struct bt_conn *conn, uint8_t err) { if (err) { return; }
/* 请求 2M PHY 以获得更高吞吐量 */ struct bt_conn_le_phy_param phy_param = { .options = BT_CONN_LE_PHY_OPT_NONE, .pref_tx_phy = BT_GAP_LE_PHY_2M, .pref_rx_phy = BT_GAP_LE_PHY_2M, };
int phy_err = bt_conn_le_phy_update(conn, &phy_param); if (phy_err) { printk("PHY 更新请求失败 (err %d)\n", phy_err); } }
`bt_conn_le_phy_update` 函数向远程设备发送 PHY 更新请求。`pref_tx_phy` 和 `pref_rx_phy` 字段分别表示发送和接收数据时的首选 PHY。远程设备可以接受或建议替代方案。只有当双方设备均支持所请求的 PHY 时,更新才能成功。
为了监控 PHY 变化,注册回调函数:
static void phy_updated(struct bt_conn *conn, struct bt_conn_le_phy_info *param) { printk("PHY 已更新: TX PHY %u, RX PHY %u\n", param->tx_phy, param->rx_phy); }
BT_CONN_CB_DEFINE(conn_callbacks) = { .connected = connected, .disconnected = disconnected, .le_phy_updated = phy_updated, };
`le_phy_updated` 回调函数会在连接上的 PHY 发生变化时触发。`tx_phy` 和 `rx_phy` 字段报告每个方向当前使用的 PHY。值为 1 表示 1M PHY,2 表示 2M PHY,4 表示 Coded PHY。发送和接收的 PHY 可以不同(非对称 PHY),尽管大多数应用在两个方向使用相同的 PHY。
对于使用 S=8 编码(最大距离)的 Coded PHY,设置 options 字段:
struct bt_conn_le_phy_param phy_param = { .options = BT_CONN_LE_PHY_OPT_CODED_S8, .pref_tx_phy = BT_GAP_LE_PHY_CODED, .pref_rx_phy = BT_GAP_LE_PHY_CODED, };
`BT_CONN_LE_PHY_OPT_CODED_S8` 选项选择 S=8 编码,在牺牲最低吞吐量的前提下提供最大距离扩展。省略此选项(或使用 `BT_CONN_LE_PHY_OPT_CODED_S2`)则选择 S=2 编码,提供中等距离扩展并保持更好的吞吐量。
## 通过 BLE 进行固件更新
在现场部署设备固件更新是任何量产 BLE 产品的关键功能。用户不应需要连接 USB 线缆或前往服务中心即可获取 bug 修复和新功能。
Zephyr 通过与 MCUboot(一个安全的开源引导加载程序)集成,支持通过 BLE 进行设备固件更新(DFU)。
DFU 架构包含两个组件。MCUboot 是在你的应用程序运行之前启动的引导加载程序。它管理两个固件槽位:活动槽位(正在运行的固件)和升级槽位(等待应用的新固件)。当新固件写入升级槽位后,MCUboot 会验证其加密签名,交换槽位,并启动新固件。如果新固件未能确认自身有效(标记为有效),MCUboot 会在下次重启时自动回滚到之前的版本。
DFU 的 BLE 传输通过 GATT 服务上的 SMP(简单管理协议)实现。mcumgr 库实现了 SMP,Zephyr 包含一个 BLE SMP 传输,暴露了一个 SMP GATT 服务。手机应用(如 nRF Connect 或 mcumgr CLI)连接到该服务,并以分块形式上传新的固件镜像。
在你的 `prj.conf` 中启用 DFU:
CONFIG_BOOTLOADER_MCUBOOT=y CONFIG_MCUMGR=y CONFIG_MCUMGR_TRANSPORT_BT=y CONFIG_MCUMGR_GRP_IMG=y CONFIG_MCUMGR_GRP_OS=y CONFIG_IMG_MANAGER=y CONFIG_STREAM_FLASH=y CONFIG_FLASH_MAP=y CONFIG_FLASH=y
`CONFIG_BOOTLOADER_MCUBOOT=y` 告诉构建系统该应用程序运行在 MCUboot 之下,这会改变链接脚本和镜像格式。`CONFIG_MCUMGR=y` 启用 mcumgr 管理库。`CONFIG_MCUMGR_TRANSPORT_BT=y` 启用 BLE SMP 传输,它会创建一个 GATT 服务,手机可通过该服务上传固件。`CONFIG_MCUMGR_GRP_IMG=y` 启用图像管理命令组(上传、确认、擦除)。`CONFIG_MCUMGR_GRP_OS=y` 启用操作系统管理组(重启、回显)。
在应用程序中注册 BLE SMP 传输:
#include <zephyr/mgmt/mcumgr/transport/smp_bt.h>
int main(void) { int err;
err = bt_enable(NULL); if (err) { printk("Bluetooth init failed (err %d)\n", err); return 0; }
/* 启动用于 DFU 的 SMP BLE 传输 */ smp_bt_register();
/* 开始广播(包含 SMP 服务 UUID) */ bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);
printk("DFU 支持设备已准备就绪\n"); return 0; }
`smp_bt_register()` 调用将 SMP GATT 服务注册到蓝牙协议栈。此后,任何连接的 BLE 中央设备都可以发现该 SMP 服务,并使用 mcumgr 协议上传固件。
使用 MCUboot 构建需要先烧录引导加载程序。MCUboot 是独立构建并烧录到闪存启动分区中的:
west build -b nrf52840dk/nrf52840 bootloader/mcuboot/boot/zephyr \ -d build_mcuboot west flash -d build_mcuboot
west build -b nrf52840dk/nrf52840 my_dfu_app west flash
前两个命令构建并烧录 MCUboot。后两个命令构建并烧录你的应用程序。MCUboot 占据闪存的前部区域,并从主槽位启动你的应用程序。
执行 DFU 时,你需要构建应用程序的新版本,这会在构建目录中生成 `zephyr.signed.bin` 文件。使用 nRF Connect 移动应用(内置 DFU 支持)或 mcumgr 命令行工具将该文件上传到设备。上传通过 BLE SMP 连接完成。上传完成后,设备重启,MCUboot 验证新镜像,并将其切换到活动槽位。
MCUboot 支持多种对生产至关重要的安全特性。镜像签名确保只有使用你私钥签名的固件才能被安装。镜像加密可防止固件镜像在传输过程中被逆向工程。回滚保护机制会在新版本无法成功启动时恢复到之前的固件。这些功能需要额外配置,但对于接受空中更新的任何产品都是必不可少的。
## Zephyr 上的 Bluetooth Mesh
BLE 点对点连接适用于直接与手机或网关通信的设备。但在需要控制建筑物内数百个灯泡时,这种方式就会失效。你无法逐一连接每个设备。Bluetooth Mesh 解决了这个问题。
Bluetooth Mesh 是基于 BLE 的多对多网络标准。Mesh 网络中的设备可以相互转发消息,从而将通信范围远远扩展到单个 BLE 连接之外。开启灯光的指令可以从一个设备发出,经多个中继节点转发,最终到达建筑内的每一个灯泡。
Zephyr 包含完整的 Bluetooth Mesh 实现。以下是其概念架构。
Mesh 设备具有不同的角色。**中继节点(relay node)** 转发其他设备的消息,扩展网络覆盖范围。**代理节点(proxy node)** 在 GATT 连接设备(如手机)和 Mesh 网络之间建立桥梁,使不支持 Mesh 的手机也能通过代理控制 Mesh 设备。**朋友节点(friend node)** 为附近大部分时间处于休眠状态的低功耗节点存储消息。**低功耗节点(low-power node)** 定期唤醒,并向其“朋友”查询是否有待接收的消息。
Mesh 通信采用发布/订阅模型。设备向特定地址发布消息,订阅该地址的设备会接收这些消息。例如,开关设备向某个组地址发布“打开”指令,所有订阅该组地址的灯泡都会亮起。
Mesh 中的数据组织通过 **模型(models)** 实现。模型定义了设备可以发送和接收的一组消息。蓝牙 SIG 定义了一些通用场景的标准模型:通用开关(Generic OnOff,用于开关和灯具)、通用亮度(Generic Level,用于调光器)、传感器(Sensor,用于传感器数据)以及照明(Lighting,用于色温、亮度、色调)。你也可以自定义模型。
以下是在 `prj.conf` 中的最小 Mesh 节点配置:
CONFIG_BT=y CONFIG_BT_MESH=y CONFIG_BT_MESH_RELAY=y CONFIG_BT_MESH_PB_ADV=y CONFIG_BT_MESH_PB_GATT=y CONFIG_BT_MESH_GATT_PROXY=y CONFIG_BT_MESH_CFG_CLI=y CONFIG_BT_MESH_HEALTH_SRV=y
`CONFIG_BT_MESH=y` 启用 Mesh 协议栈。`CONFIG_BT_MESH_RELAY=y` 使该节点成为中继节点,转发其他节点的消息。`CONFIG_BT_MESH_PB_ADV=y` 和 `CONFIG_BT_MESH_PB_GATT=y` 启用通过广播和 GATT 连接进行设备配对(provisioning)。`CONFIG_BT_MESH_GATT_PROXY=y` 启用代理角色以支持手机连接。
完整的 Mesh 应用程序包括定义设备组成数据(支持哪些模型)、实现模型处理器以及设置配对流程。Zephyr 示例目录 (`samples/bluetooth/mesh/`) 包含多个完整示例,如灯泡、开关和传感器服务器,展示了完整的实现模式。
## LE Audio:下一代蓝牙音频
[LE Audio](https://www.freecodecamp.org/news/the-bluetooth-le-audio-handbook/) 是近年来蓝牙规范最重要的更新。它用完全基于 BLE 的新系统取代了传统的经典蓝牙音频协议(A2DP)。Zephyr 对 LE Audio 的实现是目前开源栈中最完整之一。
LE Audio 的核心是 LC3(低复杂度通信编解码器)。与经典蓝牙音频中使用的 SBC 编解码器相比,LC3 在比特率仅为一半的情况下,提供了更优的音频质量。这意味着更好的音质和更低的功耗。
LE Audio 引入了两种通信模式。**连接等时流(Connected Isochronous Streams, CIS)** 是点对点的音频连接,类似于经典蓝牙音频,但效率更高。CIS 用于手机与耳机、助听器以及其他配对音频场景之间的连接。
**广播等时流(Broadcast Isochronous Streams, BIS)** 是一对多的广播。单个源可以向无限数量的接收设备广播音频。这是 Auracast 技术的基础,设想在公共场所广播音频,任何兼容设备都可以收听(例如:机场中的静音电视、剧院中的听力辅助、会议厅中的多语言广播)。
Zephyr 实现了完整的 LE Audio 配置文件栈:BAP(基本音频配置文件)、PACS(发布音频能力)、ASCS(音频流控制)、VCP(音量控制)、MCP(媒体控制)、CCP(呼叫控制)、TMAP(电话与媒体音频配置文件)以及 CAP(通用音频配置文件)。
LE Audio 项目的 `prj.conf` 包含以下配置:
CONFIG_BT=y CONFIG_BT_AUDIO=y CONFIG_BT_BAP_UNICAST_SERVER=y CONFIG_BT_PACS=y CONFIG_BT_ASCS=y CONFIG_BT_ISO=y CONFIG_BT_PAC_SNK=y CONFIG_BT_PAC_SRC=y
LE Audio 开发比基础 BLE 外设开发复杂得多。它涉及管理等时通道、配置编解码器参数、处理音频数据流,并实现各种配置文件层。Zephyr 提供的示例 `samples/bluetooth/bap_unicast_server` 和 `samples/bluetooth/bap_broadcast_source` 是最佳的入门起点。
如果你正在开发助听器、耳机或其他音频设备,那么在 Zephyr 上使用 LE Audio 值得投入大量精力。开源栈让你能够完全了解其实现细节,而 Nordic 的 nRF5340 和 nRF54 系列芯片则提供了所需的硬件支持。
## 调试蓝牙应用
BLE 应用比普通嵌入式应用更难调试,因为无线通信是不可见的。你无法在“空中”设置断点。以下是让 BLE 调试变得可行的工具和技术。
**Zephyr 的日志子系统** 是你的第一道防线。在 `prj.conf` 中启用详细的蓝牙日志:
CONFIG_LOG=y CONFIG_BT_DEBUG_LOG=y CONFIG_BT_LOG_LEVEL_DBG=4
`CONFIG_LOG=y` 启用日志框架。`CONFIG_BT_DEBUG_LOG=y` 和 `CONFIG_BT_LOG_LEVEL_DBG=4` 启用蓝牙栈的调试级别日志。
这会产生非常详细输出,显示每个 HCI 命令、广告事件、连接事件、GATT 操作和错误。输出会发送到控制台(通常是 UART)。由于输出极其冗长,仅在主动调试期间启用。
**Zephyr Shell** 提供了一个交互式命令行,用于蓝牙操作:
CONFIG_SHELL=y CONFIG_BT_SHELL=y
启用后,你可以获得诸如 `bt init`、`bt advertise on`、`bt connect`、`bt gatt discover` 和 `bt gatt read` 等 shell 命令,通过串口控制台交互式地控制和检查蓝牙栈。这对于测试非常有价值,因为你可以在不修改固件的情况下手动触发操作并查看结果。
**nRF Connect for Mobile**(iOS/Android)是一个必不可少的配套工具。除了扫描和连接外,它还能显示所有 GATT 服务和特征值,允许你读写/订阅特征值,显示原始广告数据,并带时间戳记录 BLE 事件。使用它来验证你的设备是否正确广播、GATT 数据库是否正确,以及读写和通知功能是否按预期工作。
**蓝牙嗅探器** 可以捕获实际的无线数据包。nRF52840 DK 可以配合 Nordic 的 nRF Sniffer for Bluetooth LE 固件作为嗅探器使用。结合 Wireshark(具有 BLE 协议解析器),你可以检查线路上的每一个数据包:广告 PDU、连接事件、GATT 请求/响应以及配对交换。当协议层面出现问题时,这是终极调试工具。
**常见的调试模式。** 如果广告不可见,请检查广告数据是否未超过 31 字节(针对传统广告),并且标志字段是否存在。
如果连接立即断开,请确认启用了 `CONFIG_BT_PERIPHERAL`(而不仅仅是 `CONFIG_BT_BROADCASTER`)。如果 GATT 读取返回零长度数据,请验证你的读取回调是否从 `bt_gatt_attr_read` 返回了正确的值。如果通知未到达,请确认中心设备已向 CCCD 写入 0x0001。如果配对失败,请确保设置了 `CONFIG_BT_SMP=y` 并注册了认证回调函数。
## BLE 设备的功耗优化
一个电池一周就耗尽的 BLE 设备是失败的产品。功耗优化不是事后考虑,而是核心设计约束。Zephyr 提供了必要的工具,但你需要正确使用它们。
BLE 设备中最大的功耗来源是射频模块。每一次广告事件、连接事件和扫描窗口都会激活射频收发器几毫秒,消耗数毫安电流。减少射频使用是延长电池寿命的主要手段。
对于广告,增加广告间隔。1000 ms 的间隔功耗大约是 200 ms 间隔的五分之一。如果快速发现不是关键需求,可使用更长的间隔。
你还可以采用两阶段策略:上电后前 30 秒以快速间隔(如 100 ms)广播,之后切换到慢速间隔(如 1000 ms)进入稳态。Zephyr 的广告 API 支持在广播过程中动态更改参数。
对于连接设备,使用外设延迟参数。外设延迟为 4 表示外设可以在必须响应之前跳过 4 个连接事件。如果连接间隔为 50 ms,且外设延迟为 4,则外设每 250 ms 唤醒一次,而不是每 50 ms 唤醒一次。当不需要高吞吐量时,可向中心设备请求更长的连接间隔:
static struct bt_le_conn_param conn_params = { .interval_min = 80, /* 100 ms (单位为 1.25 ms) */ .interval_max = 160, /* 200 ms */ .latency = 4, .timeout = 400, /* 4 秒 (单位为 10 ms) */ };
/* 连接建立后:*/ bt_conn_le_param_update(conn, &conn_params);
`bt_conn_le_param_update` 函数会向中心设备发送连接参数更新请求。中心设备可以接受或拒绝该请求。大多数中心设备(如手机)会接受合理的参数范围。
启用 Zephyr 的系统电源管理:
CONFIG_PM=y CONFIG_PM_DEVICE=y
启用电源管理后,内核会在没有任何线程准备运行时将处理器置于低功耗状态。
在 nRF52840 上,空闲电流从约 3 mA(活动状态)降至约 1.5 微安(系统关闭但保留 RAM)。这种差异非常显著。Zephyr 的电源管理策略会根据下一个计划唤醒事件自动选择系统可进入的最深睡眠状态。
在设备树覆盖文件中禁用未使用的外设。每个启用的外设(UART、SPI、I2C)即使在空闲时也会消耗电力。如果你在生产环境中不需要 UART(仅用于开发日志记录),请将其禁用:
&uart0 { status = "disabled"; };
测量实际电流消耗。像 Nordic Power Profiler Kit II 这样的工具可以以微安级分辨率实时显示电流消耗。Zephyr 的 `CONFIG_THREAD_ANALYZER` 可帮助你合理设置线程栈大小(分配过大的栈会浪费 RAM,意味着需要为更多 RAM 供电)。
一个优化良好的 BLE 传感器的目标是平均电流仅为个位数微安,这意味着在纽扣电池上可运行数年。Zephyr 使这一目标成为可能,但这需要关注每一层:无线电调度、外设管理、时钟配置和应用设计。
## Zephyr Bluetooth 与其他协议栈对比
在选择蓝牙协议栈时,你有多种选择。以下是 Zephyr 与其他方案的对比。
### 1. Zephyr vs. Nordic SoftDevice
Nordic 的 SoftDevice 是他们在转向 Zephyr 之前的专有 BLE 协议栈。SoftDevice 是一个预编译的二进制文件:你无法阅读或修改其代码。Nordic 的 nRF Connect SDK(基于 Zephyr 构建)用 Zephyr 的开源协议栈取代了 SoftDevice。如果你正在启动新的 Nordic 项目,请使用 Zephyr。SoftDevice 已属过时。
### Zephyr vs. NimBLE (Apache Mynewt)
NimBLE 是一个轻量级、开源的 BLE 主机协议栈,最初来自 Apache Mynewt 项目(也可独立使用,并移植到 ESP-IDF)。NimBLE 比 Zephyr 的协议栈更小,可能更适合内存极其受限的设备。Zephyr 的协议栈功能更完整(支持 LE Audio、Mesh、方向查找等),并且拥有更广泛的行业采用度。对于新产品,除非你严重受限于 RAM,否则 Zephyr 是更好的选择。
### Zephyr vs. ESP-IDF Bluetooth
Espressif 的 ESP-IDF 包含适用于 ESP32 芯片的蓝牙协议栈(Bluedroid 或 NimBLE)。如果你仅使用 ESP32 硬件,ESP-IDF 是一个可行的选择。Zephyr 支持 ESP32,并为你提供跨硬件的可移植性、统一的构建系统以及更全面的 BLE 功能集。如果你未来可能需要更换芯片,Zephyr 的可移植性是一个显著优势。
### Zephyr vs. 厂商 SDK 配套的专有协议栈
许多芯片厂商提供自己的 BLE SDK 和闭源协议栈。这些方案工作良好,但会将你锁定在特定厂商的生态系统中。Zephyr 提供可移植性、开源特性以及庞大社区的集体努力。代价是学习曲线更陡峭。
对于需要在固定时间表内交付且仅使用一种已知芯片的产品,厂商 SDK 可能更快上手。而对于涵盖多种芯片或重视长期可维护性的产品线,Zephyر 是更优选择。
## 接下来的方向
本手册已涵盖大量内容。你现在应理解 BLE 基础知识、GAP 广播、GATT 服务、连接、通知、配对、Mesh、LE Audio、调试和电源优化。你已经为每个主要概念编写了代码。以下是继续深入的方法。
探索 Zephyr 的蓝牙示例目录(`zephyr/samples/bluetooth/`)。其中包含超过 30 个蓝牙专用示例。`peripheral_hr` 示例实现了完整的 heart rate profile。`central` 示例展示了如何构建扫描/连接的中心角色。`mesh/` 子目录包含用于 Mesh 开发的开关灯、灯泡和传感器示例。`bap_*` 示例演示了 LE Audio。
阅读 Zephyr 蓝牙文档(`docs.zephyrproject.org/latest/connectivity/bluetooth/`)。API 参考涵盖了每一个函数、宏和配置选项。蓝牙架构文档解释了主机、控制器和 HCI 层之间的交互方式。
如果你使用 Nordic 硬件,请搭建 nRF Connect SDK 环境。nRF Connect SDK 在 Zephyr 基础上添加了 Nordic 特定功能,包括他们的蓝牙库、专有无线协议(ESB、Gazell)以及蜂窝调制解调器支持。它使用相同的 Zephyr 内核和构建系统。
动手构建真实项目。例如:为你的台灯制作一个 BLE 遥控器;制作一个报告房间温湿度的传感器;设计一个带 BLE 连接的自定义键盘;或者开发一个宠物追踪器。
巩固这些知识的最佳方式是在真实硬件上遇到并解决实际问题。每个 BLE 产品都有其独特之处(如 iOS 与 Android 的连接参数协商、绑定丢失后的重连处理、为高效数据传输管理 MTU 大小等),而这些经验只能通过实践获得。
蓝牙低功耗(BLE)生态系统庞大且持续扩展。Zephyr 为你提供了一个生产级、开源的基础平台,拥有活跃的社区和企业支持,确保其在未来多年内将持续积极开发。现在,你已具备在该平台上构建应用所需的知识。
## 总结
本手册涵盖了在 Zephyr 操作系统上进行蓝牙开发的全部内容,从基础概念到可用于生产的高级功能。
BLE 基础部分建立了后续所有内容所依赖的思维模型:GAP 控制发现与连接,GATT 定义数据结构与交换方式,服务与特征构成设备的 API,UUID 标识每个组件。这些概念并非 Zephyr 独有——它们适用于任何 BLE 协议栈。
在 Zephyr 方面,你逐步构建了越来越复杂的应用程序。信标示例展示了最简 BLE 配置:初始化协议栈并开始广播。LED 服务引入了 GATT 的读写特性,演示了如何通过手机控制硬件。通知示例增加了实时数据推送功能,而完整的传感器节点则将广播、GATT、连接管理及周期性数据传输整合为一个统一的应用程序。
这些模式(广播数据构造、GATT 回调签名、CCC 处理、连接引用管理)将在你构建的每一个 BLE 项目中反复出现。
标准心率配置文件展示了 SIG 定义的 16 位 UUID 如何与 Zephyr 的预定义 UUID 宏集成,从而实现与通用 BLE 应用的互操作性。
中心角色示例展示了 BLE 的另一面:扫描、连接并发现远程设备的服务。在建立连接后,MTU 协商和 PHY 选择是优化数据吞吐量和通信距离的两个主要手段,两者都需要在 Kconfig 中协调配置,并通过运行时 API 调用实现。
DFU 部分解决了生产环境中固件现场更新的需求,通过 BLE 上的 SMP 协议集成了 MCUboot。
除了点对点连接,蓝牙 Mesh 将 BLE 扩展为多对多网络,适用于楼宇自动化和大规模物联网部署;而 LE Audio 则代表下一代无线音频技术,支持 LC3 编解码器和广播功能。配对与安全机制保护生产设备免受未经授权的访问。电源优化、调试工具以及协议栈对比,构成了发布实际产品的实用知识体系。
本文介绍的代码模式和 Kconfig 选项,构成了在 Zephyr 上构建任何 BLE 设备的工具箱,无论是简单的信标,还是复杂的多协议网关。
* * *
* * *
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人获得开发者职位。[立即开始](https://www.freecodecamp.org/learn)